Compare commits

...
Author SHA1 Message Date
l0ng-ai 7d320a2967 feat(app): open a file at a line and column from the command line
`openidea --line 120 --column 8 path/to/File.java` opens the file with the caret at that
position (1-based; `--line=120` works too). Without `--column` the caret goes to the
line's start, and out-of-range values clamp to the end of the file or line. The position
is kept by file until the window that opens it takes its launch, so it works for a new
instance, a second instance forwarding to a running one, a file of a project open in
another window, and a file that is already open (the caret moves again). The path
argument now skips the values of these flags.

Closes #829
2026-11-04 08:25:28 +08:00
5 changed files with 148 additions and 14 deletions

No files matched your search

+1
View File
@@ -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::<Vec<_>>(), &cwd)
{
windows::remember_cli_position(&windows, &std::env::args().collect::<Vec<_>>(), &path);
windows.set_launch("main", windows::path_launch(path));
}
let menu_state = menu::MenuState::default();
+106 -6
View File
@@ -47,6 +47,18 @@ pub struct Launch {
pub settings_page: Option<String>,
/// 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 <file>`).
#[serde(default)]
pub positions: Vec<FilePosition>,
}
/// 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<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)>>,
/// Most recently focused window label (menu events go there).
last_focused: Mutex<Option<String>>,
/// Set by Quit while windows close one by one.
@@ -79,7 +93,21 @@ impl WindowsState {
}
pub fn take_launch(&self, label: &str) -> Option<Launch> {
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<S
Some(sorted[j].clone())
}
/// Flags followed by a value (`--line 12`).
const VALUE_FLAGS: [&str; 2] = ["--line", "--column"];
/// The project path from command-line arguments (`openidea /path/to/project`):
/// the first argument after the binary that is not a flag, resolved against `cwd`.
/// 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> {
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 <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 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)))
}
/// 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<String>, cwd: String) {
match cli_path(&args, Path::new(&cwd)) {
Some(path) => open_path(app, &path),
Some(path) => {
remember_cli_position(&app.state::<WindowsState>(), &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]
+11
View File
@@ -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 <file>`). */
positions?: FilePosition[],
} | null>("window_take_launch"),
windowCloseContext: () => __TAURI_INVOKE<CloseContext>("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 <file>`). */
positions?: FilePosition[],
};
export type LaunchConfig = {
+14 -1
View File
@@ -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", () => {
+16 -7
View File
@@ -63,15 +63,23 @@ async function openHere(path: string): Promise<boolean> {
}
/** Opens `files` as editor tabs of this window, focusing the last. */
async function openInEditor(files: readonly string[]): Promise<void> {
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<void> {
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<void> {
async function openFilesHere(files: readonly string[], positions: Positions = []): Promise<void> {
const s = useProject.getState();
if (!s.project && !s.lightEdit) {
try {
@@ -80,7 +88,7 @@ async function openFilesHere(files: readonly string[]): Promise<void> {
return openFailed(files[0]!, e);
}
}
await openInEditor(files);
await openInEditor(files, positions);
}
/**
@@ -206,9 +214,10 @@ export async function consumeLaunch(): 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);
} 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) =>