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
This commit is contained in:
l0ng-ai
2026-10-28 23:56:23 +08:00
committed by GitHub
parent 2d7726cf53
commit 1d383e608c
12 changed files with 581 additions and 42 deletions
+21 -1
View File
@@ -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<Mutex<Option<Vec<PathBuf>>>> = 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::<WindowsState>().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::<WindowsState>().is_some_and(|w| w.restart_requested())
+317 -35
View File
@@ -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<PathBuf>) {
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<Launch> {
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<WebviewWindow> {
/// 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<PathBuf>) {
let launch = Launch { files: files.into_iter().map(Into::into).collect(), ..Default::default() };
app.state::<WindowsState>().set_launch(window.label(), launch);
app.state::<WindowsState>().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::<AppState>();
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::<WindowsState>().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<PathBuf>),
/// A free window opens this.
Launch(String, Launch),
/// A new window opens the project folder or (without one) the files in LightEdit.
New(Option<PathBuf>, Vec<PathBuf>),
}
/// 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<OpenStep> {
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::<WindowsState>().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<PathBuf>)> = 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<PathBuf>, files: Vec<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) {
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<PathBuf>) {
let main = pending_holds(app.state::<WindowsState>().pending(MAIN).as_ref());
run_open(app, plan_open(resolve(paths), &[(MAIN.to_owned(), main)]));
}
fn run_open(app: &AppHandle, steps: Vec<OpenStep>) {
let windows = app.state::<WindowsState>();
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<PathBuf> {
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();
+99
View File
@@ -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"
}
]
}
}
+19
View File
@@ -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();
+5 -1
View File
@@ -102,6 +102,9 @@ export function isTextInput(input: EditorInput): input is EditorInput & { path:
export const useTabs = create<TabsState>(() => initialTabsState());
/** Session key of the LightEdit window: its tabs reopen the next time a window switches to LightEdit. */
export const LIGHT_EDIT_SESSION = "<lightEdit>";
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<typeof setTimeout> | 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);
}
}),
);
@@ -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();
});
});
+2 -1
View File
@@ -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) => {
+19
View File
@@ -296,6 +296,25 @@ export function windowActions(): Action[] {
];
}
async function closeThisWindow(): Promise<void> {
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<void> = 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;
+7
View File
@@ -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();
+2 -2
View File
@@ -106,9 +106,9 @@ export async function reopenLastProject(): Promise<void> {
/* 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);
}
+12 -2
View File
@@ -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 (
<div className={styles.empty}>
<dl className={styles.hints}>
{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 (
+16
View File
@@ -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(<EmptyEditor />);
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(<EmptyEditor />);
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);