feat: add terminal tabs and split pane controls to workspace UI

This commit is contained in:
Mohamed Boudra
2026-03-18 19:55:04 +07:00
parent 5f2d4ac122
commit b763d4358e
6 changed files with 267 additions and 17 deletions
@@ -145,6 +145,14 @@ interface SplitPaneInLayoutResult {
paneId: string;
}
interface SplitPaneEmptyInLayoutInput {
layout: WorkspaceLayout;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
createNodeId: (prefix: "pane" | "group") => string;
maxTreeDepth: number;
}
interface MoveTabToPaneInLayoutInput {
layout: WorkspaceLayout;
tabId: string;
@@ -1066,6 +1074,60 @@ export function splitPaneInLayout(input: SplitPaneInLayoutInput): SplitPaneInLay
};
}
export function splitPaneEmptyInLayout(input: SplitPaneEmptyInLayoutInput): SplitPaneInLayoutResult | null {
const layout = asInternalLayout(input.layout);
if (!findPaneById(layout.root, input.targetPaneId)) {
return null;
}
const direction = input.position === "left" || input.position === "right" ? "horizontal" : "vertical";
const insertAfter = input.position === "right" || input.position === "bottom";
const targetPath = findPanePathById(layout.root, input.targetPaneId);
invariant(targetPath, `Target pane not found: ${input.targetPaneId}`);
const targetNode = getNodeAtPath(layout.root, targetPath);
invariant(targetNode.kind === "pane", "Expected target pane");
const newPaneId = input.createNodeId("pane");
const newPaneNode = createPaneNode({ id: newPaneId });
const parentPath = targetPath.slice(0, -1);
const targetIndex = targetPath[targetPath.length - 1] ?? 0;
const parentNode = parentPath.length > 0 ? getNodeAtPath(layout.root, parentPath) : null;
let nextRoot: SplitNodeInternal;
if (parentNode?.kind === "group" && parentNode.group.direction === direction) {
const targetSize = parentNode.group.sizes[targetIndex] ?? 0;
const nextSizes = parentNode.group.sizes.slice();
const insertIndex = insertAfter ? targetIndex + 1 : targetIndex;
nextSizes.splice(insertIndex, 0, targetSize / 2);
nextSizes[targetIndex + (insertAfter ? 0 : 1)] = targetSize / 2;
nextRoot = replaceNodeAtPath(layout.root, parentPath, () =>
insertChildIntoGroup(parentNode, { index: insertIndex, node: newPaneNode, sizes: nextSizes })
);
} else {
const newGroup = createGroupNode({
id: input.createNodeId("group"),
direction,
children: insertAfter ? [targetNode, newPaneNode] : [newPaneNode, targetNode],
sizes: [0.5, 0.5],
});
nextRoot = replaceNodeAtPath(layout.root, targetPath, () => newGroup);
}
if (getTreeDepth(nextRoot) > input.maxTreeDepth) {
return null;
}
return {
paneId: newPaneId,
layout: {
root: nextRoot,
focusedPaneId: newPaneId,
},
};
}
export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): WorkspaceLayout | null {
const layout = asInternalLayout(input.layout);
const sourcePane = findPaneContainingTab(layout.root, input.tabId);
@@ -26,6 +26,7 @@ import {
reorderFocusedPaneTabsInLayout,
reorderPaneTabsInLayout,
retargetTabInLayout,
splitPaneEmptyInLayout,
splitPaneInLayout,
type SplitGroup,
type SplitNode,
@@ -66,6 +67,13 @@ interface WorkspaceLayoutStore {
position: "left" | "right" | "top" | "bottom";
}
) => string | null;
splitPaneEmpty: (
workspaceKey: string,
input: {
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}
) => string | null;
moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void;
focusPane: (workspaceKey: string, paneId: string) => void;
resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void;
@@ -252,6 +260,39 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
return result.paneId;
},
splitPaneEmpty: (workspaceKey, input) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId);
if (!normalizedWorkspaceKey || !normalizedTargetPaneId) {
return null;
}
const result = splitPaneEmptyInLayout({
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
targetPaneId: normalizedTargetPaneId,
position: input.position,
maxTreeDepth: MAX_TREE_DEPTH,
createNodeId: (prefix) => {
const randomValue =
typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}_${randomValue}`;
},
});
if (!result) {
return null;
}
set((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: result.layout,
},
}));
return result.paneId;
},
moveTabToPane: (workspaceKey, tabId, toPaneId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId);