Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 74 additions & 11 deletions apps/desktop/src/renderer/screens/main/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Button } from "@superset/ui/button";
import { useState } from "react";
import { useCallback, useState } from "react";
import { DndProvider } from "react-dnd";
import { useHotkeys } from "react-hotkeys-hook";
import { HiArrowPath } from "react-icons/hi2";
Expand All @@ -9,7 +9,9 @@ import { useCurrentView, useOpenSettings } from "renderer/stores/app-state";
import { useSidebarStore } from "renderer/stores/sidebar-state";
import { getPaneDimensions } from "renderer/stores/tabs/pane-refs";
import { useWindowsStore } from "renderer/stores/tabs/store";
import type { Window } from "renderer/stores/tabs/types";
import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener";
import { findPanePath, getFirstPaneId } from "renderer/stores/tabs/utils";
import { HOTKEYS } from "shared/hotkeys";
import { dragDropManager } from "../../lib/dnd";
import { AppFrame } from "./components/AppFrame";
Expand Down Expand Up @@ -40,8 +42,10 @@ export function MainScreen() {
const splitPaneAuto = useWindowsStore((s) => s.splitPaneAuto);
const splitPaneVertical = useWindowsStore((s) => s.splitPaneVertical);
const splitPaneHorizontal = useWindowsStore((s) => s.splitPaneHorizontal);
const setFocusedPane = useWindowsStore((s) => s.setFocusedPane);
const activeWindowIds = useWindowsStore((s) => s.activeWindowIds);
const focusedPaneIds = useWindowsStore((s) => s.focusedPaneIds);
const windows = useWindowsStore((s) => s.windows);

// Listen for agent completion hooks from main process
useAgentHookListener();
Expand All @@ -51,6 +55,7 @@ export function MainScreen() {
? activeWindowIds[activeWorkspaceId]
: null;
const focusedPaneId = activeWindowId ? focusedPaneIds[activeWindowId] : null;
const activeWindow = windows.find((w) => w.id === activeWindowId);
const isWorkspaceView = currentView === "workspace";

// Register global shortcuts
Expand All @@ -62,26 +67,84 @@ export function MainScreen() {
if (isWorkspaceView) toggleSidebar();
}, [toggleSidebar, isWorkspaceView]);

/**
* Resolves the target pane for split operations.
* If the focused pane is desynced from layout (e.g., was removed),
* falls back to first pane and corrects focus state.
*/
const resolveSplitTarget = useCallback(
(paneId: string, windowId: string, targetWindow: Window) => {
const path = findPanePath(targetWindow.layout, paneId);
if (path !== null) return { path, paneId };

// Focused pane not in layout - correct focus and use first pane
const firstPaneId = getFirstPaneId(targetWindow.layout);
const firstPanePath = findPanePath(targetWindow.layout, firstPaneId);
setFocusedPane(windowId, firstPaneId);
return { path: firstPanePath ?? [], paneId: firstPaneId };
},
[setFocusedPane],
);

useHotkeys(HOTKEYS.SPLIT_AUTO.keys, () => {
if (isWorkspaceView && activeWindowId && focusedPaneId) {
const dimensions = getPaneDimensions(focusedPaneId);
if (isWorkspaceView && activeWindowId && focusedPaneId && activeWindow) {
const target = resolveSplitTarget(
focusedPaneId,
activeWindowId,
activeWindow,
);
if (!target) return;
const dimensions = getPaneDimensions(target.paneId);
if (dimensions) {
splitPaneAuto(activeWindowId, focusedPaneId, dimensions);
splitPaneAuto(activeWindowId, target.paneId, dimensions, target.path);
}
}
}, [activeWindowId, focusedPaneId, splitPaneAuto, isWorkspaceView]);
}, [
activeWindowId,
focusedPaneId,
activeWindow,
splitPaneAuto,
resolveSplitTarget,
isWorkspaceView,
]);

useHotkeys(HOTKEYS.SPLIT_RIGHT.keys, () => {
if (isWorkspaceView && activeWindowId && focusedPaneId) {
splitPaneVertical(activeWindowId, focusedPaneId);
if (isWorkspaceView && activeWindowId && focusedPaneId && activeWindow) {
const target = resolveSplitTarget(
focusedPaneId,
activeWindowId,
activeWindow,
);
if (!target) return;
splitPaneVertical(activeWindowId, target.paneId, target.path);
}
}, [activeWindowId, focusedPaneId, splitPaneVertical, isWorkspaceView]);
}, [
activeWindowId,
focusedPaneId,
activeWindow,
splitPaneVertical,
resolveSplitTarget,
isWorkspaceView,
]);

useHotkeys(HOTKEYS.SPLIT_DOWN.keys, () => {
if (isWorkspaceView && activeWindowId && focusedPaneId) {
splitPaneHorizontal(activeWindowId, focusedPaneId);
if (isWorkspaceView && activeWindowId && focusedPaneId && activeWindow) {
const target = resolveSplitTarget(
focusedPaneId,
activeWindowId,
activeWindow,
);
if (!target) return;
splitPaneHorizontal(activeWindowId, target.paneId, target.path);
}
}, [activeWindowId, focusedPaneId, splitPaneHorizontal, isWorkspaceView]);
}, [
activeWindowId,
focusedPaneId,
activeWindow,
splitPaneHorizontal,
resolveSplitTarget,
isWorkspaceView,
]);

const showStartView =
!isLoading && !activeWorkspace && currentView !== "settings";
Expand Down
101 changes: 101 additions & 0 deletions apps/desktop/src/renderer/stores/tabs/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from "bun:test";
import type { MosaicNode } from "react-mosaic-component";
import { findPanePath } from "./utils";

describe("findPanePath", () => {
it("returns empty array for single pane layout matching the id", () => {
const layout: MosaicNode<string> = "pane-1";
const result = findPanePath(layout, "pane-1");
expect(result).toEqual([]);
});

it("returns null for single pane layout not matching the id", () => {
const layout: MosaicNode<string> = "pane-1";
const result = findPanePath(layout, "pane-2");
expect(result).toBeNull();
});

it("returns correct path for pane in first branch", () => {
const layout: MosaicNode<string> = {
direction: "row",
first: "pane-1",
second: "pane-2",
};
const result = findPanePath(layout, "pane-1");
expect(result).toEqual(["first"]);
});

it("returns correct path for pane in second branch", () => {
const layout: MosaicNode<string> = {
direction: "row",
first: "pane-1",
second: "pane-2",
};
const result = findPanePath(layout, "pane-2");
expect(result).toEqual(["second"]);
});

it("returns correct path for deeply nested pane", () => {
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "column",
first: "pane-1",
second: "pane-2",
},
second: {
direction: "column",
first: "pane-3",
second: "pane-4",
},
};

expect(findPanePath(layout, "pane-1")).toEqual(["first", "first"]);
expect(findPanePath(layout, "pane-2")).toEqual(["first", "second"]);
expect(findPanePath(layout, "pane-3")).toEqual(["second", "first"]);
expect(findPanePath(layout, "pane-4")).toEqual(["second", "second"]);
});

it("returns null for missing pane id in complex layout", () => {
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "column",
first: "pane-1",
second: "pane-2",
},
second: "pane-3",
};
const result = findPanePath(layout, "pane-99");
expect(result).toBeNull();
});

it("handles asymmetric nested layouts", () => {
const layout: MosaicNode<string> = {
direction: "row",
first: "pane-1",
second: {
direction: "column",
first: {
direction: "row",
first: "pane-2",
second: "pane-3",
},
second: "pane-4",
},
};

expect(findPanePath(layout, "pane-1")).toEqual(["first"]);
expect(findPanePath(layout, "pane-2")).toEqual([
"second",
"first",
"first",
]);
expect(findPanePath(layout, "pane-3")).toEqual([
"second",
"first",
"second",
]);
expect(findPanePath(layout, "pane-4")).toEqual(["second", "second"]);
});
});
30 changes: 29 additions & 1 deletion apps/desktop/src/renderer/stores/tabs/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MosaicNode } from "react-mosaic-component";
import type { MosaicBranch, MosaicNode } from "react-mosaic-component";
import type { Pane, PaneType, Window } from "./types";

/**
Expand Down Expand Up @@ -195,3 +195,31 @@ export const getFirstPaneId = (layout: MosaicNode<string>): string => {
}
return getFirstPaneId(layout.first);
};

/**
* Finds the path to a specific pane ID in a mosaic layout
* Returns the path as an array of MosaicBranch ("first" | "second"), or null if not found
*/
export const findPanePath = (
layout: MosaicNode<string>,
paneId: string,
currentPath: MosaicBranch[] = [],
): MosaicBranch[] | null => {
if (typeof layout === "string") {
return layout === paneId ? currentPath : null;
}

const firstPath = findPanePath(layout.first, paneId, [
...currentPath,
"first",
]);
if (firstPath) return firstPath;

const secondPath = findPanePath(layout.second, paneId, [
...currentPath,
"second",
]);
if (secondPath) return secondPath;

return null;
};
16 changes: 8 additions & 8 deletions apps/desktop/src/shared/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,24 +89,24 @@ export const HOTKEYS = {
label: "Toggle Sidebar",
category: "Layout",
},
SPLIT_AUTO: {
keys: "meta+d",
label: "Split Pane Auto",
category: "Layout",
description: "Split the current pane along its longer side",
},
SPLIT_RIGHT: {
keys: "meta+shift+d",
keys: "meta+d",
label: "Split Right",
category: "Layout",
description: "Split the current pane to the right",
},
SPLIT_DOWN: {
keys: "meta+alt+d",
keys: "meta+shift+d",
label: "Split Down",
category: "Layout",
description: "Split the current pane downward",
},
SPLIT_AUTO: {
keys: "meta+e",
label: "Split Pane Auto",
category: "Layout",
description: "Split the current pane along its longer side",
},

// Terminal
FIND_IN_TERMINAL: {
Expand Down