дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02: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
Цей коміт міститься в:
1 джерело
2d7726cf53
коміт
1d383e608c
12 files changed
+581
-42
No files matched your search
@@ -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
Заблокувати користувача