feat(editor): drag an editor tab out to a standalone window (#470)

Dragging an editor tab past the window edge, or choosing Open in New Window from its menu, now opens the file in its own window and closes the tab here. A text file is saved first and a custom document runs its Save / Do not Save / Cancel guard once; if saving or opening fails, the tab stays. The new window is forced even when the project is already open elsewhere, and a file outside the project opens as LightEdit.

Dragging out tool windows is not covered. Refs #454

Co-authored-by: a-rookie-of-C-language <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-10-31 00:57:53 +08:00
1 джерело 986b0ade58
коміт 2754cf327f
12 files changed
+345 -12

No files matched your search

+1
Переглянути файл
@@ -340,6 +340,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
sdk::sdk_python_pip,
window::window_open_project,
window::window_open_files,
window::window_open_file_in_new_window,
window::window_open_remote,
window::window_new,
window::window_open_settings,
+13
Переглянути файл
@@ -39,6 +39,19 @@ pub async fn window_open_files(
Ok(windows::open_files_window(&app, window.label(), files))
}
/// Detaches an editor tab: opens `file` in a forced new window (`project`, None
/// for LightEdit, decided by the caller), bypassing the focus-an-existing-window
/// routing of [`window_open_project`] / [`window_open_files`].
#[tauri::command]
#[specta::specta]
pub async fn window_open_file_in_new_window(
app: AppHandle,
project: Option<PathBuf>,
file: PathBuf,
) -> IdeResult<OpenOutcome> {
windows::open_file_in_new_window(&app, project, file)
}
/// [`window_open_project`] for folder `path` on the host of SSH configuration
/// `ssh_config` (Remote Development); a new window connects itself.
#[tauri::command]
+10
Переглянути файл
@@ -411,6 +411,16 @@ pub fn open_project_window(
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
}
/// Opens `file` in a forced new window: `project` (None: LightEdit) plus the
/// single file, always creating a window even when the project is already open
/// in another one (unlike [`open_project_window`], which focuses it). Used to
/// detach an editor tab out of its window.
pub fn open_file_in_new_window(app: &AppHandle, project: Option<PathBuf>, file: PathBuf) -> IdeResult<OpenOutcome> {
tracing::info!("open_file_in_new_window {} project={:?}", file.display(), project);
let window = create_window_with(app, project, vec![file])?;
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
}
/// Loose local `files` asked for by window `caller` (File | Open File, a drop on the Welcome
/// screen), routed like [`open_paths`]; returns the files `caller` opens itself.
pub fn open_files_window(app: &AppHandle, caller: &str, files: Vec<PathBuf>) -> Vec<ide_core::IpcPath> {
+6
Переглянути файл
@@ -686,6 +686,12 @@ export const commands = {
* itself, in LightEdit.
*/
windowOpenFiles: (files: string[]) => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("window_open_files", { files })),
/**
* Detaches an editor tab: opens `file` in a forced new window (`project`, None
* for LightEdit, decided by the caller), bypassing the focus-an-existing-window
* routing of [`window_open_project`] / [`window_open_files`].
*/
windowOpenFileInNewWindow: (project: string | null, file: string) => typedError<OpenOutcome, IdeError>(__TAURI_INVOKE("window_open_file_in_new_window", { project, file })),
/**
* [`window_open_project`] for folder `path` on the host of SSH configuration
* `ssh_config` (Remote Development); a new window connects itself.
+49 -1
Переглянути файл
@@ -7,6 +7,7 @@ import { EditorSelection } from "@codemirror/state";
import { create } from "zustand";
import { setContext } from "@/platform/context";
import { shortcutLabel } from "@/platform/keymap";
import { isSameOrInside } from "@/platform/paths";
import { basename, useProject } from "@/platform/project";
import { editorPrefs } from "@/platform/editorSettings";
import {
@@ -31,6 +32,8 @@ import {
import { useDistractionFree } from "@/workbench/distractionFree";
import { ask } from "@/workbench/messages";
import { t } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { isRemoteWindow } from "@/platform/remote";
import { fileIconName, showContextMenu, type MenuItem } from "@/ui";
import { usePlatform } from "@/platform/platformInfo";
import {
@@ -1059,7 +1062,7 @@ export class EditorController implements SlotHost {
/** Close exactly the originally selected tabs, evaluating references after every await. */
private removeGuarded(
select: (s: TabsState) => TabsState,
options: { protect?: (tab: Tab) => boolean; clearHistory?: boolean } = {},
options: { protect?: (tab: Tab) => boolean; clearHistory?: boolean; beforeRemove?: () => Promise<boolean> } = {},
): Promise<boolean> {
const generation = this.projectGeneration;
const initial = useTabs.getState();
@@ -1129,6 +1132,7 @@ export class EditorController implements SlotHost {
}
authorized.add(d.input.key);
}
if (options.beforeRemove && !(await options.beforeRemove())) return false;
// Commit only the original target ids against the latest tab state.
if (generation !== this.projectGeneration) return false;
const before = useTabs.getState();
@@ -1303,6 +1307,46 @@ export class EditorController implements SlotHost {
changeSplitOrientation(paneId: string) {
this.apply((s) => changeSplitOrientation(s, paneId));
}
/** Save or resolve the dirty guard, open the window, then remove the original tab. */
async detachTab(paneId: string, tabId: string): Promise<boolean> {
const tab = useTabs.getState().panes[paneId]?.tabs.find((t) => t.id === tabId);
const path = tab?.input.path;
if (
!tab ||
!path ||
(tab.input.kind !== "file" && !documentLifecycle(tab.input, useContributions.getState().editorKinds))
)
return false;
if (isRemoteWindow()) {
notifications().notify({ level: "warning", title: t("app.window.desktopOnly") });
return false;
}
const project = useProject.getState().project;
const inProject =
!!project && (project.workspace?.projects ?? [project.root]).some((root) => isSameOrInside(path, root));
return this.removeGuarded((s) => (s.panes[paneId] ? closeTab(s, paneId, tabId) : s), {
beforeRemove: async () => {
if (!useTabs.getState().panes[paneId]?.tabs.some((t) => t.id === tabId)) return false;
if (isTextTab(tab)) {
const slot = await this.ensureSlot(tabId);
if (!slot) return false;
await slot.model.save("explicit");
}
try {
await call(commands.windowOpenFileInNewWindow(inProject ? project.root : null, path));
return true;
} catch (e) {
notifications().notify({
level: "error",
title: t("app.window.openFailed", { name: basename(path) }),
body: e instanceof Error ? e.message : String(e),
key: `app.open:${path}`,
});
return false;
}
},
});
}
/** IDEA "Open in Opposite Group": the tab stays and a copy opens next door (or in a new split). */
openInOpposite(paneId: string, tabId: string) {
const s = useTabs.getState();
@@ -1603,6 +1647,10 @@ export class EditorController implements SlotHost {
{ label: "Unsplit All", run: () => void this.unsplitAll(paneId) },
] as MenuItem[])
: []),
...(tab.input.path &&
(tab.input.kind === "file" || documentLifecycle(tab.input, useContributions.getState().editorKinds))
? ([{ label: "Open in New Window", run: () => void this.detachTab(paneId, tab.id) }] as MenuItem[])
: []),
{ kind: "separator" },
...(path
? ([
+179
Переглянути файл
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorController, useTabs } from "./controller";
import { createMemoryBackend } from "./backend";
import { initialTabsState } from "./model/tabs";
import { useProject } from "@/platform/project";
import { commands } from "@/platform/ipc";
import * as contributions from "@/workbench/contributions";
import { ask } from "@/workbench/messages";
import type * as Messages from "@/workbench/messages";
import { registerEditorKind, type EditorDocumentLifecycle } from "@/workbench/contributions";
vi.mock("@/workbench/messages", async (original) => ({
...(await original<typeof Messages>()),
ask: vi.fn(),
}));
const notify = vi.fn();
let ctrl: EditorController;
let unregister: (() => void) | undefined;
const path = "/p/a.ts";
const current = () => {
const s = useTabs.getState();
return [s.activePane, s.panes[s.activePane]!.active!] as const;
};
const hasTab = (id: string) => Object.values(useTabs.getState().panes).some((p) => p.tabs.some((t) => t.id === id));
beforeEach(() => {
notify.mockReset();
vi.spyOn(contributions, "notifications").mockReturnValue({ notify });
useTabs.setState(initialTabsState(), true);
useProject.setState({ project: null, settings: { files: { autoSave: false } } as never });
vi.spyOn(commands, "windowOpenFileInNewWindow").mockResolvedValue({
status: "ok",
data: { kind: "opened", label: "second" },
});
});
afterEach(() => {
ctrl?.stop();
unregister?.();
unregister = undefined;
vi.restoreAllMocks();
vi.mocked(ask).mockReset();
useProject.setState({ project: null, settings: null });
});
describe("detachTab", () => {
it.each([false, true])("waits for text save before opening and closing, autosave=%s", async (autoSave) => {
useProject.setState({ settings: { files: { autoSave } } as never });
const backend = createMemoryBackend({ [path]: "original\n" });
ctrl = new EditorController(backend);
ctrl.start();
await ctrl.openFile(path, { focus: false });
const [pane, id] = current();
ctrl.slotFor(id)!.dispatchLocal({ changes: { from: 0, to: 8, insert: "edited" } });
let finish!: () => void;
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const originalSave = backend.save;
const save = vi.spyOn(backend, "save").mockImplementation(async (doc) => {
await gate;
await originalSave(doc);
});
vi.mocked(commands.windowOpenFileInNewWindow).mockImplementation(async () => {
expect(hasTab(id)).toBe(true);
expect((await backend.snapshot(ctrl.slotFor(id)!.model.doc)).text).toBe("edited\n");
return { status: "ok", data: { kind: "opened", label: "second" } };
});
const result = ctrl.detachTab(pane, id);
await vi.waitFor(() => expect(save).toHaveBeenCalled());
expect(commands.windowOpenFileInNewWindow).not.toHaveBeenCalled();
expect(hasTab(id)).toBe(true);
finish();
expect(await result).toBe(true);
expect(hasTab(id)).toBe(false);
expect(ask).not.toHaveBeenCalled();
});
it("keeps the tab and opens no window when text saving fails", async () => {
const backend = createMemoryBackend({ [path]: "original\n" });
ctrl = new EditorController(backend);
ctrl.start();
await ctrl.openFile(path, { focus: false });
const [pane, id] = current();
vi.spyOn(backend, "save").mockRejectedValue(new Error("save failed"));
expect(await ctrl.detachTab(pane, id)).toBe(false);
expect(hasTab(id)).toBe(true);
expect(commands.windowOpenFileInNewWindow).not.toHaveBeenCalled();
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", body: "save failed" }));
});
it("keeps a pinned text tab if window creation fails", async () => {
ctrl = new EditorController(createMemoryBackend({ [path]: "original\n" }));
ctrl.start();
await ctrl.openFile(path, { focus: false });
const [pane, id] = current();
ctrl.togglePin(pane, id);
vi.mocked(commands.windowOpenFileInNewWindow).mockRejectedValue(new Error("window failed"));
expect(await ctrl.detachTab(pane, id)).toBe(false);
expect(useTabs.getState().panes[pane]!.tabs.find((t) => t.id === id)?.pinned).toBe(true);
});
it.each(["save", "discard", "cancel", "save failure", "window failure"])(
"runs the custom document guard once: %s",
async (decision) => {
const events: string[] = [];
const lifecycle: EditorDocumentLifecycle = {
isDirty: () => true,
flush: async () => {},
prepareClose: async () => {
events.push("prepare");
},
cancelClose: () => {
events.push("cancelClose");
},
save: async () => {
events.push("save");
if (decision === "save failure") throw new Error("save failed");
},
discard: async () => {
events.push("discard");
},
close: async () => {
events.push("close");
},
};
unregister = registerEditorKind({
kind: "detach-test",
title: () => "test",
component: () => null,
document: lifecycle,
});
ctrl = new EditorController(createMemoryBackend());
ctrl.start();
await ctrl.openInput({ kind: "detach-test", key: "custom", path: "/p/a.ipynb" }, { focus: false });
const [pane, id] = current();
vi.mocked(ask).mockImplementation(async () => {
events.push("ask");
expect(commands.windowOpenFileInNewWindow).not.toHaveBeenCalled();
return {
button: decision === "save failure" || decision === "window failure" ? "save" : decision,
checked: false,
};
});
vi.mocked(commands.windowOpenFileInNewWindow).mockImplementation(async () => {
events.push("open");
expect(hasTab(id)).toBe(true);
if (decision === "window failure") throw new Error("window failed");
return { status: "ok", data: { kind: "opened", label: "second" } };
});
const success = decision === "save" || decision === "discard";
expect(await ctrl.detachTab(pane, id)).toBe(success);
expect(hasTab(id)).toBe(!success);
expect(ask).toHaveBeenCalledOnce();
expect(events).toEqual(
success
? ["prepare", "ask", decision, "open", "close"]
: decision === "cancel"
? ["prepare", "ask", "cancelClose"]
: decision === "save failure"
? ["prepare", "ask", "save", "cancelClose"]
: ["prepare", "ask", "save", "open", "cancelClose"],
);
},
);
it.each([
["C:\\project\\a.ts", ["C:/project"], "C:/project"],
["C:\\other\\a.ts", ["C:/project", "C:/other"], "C:/project"],
["C:\\project-other\\a.ts", ["C:/project"], null],
])("routes %s by workspace containment", async (file, projects, expected) => {
useProject.setState({ project: { root: "C:/project", workspace: { projects } } as never });
ctrl = new EditorController(createMemoryBackend({ [file]: "text\n" }));
ctrl.start();
await ctrl.openFile(file, { focus: false });
expect(await ctrl.detachTab(...current())).toBe(true);
expect(commands.windowOpenFileInNewWindow).toHaveBeenCalledWith(expected, file);
});
});
+6
Переглянути файл
@@ -198,6 +198,11 @@ function PaneView({ paneId }: { paneId: string }) {
const pinnedRow =
tabPrefs.pinnedSeparateRow && (vertical || !tabPrefs.multipleRows) && pinnedCount > 0 && pinnedCount < tabs.length;
const dnd = useMemo(() => {
// A tab released outside the window detaches into a forced new window.
const dropOutside = (raw: string) => {
const p = parseTabPayload(raw);
if (p) void ctrl.detachTab(p.paneId, p.tabId);
};
const strip = (offset: number): TabsDnd => ({
type: EDITOR_TAB_MIME,
payload: (tabId) => JSON.stringify({ paneId, tabId }),
@@ -205,6 +210,7 @@ function PaneView({ paneId }: { paneId: string }) {
const p = parseTabPayload(raw);
if (p) ctrl.moveTab(p.paneId, p.tabId, paneId, index + offset);
},
onDropOutside: dropOutside,
});
return { all: strip(0), rest: strip(pinnedCount) };
}, [ctrl, paneId, pinnedCount]);
+2 -5
Переглянути файл
@@ -1,4 +1,6 @@
/** POSIX path helpers for backend paths (the backend always reports `/`-separated absolute paths). */
import { isSameOrInside } from "@/platform/paths";
export { isSameOrInside } from "@/platform/paths";
export function joinPath(dir: string, name: string): string {
if (!name) return dir;
@@ -15,11 +17,6 @@ export function nameOf(path: string): string {
return i < 0 ? path : path.slice(i + 1);
}
/** True when `path` equals `ancestor` or lies below it. */
export function isSameOrInside(path: string, ancestor: string): boolean {
return path === ancestor || path.startsWith(ancestor.endsWith("/") ? ancestor : ancestor + "/");
}
export function isInside(path: string, ancestor: string): boolean {
return path !== ancestor && isSameOrInside(path, ancestor);
}
+7
Переглянути файл
@@ -20,3 +20,10 @@ export function ipcPath(p: string): string {
if (path.startsWith("\\\\?\\")) path = path.startsWith("\\\\?\\UNC\\") ? `\\\\${path.slice(8)}` : path.slice(4);
return DRIVE.test(path) || NETWORK.test(path) ? path.replace(/\\/g, "/") : path;
}
/** True when `path` equals `ancestor` or lies below it. */
export function isSameOrInside(path: string, ancestor: string): boolean {
path = ipcPath(path);
ancestor = ipcPath(ancestor);
return path === ancestor || path.startsWith(ancestor.endsWith("/") ? ancestor : ancestor + "/");
}
+23 -2
Переглянути файл
@@ -20,7 +20,7 @@ import { Icon, type IconName } from "./icons";
import { IconButton } from "./Button";
import { Menu, type MenuItem } from "./Menu";
import { tooltipAttrs } from "./Tooltip";
import { registerDropTarget, startPointerDrag } from "./pointerDrag";
import { isOutsideWindow, registerDropTarget, startPointerDrag } from "./pointerDrag";
import { T } from "./T";
import styles from "./Tabs.module.css";
import { tx } from "@/platform/i18n";
@@ -53,6 +53,12 @@ export interface TabsDnd {
type: string;
payload: (id: string) => string;
onDrop: (payload: string, index: number) => void;
/**
* A dragged tab released with no accepting target (the pointer left the
* window): the editor uses it to detach the tab into a new window. Gets the
* dragged tab's payload and the release point in client coordinates.
*/
onDropOutside?: (payload: string, x: number, y: number) => void;
}
/** Insertion index for a drop at `x` given the tabs' horizontal extents. Exported for tests. */
@@ -288,7 +294,22 @@ export function Tabs({
{ type: dnd.type, payload: dnd.payload(item.id) },
{
onStart: () => setDragging(item.id),
onEnd: () => setDragging(null),
onEnd: (dragged, drop) => {
setDragging(null);
if (!dragged || !drop || drop.accepted) return;
const outside = dnd.onDropOutside;
if (!outside) return;
const el = document.documentElement;
const r = el.getBoundingClientRect();
// The ghost (fixed, translated from the tab) never
// covers the release point, so a miss outside the
// viewport means the pointer left the window.
const rect =
r.width || r.height
? r
: { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
if (isOutsideWindow(rect, drop.x, drop.y)) outside(dnd.payload(item.id), drop.x, drop.y);
},
},
);
}
+19
Переглянути файл
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { isOutsideWindow } from "./pointerDrag";
const viewport = { left: 0, top: 0, right: 1280, bottom: 800 };
describe("isOutsideWindow", () => {
it("treats the viewport interior as inside (issue #454: drag a tab out)", () => {
expect(isOutsideWindow(viewport, 640, 400)).toBe(false);
expect(isOutsideWindow(viewport, 0, 0)).toBe(false);
expect(isOutsideWindow(viewport, 1279, 799)).toBe(false);
});
it("treats every side of the viewport as outside", () => {
expect(isOutsideWindow(viewport, -1, 400)).toBe(true);
expect(isOutsideWindow(viewport, 1280, 400)).toBe(true);
expect(isOutsideWindow(viewport, 640, -1)).toBe(true);
expect(isOutsideWindow(viewport, 640, 800)).toBe(true);
});
});
+30 -4
Переглянути файл
@@ -51,6 +51,19 @@ export interface DragSession {
cancel(): void;
}
/**
* Whether client point (`x`, `y`) is outside `rect` (the window's viewport):
* a tab drag released there detaches into a new window instead of reordering.
* Pure, so detach tests don't need a DOM.
*/
export function isOutsideWindow(
rect: { left: number; top: number; right: number; bottom: number },
x: number,
y: number,
): boolean {
return x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom;
}
export function dragSession(data: DragData): DragSession {
let current: DropTarget | null = null;
const enter = (t: DropTarget | null) => {
@@ -84,16 +97,25 @@ export function setDragRequiresAlt(fn: () => boolean) {
}
export const dragRequiresAlt = () => requiresAlt();
export interface PointerDrop {
x: number;
y: number;
/** True when a registered target accepted the drop. */
accepted: boolean;
}
/**
* Starts a drag from a primary-button press on `source` once the pointer moved
* past the threshold; a ghost copy of `source` follows the pointer. Escape
* cancels. `onEnd` gets whether the drag happened (to swallow the click).
* cancels. `onEnd` gets whether the drag happened (to swallow the click) and,
* when it did, where the pointer was released and whether a target accepted it
* (an editor tab released outside the window detaches into a new window).
*/
export function startPointerDrag(
e: { button: number; clientX: number; clientY: number; altKey: boolean },
source: HTMLElement,
data: DragData,
opts: { onStart?: () => void; onEnd?: (dragged: boolean) => void } = {},
opts: { onStart?: () => void; onEnd?: (dragged: boolean, drop?: PointerDrop) => void } = {},
) {
if (e.button !== 0 || (requiresAlt() && !e.altKey)) return;
const start = { x: e.clientX, y: e.clientY };
@@ -124,8 +146,12 @@ export function startPointerDrag(
const onUp = (ev: PointerEvent) => {
const dragged = !!ghost;
cleanup();
if (dragged) session.drop(ev.clientX, ev.clientY);
opts.onEnd?.(dragged);
if (!dragged) {
opts.onEnd?.(false);
return;
}
const accepted = session.drop(ev.clientX, ev.clientY);
opts.onEnd?.(true, { x: ev.clientX, y: ev.clientY, accepted });
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key !== "Escape" || !ghost) return;