Skip to content
Open
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
15 changes: 9 additions & 6 deletions src-tauri/src/commands/ui/window.rs
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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}"))?;

Expand Down
9 changes: 5 additions & 4 deletions src/features/panes/components/pane-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -803,9 +803,10 @@ export function PaneContainer({ pane }: PaneContainerProps) {
<div
ref={containerRef}
data-pane-container
className={`relative flex h-full w-full flex-col overflow-hidden bg-primary-bg ${
isActivePane ? "ring-1 ring-accent/30" : ""
} ${isDragOver ? "ring-2 ring-accent" : ""}`}
data-pane-id={pane.id}
className={`@container relative flex h-full w-full flex-col overflow-hidden bg-primary-bg transition-all duration-200 ${
isActivePane ? "ring-1 ring-border/40 ring-inset z-10" : "z-0"
} ${isDragOver ? "ring-2 ring-accent ring-inset z-20" : ""}`}
onClick={handlePaneClick}
onMouseUp={handleMouseUp}
onDragOver={handleDragOver}
Expand All @@ -816,7 +817,7 @@ export function PaneContainer({ pane }: PaneContainerProps) {
<div className="pointer-events-none absolute inset-0 z-40 bg-accent/10" />
)}
<SplitDropOverlay visible={isTabDragOver} onDrop={handleSplitDrop} />
{paneBuffers.length > 0 && <TabBar paneId={pane.id} onTabClick={handleTabClick} />}
<TabBar paneId={pane.id} onTabClick={handleTabClick} isActivePane={isActivePane} />
<div className="relative min-h-0 flex-1 overflow-hidden">
{(!activeBuffer || activeBuffer.type === "newTab") && !shouldRenderCarousel && (
<EmptyEditorState />
Expand Down
43 changes: 26 additions & 17 deletions src/features/panes/components/pane-resize-handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const startPositionRef = useRef(0);
const startSizesRef = useRef(initialSizes);
const currentSizesRef = useRef(initialSizes);

const isHorizontal = direction === "horizontal";

Expand All @@ -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],
);
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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 (
<div
ref={containerRef}
className={`group relative flex shrink-0 items-center justify-center ${
isHorizontal ? "h-full w-1 cursor-col-resize" : "h-1 w-full cursor-row-resize"
isHorizontal ? "h-full w-px cursor-col-resize z-10" : "h-px w-full cursor-row-resize z-10"
}`}
onMouseDown={handleMouseDown}
role="separator"
Expand All @@ -88,9 +91,15 @@ export function PaneResizeHandle({ direction, onResize, initialSizes }: PaneResi
aria-valuemax={100 - MIN_PANE_SIZE}
tabIndex={0}
>
{/* Invisible expanded hit area for easier grabbing */}
<div
className={`absolute ${isHorizontal ? "inset-y-0 -inset-x-2" : "inset-x-0 -inset-y-2"}`}
/>

{/* Visible line */}
<div
className={`bg-border transition-colors ${
isDragging ? "bg-accent" : "group-hover:bg-accent"
className={`transition-colors ${
isDragging ? "bg-accent/80" : "bg-border/30 group-hover:bg-border/80"
} ${isHorizontal ? "h-full w-px" : "h-px w-full"}`}
/>
{isDragging && (
Expand Down
64 changes: 59 additions & 5 deletions src/features/panes/components/split-view-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 <PaneContainer pane={node} />;
}
Expand All @@ -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 (
Expand Down Expand Up @@ -156,6 +181,7 @@ function PaneNodeRenderer({ node }: PaneNodeRendererProps) {
index={i}
entries={flatEntries}
onResize={handleFlatResize}
onResizeEnd={handleFlatResizeEnd}
/>
)}
</div>
Expand All @@ -170,27 +196,47 @@ 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);
},
[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 (
<PaneResizeHandle direction={direction} onResize={handleResize} initialSizes={initialSizes} />
<PaneResizeHandle
direction={direction}
onResize={handleResize}
onResizeEnd={handleResizeEnd}
initialSizes={initialSizes}
/>
);
}

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
Expand All @@ -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;

Expand Down
4 changes: 3 additions & 1 deletion src/features/panes/constants/pane.ts
Original file line number Diff line number Diff line change
@@ -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];

Expand Down
8 changes: 4 additions & 4 deletions src/features/panes/hooks/use-pane-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down
65 changes: 65 additions & 0 deletions src/features/panes/stores/pane-store.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading