From 1d383e608c253f1313fbdf1eec0c774037cfbeb8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:56:23 +0800 Subject: [PATCH] feat(workbench): Open files from Finder in the single-file window (#152) On macOS, OpenIDEA is now listed under Finder's Open With for text and source files and for folders. It never becomes the default app for any of them: all document types are declared with rank Alternate, and only in tauri.macos.conf.json, so Windows and Linux installers register nothing. Files opened from Finder, the Dock or a second instance share one routing path (plan_open). A file inside a local project that is already open, or still opening, goes to that project's window, choosing the deepest root when projects are nested. Any other file goes to the existing single-file window, or to a new one. A folder focuses the window that already has it open, otherwise it opens as a project. Paths that arrive before setup are queued and opened with the first launch. The single-file window now closes with its last tab, restores its files the next time it opens, and only offers Open File and drop hints when empty. Also fixes a regression: reopening projects on startup no longer replaces a file opened at cold start. Refs #128 --- src-tauri/src/lib.rs | 22 +- src-tauri/src/windows.rs | 352 ++++++++++++++++-- src-tauri/tauri.macos.conf.json | 99 +++++ src/editor/core/controller.test.ts | 19 + src/editor/core/controller.ts | 6 +- .../app/__tests__/lightEditClose.test.ts | 62 +++ src/features/app/index.ts | 3 +- src/features/app/windows.ts | 19 + src/features/welcome/session.test.ts | 7 + src/features/welcome/session.ts | 4 +- src/workbench/EditorArea.tsx | 14 +- src/workbench/lightEdit.test.tsx | 16 + 12 files changed, 581 insertions(+), 42 deletions(-) create mode 100644 src-tauri/tauri.macos.conf.json create mode 100644 src/features/app/__tests__/lightEditClose.test.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 87cec86a..9005f1ba 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,7 +13,10 @@ mod state; mod theme_frame; mod windows; +use parking_lot::Mutex; use state::AppState; +use std::path::PathBuf; +use std::sync::Arc; use tauri::{Emitter, Manager}; use windows::WindowsState; @@ -74,6 +77,9 @@ pub fn run() { #[cfg(debug_assertions)] let app = app.plugin(tauri_plugin_mcp_bridge::Builder::new().bind_address("127.0.0.1").build()); + // Paths macOS asked to open (Finder's Open With, the Dock) before setup ran: the first window takes them. + let early_opens: Arc>>> = Arc::default(); + let setup_opens = early_opens.clone(); app.invoke_handler(builder.invoke_handler()) .setup(move |app| { builder.mount_events(app); @@ -89,6 +95,9 @@ pub fn run() { app.manage(state); app.manage(windows); app.manage(menu_state); + if let Some(paths) = setup_opens.lock().take() { + windows::open_paths_at_launch(app.handle(), paths); + } app.on_menu_event(on_menu_event); windows::create_main_window(app.handle())?; Ok(()) @@ -115,7 +124,18 @@ pub fn run() { }) .build(context) .expect("error while running OpenIDEA") - .run(|app, event| { + .run(move |app, event| { + #[cfg(target_os = "macos")] + if let tauri::RunEvent::Opened { urls } = &event { + let paths = windows::opened_paths(urls); + if app.try_state::().is_some() { + windows::open_paths(app, paths); + } else { + early_opens.lock().get_or_insert_default().extend(paths); + } + } + #[cfg(not(target_os = "macos"))] + let _ = &early_opens; // Quit after installing an update (`app_install_update`): relaunch. if let tauri::RunEvent::Exit = event && app.try_state::().is_some_and(|w| w.restart_requested()) diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 06f1d75e..a5618841 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -77,6 +77,17 @@ impl WindowsState { self.launches.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) { + let mut launches = self.launches.lock(); + let launch = launches.entry(label.to_owned()).or_default(); + launch.files.extend(files.into_iter().map(Into::into)); + } + + fn pending(&self, label: &str) -> Option { + self.launches.lock().get(label).cloned() + } + pub fn focused(&self, label: &str) { *self.last_focused.lock() = Some(label.to_owned()); } @@ -206,8 +217,7 @@ pub fn find_light_window(app: &AppHandle) -> Option { /// 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) { - let launch = Launch { files: files.into_iter().map(Into::into).collect(), ..Default::default() }; - app.state::().set_launch(window.label(), launch); + app.state::().add_files(window.label(), files); let _ = app.emit_to(window.label(), LAUNCH_EVENT, ()); focus(window); } @@ -422,44 +432,198 @@ pub fn open_remote_window(app: &AppHandle, ssh_config: &str, path: &str, new_win Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } -/// Opens `path` from the command line / a second instance: a folder focuses the -/// window that has it, a file joins the LightEdit window; else a window without -/// a project is reused, else a new one is made. -pub fn open_path(app: &AppHandle, path: &Path) { - let path = match dunce::canonicalize(path) { - Ok(p) => p, - Err(e) => { - tracing::warn!("cannot open {}: {e}", path.display()); - return; +/// What a window holds, for routing paths opened from outside (command line, Finder). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Holds { + /// Nothing yet (Welcome screen): free to take a launch. + Nothing, + LightEdit, + Project(PathBuf), + /// A remote project. + Other, +} + +/// What window `label` holds: its open project, else what its pending launch is about to open. +fn holds(app: &AppHandle, label: &str) -> Holds { + let state = app.try_state::(); + if let Some(project) = state.as_ref().and_then(|s| s.project(label).ok()) { + return if project.light { Holds::LightEdit } else { Holds::Project(project.root.clone()) }; + } + if state.is_some_and(|s| s.remote_dev.get(label).is_some()) { + return Holds::Other; + } + pending_holds(app.state::().pending(label).as_ref()) +} + +fn pending_holds(launch: Option<&Launch>) -> Holds { + match launch { + Some(Launch { project: Some(root), .. }) => Holds::Project(root.to_path_buf()), + Some(Launch { remote: Some(_), .. }) => Holds::Other, + Some(l) if !l.files.is_empty() => Holds::LightEdit, + _ => Holds::Nothing, + } +} + +/// One step of opening paths from outside; see [`plan_open`]. +#[derive(Debug, PartialEq, Eq)] +pub enum OpenStep { + /// The window already has the folder open. + Focus(String), + /// Files open in this window: in its project, or join its LightEdit session. + Files(String, Vec), + /// A free window opens this. + Launch(String, Launch), + /// A new window opens the project folder or (without one) the files in LightEdit. + New(Option, Vec), +} + +/// Where a file inside a project goes: a window with it open, or the step opening it. +enum ProjectTarget { + Window(String), + Step(usize), +} + +/// Routes canonical `paths` (with whether each is a folder) over `windows` (the preferred +/// free one first). A folder focuses the window that has it open, else takes a free window, +/// else a new one. A file inside a local project being opened (the deepest root if nested) +/// opens in that project's window; the others all join the LightEdit window, else a free +/// window or a new one switches to LightEdit for them. +pub fn plan_open(paths: Vec<(PathBuf, bool)>, windows: &[(String, Holds)]) -> Vec { + let mut free = windows.iter().filter(|(_, h)| *h == Holds::Nothing).map(|(l, _)| l.clone()); + let (mut dirs, mut files) = (Vec::new(), Vec::new()); + for (path, is_dir) in paths { + let list = if is_dir { &mut dirs } else { &mut files }; + if !list.contains(&path) { + list.push(path); } - }; - let is_dir = path.is_dir(); - if is_dir && let Some(w) = find_project_window(app, &path) { - focus(&w); - return; } - if !is_dir && let Some(w) = find_light_window(app) { - send_files(app, &w, vec![path]); - return; - } - let free = target_window(app) - .filter(|w| !has_project(app, w.label())) - .filter(|w| !is_settings_window(w.label())) - .or_else(|| { - app.webview_windows().into_values().find(|w| !is_settings_window(w.label()) && !has_project(app, w.label())) + let mut steps = Vec::new(); + let mut roots: Vec<(PathBuf, ProjectTarget)> = windows + .iter() + .filter_map(|(label, h)| match h { + Holds::Project(root) => Some((root.clone(), ProjectTarget::Window(label.clone()))), + _ => None, + }) + .collect(); + for dir in dirs { + if let Some((label, _)) = windows.iter().find(|(_, h)| matches!(h, Holds::Project(root) if *root == dir)) { + steps.push(OpenStep::Focus(label.clone())); + continue; + } + roots.push((dir.clone(), ProjectTarget::Step(steps.len()))); + steps.push(match free.next() { + Some(label) => OpenStep::Launch(label, path_launch_of(Some(dir), Vec::new())), + None => OpenStep::New(Some(dir), Vec::new()), }); - let launch = path_launch(path); - match free { - Some(w) => { - app.state::().set_launch(w.label(), launch); - let _ = app.emit_to(w.label(), LAUNCH_EVENT, ()); - focus(&w); - } - None => { - let files = launch.files.into_iter().map(ide_core::IpcPath::into_path_buf).collect(); - spawn_window_with(app, launch.project.map(ide_core::IpcPath::into_path_buf), files) + } + let mut in_windows: Vec<(String, Vec)> = Vec::new(); + let mut loose = Vec::new(); + for file in files { + let deepest = + roots.iter().filter(|(root, _)| file.starts_with(root)).max_by_key(|(root, _)| root.components().count()); + match deepest.map(|(_, target)| target) { + Some(ProjectTarget::Window(label)) => match in_windows.iter_mut().find(|(l, _)| l == label) { + Some((_, list)) => list.push(file), + None => in_windows.push((label.clone(), vec![file])), + }, + Some(ProjectTarget::Step(i)) => match &mut steps[*i] { + OpenStep::Launch(_, launch) => launch.files.push(file.into()), + OpenStep::New(_, list) => list.push(file), + OpenStep::Focus(_) | OpenStep::Files(..) => unreachable!("folders opened by the plan launch or open"), + }, + None => loose.push(file), } } + steps.extend(in_windows.into_iter().map(|(label, files)| OpenStep::Files(label, files))); + if !loose.is_empty() { + let light = windows.iter().find(|(_, h)| *h == Holds::LightEdit).map(|(l, _)| l.clone()); + steps.push(match (light, free.next()) { + (Some(label), _) => OpenStep::Files(label, loose), + (None, Some(label)) => OpenStep::Launch(label, path_launch_of(None, loose)), + (None, None) => OpenStep::New(None, loose), + }); + } + steps +} + +fn path_launch_of(project: Option, files: Vec) -> Launch { + Launch { + project: project.map(Into::into), + files: files.into_iter().map(Into::into).collect(), + ..Default::default() + } +} + +/// Canonical existing `paths` with whether each is a folder; the others are logged and dropped. +fn resolve(paths: Vec) -> Vec<(PathBuf, bool)> { + paths + .into_iter() + .filter_map(|path| match dunce::canonicalize(&path) { + Ok(p) => { + let is_dir = p.is_dir(); + Some((p, is_dir)) + } + Err(e) => { + tracing::warn!("cannot open {}: {e}", path.display()); + None + } + }) + .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 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) { + let preferred = target_window(app).map(|w| w.label().to_owned()); + let mut labels = project_window_labels(app); + if let Some(i) = preferred.and_then(|p| labels.iter().position(|l| *l == p)) { + let first = labels.remove(i); + labels.insert(0, first); + } + let windows: Vec<(String, Holds)> = labels.into_iter().map(|l| (l.clone(), holds(app, &l))).collect(); + run_open(app, plan_open(resolve(paths), &windows)); +} + +/// [`open_paths`] while the app is starting, before the `main` window exists: it takes them as its launch. +pub fn open_paths_at_launch(app: &AppHandle, paths: Vec) { + let main = pending_holds(app.state::().pending(MAIN).as_ref()); + run_open(app, plan_open(resolve(paths), &[(MAIN.to_owned(), main)])); +} + +fn run_open(app: &AppHandle, steps: Vec) { + let windows = app.state::(); + for step in steps { + match step { + OpenStep::Focus(label) => { + if let Some(w) = app.get_webview_window(&label) { + focus(&w); + } + } + OpenStep::Files(label, files) => match app.get_webview_window(&label) { + Some(w) => send_files(app, &w, files), + None => windows.add_files(&label, files), + }, + OpenStep::Launch(label, launch) => { + 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), + } + } +} + +/// Local files and folders of the URLs macOS asks the app to open (Finder's Open With, the Dock). +#[cfg(target_os = "macos")] +pub fn opened_paths(urls: &[tauri::Url]) -> Vec { + urls.iter().filter_map(|u| u.to_file_path().ok()).collect() } /// What a window opens for a command-line path: a folder as its project, a file in LightEdit. @@ -650,6 +814,124 @@ mod tests { assert_eq!((launch.project, launch.files), (None, vec![file.into()])); } + fn p(s: &str) -> PathBuf { + PathBuf::from(s) + } + + fn w(label: &str, holds: Holds) -> (String, Holds) { + (label.to_owned(), holds) + } + + #[test] + fn opening_at_launch_gives_the_first_window_files_or_folder() { + let main = [w("main", Holds::Nothing)]; + let files = plan_open(vec![(p("/x/a.txt"), false), (p("/y/b.rs"), false)], &main); + assert_eq!(files, [OpenStep::Launch("main".into(), path_launch_of(None, vec![p("/x/a.txt"), p("/y/b.rs")]))]); + let dir = plan_open(vec![(p("/proj"), true)], &main); + assert_eq!(dir, [OpenStep::Launch("main".into(), path_launch_of(Some(p("/proj")), Vec::new()))]); + // Folder and files: the folder takes the first window, the files a new LightEdit one. + let both = plan_open(vec![(p("/x/a.txt"), false), (p("/proj"), true)], &main); + assert_eq!( + both, + [ + OpenStep::Launch("main".into(), path_launch_of(Some(p("/proj")), Vec::new())), + OpenStep::New(None, vec![p("/x/a.txt")]), + ] + ); + } + + #[test] + fn files_opened_at_launch_join_files_from_the_command_line() { + let cli = path_launch_of(None, vec![p("/x/a.txt")]); + let main = [w("main", pending_holds(Some(&cli)))]; + assert_eq!( + plan_open(vec![(p("/x/b.txt"), false)], &main), + [OpenStep::Files("main".into(), vec![p("/x/b.txt")])] + ); + let st = WindowsState::default(); + st.set_launch("main", cli); + st.add_files("main", vec![p("/x/b.txt")]); + assert_eq!(st.take_launch("main").unwrap().files, [p("/x/a.txt"), p("/x/b.txt")]); + } + + #[test] + fn files_inside_an_open_project_open_in_its_window_and_the_others_in_light_edit() { + let windows = [ + w("main", Holds::Project(p("/proj"))), + w("project-1", Holds::LightEdit), + w("project-2", Holds::Project(p("/proj/sub"))), + ]; + let steps = plan_open( + vec![ + (p("/proj/src/a.rs"), false), + (p("/x/b.txt"), false), + (p("/proj/sub/c.rs"), false), + (p("/projects/d.rs"), false), + (p("/proj/e.rs"), false), + ], + &windows, + ); + assert_eq!( + steps, + [ + OpenStep::Files("main".into(), vec![p("/proj/src/a.rs"), p("/proj/e.rs")]), + // The deepest root wins. + OpenStep::Files("project-2".into(), vec![p("/proj/sub/c.rs")]), + // `/projects` is not inside `/proj`. + OpenStep::Files("project-1".into(), vec![p("/x/b.txt"), p("/projects/d.rs")]), + ] + ); + } + + #[test] + fn files_inside_a_folder_being_opened_open_in_its_project() { + let opening = path_launch_of(Some(p("/a")), Vec::new()); + let windows = [w("main", pending_holds(Some(&opening))), w("project-1", Holds::Nothing)]; + let steps = plan_open(vec![(p("/a/x.rs"), false), (p("/b"), true), (p("/b/y.rs"), false)], &windows); + assert_eq!( + steps, + [ + OpenStep::Launch("project-1".into(), path_launch_of(Some(p("/b")), vec![p("/b/y.rs")])), + OpenStep::Files("main".into(), vec![p("/a/x.rs")]), + ] + ); + assert_eq!( + plan_open(vec![(p("/c"), true), (p("/c/z.rs"), false)], &[]), + [OpenStep::New(Some(p("/c")), vec![p("/c/z.rs")])] + ); + } + + #[test] + fn remote_projects_do_not_take_local_files() { + let windows = [w("main", Holds::Other), w("project-1", Holds::LightEdit)]; + assert_eq!( + plan_open(vec![(p("/home/me/a.rs"), false)], &windows), + [OpenStep::Files("project-1".into(), vec![p("/home/me/a.rs")])] + ); + } + + #[test] + fn files_opened_while_running_take_a_free_window_else_a_new_one() { + let free = [w("main", Holds::Project(p("/proj"))), w("project-1", Holds::Nothing)]; + assert_eq!( + plan_open(vec![(p("/x/a.txt"), false)], &free), + [OpenStep::Launch("project-1".into(), path_launch_of(None, vec![p("/x/a.txt")]))] + ); + let busy = [w("main", Holds::Project(p("/proj"))), w("project-1", Holds::Other)]; + assert_eq!(plan_open(vec![(p("/x/a.txt"), false)], &busy), [OpenStep::New(None, vec![p("/x/a.txt")])]); + } + + #[test] + fn folders_opened_while_running_focus_their_window_else_open_as_projects() { + let windows = [w("main", Holds::LightEdit), w("project-1", Holds::Project(p("/a")))]; + let steps = plan_open(vec![(p("/a"), true), (p("/b"), true), (p("/b"), true)], &windows); + assert_eq!(steps, [OpenStep::Focus("project-1".into()), OpenStep::New(Some(p("/b")), Vec::new())]); + // A window still opening the folder (pending launch) counts as having it. + let opening = path_launch_of(Some(p("/a")), Vec::new()); + let windows = [w("project-2", pending_holds(Some(&opening)))]; + assert_eq!(plan_open(vec![(p("/a"), true)], &windows), [OpenStep::Focus("project-2".into())]); + } + #[test] fn launches_are_taken_once() { let st = WindowsState::default(); diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 00000000..3993b304 --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,99 @@ +{ + "bundle": { + "fileAssociations": [ + { + "name": "Text and Source Code", + "ext": [], + "contentTypes": [ + "public.text", + "public.plain-text", + "public.source-code", + "public.script", + "public.shell-script", + "public.json", + "public.xml", + "public.yaml", + "public.python-script" + ], + "role": "Editor", + "rank": "Alternate" + }, + { + "name": "Source Code", + "ext": [ + "rs", + "go", + "py", + "pyi", + "pyw", + "cjs", + "scss", + "less", + "rst", + "tex", + "ps1", + "bat", + "fish", + "kt", + "kts", + "gradle", + "groovy", + "scala", + "sc", + "ts", + "mts", + "cts", + "jsx", + "vue", + "svelte", + "dart", + "cs", + "fs", + "fsx", + "hs", + "ex", + "exs", + "erl", + "clj", + "cljs", + "edn", + "lua", + "zig", + "nix", + "jl", + "ml", + "mli", + "tf", + "hcl", + "proto", + "graphql", + "gql", + "sql", + "http", + "properties", + "ini", + "cfg", + "conf", + "env", + "lock", + "log", + "diff", + "patch", + "gitignore", + "dockerignore", + "editorconfig", + "dockerfile" + ], + "role": "Editor", + "rank": "Alternate" + }, + { + "name": "Folder", + "ext": [], + "contentTypes": ["public.folder"], + "role": "Viewer", + "rank": "Alternate" + } + ] + } +} diff --git a/src/editor/core/controller.test.ts b/src/editor/core/controller.test.ts index 53048309..8079c4f8 100644 --- a/src/editor/core/controller.test.ts +++ b/src/editor/core/controller.test.ts @@ -65,6 +65,25 @@ describe("EditorController", () => { expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts", "/p/b.ts"]); }); + it("reopens the LightEdit files the next time a window switches to LightEdit", async () => { + ctrl = new EditorController(createMemoryBackend({ "/x/a.txt": "a", "/y/b.md": "b" })); + ctrl.start(); + useProject.setState({ lightEdit: true }); + await ctrl.openFile("/x/a.txt", { focus: false }); + await ctrl.openFile("/y/b.md", { focus: false }); + // The LightEdit window closes with its tabs open. + useProject.setState({ lightEdit: false }); + expect(openFilePaths(useTabs.getState())).toEqual([]); + useProject.setState({ lightEdit: true }); + expect(openFilePaths(useTabs.getState())).toEqual(["/x/a.txt", "/y/b.md"]); + // Closing every tab leaves nothing to reopen. + ctrl.closeEverything(); + useProject.setState({ lightEdit: false }); + useProject.setState({ lightEdit: true }); + expect(openFilePaths(useTabs.getState())).toEqual([]); + useProject.setState({ lightEdit: false }); + }); + it("closes a tab by its input key", async () => { ctrl = new EditorController(createMemoryBackend()); ctrl.start(); diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index a5f5efe2..c18c72ef 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -102,6 +102,9 @@ export function isTextInput(input: EditorInput): input is EditorInput & { path: export const useTabs = create(() => initialTabsState()); +/** Session key of the LightEdit window: its tabs reopen the next time a window switches to LightEdit. */ +export const LIGHT_EDIT_SESSION = ""; + export interface EditorStatus { path: string; doc: number; @@ -179,7 +182,7 @@ export class EditorController implements SlotHost { activeSlot: EditorSlot | null = null; private statusScheduled = false; private disposers: Array<() => void> = []; - /** Project root whose editor session is being tracked (null while switching). */ + /** Project root (or {@link LIGHT_EDIT_SESSION}) whose editor session is being tracked (null while switching). */ private sessionRoot: string | null = null; private sessionTimer: ReturnType | undefined; /** Restored caret / scroll per path, applied when the file's first slot is created. */ @@ -237,6 +240,7 @@ export class EditorController implements SlotHost { this.nav.clear(); this.edits.clear(); if (root) this.restoreSession(root); + else if (s.lightEdit) this.restoreSession(LIGHT_EDIT_SESSION); } }), ); diff --git a/src/features/app/__tests__/lightEditClose.test.ts b/src/features/app/__tests__/lightEditClose.test.ts new file mode 100644 index 00000000..a002941f --- /dev/null +++ b/src/features/app/__tests__/lightEditClose.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useProject } from "@/platform/project"; +import { provideEditorService, type EditorInput, type OpenEditorTab } from "@/workbench/contributions"; +import { installLightEditAutoClose } from "../windows"; + +/** An editor service whose tabs the test drives. */ +function fakeEditor() { + const listeners = new Set<(input: EditorInput | null) => void>(); + let tabs: string[] = []; + provideEditorService({ + openFile: async () => {}, + openInput: async () => {}, + activePath: () => tabs.at(-1) ?? null, + activeCursor: () => null, + saveAll: async () => {}, + openTabs: () => tabs.map((path) => ({ input: { kind: "file", key: path, path } }) as OpenEditorTab), + onActiveInputChange: (fn) => { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }); + return { + setTabs(next: string[]) { + tabs = next; + const path = next.at(-1); + for (const fn of listeners) fn(path ? ({ kind: "file", key: path, path } as EditorInput) : null); + }, + }; +} + +describe("LightEdit window", () => { + const close = vi.fn(async () => {}); + let dispose: () => void = () => {}; + beforeEach(() => close.mockClear()); + afterEach(() => { + dispose(); + useProject.setState({ project: null, lightEdit: false }); + }); + + it("closes when its last editor tab closes", () => { + const editor = fakeEditor(); + useProject.setState({ project: null, lightEdit: true }); + dispose = installLightEditAutoClose(close); + editor.setTabs(["/x/a.txt", "/x/b.txt"]); + editor.setTabs(["/x/a.txt"]); + expect(close).not.toHaveBeenCalled(); + editor.setTabs([]); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("stays open in a project window or while leaving LightEdit", () => { + const editor = fakeEditor(); + dispose = installLightEditAutoClose(close); + useProject.setState({ project: { root: "/p", name: "p" }, lightEdit: false } as never); + editor.setTabs(["/p/a.rs"]); + editor.setTabs([]); + // Closing the project (or the LightEdit session) closes every tab after `lightEdit` is off. + useProject.setState({ project: null, lightEdit: false }); + editor.setTabs([]); + expect(close).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/app/index.ts b/src/features/app/index.ts index 687d3d63..a5cb87d2 100644 --- a/src/features/app/index.ts +++ b/src/features/app/index.ts @@ -15,7 +15,7 @@ import { installMenuBridge } from "./menuBridge"; import { NewProjectOverlay } from "./NewProject"; import { projectActions, registerPowerSaveBanner } from "./projectActions"; import { installWindowTitle } from "./title"; -import { installLaunchHandling, windowActions } from "./windows"; +import { installLaunchHandling, installLightEditAutoClose, windowActions } from "./windows"; /** The UI locale of a secondary (Settings) window. */ export const activateLocale = installLocaleSync; @@ -42,6 +42,7 @@ export function activate(): () => void { installWindowTitle(), installCloseGuard(), installLaunchHandling(), + installLightEditAutoClose(), ]; let disposed = false; void buildInfo().then((b) => { diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 6d720694..09c3c66f 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -296,6 +296,25 @@ export function windowActions(): Action[] { ]; } +async function closeThisWindow(): Promise { + if (!isTauri()) return; + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + await getCurrentWindow().close(); +} + +/** + * LightEdit: closing its last editor tab closes the window (through the close guard, + * so the last window shows the Welcome screen instead). + */ +export function installLightEditAutoClose(closeWindow: () => Promise = closeThisWindow): () => void { + const editor = maybeEditorService(); + const off = editor?.onActiveInputChange?.((input) => { + if (input || !useProject.getState().lightEdit || editor?.openTabs?.().length) return; + void closeWindow().catch((e) => console.warn("could not close the window", e)); + }); + return () => off?.(); +} + export function installLaunchHandling(): () => void { if (!isTauri()) return () => {}; let disposed = false; diff --git a/src/features/welcome/session.test.ts b/src/features/welcome/session.test.ts index a45f5413..45c464d3 100644 --- a/src/features/welcome/session.test.ts +++ b/src/features/welcome/session.test.ts @@ -46,6 +46,13 @@ describe("reopenLastProject", () => { expect(openProject).not.toHaveBeenCalled(); }); + it("leaves files opened at launch in LightEdit", async () => { + provideStartupLaunch(Promise.resolve().then(() => useProject.setState({ lightEdit: true }))); + await reopenLastProject(); + expect(openProject).not.toHaveBeenCalled(); + useProject.setState({ lightEdit: false }); + }); + it("reopens the last project when the launch named none", async () => { provideStartupLaunch(Promise.resolve()); await reopenLastProject(); diff --git a/src/features/welcome/session.ts b/src/features/welcome/session.ts index cb6a1356..65c4b98a 100644 --- a/src/features/welcome/session.ts +++ b/src/features/welcome/session.ts @@ -106,9 +106,9 @@ export async function reopenLastProject(): Promise { /* use the local preference */ } if (!reopenEnabled(settings, useSession.getState().reopenPref)) return; - // A project passed on the command line wins over the last session's. + // A project or files passed on the command line (or from Finder) win over the last session's. await startupLaunchHandled(); - if (useProject.getState().project) return; + if (useProject.getState().project || useProject.getState().lightEdit) return; await reopen(path); } diff --git a/src/workbench/EditorArea.tsx b/src/workbench/EditorArea.tsx index 64f980da..6d55ce12 100644 --- a/src/workbench/EditorArea.tsx +++ b/src/workbench/EditorArea.tsx @@ -1,11 +1,14 @@ import { executeAction, useActions } from "@/platform/actions"; import { shortcutLabel } from "@/platform/keymap"; +import { useProject } from "@/platform/project"; import { T } from "@/ui"; import { useWorkbenchSlots } from "./contributions"; import { ErrorBoundary, Slot } from "./ErrorBoundary"; import styles from "./EditorArea.module.css"; -const HINTS: Array<{ label: string; action?: string; fallback?: string }> = [ +type Hint = { label: string; action?: string; fallback?: string }; + +const HINTS: Hint[] = [ { label: "Search Everywhere", action: "navigate.searchEverywhere", fallback: "Double ⇧" }, { label: "Go to File", action: "navigate.gotoFile" }, { label: "Recent Files", action: "navigate.recentFiles" }, @@ -13,13 +16,20 @@ const HINTS: Array<{ label: string; action?: string; fallback?: string }> = [ { label: "Drop files here to open them" }, ]; +/** LightEdit has no project to navigate: only opening files works there. */ +const LIGHT_EDIT_HINTS: Hint[] = [ + { label: "Open File", action: "file.openFile" }, + { label: "Drop files here to open them" }, +]; + /** IDEA's empty editor: a few navigation hints with their shortcuts. */ export function EmptyEditor() { const actions = useActions((s) => s.actions); + const lightEdit = useProject((s) => s.lightEdit); return (
- {HINTS.map((h) => { + {(lightEdit ? LIGHT_EDIT_HINTS : HINTS).map((h) => { const keys = h.action ? (shortcutLabel(h.action) ?? h.fallback) : undefined; const runnable = !!h.action && actions.has(h.action); return ( diff --git a/src/workbench/lightEdit.test.tsx b/src/workbench/lightEdit.test.tsx index fffebdc1..56075374 100644 --- a/src/workbench/lightEdit.test.tsx +++ b/src/workbench/lightEdit.test.tsx @@ -5,6 +5,7 @@ import { evaluateWhen } from "@/platform/context"; import { useProject } from "@/platform/project"; import { activateWorkbench } from "./activate"; import { registerStatusItem, registerTitleItem, registerToolWindow } from "./contributions"; +import { EmptyEditor } from "./EditorArea"; import { chooseProjectWindow } from "./projectWindow"; import { Workbench } from "./Workbench"; @@ -51,6 +52,21 @@ describe("LightEdit window", () => { expect(screen.queryByText("Welcome to OpenIDEA")).toBeNull(); }); + it("hints only at opening files in an empty editor", () => { + render(); + expect(screen.getByText("Open File")).toBeInTheDocument(); + expect(screen.getByText("Drop files here to open them")).toBeInTheDocument(); + for (const project of ["Search Everywhere", "Go to File", "Recent Files", "Navigation Bar"]) + expect(screen.queryByText(project)).toBeNull(); + }); + + it("keeps the navigation hints in a project window", () => { + useProject.setState({ lightEdit: false }); + render(); + expect(screen.getByText("Go to File")).toBeInTheDocument(); + expect(screen.queryByText("Open File")).toBeNull(); + }); + it("sets the context keys that keep project actions off", () => { const dispose = activateWorkbench(); expect(evaluateWhen("lightEdit")).toBe(true);