diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 909118a4..676a8664 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -781,6 +781,9 @@ pub fn spec(os: Os) -> Vec { act("editor.splitDown", "Split Down"), act("editor.unsplit", "Unsplit"), act("editor.moveToOppositeGroup", "Move to Opposite Group"), + act("editor.openInOppositeGroup", "Open in Opposite Group"), + act("editor.changeSplitOrientation", "Change Splitter Orientation"), + act("editor.unsplitAll", "Unsplit All"), act("editor.nextSplitter", "Go to Next Splitter"), act("editor.prevSplitter", "Go to Previous Splitter"), ], diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index 018dd05b..2c8c570c 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -904,6 +904,30 @@ export function registerEditorActions(): () => void { when: "editorSplit", run: () => ctrl.unsplit(useTabs.getState().activePane), }, + { + id: "editor.unsplitAll", + title: "Unsplit All", + category: "Window", + when: "editorSplit", + run: () => ctrl.unsplitAll(useTabs.getState().activePane), + }, + { + id: "editor.changeSplitOrientation", + title: "Change Splitter Orientation", + category: "Window", + when: "editorSplit", + run: () => ctrl.changeSplitOrientation(useTabs.getState().activePane), + }, + { + id: "editor.openInOppositeGroup", + title: "Open in Opposite Group", + category: "Window", + when: TABS_WHEN, + run: () => { + const t = activePaneTab(); + if (t) ctrl.openInOpposite(t.paneId, t.tabId); + }, + }, { id: "editor.copyPath", title: "Copy Path", diff --git a/src/editor/core/controller.test.ts b/src/editor/core/controller.test.ts index 8079c4f8..21ead41a 100644 --- a/src/editor/core/controller.test.ts +++ b/src/editor/core/controller.test.ts @@ -65,6 +65,23 @@ describe("EditorController", () => { expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts", "/p/b.ts"]); }); + it("opens a copy of a tab in the opposite group, splitting when there is none", async () => { + ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "", "/p/b.ts": "" })); + ctrl.start(); + await ctrl.openFile("/p/a.ts", { focus: false }); + await ctrl.openFile("/p/b.ts", { focus: false }); + const paths = (paneId: string) => useTabs.getState().panes[paneId]!.tabs.map((t) => t.input.path); + const first = useTabs.getState().activePane; + const tabA = useTabs.getState().panes[first]!.tabs[0]!.id; + ctrl.openInOpposite(first, tabA); + const second = useTabs.getState().activePane; + expect(second).not.toBe(first); + expect(paths(second)).toEqual(["/p/a.ts"]); + ctrl.openInOpposite(first, useTabs.getState().panes[first]!.tabs[1]!.id); + expect(paths(second)).toEqual(["/p/a.ts", "/p/b.ts"]); + expect(paths(first)).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(); diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index a19d8c70..deca0802 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -31,6 +31,7 @@ import { fileIconName, showContextMenu, type MenuItem } from "@/ui"; import { usePlatform } from "@/platform/platformInfo"; import { activateTab, + changeSplitOrientation, closeAll, closeKey, closeOthers, @@ -57,6 +58,7 @@ import { splitPane, tabOptions, togglePin, + unsplitAll, type SplitEdge, type Tab, type TabsState, @@ -1009,6 +1011,30 @@ export class EditorController implements SlotHost { unsplit(paneId: string) { this.apply((s) => removePane(s, paneId)); } + unsplitAll(paneId: string) { + this.apply((s) => unsplitAll(s, paneId)); + this.focus(); + } + changeSplitOrientation(paneId: string) { + this.apply((s) => changeSplitOrientation(s, paneId)); + } + /** 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(); + const tab = s.panes[paneId]?.tabs.find((t) => t.id === tabId); + if (!tab) return; + const target = neighbourPane(s, paneId); + if (!target) { + this.activate(paneId, tabId, false); + return this.split(paneId, "row"); + } + const r = openTab(s, target, tab.input); + useTabs.setState(r.state, true); + void this.ensureSlot(r.tabId, { from: this.slots.get(tabId) }).then((slot) => { + if (slot?.view) slot.focus(); + else if (slot) slot.wantsFocus = true; + }); + } closeActiveTab() { const s = useTabs.getState(); @@ -1183,7 +1209,10 @@ export class EditorController implements SlotHost { ...(split ? ([ { label: "Move to Opposite Group", run: () => this.moveToOpposite(paneId, tab.id) }, + { label: "Open in Opposite Group", run: () => this.openInOpposite(paneId, tab.id) }, + { label: "Change Splitter Orientation", run: () => this.changeSplitOrientation(paneId) }, { label: "Unsplit", run: () => this.unsplit(paneId) }, + { label: "Unsplit All", run: () => this.unsplitAll(paneId) }, ] as MenuItem[]) : []), { kind: "separator" }, diff --git a/src/editor/core/model/model.test.ts b/src/editor/core/model/model.test.ts index b432a8eb..062afa2c 100644 --- a/src/editor/core/model/model.test.ts +++ b/src/editor/core/model/model.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + changeSplitOrientation, closeAll, closeKey, closeOthers, @@ -19,6 +20,7 @@ import { splitPane, tabOptions, togglePin, + unsplitAll, type TabsState, } from "./tabs"; import { NavigationHistory } from "./navHistory"; @@ -168,6 +170,29 @@ describe("tab model", () => { expect(s.layout).toEqual({ kind: "pane", paneId: split.paneId }); }); + it("flips the orientation of a split and unsplits everything", () => { + let s = open(initialTabsState(), "a"); + const first = s.activePane; + const right = splitPane(s, first, "row"); + const below = splitPane(right.state, right.paneId, "column"); + s = below.state; + const dirs = (st: TabsState) => + st.layout.kind === "split" + ? [st.layout.dir, ...st.layout.children.map((c) => (c.kind === "split" ? c.dir : "pane"))] + : []; + expect(dirs(s)).toEqual(["row", "pane", "column"]); + // The nested split turns side by side and merges into the outer row. + s = changeSplitOrientation(s, below.paneId); + expect(dirs(s)).toEqual(["row", "pane", "pane", "pane"]); + s = changeSplitOrientation(s, first); + expect(dirs(s)).toEqual(["column", "pane", "pane", "pane"]); + expect(changeSplitOrientation(open(initialTabsState(), "x"), "nope").layout.kind).toBe("pane"); + s = unsplitAll(s, right.paneId); + expect(paneIds(s)).toEqual([right.paneId]); + expect(s.activePane).toBe(right.paneId); + expect(labels(s)).toEqual(["a"]); + }); + it("closes a key everywhere and cycles tabs", () => { let s = initialTabsState(); s = open(s, "a"); diff --git a/src/editor/core/model/tabs.ts b/src/editor/core/model/tabs.ts index 0ed7a538..2dcb9ee6 100644 --- a/src/editor/core/model/tabs.ts +++ b/src/editor/core/model/tabs.ts @@ -437,6 +437,30 @@ export function removePane(s: TabsState, paneId: string): TabsState { return { ...s, layout, panes, activePane }; } +/** Flips the split that directly holds `paneId` between side by side and stacked. */ +export function changeSplitOrientation(s: TabsState, paneId: string): TabsState { + let changed = false; + const flipped = mapLayout(s.layout, (n) => { + if (n.kind !== "split" || !n.children.some((c) => c.kind === "pane" && c.paneId === paneId)) return n; + changed = true; + const dir = n.dir === "row" ? "column" : "row"; + return { ...n, dir, children: n.children.flatMap((c) => (c.kind === "split" && c.dir === dir ? c.children : [c])) }; + })!; + // The parent flattens a flipped split that now runs its way; the flipped one absorbs its children. + return changed ? { ...s, layout: flipped } : s; +} + +/** Removes every pane except `keepPane`. */ +export function unsplitAll(s: TabsState, keepPane: string): TabsState { + if (!s.panes[keepPane]) return s; + return { + ...s, + layout: { kind: "pane", paneId: keepPane }, + panes: { [keepPane]: s.panes[keepPane] }, + activePane: keepPane, + }; +} + /** The pane right of / below `paneId`, if any (for "open to the side"). */ export function neighbourPane(s: TabsState, paneId: string): string | null { const order = paneIds(s); diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 8c4182d0..7034ea9e 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -398,6 +398,9 @@ "menu.editor.closeAllTabs": "Close All Tabs", "menu.editor.reopenClosedTab": "Reopen Closed Tab", "menu.editor.moveToOppositeGroup": "Move to Opposite Group", + "menu.editor.openInOppositeGroup": "Open in Opposite Group", + "menu.editor.changeSplitOrientation": "Change Splitter Orientation", + "menu.editor.unsplitAll": "Unsplit All", "menu.navigate.showFilePath": "File Path", "menu.navigate.customRegion": "Custom Folding…", "menu.code.showErrorDescription": "Error Description", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 7472ad9d..fb0ace45 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -225,6 +225,7 @@ "Caret should be positioned at the name of the element to be refactored": "光标应位于要重构的元素名称上", "Cascade": "级联", "Case sensitive": "区分大小写", + "Change Splitter Orientation": "更改拆分方向", "Change Signature…": "更改签名…", "Change actions": "更改操作", "Change font size with Command+Mouse Wheel": "使用 Command+鼠标滚轮更改字体大小", @@ -1271,6 +1272,7 @@ "Open in Browser": "在浏览器中打开", "Open in Find Tool Window": "在“查找”工具窗口中打开", "Open in Find Window": "在查找窗口中打开", + "Open in Opposite Group": "在相对组中打开", "Open in New Window": "在新窗口中打开", "Open in Terminal": "在终端中打开", "Open in editor": "在编辑器中打开", @@ -2154,6 +2156,7 @@ "Unshelve Changes": "取消搁置更改", "Unshelve…": "取消搁置…", "Unsplit": "取消拆分", + "Unsplit All": "全部取消拆分", "Unstage": "取消暂存", "Unstash Changes…": "取消储藏更改…", "Update": "更新", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 52855c67..38e55b6d 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -398,6 +398,9 @@ "menu.editor.closeAllTabs": "关闭所有标签页", "menu.editor.reopenClosedTab": "重新打开已关闭的标签页", "menu.editor.moveToOppositeGroup": "移动到另一组", + "menu.editor.openInOppositeGroup": "在相对组中打开", + "menu.editor.changeSplitOrientation": "更改拆分方向", + "menu.editor.unsplitAll": "全部取消拆分", "menu.navigate.showFilePath": "文件路径", "menu.navigate.customRegion": "自定义折叠…", "menu.code.showErrorDescription": "错误描述",