From c511f85a125718ec0936f8f0dd8d1f79e7ac9a47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Fri, 4 Sep 2026 21:22:02 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(windows):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E9=85=8D=E7=BD=AE=E5=88=97=E8=A1=A8=E5=B7=A6?= =?UTF-8?q?=E5=8F=B3=E6=8B=96=E6=8B=BD=E8=B0=83=E6=95=B4=E5=AE=BD=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #462 Co-authored-by: Cursor --- windows/tauri/src-tauri/Cargo.toml | 1 + .../run-configuration-list-split.tsx | 143 ++++++++++++++++++ .../src/features/run/components/run-pane.tsx | 129 ++++++++-------- .../run/stores/run-preferences.store.ts | 35 +++++ .../run-configuration-list-layout.test.ts | 31 ++++ .../utils/run-configuration-list-layout.ts | 23 +++ windows/tauri/src/i18n/locale.ts | 2 + 7 files changed, 303 insertions(+), 61 deletions(-) create mode 100644 windows/tauri/src/features/run/components/run-configuration-list-split.tsx create mode 100644 windows/tauri/src/features/run/stores/run-preferences.store.ts create mode 100644 windows/tauri/src/features/run/utils/run-configuration-list-layout.test.ts create mode 100644 windows/tauri/src/features/run/utils/run-configuration-list-layout.ts 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.tsx b/windows/tauri/src/features/run/components/run-configuration-list-split.tsx new file mode 100644 index 000000000..e8b1d9e78 --- /dev/null +++ b/windows/tauri/src/features/run/components/run-configuration-list-split.tsx @@ -0,0 +1,143 @@ +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"; + +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 on mouseup, + * 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 [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], + ); + + 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]); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + setIsResizing(true); + + const startX = event.clientX; + const startWidth = width; + let currentWidth = startWidth; + let rafId: number | null = null; + const listEl = listRef.current; + + const handleMouseMove = (moveEvent: MouseEvent) => { + currentWidth = clampWidth(startWidth + (moveEvent.clientX - startX)); + if (rafId !== null) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + if (listEl) { + listEl.style.width = `${currentWidth}px`; + } + }); + }; + + const handleMouseUp = () => { + if (rafId !== null) cancelAnimationFrame(rafId); + setWidth(currentWidth); + setIsResizing(false); + setConfigurationListWidth(currentWidth); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, + [width, clampWidth, 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/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 705a01365..9f5580992 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", @@ -5333,6 +5334,7 @@ const catalogs = { "run.clearOutput": "清除运行输出", "run.minimize": "最小化", "run.configurations": "运行配置", + "run.resizeConfigurationList": "调整运行配置列表宽度", "run.services": "服务", "run.applications": "应用", "run.configurationDetails": "配置详情", From ab99db1eb507a70428ae244ad070718fc0f82b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Fri, 4 Sep 2026 22:01:20 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(windows):=20=E5=AE=8C=E5=96=84=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E9=85=8D=E7=BD=AE=E5=88=97=E8=A1=A8=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E6=B8=85=E7=90=86=E4=B8=8E=E9=94=AE=E7=9B=98=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 响应 PR #475 审查:提取可复用拖拽会话,在 blur/pointercancel/卸载时清理监听与 RAF,并支持方向键调整宽度。 Co-authored-by: Cursor --- .../run-configuration-list-split.test.ts | 13 ++ .../run-configuration-list-split.tsx | 100 +++++++---- ...-configuration-list-resize-session.test.ts | 170 ++++++++++++++++++ .../run-configuration-list-resize-session.ts | 118 ++++++++++++ 4 files changed, 368 insertions(+), 33 deletions(-) create mode 100644 windows/tauri/src/features/run/components/run-configuration-list-split.test.ts create mode 100644 windows/tauri/src/features/run/utils/run-configuration-list-resize-session.test.ts create mode 100644 windows/tauri/src/features/run/utils/run-configuration-list-resize-session.ts 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 index e8b1d9e78..71abce5a2 100644 --- a/windows/tauri/src/features/run/components/run-configuration-list-split.tsx +++ b/windows/tauri/src/features/run/components/run-configuration-list-split.tsx @@ -9,6 +9,11 @@ import { 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; @@ -17,8 +22,8 @@ interface RunConfigurationListSplitProps { /** * Local layout container for the Run tool window's configuration list. - * Keeps drag width mutations out of RunPane and only commits on mouseup, - * matching macOS LitheSplitPaneView persistence. + * 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(); @@ -28,6 +33,8 @@ export function RunConfigurationListSplit({ list, content }: RunConfigurationLis ); 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), @@ -39,6 +46,18 @@ export function RunConfigurationListSplit({ list, content }: RunConfigurationLis [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; @@ -59,44 +78,58 @@ export function RunConfigurationListSplit({ list, content }: RunConfigurationLis setWidth(clampWidth(storedWidth)); }, [storedWidth, clampWidth]); - const handleMouseDown = useCallback( - (event: React.MouseEvent) => { + 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(); - setIsResizing(true); + sessionRef.current?.dispose({ commit: true }); - const startX = event.clientX; - const startWidth = width; - let currentWidth = startWidth; - let rafId: number | null = null; const listEl = listRef.current; - - const handleMouseMove = (moveEvent: MouseEvent) => { - currentWidth = clampWidth(startWidth + (moveEvent.clientX - startX)); - if (rafId !== null) cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(() => { + sessionRef.current = startDocumentResizeSession({ + startX: event.clientX, + startWidth: width, + clampWidth, + applyWidth: (nextWidth) => { if (listEl) { - listEl.style.width = `${currentWidth}px`; + listEl.style.width = `${nextWidth}px`; } - }); - }; + }, + commitWidth, + onActiveChange: (active) => { + if (isMountedRef.current) { + setIsResizing(active); + } + }, + }); + }, + [width, clampWidth, commitWidth], + ); - const handleMouseUp = () => { - if (rafId !== null) cancelAnimationFrame(rafId); - setWidth(currentWidth); - setIsResizing(false); - setConfigurationListWidth(currentWidth); - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - }; + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const nextWidth = nextRunConfigurationListWidthForKey( + width, + event.key, + containerWidth || 1280, + ); + if (nextWidth == null) { + return; + } - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; + event.preventDefault(); + setWidth(nextWidth); + setConfigurationListWidth(nextWidth); }, - [width, clampWidth, setConfigurationListWidth], + [width, containerWidth, setConfigurationListWidth], ); const minWidth = Math.min( @@ -114,7 +147,8 @@ export function RunConfigurationListSplit({ list, content }: RunConfigurationLis > {list}
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.at(-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); + }, + }; +} From fcd604f29c7f68192724f43a0fa53ed99e24f142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Fri, 4 Sep 2026 22:14:15 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(windows):=20=E4=BF=AE=E5=A4=8D=20CI=20t?= =?UTF-8?q?ypecheck=20=E5=AF=B9=20Array.at=20=E7=9A=84=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../run/utils/run-configuration-list-resize-session.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 05248be26..1982c1178 100644 --- 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 @@ -83,7 +83,7 @@ describe("run configuration list resize session", () => { target.dispatch("pointerup", new Event("pointerup")); expect(committed).toEqual([270]); - expect(active.at(-1)).toBe(false); + expect(active[active.length - 1]).toBe(false); expect(bodyStyle.cursor).toBe(""); expect(bodyStyle.userSelect).toBe(""); expect(target.listeners.get("pointermove")?.size ?? 0).toBe(0);