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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-28 23:56:23 +08:00
1 джерело 2d7726cf53
коміт 1d383e608c
12 files changed
+581 -42

No files matched your search

+62
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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);
}