diff --git a/src-tauri/src/commands/ui/window.rs b/src-tauri/src/commands/ui/window.rs index ccfbf9e21..5a6e6375f 100644 --- a/src-tauri/src/commands/ui/window.rs +++ b/src-tauri/src/commands/ui/window.rs @@ -1,8 +1,6 @@ use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicU32, Ordering}; -use tauri::{ - AppHandle, Manager, TitleBarStyle, WebviewBuilder, WebviewUrl, WebviewWindow, command, -}; +use tauri::{AppHandle, Manager, WebviewBuilder, WebviewUrl, WebviewWindow, command}; // Counter for generating unique web viewer labels static WEB_VIEWER_COUNTER: AtomicU32 = AtomicU32::new(0); @@ -87,16 +85,21 @@ pub fn create_app_window_internal( ); let url = build_window_open_url(request.as_ref()); - let window = tauri::WebviewWindowBuilder::new(app, &label, WebviewUrl::App(url.into())) + let window_builder = tauri::WebviewWindowBuilder::new(app, &label, WebviewUrl::App(url.into())) .title("") .inner_size(1200.0, 800.0) .min_inner_size(400.0, 400.0) .center() .decorations(true) .resizable(true) - .shadow(true) + .shadow(true); + + #[cfg(target_os = "macos")] + let window_builder = window_builder .hidden_title(true) - .title_bar_style(TitleBarStyle::Overlay) + .title_bar_style(tauri::TitleBarStyle::Overlay); + + let window = window_builder .build() .map_err(|e| format!("Failed to create app window: {e}"))?; diff --git a/src/features/panes/components/pane-container.tsx b/src/features/panes/components/pane-container.tsx index fa16292eb..3635b4bca 100644 --- a/src/features/panes/components/pane-container.tsx +++ b/src/features/panes/components/pane-container.tsx @@ -803,9 +803,10 @@ export function PaneContainer({ pane }: PaneContainerProps) {
)} - {paneBuffers.length > 0 && } +
{(!activeBuffer || activeBuffer.type === "newTab") && !shouldRenderCarousel && ( diff --git a/src/features/panes/components/pane-resize-handle.tsx b/src/features/panes/components/pane-resize-handle.tsx index 2a2a3ba2e..ba399fab5 100644 --- a/src/features/panes/components/pane-resize-handle.tsx +++ b/src/features/panes/components/pane-resize-handle.tsx @@ -4,14 +4,21 @@ import { MIN_PANE_SIZE } from "../constants/pane"; interface PaneResizeHandleProps { direction: "horizontal" | "vertical"; onResize: (sizes: [number, number]) => void; + onResizeEnd?: (sizes: [number, number]) => void; initialSizes: [number, number]; } -export function PaneResizeHandle({ direction, onResize, initialSizes }: PaneResizeHandleProps) { +export function PaneResizeHandle({ + direction, + onResize, + onResizeEnd, + initialSizes, +}: PaneResizeHandleProps) { const [isDragging, setIsDragging] = useState(false); const containerRef = useRef(null); const startPositionRef = useRef(0); const startSizesRef = useRef(initialSizes); + const currentSizesRef = useRef(initialSizes); const isHorizontal = direction === "horizontal"; @@ -21,6 +28,7 @@ export function PaneResizeHandle({ direction, onResize, initialSizes }: PaneResi setIsDragging(true); startPositionRef.current = isHorizontal ? e.clientX : e.clientY; startSizesRef.current = initialSizes; + currentSizesRef.current = initialSizes; }, [isHorizontal, initialSizes], ); @@ -37,6 +45,7 @@ export function PaneResizeHandle({ direction, onResize, initialSizes }: PaneResi const handleSize = isHorizontal ? handle.offsetWidth : handle.offsetHeight; const containerSize = (isHorizontal ? containerRect.width : containerRect.height) - handleSize; + if (containerSize <= 0) return; const currentPosition = isHorizontal ? e.clientX : e.clientY; const delta = currentPosition - startPositionRef.current; @@ -46,22 +55,16 @@ export function PaneResizeHandle({ direction, onResize, initialSizes }: PaneResi const scaledDelta = (delta / containerSize) * pairTotal; let newFirstSize = startSizesRef.current[0] + scaledDelta; - let newSecondSize = startSizesRef.current[1] - scaledDelta; - - const minSize = Math.min(MIN_PANE_SIZE, pairTotal * 0.1); - if (newFirstSize < minSize) { - newFirstSize = minSize; - newSecondSize = pairTotal - minSize; - } else if (newSecondSize < minSize) { - newSecondSize = minSize; - newFirstSize = pairTotal - minSize; - } - - onResize([newFirstSize, newSecondSize]); + newFirstSize = Math.max(0, Math.min(pairTotal, newFirstSize)); + const nextSizes: [number, number] = [newFirstSize, pairTotal - newFirstSize]; + + currentSizesRef.current = nextSizes; + onResize(nextSizes); }; const handleMouseUp = () => { setIsDragging(false); + onResizeEnd?.(currentSizesRef.current); }; document.addEventListener("mousemove", handleMouseMove); @@ -71,13 +74,13 @@ export function PaneResizeHandle({ direction, onResize, initialSizes }: PaneResi document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; - }, [isDragging, isHorizontal, onResize]); + }, [isDragging, isHorizontal, onResize, onResizeEnd]); return (
+ {/* Invisible expanded hit area for easier grabbing */} +
+ + {/* Visible line */}
{isDragging && ( diff --git a/src/features/panes/components/split-view-root.tsx b/src/features/panes/components/split-view-root.tsx index 65a12c144..46c5e4ed8 100644 --- a/src/features/panes/components/split-view-root.tsx +++ b/src/features/panes/components/split-view-root.tsx @@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo } from "react"; import { IS_MAC } from "@/utils/platform"; import { usePaneStore } from "../stores/pane-store"; import type { PaneNode, PaneSplit } from "../types/pane"; -import { getAllPaneGroups } from "../utils/pane-tree"; +import { + getAllPaneGroups, + getCollapsedSplitRepairs, + normalizePanePairSizes, +} from "../utils/pane-tree"; import { PaneContainer } from "./pane-container"; import { PaneResizeHandle } from "./pane-resize-handle"; @@ -117,6 +121,27 @@ function PaneNodeRenderer({ node }: PaneNodeRendererProps) { [flatEntries, updatePaneSizes], ); + const handleFlatResizeEnd = useCallback( + (index: number, sizes: [number, number]) => { + if (!flatEntries) return; + + const repairedSizes = normalizePanePairSizes(sizes); + const newSizes = flatEntries.map((entry) => entry.size); + newSizes[index] = repairedSizes[0]; + newSizes[index + 1] = repairedSizes[1]; + + const updatedEntries = flatEntries.map((entry, entryIndex) => ({ + ...entry, + size: newSizes[entryIndex], + })); + + writeFlatSizesToTree(updatedEntries, (splitId, splitSizes) => { + updatePaneSizes(splitId, splitSizes); + }); + }, + [flatEntries, updatePaneSizes], + ); + if (node.type === "group") { return ; } @@ -125,7 +150,7 @@ function PaneNodeRenderer({ node }: PaneNodeRendererProps) { // If only 2 entries (no flattening benefit), still use the flat approach for consistency const totalSize = flatEntries.reduce((sum, e) => sum + e.size, 0); - const handleWidth = 4; // w-1 = 4px + const handleWidth = 1; // w-px = 1px const handleCount = flatEntries.length - 1; return ( @@ -156,6 +181,7 @@ function PaneNodeRenderer({ node }: PaneNodeRendererProps) { index={i} entries={flatEntries} onResize={handleFlatResize} + onResizeEnd={handleFlatResizeEnd} /> )}
@@ -170,9 +196,16 @@ interface FlatResizeHandleProps { index: number; entries: FlatEntry[]; onResize: (index: number, sizes: [number, number]) => void; + onResizeEnd: (index: number, sizes: [number, number]) => void; } -function FlatResizeHandle({ direction, index, entries, onResize }: FlatResizeHandleProps) { +function FlatResizeHandle({ + direction, + index, + entries, + onResize, + onResizeEnd, +}: FlatResizeHandleProps) { const handleResize = useCallback( (sizes: [number, number]) => { onResize(index, sizes); @@ -180,17 +213,30 @@ function FlatResizeHandle({ direction, index, entries, onResize }: FlatResizeHan [index, onResize], ); + const handleResizeEnd = useCallback( + (sizes: [number, number]) => { + onResizeEnd(index, sizes); + }, + [index, onResizeEnd], + ); + const initialSizes: [number, number] = [entries[index].size, entries[index + 1].size]; return ( - + ); } export function SplitViewRoot() { const root = usePaneStore.use.root(); const fullscreenPaneId = usePaneStore.use.fullscreenPaneId(); - const { exitPaneFullscreen } = usePaneStore.use.actions(); + const { exitPaneFullscreen, updatePaneSizes } = usePaneStore.use.actions(); + const collapsedSplitRepairs = useMemo(() => getCollapsedSplitRepairs(root), [root]); const fullscreenPane = useMemo( () => fullscreenPaneId @@ -205,6 +251,14 @@ export function SplitViewRoot() { } }, [exitPaneFullscreen, fullscreenPane, fullscreenPaneId]); + useEffect(() => { + if (collapsedSplitRepairs.length === 0) return; + + collapsedSplitRepairs.forEach(({ splitId, sizes }) => { + updatePaneSizes(splitId, sizes); + }); + }, [collapsedSplitRepairs, updatePaneSizes]); + const titleBarHeight = IS_MAC ? 44 : 28; const footerHeight = 32; diff --git a/src/features/panes/constants/pane.ts b/src/features/panes/constants/pane.ts index d0ca12d74..6258d934b 100644 --- a/src/features/panes/constants/pane.ts +++ b/src/features/panes/constants/pane.ts @@ -1,4 +1,6 @@ -export const MIN_PANE_SIZE = 10; +export const MIN_PANE_SIZE = 20; + +export const AUTO_REBALANCE_PRIMARY_SIZE = 40; export const DEFAULT_SPLIT_RATIO: [number, number] = [50, 50]; diff --git a/src/features/panes/hooks/use-pane-keyboard.ts b/src/features/panes/hooks/use-pane-keyboard.ts index abdeb369e..fb32454d5 100644 --- a/src/features/panes/hooks/use-pane-keyboard.ts +++ b/src/features/panes/hooks/use-pane-keyboard.ts @@ -16,8 +16,8 @@ export function usePaneKeyboard() { if (e.key === "\\" && !e.shiftKey) { e.preventDefault(); const activePane = paneStore.actions.getActivePane(); - if (activePane?.activeBufferId) { - paneStore.actions.splitPane(activePane.id, "horizontal", activePane.activeBufferId); + if (activePane) { + paneStore.actions.splitPane(activePane.id, "horizontal"); } return; } @@ -26,8 +26,8 @@ export function usePaneKeyboard() { if (e.key === "\\" && e.shiftKey) { e.preventDefault(); const activePane = paneStore.actions.getActivePane(); - if (activePane?.activeBufferId) { - paneStore.actions.splitPane(activePane.id, "vertical", activePane.activeBufferId); + if (activePane) { + paneStore.actions.splitPane(activePane.id, "vertical"); } return; } diff --git a/src/features/panes/stores/pane-store.test.ts b/src/features/panes/stores/pane-store.test.ts new file mode 100644 index 000000000..ffa67c60a --- /dev/null +++ b/src/features/panes/stores/pane-store.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, test } from "vite-plus/test"; +import { ROOT_PANE_ID } from "../constants/pane"; +import type { PaneGroup } from "../types/pane"; +import { usePaneStore } from "./pane-store"; + +function createRootPane(bufferIds: string[] = [], activeBufferId: string | null = null): PaneGroup { + return { + id: ROOT_PANE_ID, + type: "group", + bufferIds, + activeBufferId, + }; +} + +describe("pane-store splitPane", () => { + beforeEach(() => { + usePaneStore.getState().actions.reset(); + }); + + test("creates an empty active pane when no buffer is provided", () => { + usePaneStore.setState({ + root: createRootPane(["buffer-1"], "buffer-1"), + activePaneId: ROOT_PANE_ID, + fullscreenPaneId: null, + }); + + const newPaneId = usePaneStore.getState().actions.splitPane(ROOT_PANE_ID, "horizontal"); + const state = usePaneStore.getState(); + + expect(newPaneId).not.toBeNull(); + if (state.root.type !== "split" || !newPaneId) return; + + expect(state.activePaneId).toBe(newPaneId); + + const newPane = state.root.children.find((child) => child.id === newPaneId); + expect(newPane?.type).toBe("group"); + if (!newPane || newPane.type !== "group") return; + + expect(newPane.bufferIds).toEqual([]); + expect(newPane.activeBufferId).toBeNull(); + }); + + test("seeds the new pane with the requested buffer when one is provided", () => { + usePaneStore.setState({ + root: createRootPane(["buffer-1"], "buffer-1"), + activePaneId: ROOT_PANE_ID, + fullscreenPaneId: null, + }); + + const newPaneId = usePaneStore + .getState() + .actions.splitPane(ROOT_PANE_ID, "horizontal", "buffer-1"); + const state = usePaneStore.getState(); + + expect(newPaneId).not.toBeNull(); + if (state.root.type !== "split" || !newPaneId) return; + + const newPane = state.root.children.find((child) => child.id === newPaneId); + expect(newPane?.type).toBe("group"); + if (!newPane || newPane.type !== "group") return; + + expect(newPane.bufferIds).toEqual(["buffer-1"]); + expect(newPane.activeBufferId).toBe("buffer-1"); + }); +}); diff --git a/src/features/panes/utils/pane-tree.test.ts b/src/features/panes/utils/pane-tree.test.ts index 6a6bb7d4f..b84a057c8 100644 --- a/src/features/panes/utils/pane-tree.test.ts +++ b/src/features/panes/utils/pane-tree.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { getAdjacentPane, splitPane } from "./pane-tree"; +import { + getAdjacentPane, + getCollapsedSplitRepairs, + normalizePanePairSizes, + splitPane, +} from "./pane-tree"; import type { PaneGroup, PaneNode } from "../types/pane"; function createNamedPane(id: string): PaneGroup { @@ -64,3 +69,43 @@ describe("getAdjacentPane", () => { expect(getAdjacentPane(root, "bottom-right", "up")?.id).toBe("top-right"); }); }); + +describe("normalizePanePairSizes", () => { + it("keeps healthy split sizes unchanged", () => { + expect(normalizePanePairSizes([50, 50])).toEqual([50, 50]); + }); + + it("repairs a collapsed first pane to a 40/60 split", () => { + expect(normalizePanePairSizes([19, 81])).toEqual([40, 60]); + }); + + it("repairs a collapsed second pane to a 60/40 split", () => { + expect(normalizePanePairSizes([81, 19])).toEqual([60, 40]); + }); + + it("keeps the 20% boundary unchanged", () => { + expect(normalizePanePairSizes([20, 80])).toEqual([20, 80]); + }); +}); + +describe("getCollapsedSplitRepairs", () => { + it("collects repairs for collapsed splits in the tree", () => { + const left = createNamedPane("left"); + const right = createNamedPane("right"); + + const root: PaneNode = { + id: "root-split", + type: "split", + direction: "horizontal", + children: [left, right], + sizes: [10, 90], + }; + + expect(getCollapsedSplitRepairs(root)).toEqual([ + { + splitId: "root-split", + sizes: [40, 60], + }, + ]); + }); +}); diff --git a/src/features/panes/utils/pane-tree.ts b/src/features/panes/utils/pane-tree.ts index 936234401..99e241bea 100644 --- a/src/features/panes/utils/pane-tree.ts +++ b/src/features/panes/utils/pane-tree.ts @@ -1,7 +1,12 @@ import { nanoid } from "nanoid"; -import { DEFAULT_SPLIT_RATIO } from "../constants/pane"; +import { AUTO_REBALANCE_PRIMARY_SIZE, DEFAULT_SPLIT_RATIO, MIN_PANE_SIZE } from "../constants/pane"; import type { PaneGroup, PaneNode, PaneSplit, SplitDirection, SplitPlacement } from "../types/pane"; +interface SplitSizeRepair { + splitId: string; + sizes: [number, number]; +} + export function createPaneGroup( bufferIds: string[] = [], activeBufferId: string | null = null, @@ -128,6 +133,45 @@ export function splitPane( return root; } +export function normalizePanePairSizes(sizes: [number, number]): [number, number] { + const pairTotal = sizes[0] + sizes[1]; + if (pairTotal <= 0) { + return sizes; + } + + const minimumSize = (pairTotal * MIN_PANE_SIZE) / 100; + const primarySize = (pairTotal * AUTO_REBALANCE_PRIMARY_SIZE) / 100; + const secondarySize = pairTotal - primarySize; + + if (sizes[0] < minimumSize) { + return [primarySize, secondarySize]; + } + + if (sizes[1] < minimumSize) { + return [secondarySize, primarySize]; + } + + return sizes; +} + +export function getCollapsedSplitRepairs(root: PaneNode): SplitSizeRepair[] { + if (root.type === "group") { + return []; + } + + const repairs = [ + ...getCollapsedSplitRepairs(root.children[0]), + ...getCollapsedSplitRepairs(root.children[1]), + ]; + const normalizedSizes = normalizePanePairSizes(root.sizes); + + if (normalizedSizes[0] !== root.sizes[0] || normalizedSizes[1] !== root.sizes[1]) { + repairs.push({ splitId: root.id, sizes: normalizedSizes }); + } + + return repairs; +} + export function closePane(root: PaneNode, paneId: string): PaneNode | null { if (root.id === paneId) { return null; diff --git a/src/features/tabs/components/tab-bar-item.tsx b/src/features/tabs/components/tab-bar-item.tsx index 2484a3178..53550d9c3 100644 --- a/src/features/tabs/components/tab-bar-item.tsx +++ b/src/features/tabs/components/tab-bar-item.tsx @@ -22,6 +22,7 @@ interface TabBarItemProps { displayName: string; index: number; isActive: boolean; + isPaneActive: boolean; isDraggedTab: boolean; showDropIndicatorBefore: boolean; tabRef: (el: HTMLDivElement | null) => void; @@ -39,6 +40,7 @@ const TabBarItem = memo(function TabBarItem({ buffer, displayName, isActive, + isPaneActive, isDraggedTab, showDropIndicatorBefore, tabRef, @@ -83,7 +85,7 @@ const TabBarItem = memo(function TabBarItem({ tabIndex={isActive ? 0 : -1} isActive={isActive} isDragged={isDraggedTab} - className={isActive ? "bg-hover/80" : undefined} + className={isActive ? (isPaneActive ? "bg-hover/80" : "bg-hover/30") : undefined} onMouseDown={onMouseDown} onDoubleClick={onDoubleClick} onContextMenu={onContextMenu} @@ -178,7 +180,7 @@ const TabBarItem = memo(function TabBarItem({ void; + isActivePane?: boolean; } const DRAG_THRESHOLD = 5; @@ -37,7 +38,7 @@ interface TabPosition { center: number; } -const TabBar = ({ paneId, onTabClick: externalTabClick }: TabBarProps) => { +const TabBar = ({ paneId, onTabClick: externalTabClick, isActivePane = true }: TabBarProps) => { // Get everything from stores const allBuffers = useBufferStore.use.buffers(); const globalActiveBufferId = useBufferStore.use.activeBufferId(); @@ -142,9 +143,9 @@ const TabBar = ({ paneId, onTabClick: externalTabClick }: TabBarProps) => { }, [jumpListActions]); const handleSplitActivePane = useCallback(() => { - if (!paneId || !activeBufferId) return; - splitPane(paneId, "horizontal", activeBufferId); - }, [activeBufferId, paneId, splitPane]); + if (!paneId) return; + splitPane(paneId, "horizontal"); + }, [paneId, splitPane]); const handleTogglePaneFullscreen = useCallback(() => { if (!paneId) return; @@ -727,18 +728,15 @@ const TabBar = ({ paneId, onTabClick: externalTabClick }: TabBarProps) => { const MemoizedTabContextMenu = useMemo(() => TabContextMenu, []); - // Hide tab bar when no buffers are open - if (buffers.length === 0) { - return null; - } - const { isDragging, draggedIndex, dropTargetIndex, currentPosition } = dragState; return ( <>
{ onDragLeave={handleTabBarDragLeave} onDrop={handleTabBarDrop} > -
+
- {paneId && activeBufferId && ( - - + + )} + {paneId && ( + - - - - )} - {paneId && ( - - - - )} + + + )} +
diff --git a/src/features/window/components/menu-bar/window-menu-bar.tsx b/src/features/window/components/menu-bar/window-menu-bar.tsx index 7a95fba21..60d33ccde 100644 --- a/src/features/window/components/menu-bar/window-menu-bar.tsx +++ b/src/features/window/components/menu-bar/window-menu-bar.tsx @@ -109,7 +109,7 @@ const CustomMenuBar = ({ activeMenu, setActiveMenu, compactFloating = false }: P Toggle AI Chat - handleClickEmit("menu_split_editor")}>Split Editor + handleClickEmit("menu_split_editor")}>Split Pane { const paneStore = usePaneStore.getState(); const activePane = paneStore.actions.getActivePane(); - if (activePane?.activeBufferId) { - paneStore.actions.splitPane(activePane.id, "horizontal", activePane.activeBufferId); + if (activePane) { + paneStore.actions.splitPane(activePane.id, "horizontal"); } }, onToggleVim: () => {