feat(app): support the diff command line option (#1811)

Signed-off-by: Inf1mum <[email protected]>
This commit is contained in:
Vadim Lazuk authored and GitHub committed 2026-11-07 03:03:12 +08:00
1 parent b5974ab3ac
commit aa3323ddc5
5 files changed
+183 -8

No files matched your search

+13 -5
View File
@@ -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::<Vec<_>>(), &cwd)
{
windows::remember_cli_position(&windows, &std::env::args().collect::<Vec<_>>(), &path);
windows.set_launch("main", windows::path_launch(path));
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 => {}
}
}
let menu_state = menu::MenuState::default();
menu::install(app.handle(), &menu_state, &state.recent.list());
+114 -2
View File
@@ -57,6 +57,9 @@ pub struct Launch {
/// Comparison windows: the revisions to compare (boxed: most launches have none).
#[serde(default)]
pub comparison: Option<Box<ComparisonLaunch>>,
/// `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>>,
/// Deployment windows: the server and local selection to compare.
#[serde(default)]
pub deployment: Option<DeploymentLaunch>,
@@ -93,6 +96,13 @@ pub struct ComparisonLaunch {
pub repo: Option<ide_core::IpcPath>,
}
/// 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<S
/// 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`.
pub fn cli_path(args: &[String], cwd: &Path) -> Option<PathBuf> {
@@ -374,6 +387,50 @@ pub fn cli_path(args: &[String], cwd: &Path) -> Option<PathBuf> {
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<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() }))
}
// 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)> {
@@ -1064,8 +1121,9 @@ pub fn on_second_instance(app: &AppHandle, args: Vec<String>, 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::<WindowsState>(), &args, &path);
open_path(app, &path)
}
@@ -1079,6 +1137,27 @@ pub fn on_second_instance(app: &AppHandle, args: Vec<String>, 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::<WindowsState>().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)));
+10
View File
@@ -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,
};
@@ -46,6 +46,8 @@ vi.mock("@/platform/project", async (orig) => {
const real = await orig<typeof import("@/platform/project")>();
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", () => {
+16 -1
View File
@@ -212,7 +212,7 @@ async function pickFiles(title: string): Promise<string[]> {
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<void> {
if (!isTauri()) return;
const launch = await commands.windowTakeLaunch().catch(() => null);
@@ -221,6 +221,7 @@ export async function consumeLaunch(): Promise<void> {
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<void> {
}
}
/**
* `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<void> {
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 }) => {