diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 50d35a6dc..363cb7335 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -52,6 +52,7 @@ import { removePane, reopenClosed, setActivePane, + splitAtEdge, sortTabs, splitPane, tabOptions, @@ -784,7 +785,7 @@ export class EditorController implements SlotHost { this.navSuppress++; try { let s = useTabs.getState(); - let paneId = s.activePane; + let paneId = o.pane && s.panes[o.pane] ? o.pane : s.activePane; if (o.side) { const right = neighbourPane(s, paneId); if (right) paneId = right; @@ -799,7 +800,7 @@ export class EditorController implements SlotHost { } const found = findTab(s, input.key, paneId); let tabId: string; - if (found && (!o.side || found.paneId === paneId)) { + if (found && (!(o.side || o.pane) || found.paneId === paneId)) { paneId = found.paneId; s = activateTab(setActivePane(s, paneId), paneId, found.tab.id); if (!o.preview) s = promoteTab(s, paneId, found.tab.id); @@ -935,6 +936,20 @@ export class EditorController implements SlotHost { if (slot?.view) slot.focus(); else if (slot) slot.wantsFocus = true; } + /** Files dropped on an editor: opened in that pane, or in a new split at `edge`. */ + async openFilesIn(paths: readonly string[], paneId: string, edge: SplitEdge | "center") { + const s = useTabs.getState(); + if (!s.panes[paneId] || !paths.length) return; + let target = paneId; + if (edge !== "center") { + const r = splitAtEdge(s, paneId, edge); + useTabs.setState(r.state, true); + target = r.paneId; + } + for (const [i, path] of paths.entries()) await this.openFile(path, { pane: target, focus: i === paths.length - 1 }); + const pane = useTabs.getState().panes[target]; + if (pane && !pane.tabs.length) this.apply((st) => removePane(st, target)); + } /** IDEA "Move to Opposite Group"; without a split, "Split and Move Right". */ moveToOpposite(paneId: string, tabId: string) { const target = neighbourPane(useTabs.getState(), paneId); diff --git a/src/editor/core/model/session.test.ts b/src/editor/core/model/session.test.ts index 27aab7a45..11697d129 100644 --- a/src/editor/core/model/session.test.ts +++ b/src/editor/core/model/session.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { initialTabsState, moveTabToNewSplit, openTab, paneIds, splitPane, togglePin, type TabsState } from "./tabs"; +import { initialTabsState, moveTabToNewSplit, openTab, paneIds, splitAtEdge, splitPane, togglePin, type TabsState } from "./tabs"; import { loadSession, parseSession, restoreTabsState, serializeSession, slashPath } from "./session"; const file = (path: string) => ({ kind: "file", key: `file:${path}`, path }); @@ -145,3 +145,15 @@ describe("sessions saved with Windows backslash paths", () => { } }); }); + +describe("splitAtEdge", () => { + it("adds an empty active pane on the given side", () => { + const s = open(initialTabsState(), "/a"); + const pane = s.activePane; + const r = splitAtEdge(s, pane, "right"); + expect(paneIds(r.state)).toEqual([pane, r.paneId]); + expect(r.state.panes[r.paneId]!.tabs).toEqual([]); + expect(r.state.activePane).toBe(r.paneId); + expect(paths(r.state, pane)).toEqual(["/a"]); + }); +}); diff --git a/src/editor/core/model/tabs.ts b/src/editor/core/model/tabs.ts index 5e6ef31fe..7a8a52c2a 100644 --- a/src/editor/core/model/tabs.ts +++ b/src/editor/core/model/tabs.ts @@ -425,21 +425,29 @@ export function moveTabToNewSplit( const tab = src?.tabs.find((t) => t.id === tabId); if (!src || !tab || !s.panes[targetPane]) return null; if (fromPane === targetPane && src.tabs.length === 1) return null; - let next = { ...removeTabs(s, fromPane, new Set([tabId])), closed: s.closed }; + const next = { ...removeTabs(s, fromPane, new Set([tabId])), closed: s.closed }; // The source pane may have collapsed away; split the target if it survived, else the active pane. const anchor = next.panes[targetPane] ? targetPane : next.activePane; - const id = nextId("pane"); const moved: Tab = { ...tab, preview: false }; - const pane: Pane = { id, tabs: [moved], active: moved.id, mru: [moved.id] }; + return splitAtEdge(next, anchor, edge, { tabs: [moved], active: moved.id, mru: [moved.id] }); +} + +/** A new pane (empty unless `content` is given) at `edge` of `anchor`, made active. */ +export function splitAtEdge( + s: TabsState, + anchor: string, + edge: SplitEdge, + content: Omit = { tabs: [], active: null, mru: [] }, +): { state: TabsState; paneId: string } { + const id = nextId("pane"); const dir = edge === "left" || edge === "right" ? "row" : "column"; const before = edge === "left" || edge === "top"; - const layout = mapLayout(next.layout, (n) => { + const layout = mapLayout(s.layout, (n) => { if (n.kind === "pane" && n.paneId === anchor) { const added: LayoutNode = { kind: "pane", paneId: id }; return { kind: "split", id: nextId("split"), dir, children: before ? [added, n] : [n, added] }; } return n; })!; - next = { ...next, layout, panes: { ...next.panes, [id]: pane }, activePane: id }; - return { state: next, paneId: id }; + return { state: { ...s, layout, panes: { ...s.panes, [id]: { id, ...content } }, activePane: id }, paneId: id }; } diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index 18a223169..ba47dd505 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -4,7 +4,7 @@ * decorations, middle-click close, Alt+click close others, double-click to * keep a preview / maximize the editor, context menu) and the active editor. */ -import { Fragment, useMemo, useRef, useState, type DragEvent, type FocusEvent, type ReactNode } from "react"; +import { Fragment, useEffect, useMemo, useRef, useState, type FocusEvent, type ReactNode } from "react"; import clsx from "clsx"; import { useShallow } from "zustand/react/shallow"; import { executeAction } from "@/platform/actions"; @@ -32,6 +32,8 @@ import { SplitPanel, Tabs, showContextMenu, + registerDropTarget, + FILES_DRAG, type TabItem, type TabsDnd, } from "@/ui"; @@ -197,6 +199,33 @@ function PaneView({ paneId }: { paneId: string }) { return { all: strip(0), rest: strip(pinnedCount) }; }, [ctrl, paneId, pinnedCount]); + // Dropping a tab or project files on the editor: an edge splits, the middle opens / moves here. + const contentRef = useRef(null); + const hasPane = !!pane; + useEffect(() => { + const el = contentRef.current; + if (!el) return; + const zoneAt = (x: number, y: number) => dropZoneAt(x, y, el.getBoundingClientRect()); + return registerDropTarget(el, { + accepts: (type) => type === EDITOR_TAB_MIME || type === FILES_DRAG, + over: (_, x, y) => setDropZone(zoneAt(x, y)), + leave: () => setDropZone(null), + drop: (data, x, y) => { + const zone = zoneAt(x, y); + setDropZone(null); + if (data.type === FILES_DRAG) { + void ctrl.openFilesIn(data.payload.split("\n").filter(Boolean), paneId, zone); + return; + } + const p = parseTabPayload(data.payload); + if (!p) return; + const target = useTabs.getState().panes[paneId]; + if (zone === "center") ctrl.moveTab(p.paneId, p.tabId, paneId, target?.tabs.length ?? 0); + else ctrl.moveTabToSplit(p.paneId, p.tabId, paneId, zone); + }, + }); + }, [ctrl, paneId, hasPane]); + if (!pane) return null; const active = pane.tabs.find((t) => t.id === pane.active) ?? null; @@ -208,33 +237,6 @@ function PaneView({ paneId }: { paneId: string }) { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setContext("editorFocus", false); }; - // Dropping a tab on the editor: an edge splits, the middle moves the tab here. - const isTabDrag = (e: DragEvent) => [...e.dataTransfer.types].includes(EDITOR_TAB_MIME); - const contentDnd = { - onDragOverCapture: (e: DragEvent) => { - if (!isTabDrag(e)) return; - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = "move"; - const zone = dropZoneAt(e.clientX, e.clientY, e.currentTarget.getBoundingClientRect()); - if (zone !== dropZone) setDropZone(zone); - }, - onDragLeave: (e: DragEvent) => { - if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropZone(null); - }, - onDropCapture: (e: DragEvent) => { - if (!isTabDrag(e)) return; - e.preventDefault(); - e.stopPropagation(); - const zone = dropZoneAt(e.clientX, e.clientY, e.currentTarget.getBoundingClientRect()); - setDropZone(null); - const p = parseTabPayload(e.dataTransfer.getData(EDITOR_TAB_MIME)); - if (!p) return; - if (zone === "center") ctrl.moveTab(p.paneId, p.tabId, paneId, pane.tabs.length); - else ctrl.moveTabToSplit(p.paneId, p.tabId, paneId, zone); - }, - }; - const hidden = tabPrefs.placement === "none" || distractionFree; const strip = (stripItems: TabItem[], stripDnd: TabsDnd, trailing: ReactNode) => ( {active ? : } {dropZone &&