mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
+21
-1
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user