From eb61091f6dce5d9f2bd398f0a1c609da4c2b278e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:46:34 +0900 Subject: [PATCH] feat(team-mode): add rebalance-team-window command with tests --- .../rebalance-team-window.test.ts | 84 +++++++++++++++++++ .../team-layout-tmux/rebalance-team-window.ts | 70 ++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/features/team-mode/team-layout-tmux/rebalance-team-window.test.ts create mode 100644 src/features/team-mode/team-layout-tmux/rebalance-team-window.ts diff --git a/src/features/team-mode/team-layout-tmux/rebalance-team-window.test.ts b/src/features/team-mode/team-layout-tmux/rebalance-team-window.test.ts new file mode 100644 index 000000000..d392b6c15 --- /dev/null +++ b/src/features/team-mode/team-layout-tmux/rebalance-team-window.test.ts @@ -0,0 +1,84 @@ +/// + +import { beforeEach, describe, expect, it, mock } from "bun:test" + +import { + rebalanceTeamWindowWith, + type RebalanceTeamWindowDeps, +} from "./rebalance-team-window" + +describe("rebalanceTeamWindowWith", () => { + let runTmux: RebalanceTeamWindowDeps["runTmux"] + let log: RebalanceTeamWindowDeps["log"] + let calls: Array> + + beforeEach(() => { + calls = [] + runTmux = mock(async (args: string[]): Promise<{ success: boolean }> => { + calls.push(args) + return { success: true } + }) + log = mock((): void => undefined) + }) + + it("#given main-vertical #when rebalance #then select-layout, set main-pane-width 60%, re-select-layout", async () => { + // given + const deps: RebalanceTeamWindowDeps = { runTmux, log } + + // when + const result = await rebalanceTeamWindowWith("@1", "main-vertical", deps) + + // then + expect(result).toBe(true) + expect(calls).toEqual([ + ["select-layout", "-t", "@1", "main-vertical"], + ["set-window-option", "-t", "@1", "main-pane-width", "60%"], + ["select-layout", "-t", "@1", "main-vertical"], + ]) + }) + + it("#given focus windowId and pane-list shrunk from 3 to 2 #when rebalanceTeamWindow runs #then select-layout is invoked with main-vertical", async () => { + // given + const deps: RebalanceTeamWindowDeps = { runTmux, log } + + // when + const result = await rebalanceTeamWindowWith("@focus", "main-vertical", deps) + + // then + expect(result).toBe(true) + expect(calls).toEqual([ + ["select-layout", "-t", "@focus", "main-vertical"], + ["set-window-option", "-t", "@focus", "main-pane-width", "60%"], + ["select-layout", "-t", "@focus", "main-vertical"], + ]) + }) + + it("#given tiled #when rebalance #then only select-layout called", async () => { + // given + const deps: RebalanceTeamWindowDeps = { runTmux, log } + + // when + const result = await rebalanceTeamWindowWith("@1", "tiled", deps) + + // then + expect(result).toBe(true) + expect(calls).toEqual([["select-layout", "-t", "@1", "tiled"]]) + }) + + it("#given select-layout fails #when rebalance #then returns false, log once", async () => { + // given + runTmux = mock(async (args: string[]): Promise<{ success: boolean }> => { + calls.push(args) + return { success: false } + }) + + const deps: RebalanceTeamWindowDeps = { runTmux, log } + + // when + const result = await rebalanceTeamWindowWith("@1", "main-vertical", deps) + + // then + expect(result).toBe(false) + expect(log).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/features/team-mode/team-layout-tmux/rebalance-team-window.ts b/src/features/team-mode/team-layout-tmux/rebalance-team-window.ts new file mode 100644 index 000000000..23abd0716 --- /dev/null +++ b/src/features/team-mode/team-layout-tmux/rebalance-team-window.ts @@ -0,0 +1,70 @@ +export type RebalanceLayout = "main-vertical" | "tiled" + +export type RebalanceTeamWindowDeps = { + runTmux: (args: string[]) => Promise<{ success: boolean }> + log: (message: string, meta?: Record) => void +} + +export async function rebalanceTeamWindowWith( + windowId: string, + layout: RebalanceLayout, + deps: RebalanceTeamWindowDeps, +): Promise { + if (windowId.length === 0) { + return false + } + + const selectLayoutArgs = ["select-layout", "-t", windowId, layout] + const initialLayout = await deps.runTmux(selectLayoutArgs) + if (!initialLayout.success) { + deps.log("[rebalanceTeamWindow] FAILED", { windowId, layout, step: "select-layout" }) + return false + } + + if (layout === "tiled") { + return true + } + + const setMainPaneWidth = await deps.runTmux([ + "set-window-option", + "-t", + windowId, + "main-pane-width", + "60%", + ]) + if (!setMainPaneWidth.success) { + deps.log("[rebalanceTeamWindow] FAILED", { windowId, layout, step: "set-window-option" }) + return false + } + + // tmux applies main-pane-width against the active layout, so select-layout again after resizing. + const finalLayout = await deps.runTmux(selectLayoutArgs) + if (!finalLayout.success) { + deps.log("[rebalanceTeamWindow] FAILED", { windowId, layout, step: "select-layout" }) + return false + } + + return true +} + +export async function rebalanceTeamWindow( + windowId: string, + layout: RebalanceLayout, +): Promise { + const [{ log }, { getTmuxPath }, { runTmuxCommand }] = await Promise.all([ + import("../../../shared"), + import("../../../tools/interactive-bash/tmux-path-resolver"), + import("../../../shared/tmux"), + ]) + + const tmuxPath = await getTmuxPath() + if (!tmuxPath) { + log("[rebalanceTeamWindow] SKIP: tmux not found", { windowId, layout }) + return false + } + + return rebalanceTeamWindowWith(windowId, layout, { + runTmux: (args) => runTmuxCommand(tmuxPath, args), + log, + }) +}