diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index dc14515d2..13ba74e89 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -4,6 +4,7 @@ version = "0.3.0" description = "Lithe Windows desktop application" edition = "2021" license = "Apache-2.0" +default-run = "lithe-windows" [features] test-support = [] diff --git a/windows/tauri/src/features/run/components/run-configuration-list-split.test.ts b/windows/tauri/src/features/run/components/run-configuration-list-split.test.ts new file mode 100644 index 000000000..33cf46909 --- /dev/null +++ b/windows/tauri/src/features/run/components/run-configuration-list-split.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test"; + +test("run configuration list split wires keyboard resize and drag session cleanup", async () => { + const source = await Bun.file( + new URL("./run-configuration-list-split.tsx", import.meta.url), + ).text(); + + expect(source).toContain("onKeyDown={handleKeyDown}"); + expect(source).toContain("nextRunConfigurationListWidthForKey"); + expect(source).toContain("startDocumentResizeSession"); + expect(source).toContain('sessionRef.current?.dispose({ commit: true })'); + expect(source).toContain("onPointerDown={handlePointerDown}"); +}); diff --git a/windows/tauri/src/features/run/components/run-configuration-list-split.tsx b/windows/tauri/src/features/run/components/run-configuration-list-split.tsx new file mode 100644 index 000000000..71abce5a2 --- /dev/null +++ b/windows/tauri/src/features/run/components/run-configuration-list-split.tsx @@ -0,0 +1,177 @@ +import type React from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "@/i18n/locale-provider"; +import { cn } from "@/utils/cn"; +import { useRunPreferencesStore } from "../stores/run-preferences.store"; +import { + RUN_CONFIGURATION_LIST_HANDLE_THICKNESS, + RUN_CONFIGURATION_LIST_MIN_WIDTH, + clampRunConfigurationListWidth, + getRunConfigurationListMaxWidth, +} from "../utils/run-configuration-list-layout"; +import { + nextRunConfigurationListWidthForKey, + startDocumentResizeSession, + type DocumentResizeSession, +} from "../utils/run-configuration-list-resize-session"; + +interface RunConfigurationListSplitProps { + list: React.ReactNode; + content: React.ReactNode; +} + +/** + * Local layout container for the Run tool window's configuration list. + * Keeps drag width mutations out of RunPane and only commits when a resize + * session ends, matching macOS LitheSplitPaneView persistence. + */ +export function RunConfigurationListSplit({ list, content }: RunConfigurationListSplitProps) { + const { t } = useTranslation(); + const storedWidth = useRunPreferencesStore((state) => state.configurationListWidth); + const setConfigurationListWidth = useRunPreferencesStore( + (state) => state.actions.setConfigurationListWidth, + ); + const containerRef = useRef(null); + const listRef = useRef(null); + const sessionRef = useRef(null); + const isMountedRef = useRef(true); + const [containerWidth, setContainerWidth] = useState(0); + const [width, setWidth] = useState(() => + clampRunConfigurationListWidth(storedWidth, typeof window !== "undefined" ? window.innerWidth : 1280), + ); + const [isResizing, setIsResizing] = useState(false); + + const clampWidth = useCallback( + (value: number) => clampRunConfigurationListWidth(value, containerWidth || 1280), + [containerWidth], + ); + + const commitWidth = useCallback( + (nextWidth: number) => { + setConfigurationListWidth(nextWidth); + if (isMountedRef.current) { + setWidth(nextWidth); + setIsResizing(false); + } + sessionRef.current = null; + }, + [setConfigurationListWidth], + ); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + const updateWidth = () => { + const nextContainerWidth = container.getBoundingClientRect().width; + setContainerWidth(nextContainerWidth); + setWidth((current) => clampRunConfigurationListWidth(current, nextContainerWidth)); + }; + + updateWidth(); + const observer = new ResizeObserver(updateWidth); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + setWidth(clampWidth(storedWidth)); + }, [storedWidth, clampWidth]); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + // Persist the in-flight width, then drop listeners/body styles/RAF. + sessionRef.current?.dispose({ commit: true }); + sessionRef.current = null; + }; + }, []); + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + sessionRef.current?.dispose({ commit: true }); + + const listEl = listRef.current; + sessionRef.current = startDocumentResizeSession({ + startX: event.clientX, + startWidth: width, + clampWidth, + applyWidth: (nextWidth) => { + if (listEl) { + listEl.style.width = `${nextWidth}px`; + } + }, + commitWidth, + onActiveChange: (active) => { + if (isMountedRef.current) { + setIsResizing(active); + } + }, + }); + }, + [width, clampWidth, commitWidth], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const nextWidth = nextRunConfigurationListWidthForKey( + width, + event.key, + containerWidth || 1280, + ); + if (nextWidth == null) { + return; + } + + event.preventDefault(); + setWidth(nextWidth); + setConfigurationListWidth(nextWidth); + }, + [width, containerWidth, setConfigurationListWidth], + ); + + const minWidth = Math.min( + RUN_CONFIGURATION_LIST_MIN_WIDTH, + getRunConfigurationListMaxWidth(containerWidth || 1280), + ); + const maxWidth = getRunConfigurationListMaxWidth(containerWidth || 1280); + + return ( +
+
+ {list} +
+
+
+
+ {isResizing ?
: null} +
{content}
+
+ ); +} diff --git a/windows/tauri/src/features/run/components/run-pane.tsx b/windows/tauri/src/features/run/components/run-pane.tsx index d117c2223..db99daf24 100644 --- a/windows/tauri/src/features/run/components/run-pane.tsx +++ b/windows/tauri/src/features/run/components/run-pane.tsx @@ -27,6 +27,7 @@ import { workspaceRelativePath, } from "../utils/run-configuration"; import { RunConfigurationEditor } from "./run-configuration-editor"; +import { RunConfigurationListSplit } from "./run-configuration-list-split"; import { JavaCupIcon, RunIcon } from "./run-icon"; import { RunOutputText } from "./run-output-text"; @@ -191,68 +192,74 @@ export default function RunPane() { ) : null}
) : ( -
-
-
{t("run.configurations")}
-
- void actions.runConfiguration(configuration.id, currentFile)} - onEdit={setEditingId} - /> - void actions.runConfiguration(configuration.id, currentFile)} - onEdit={setEditingId} - /> -
-
-
-
-
{t("run.configurationDetails")}
- {selectedConfiguration ? ( -
- {t("run.type")} - {selectedConfiguration.kindTitle} - {selectedConfiguration.mainClass ? ( - <> - {t("run.mainClass")} - {selectedConfiguration.mainClass} - - ) : null} -
- ) : ( -
{t("run.selectConfiguration")}
- )} -
-
- -
- {isSelectedRunning ? ( - void actions.writeStdin(selectedSessionId ?? PRIMARY_SESSION_ID, input)} - /> - ) : null} - {generationNotice?.startsWith("generated:") ? ( -
- {t("run.generatedEntries", { count: generationNotice.slice("generated:".length) })} + +
+ {t("run.configurations")}
- ) : null} -
-
+
+ void actions.runConfiguration(configuration.id, currentFile)} + onEdit={setEditingId} + /> + void actions.runConfiguration(configuration.id, currentFile)} + onEdit={setEditingId} + /> +
+ + } + content={ + <> +
+
{t("run.configurationDetails")}
+ {selectedConfiguration ? ( +
+ {t("run.type")} + {selectedConfiguration.kindTitle} + {selectedConfiguration.mainClass ? ( + <> + {t("run.mainClass")} + {selectedConfiguration.mainClass} + + ) : null} +
+ ) : ( +
{t("run.selectConfiguration")}
+ )} +
+
+ +
+ {isSelectedRunning ? ( + void actions.writeStdin(selectedSessionId ?? PRIMARY_SESSION_ID, input)} + /> + ) : null} + {generationNotice?.startsWith("generated:") ? ( +
+ {t("run.generatedEntries", { count: generationNotice.slice("generated:".length) })} +
+ ) : null} + + } + /> )} {editingConfiguration ? ( diff --git a/windows/tauri/src/features/run/stores/run-preferences.store.ts b/windows/tauri/src/features/run/stores/run-preferences.store.ts new file mode 100644 index 000000000..f97ff7109 --- /dev/null +++ b/windows/tauri/src/features/run/stores/run-preferences.store.ts @@ -0,0 +1,35 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { createSelectors } from "@/utils/zustand-selectors"; +import { createSafeJSONStorage } from "@/utils/zustand-storage"; +import { RUN_CONFIGURATION_LIST_DEFAULT_WIDTH } from "../utils/run-configuration-list-layout"; + +interface RunPreferencesStore { + configurationListWidth: number; + actions: { + setConfigurationListWidth: (width: number) => void; + }; +} + +const useRunPreferencesStoreBase = create()( + persist( + (set) => ({ + configurationListWidth: RUN_CONFIGURATION_LIST_DEFAULT_WIDTH, + actions: { + setConfigurationListWidth: (configurationListWidth) => set({ configurationListWidth }), + }, + }), + { + name: "lithe-run-preferences", + storage: createSafeJSONStorage>(), + partialize: ({ actions: _, ...preferences }) => preferences, + merge: (persistedState, currentState) => ({ + ...currentState, + ...(persistedState as Partial), + actions: currentState.actions, + }), + }, + ), +); + +export const useRunPreferencesStore = createSelectors(useRunPreferencesStoreBase); diff --git a/windows/tauri/src/features/run/utils/run-configuration-list-layout.test.ts b/windows/tauri/src/features/run/utils/run-configuration-list-layout.test.ts new file mode 100644 index 000000000..8d1de2e74 --- /dev/null +++ b/windows/tauri/src/features/run/utils/run-configuration-list-layout.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { + RUN_CONFIGURATION_LIST_DEFAULT_WIDTH, + RUN_CONFIGURATION_LIST_MAX_WIDTH, + RUN_CONFIGURATION_LIST_MIN_WIDTH, + clampRunConfigurationListWidth, + getRunConfigurationListMaxWidth, +} from "./run-configuration-list-layout"; + +describe("run configuration list layout", () => { + test("keeps the macOS default within the normal bottom-pane width", () => { + expect(clampRunConfigurationListWidth(RUN_CONFIGURATION_LIST_DEFAULT_WIDTH, 900)).toBe( + RUN_CONFIGURATION_LIST_DEFAULT_WIDTH, + ); + }); + + test("clamps below the minimum and above the absolute maximum", () => { + expect(clampRunConfigurationListWidth(80, 900)).toBe(RUN_CONFIGURATION_LIST_MIN_WIDTH); + expect(clampRunConfigurationListWidth(800, 900)).toBe(RUN_CONFIGURATION_LIST_MAX_WIDTH); + }); + + test("keeps at least the list minimum when the container is narrow", () => { + // Matches macOS: max(minimumListWidth, available) so a narrow pane still shows the list. + expect(getRunConfigurationListMaxWidth(400)).toBe(RUN_CONFIGURATION_LIST_MIN_WIDTH); + expect(clampRunConfigurationListWidth(230, 400)).toBe(RUN_CONFIGURATION_LIST_MIN_WIDTH); + }); + + test("falls back to a safe width for non-finite values", () => { + expect(clampRunConfigurationListWidth(Number.NaN, 900)).toBe(RUN_CONFIGURATION_LIST_DEFAULT_WIDTH); + }); +}); diff --git a/windows/tauri/src/features/run/utils/run-configuration-list-layout.ts b/windows/tauri/src/features/run/utils/run-configuration-list-layout.ts new file mode 100644 index 000000000..839dedf3b --- /dev/null +++ b/windows/tauri/src/features/run/utils/run-configuration-list-layout.ts @@ -0,0 +1,23 @@ +/** Matches macOS RunView configuration list sizing. */ +export const RUN_CONFIGURATION_LIST_DEFAULT_WIDTH = 230; +export const RUN_CONFIGURATION_LIST_MIN_WIDTH = 180; +export const RUN_CONFIGURATION_LIST_MAX_WIDTH = 420; +export const RUN_CONFIGURATION_LIST_MIN_CONTENT_WIDTH = 320; +export const RUN_CONFIGURATION_LIST_HANDLE_THICKNESS = 4; + +export function getRunConfigurationListMaxWidth(containerWidth: number): number { + const available = containerWidth - RUN_CONFIGURATION_LIST_HANDLE_THICKNESS - RUN_CONFIGURATION_LIST_MIN_CONTENT_WIDTH; + return Math.max( + RUN_CONFIGURATION_LIST_MIN_WIDTH, + Math.min(RUN_CONFIGURATION_LIST_MAX_WIDTH, available), + ); +} + +export function clampRunConfigurationListWidth(value: number, containerWidth: number): number { + const maxWidth = getRunConfigurationListMaxWidth(containerWidth); + const minWidth = Math.min(RUN_CONFIGURATION_LIST_MIN_WIDTH, maxWidth); + if (!Number.isFinite(value)) { + return Math.min(RUN_CONFIGURATION_LIST_DEFAULT_WIDTH, maxWidth); + } + return Math.max(minWidth, Math.min(value, maxWidth)); +} diff --git a/windows/tauri/src/features/run/utils/run-configuration-list-resize-session.test.ts b/windows/tauri/src/features/run/utils/run-configuration-list-resize-session.test.ts new file mode 100644 index 000000000..1982c1178 --- /dev/null +++ b/windows/tauri/src/features/run/utils/run-configuration-list-resize-session.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "bun:test"; +import { RUN_CONFIGURATION_LIST_DEFAULT_WIDTH } from "./run-configuration-list-layout"; +import { + RUN_CONFIGURATION_LIST_RESIZE_STEP, + nextRunConfigurationListWidthForKey, + startDocumentResizeSession, +} from "./run-configuration-list-resize-session"; + +type Listener = (event: Event) => void; + +function createFakeTarget() { + const listeners = new Map>(); + + return { + listeners, + addEventListener(type: string, listener: EventListenerOrEventListenerObject) { + const set = listeners.get(type) ?? new Set(); + set.add(listener as Listener); + listeners.set(type, set); + }, + removeEventListener(type: string, listener: EventListenerOrEventListenerObject) { + listeners.get(type)?.delete(listener as Listener); + }, + dispatch(type: string, event: Event) { + for (const listener of [...(listeners.get(type) ?? [])]) { + listener(event); + } + }, + }; +} + +describe("run configuration list resize session", () => { + test("ArrowLeft and ArrowRight move by a fixed step and ignore other keys", () => { + expect(nextRunConfigurationListWidthForKey(230, "ArrowRight", 900)).toBe( + 230 + RUN_CONFIGURATION_LIST_RESIZE_STEP, + ); + expect(nextRunConfigurationListWidthForKey(230, "ArrowLeft", 900)).toBe( + 230 - RUN_CONFIGURATION_LIST_RESIZE_STEP, + ); + expect(nextRunConfigurationListWidthForKey(230, "Home", 900)).toBeNull(); + }); + + test("commits the final width when pointerup completes a drag", () => { + const target = createFakeTarget(); + const view = createFakeTarget(); + const bodyStyle = { cursor: "", userSelect: "" }; + const applied: number[] = []; + const committed: number[] = []; + const active: boolean[] = []; + let frameId = 0; + const frames = new Map(); + + const session = startDocumentResizeSession({ + startX: 100, + startWidth: RUN_CONFIGURATION_LIST_DEFAULT_WIDTH, + clampWidth: (value) => value, + applyWidth: (width) => applied.push(width), + commitWidth: (width) => committed.push(width), + onActiveChange: (isActive) => active.push(isActive), + target, + view, + bodyStyle, + scheduleFrame: (callback) => { + frameId += 1; + frames.set(frameId, callback); + return frameId; + }, + cancelFrame: (handle) => { + frames.delete(handle); + }, + }); + + expect(active).toEqual([true]); + expect(bodyStyle.cursor).toBe("col-resize"); + expect(bodyStyle.userSelect).toBe("none"); + + target.dispatch("pointermove", { clientX: 140 } as PointerEvent); + expect(frames.size).toBe(1); + for (const callback of frames.values()) { + callback(0); + } + expect(applied).toEqual([270]); + + target.dispatch("pointerup", new Event("pointerup")); + expect(committed).toEqual([270]); + expect(active[active.length - 1]).toBe(false); + expect(bodyStyle.cursor).toBe(""); + expect(bodyStyle.userSelect).toBe(""); + expect(target.listeners.get("pointermove")?.size ?? 0).toBe(0); + expect(view.listeners.get("blur")?.size ?? 0).toBe(0); + + // Idempotent after completion. + session.dispose({ commit: true }); + expect(committed).toEqual([270]); + }); + + test("cleans listeners and body styles on blur without leaving a pending frame", () => { + const target = createFakeTarget(); + const view = createFakeTarget(); + const bodyStyle = { cursor: "auto", userSelect: "auto" }; + const committed: number[] = []; + let frameId = 0; + const frames = new Map(); + + startDocumentResizeSession({ + startX: 50, + startWidth: 200, + clampWidth: (value) => value, + applyWidth: () => undefined, + commitWidth: (width) => committed.push(width), + target, + view, + bodyStyle, + scheduleFrame: (callback) => { + frameId += 1; + frames.set(frameId, callback); + return frameId; + }, + cancelFrame: (handle) => { + frames.delete(handle); + }, + }); + + target.dispatch("pointermove", { clientX: 80 } as PointerEvent); + expect(frames.size).toBe(1); + + view.dispatch("blur", new Event("blur")); + expect(committed).toEqual([230]); + expect(frames.size).toBe(0); + expect(bodyStyle.cursor).toBe(""); + expect(bodyStyle.userSelect).toBe(""); + expect(target.listeners.get("pointerup")?.size ?? 0).toBe(0); + }); + + test("dispose on unmount cancels the pending frame and can skip commit", () => { + const target = createFakeTarget(); + const view = createFakeTarget(); + const bodyStyle = { cursor: "", userSelect: "" }; + const committed: number[] = []; + let frameId = 0; + const frames = new Map(); + + const session = startDocumentResizeSession({ + startX: 10, + startWidth: 210, + clampWidth: (value) => value, + applyWidth: () => undefined, + commitWidth: (width) => committed.push(width), + target, + view, + bodyStyle, + scheduleFrame: (callback) => { + frameId += 1; + frames.set(frameId, callback); + return frameId; + }, + cancelFrame: (handle) => { + frames.delete(handle); + }, + }); + + target.dispatch("pointermove", { clientX: 40 } as PointerEvent); + session.dispose({ commit: false }); + + expect(committed).toEqual([]); + expect(frames.size).toBe(0); + expect(bodyStyle.cursor).toBe(""); + expect(target.listeners.get("pointermove")?.size ?? 0).toBe(0); + }); +}); diff --git a/windows/tauri/src/features/run/utils/run-configuration-list-resize-session.ts b/windows/tauri/src/features/run/utils/run-configuration-list-resize-session.ts new file mode 100644 index 000000000..8c7df64c7 --- /dev/null +++ b/windows/tauri/src/features/run/utils/run-configuration-list-resize-session.ts @@ -0,0 +1,118 @@ +import { + clampRunConfigurationListWidth, +} from "./run-configuration-list-layout"; + +/** Matches the file-navigator sidebar keyboard resize step. */ +export const RUN_CONFIGURATION_LIST_RESIZE_STEP = 16; + +export function nextRunConfigurationListWidthForKey( + currentWidth: number, + key: string, + containerWidth: number, +): number | null { + if (key !== "ArrowLeft" && key !== "ArrowRight") { + return null; + } + + const delta = + key === "ArrowRight" + ? RUN_CONFIGURATION_LIST_RESIZE_STEP + : -RUN_CONFIGURATION_LIST_RESIZE_STEP; + return clampRunConfigurationListWidth(currentWidth + delta, containerWidth); +} + +export interface DocumentResizeSessionOptions { + startX: number; + startWidth: number; + clampWidth: (value: number) => number; + applyWidth: (width: number) => void; + commitWidth: (width: number) => void; + onActiveChange?: (active: boolean) => void; + target?: Pick; + view?: Pick; + bodyStyle?: { cursor: string; userSelect: string }; + scheduleFrame?: (callback: FrameRequestCallback) => number; + cancelFrame?: (handle: number) => void; +} + +export interface DocumentResizeSession { + /** Removes listeners, resets body styles, cancels RAF, and optionally commits. */ + dispose: (options?: { commit?: boolean }) => void; +} + +/** + * Owns pointer-drag listeners for a horizontal list resize. + * Cleanup is idempotent and safe for mouseup, blur, pointercancel, and unmount. + */ +export function startDocumentResizeSession( + options: DocumentResizeSessionOptions, +): DocumentResizeSession { + const target = options.target ?? document; + const view = options.view ?? window; + const bodyStyle = options.bodyStyle ?? document.body.style; + const scheduleFrame = options.scheduleFrame ?? requestAnimationFrame.bind(window); + const cancelFrame = options.cancelFrame ?? cancelAnimationFrame.bind(window); + + let currentWidth = options.startWidth; + let rafId: number | null = null; + let disposed = false; + + const cleanup = (commit: boolean) => { + if (disposed) { + return; + } + disposed = true; + + if (rafId !== null) { + cancelFrame(rafId); + rafId = null; + } + + target.removeEventListener("pointermove", handlePointerMove); + target.removeEventListener("pointerup", handlePointerEnd); + target.removeEventListener("pointercancel", handlePointerEnd); + view.removeEventListener("blur", handleBlur); + + bodyStyle.cursor = ""; + bodyStyle.userSelect = ""; + options.onActiveChange?.(false); + + if (commit) { + options.commitWidth(currentWidth); + } + }; + + const handlePointerMove = (event: Event) => { + const pointerEvent = event as PointerEvent; + currentWidth = options.clampWidth(options.startWidth + (pointerEvent.clientX - options.startX)); + if (rafId !== null) { + cancelFrame(rafId); + } + rafId = scheduleFrame(() => { + options.applyWidth(currentWidth); + }); + }; + + const handlePointerEnd = () => { + cleanup(true); + }; + + const handleBlur = () => { + cleanup(true); + }; + + options.onActiveChange?.(true); + bodyStyle.cursor = "col-resize"; + bodyStyle.userSelect = "none"; + + target.addEventListener("pointermove", handlePointerMove); + target.addEventListener("pointerup", handlePointerEnd); + target.addEventListener("pointercancel", handlePointerEnd); + view.addEventListener("blur", handleBlur); + + return { + dispose: ({ commit = true } = {}) => { + cleanup(commit); + }, + }; +} diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index facbe534c..62d519c32 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1347,6 +1347,7 @@ const catalogs = { "run.clearOutput": "Clear run output", "run.minimize": "Minimize", "run.configurations": "Run configurations", + "run.resizeConfigurationList": "Resize run configuration list", "run.services": "Services", "run.applications": "Applications", "run.configurationDetails": "Configuration details", @@ -5334,6 +5335,7 @@ const catalogs = { "run.clearOutput": "清除运行输出", "run.minimize": "最小化", "run.configurations": "运行配置", + "run.resizeConfigurationList": "调整运行配置列表宽度", "run.services": "服务", "run.applications": "应用", "run.configurationDetails": "配置详情",