diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 33a9e379..bee4317f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -163,11 +163,19 @@ pub fn run() { builder.mount_events(app); let state = AppState::new(app.handle().clone())?; let windows = WindowsState::default(); - 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)); + 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 => {} + } } let menu_state = menu::MenuState::default(); menu::install(app.handle(), &menu_state, &state.recent.list()); diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index b39c6177..6ba3b105 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -57,6 +57,9 @@ pub struct Launch { /// Comparison windows: the revisions to compare (boxed: most launches have none). #[serde(default)] pub comparison: Option>, + /// `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>, /// Deployment windows: the server and local selection to compare. #[serde(default)] pub deployment: Option, @@ -93,6 +96,13 @@ pub struct ComparisonLaunch { pub repo: Option, } +/// The two files of a command-line diff (`openidea diff a.txt b.txt`), both sides editable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct DiffFilesLaunch { + pub left: ide_core::IpcPath, + pub right: ide_core::IpcPath, +} + /// 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")] @@ -356,6 +366,9 @@ pub fn cycle_target(sorted: &[String], current: &str, forward: bool) -> Option Option { @@ -374,6 +387,50 @@ pub fn cli_path(args: &[String], cwd: &Path) -> Option { None } +/// 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() })) + } + // 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)> { @@ -1064,8 +1121,9 @@ pub fn on_second_instance(app: &AppHandle, args: Vec, cwd: String) { return; } } - match cli_path(&args, Path::new(&cwd)) { - Some(path) => { + 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) } @@ -1079,6 +1137,27 @@ pub fn on_second_instance(app: &AppHandle, args: Vec, cwd: String) { } } +/// `openidea diff a.txt b.txt` while the app is running: hands the pair to the window the user is +/// looking at (a project window, never an auxiliary one), else opens a new window for it. +fn open_diff_files(app: &AppHandle, diff: DiffFilesLaunch) { + 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 Some(window) = target { + app.state::().set_launch(window.label(), launch); + let _ = app.emit_to(window.label(), LAUNCH_EVENT, ()); + focus(&window); + } 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); + } + }); + } +} + pub fn cycle(app: &AppHandle, current: &str, forward: bool) { let windows = app.webview_windows(); let sorted = project_window_labels(app); @@ -1480,6 +1559,39 @@ mod tests { ); } + #[test] + fn cli_launch_parses_the_diff_subcommand() { + let cwd = Path::new("/work"); + // Two paths: a diff pair; relative paths resolve against the cwd. + 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"))) + ); + } + #[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))); diff --git a/src/bindings.ts b/src/bindings.ts index 9fff2617..3feb2ea7 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -903,6 +903,8 @@ export const commands = { positions?: FilePosition[], /** Comparison windows: the revisions to compare (boxed: most launches have none). */ 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, /** Deployment windows: the server and local selection to compare. */ deployment?: DeploymentLaunch | null, } | null>("window_take_launch"), @@ -3077,6 +3079,12 @@ export type Diagnostic = { server?: string | null, }; +/** The two files of a command-line diff (`openidea diff a.txt b.txt`), both sides editable. */ +export type DiffFilesLaunch = { + left: IpcPath, + right: IpcPath, +}; + export type DirectoryCompareEntry = { path: string, status: DirectoryCompareStatus, @@ -4967,6 +4975,8 @@ export type Launch = { positions?: FilePosition[], /** Comparison windows: the revisions to compare (boxed: most launches have none). */ 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, /** 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 23593f9f..b2bed662 100644 --- a/src/features/app/__tests__/windows.test.ts +++ b/src/features/app/__tests__/windows.test.ts @@ -46,6 +46,8 @@ vi.mock("@/platform/project", async (orig) => { const real = await orig(); return { ...real, openProject: (p: string) => openProject(p) }; }); +const diffs: unknown[] = []; +vi.mock("@/features/vcs/diffInput", () => ({ openDiff: (data: unknown) => void diffs.push(data) })); const { openProjectInWindow, @@ -88,6 +90,7 @@ beforeEach(() => { notify.mockClear(); vi.mocked(isRemoteWindow).mockReturnValue(false); opened.length = 0; + diffs.length = 0; activePath = null; useProject.setState({ project: null, lightEdit: false, settings: null } as never); }); @@ -302,6 +305,33 @@ describe("launch", () => { await consumeLaunch(); expect(opened).toEqual(["/x/notes/a.txt @119:7", "/x/notes/b.txt (focus)"]); }); + + it("opens the CLI diff pair in LightEdit without a project (openidea diff a b)", async () => { + windowTakeLaunch.mockResolvedValueOnce({ project: null, files: [], diff: { left: "/x/a.txt", right: "/x/b.txt" } }); + await consumeLaunch(); + expect(lightEditOpen).toHaveBeenCalledWith("/x/b.txt"); + expect(diffs).toEqual([ + { + path: "/x/b.txt", + left: { type: "file", path: "/x/a.txt", label: "/x/a.txt" }, + right: { type: "worktree", label: "/x/b.txt" }, + }, + ]); + }); + + it("opens the CLI diff pair in the open project without LightEdit", async () => { + useProject.setState(projectState("/p") as never); + windowTakeLaunch.mockResolvedValueOnce({ project: null, files: [], diff: { left: "/p/a.txt", right: "/p/b.txt" } }); + await consumeLaunch(); + expect(lightEditOpen).not.toHaveBeenCalled(); + expect(diffs).toEqual([ + { + path: "/p/b.txt", + left: { type: "file", path: "/p/a.txt", label: "a.txt" }, + right: { type: "worktree", label: "b.txt" }, + }, + ]); + }); }); describe("Attach", () => { diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 051775da..4433deb4 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -212,7 +212,7 @@ async function pickFiles(title: string): Promise { return picked === null ? [] : Array.isArray(picked) ? picked : [picked]; } -/** Takes this window's pending launch (if any) and opens its project and files. */ +/** Takes this window's pending launch (if any) and opens its project, files or diff pair. */ export async function consumeLaunch(): Promise { if (!isTauri()) return; const launch = await commands.windowTakeLaunch().catch(() => null); @@ -221,6 +221,7 @@ export async function consumeLaunch(): Promise { 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) => @@ -234,6 +235,20 @@ export async function consumeLaunch(): Promise { } } +/** + * `openidea diff a.txt b.txt`: the two files side by side in the diff viewer (both editable). + * Without a project or LightEdit session the right file opens one first, like a launched file does. + */ +async function openCliDiff(diff: { left: string; right: string }): Promise { + const s = useProject.getState(); + if (!s.project && !s.lightEdit) await openLightEdit(diff.right).catch(() => {}); + const [{ openDiff }, { compareFilesData }] = await Promise.all([ + import("@/features/vcs/diffInput"), + import("@/features/vcs/compareData"), + ]); + await openDiff(compareFilesData(diff.left, diff.right)); +} + /** Closes the project tabs on `side` of the `arg` tab (default: the current one), keeping that tab. */ function closeProjectTabs(side: "others" | "left" | "right"): Action["run"] { return async ({ arg }) => {