diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index f69bd2071a565..d59cd1aa44997 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -18,12 +18,10 @@ import { type MouseEvent as ReactMouseEvent, memo, useImperativeHandle, - useEffect, - useEffectEvent, - useLayoutEffect, useRef, useState, } from "react"; +import { flushSync } from "react-dom"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; @@ -465,144 +463,158 @@ function labelTextWidth(label: HTMLElement, range: Range): number { return Number.isFinite(maxWidth) ? Math.min(width, maxWidth) : width; } -function useLabelsOverflow(element: HTMLDivElement | null): boolean { +function animateLabelWidths( + previousRects: ReadonlyMap, + animations: Map, +) { + for (const animation of animations.values()) animation.cancel(); + animations.clear(); + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + for (const [label, previousRect] of previousRects) { + if (!label.isConnected) continue; + const nextWidth = label.getBoundingClientRect().width; + if (Math.abs(previousRect.width - nextWidth) < 0.5) continue; + + const animation = label.animate( + [ + { width: `${previousRect.width}px`, maxWidth: `${previousRect.width}px` }, + { width: `${nextWidth}px`, maxWidth: `${nextWidth}px` }, + ], + { + duration: COMPOSER_CONTEXT_MOTION_DURATION_MS, + easing: COMPOSER_CONTEXT_MOTION_EASING, + fill: "backwards", + }, + ); + animations.set(label, animation); + animation.addEventListener( + "finish", + () => { + if (animations.get(label) === animation) animations.delete(label); + }, + { once: true }, + ); + } +} + +/** + * Whether the strip's labels must collapse to icons. The strip is measured when + * it, a control group or a hosted control resizes, when its text or children + * change, and when fonts load, which covers everything the widths depend on. + */ +function useLabelsOverflow(): readonly [ + attach: (element: HTMLDivElement | null) => (() => void) | undefined, + overflows: boolean, +] { const [overflows, setOverflows] = useState(false); - const pendingLabelRectsRef = useRef | null>(null); - const labelAnimationsRef = useRef(new Map()); - const measure = useEffectEvent(() => { - const current = element; - const compact = overflows; + + const attach = (current: HTMLDivElement | null) => { if (!current) return; - const stripWidth = current.clientWidth; - if (stripWidth === 0) return; - const stripStyle = getComputedStyle(current); - const available = - stripWidth - - (Number.parseFloat(stripStyle.paddingInlineStart) || 0) - - (Number.parseFloat(stripStyle.paddingInlineEnd) || 0); - const contentWidth = (parent: Element): number => { - const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; - let width = 0; - let counted = 0; - for (const child of parent.children) { + const labelAnimations = new Map(); + + const measure = () => { + // The committed attribute, not render state, so a stale closure cannot misread it. + const compact = current.hasAttribute("data-compact"); + const stripWidth = current.clientWidth; + if (stripWidth === 0) return; + const stripStyle = getComputedStyle(current); + const available = + stripWidth - + (Number.parseFloat(stripStyle.paddingInlineStart) || 0) - + (Number.parseFloat(stripStyle.paddingInlineEnd) || 0); + const contentWidth = (parent: Element): number => { + const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; + let width = 0; + let counted = 0; + for (const child of parent.children) { + if (!(child instanceof HTMLElement)) continue; + if (child.offsetWidth === 0) continue; + const style = getComputedStyle(child); + const position = style.position; + if (position === "absolute" || position === "fixed") continue; + width += + child.offsetWidth + + (Number.parseFloat(style.marginInlineStart) || 0) + + (Number.parseFloat(style.marginInlineEnd) || 0); + counted += 1; + } + return width + gap * Math.max(0, counted - 1); + }; + const stripGap = Number.parseFloat(stripStyle.columnGap) || 0; + let needed = 0; + let groups = 0; + for (const child of current.children) { if (!(child instanceof HTMLElement)) continue; - if (child.offsetWidth === 0) continue; - const style = getComputedStyle(child); - const position = style.position; - if (position === "absolute" || position === "fixed") continue; - width += - child.offsetWidth + - (Number.parseFloat(style.marginInlineStart) || 0) + - (Number.parseFloat(style.marginInlineEnd) || 0); - counted += 1; + // Reserve overflowed controls' width to prevent repeated hiding and expansion. + const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]') + ? child.querySelector('[data-chat-composer-resting-controls="true"]') + : null; + const hostedMeasurement = hostedControls + ? measureRestingComposerControls(hostedControls) + : null; + const width = hostedMeasurement + ? resolveRestingComposerControlsNaturalWidth(hostedMeasurement) + : contentWidth(hostedControls ?? child); + if (width <= 1) continue; + groups += 1; + needed += width; } - return width + gap * Math.max(0, counted - 1); - }; - const stripGap = Number.parseFloat(stripStyle.columnGap) || 0; - let needed = 0; - let groups = 0; - for (const child of current.children) { - if (!(child instanceof HTMLElement)) continue; - // Reserve overflowed controls' width to prevent repeated hiding and expansion. - const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]') - ? child.querySelector('[data-chat-composer-resting-controls="true"]') - : null; - const hostedMeasurement = hostedControls - ? measureRestingComposerControls(hostedControls) - : null; - const width = hostedMeasurement - ? resolveRestingComposerControlsNaturalWidth(hostedMeasurement) - : contentWidth(hostedControls ?? child); - if (width <= 1) continue; - groups += 1; - needed += width; - } - needed += stripGap * Math.max(0, groups - 1); - const range = document.createRange(); - for (const label of current.querySelectorAll("[data-composer-label]")) { - needed += Math.max(0, labelTextWidth(label, range) - label.getBoundingClientRect().width); - } - const nextOverflows = resolveContextStripLabelsCompact({ - compact, - neededWidth: needed, - availableWidth: available, - }); - if (nextOverflows !== compact) { - pendingLabelRectsRef.current = new Map( + needed += stripGap * Math.max(0, groups - 1); + const range = document.createRange(); + for (const label of current.querySelectorAll("[data-composer-label]")) { + needed += Math.max(0, labelTextWidth(label, range) - label.getBoundingClientRect().width); + } + const nextOverflows = resolveContextStripLabelsCompact({ + compact, + neededWidth: needed, + availableWidth: available, + }); + if (nextOverflows === compact) return; + const previousRects = new Map( Array.from(current.querySelectorAll(COMPOSER_CONTEXT_LABEL_SELECTOR)).map( - (label) => [label, label.getBoundingClientRect()], + (label) => [label, label.getBoundingClientRect()] as const, ), ); - } - setOverflows(nextOverflows); - }); - - useLayoutEffect(() => { - const previousRects = pendingLabelRectsRef.current; - if (!previousRects) return; - pendingLabelRectsRef.current = null; - - for (const animation of labelAnimationsRef.current.values()) { - animation.cancel(); - } - labelAnimationsRef.current.clear(); - - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - - for (const [label, previousRect] of previousRects) { - if (!label.isConnected) continue; - const nextWidth = label.getBoundingClientRect().width; - if (Math.abs(previousRect.width - nextWidth) < 0.5) continue; + flushSync(() => setOverflows(nextOverflows)); + animateLabelWidths(previousRects, labelAnimations); + }; - const animation = label.animate( + let stopResize = () => {}; + const observeSizes = () => { + stopResize(); + stopResize = observeResize( [ - { width: `${previousRect.width}px`, maxWidth: `${previousRect.width}px` }, - { width: `${nextWidth}px`, maxWidth: `${nextWidth}px` }, + current, + ...Array.from(current.children), + ...Array.from(current.querySelectorAll("[data-chat-composer-resting-controls='true']")), + ...Array.from(current.querySelectorAll(COMPOSER_CONTEXT_LABEL_SELECTOR)), ], - { - duration: COMPOSER_CONTEXT_MOTION_DURATION_MS, - easing: COMPOSER_CONTEXT_MOTION_EASING, - fill: "backwards", - }, + measure, ); - labelAnimationsRef.current.set(label, animation); - animation.addEventListener( - "finish", - () => { - if (labelAnimationsRef.current.get(label) === animation) { - labelAnimationsRef.current.delete(label); - } - }, - { once: true }, + }; + observeSizes(); + // Text and control changes alter natural widths without resizing a hidden + // or squeezed label, so they re-measure too. Only added or removed elements + // can change the observed set; re-subscribing delivers a measure itself. + const mutations = new MutationObserver((records) => { + const elementsChanged = records.some((record) => + [...record.addedNodes, ...record.removedNodes].some((node) => node instanceof Element), ); - } - }); - - useEffect( - () => () => { - for (const animation of labelAnimationsRef.current.values()) { - animation.cancel(); - } - }, - [], - ); - - useLayoutEffect(() => { - // oxlint-disable-next-line react/set-state-in-effect -- The label mode comes from measuring the committed DOM, which render cannot read. - measure(); - }); - - useEffect(() => { - if (!element) return; - const stopObserving = observeResize(element, measure); + if (elementsChanged) observeSizes(); + else measure(); + }); + mutations.observe(current, { childList: true, characterData: true, subtree: true }); document.fonts.addEventListener("loadingdone", measure); return () => { - stopObserving(); + stopResize(); + mutations.disconnect(); document.fonts.removeEventListener("loadingdone", measure); + for (const animation of labelAnimations.values()) animation.cancel(); }; - }, [element]); + }; - return overflows; + return [attach, overflows] as const; } export const BranchToolbar = memo(function BranchToolbar({ @@ -698,8 +710,7 @@ export const BranchToolbar = memo(function BranchToolbar({ activeEnvironment: activeEnvironmentOption, canPickEnvironment: hasMultipleEnvironments, }); - const [stripElement, setStripElement] = useState(null); - const labelsOverflow = useLabelsOverflow(stripElement); + const [attachStrip, labelsOverflow] = useLabelsOverflow(); if (!activeProject) return null; @@ -753,7 +764,7 @@ export const BranchToolbar = memo(function BranchToolbar({ return ( , "children"> & { readonly children: ReactNode; readonly escapeLabel?: ReactNode; @@ -28,19 +45,11 @@ export function CommandPaletteContent({ testId, ...commandProps }: CommandPaletteContentProps) { - const inputRef = useRef(null); - - // Wait until the dialog records restoration focus before focusing a replaced input. - useEffect(() => { - const frame = requestAnimationFrame(() => inputRef.current?.focus()); - return () => cancelAnimationFrame(frame); - }, []); - return (
- + {inputAccessory}
{ - if (!open) return; - const frame = window.requestAnimationFrame(() => { - referenceInputRef.current?.focus(); - referenceInputRef.current?.select(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - }, [open]); - const parsedReference = parsePullRequestReference(reference); const parsedDebouncedReference = parsePullRequestReference(debouncedReference); const pullRequestResolution = usePullRequestResolution({ @@ -205,7 +194,14 @@ export function PullRequestThreadDialog({ } }} > - + { + referenceInputRef.current?.focus(); + referenceInputRef.current?.select(); + return false; + }} + > diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 61feb29ab962e..7f44194f98354 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -19,13 +19,7 @@ import { PlayIcon, WrenchIcon, } from "lucide-react"; -import React, { - type FormEvent, - type KeyboardEvent, - useLayoutEffect, - useRef, - useState, -} from "react"; +import React, { type FormEvent, type KeyboardEvent, useEffect, useRef, useState } from "react"; import { keybindingValueForCommand, @@ -172,6 +166,15 @@ export function ProjectScriptEditorDialog({ const setValidationError = (message: string | null) => { if (request !== null) setErrorState({ request, message }); }; + // The last opened request stays shown while the dialog animates closed. Each + // new request mounts a fresh form, which drops the previous request's save. + const [shown, setShown] = useState<{ + readonly request: ProjectScriptEditorRequest; + readonly key: number; + } | null>(request === null ? null : { request, key: 0 }); + if (request !== null && request !== shown?.request) { + setShown({ request, key: (shown?.key ?? 0) + 1 }); + } const [deleteConfirmOpen, setDeleteConfirmOpen] = useComposerMenuState(!canEditActions); const [deleteName, setDeleteName] = useState(""); @@ -185,20 +188,23 @@ export function ProjectScriptEditorDialog({ > {/* The form owns its fields so a keystroke re-renders it, not the dialog chrome. */} - { - setDeleteName(name); - setDeleteConfirmOpen(true); - }} - onClose={onClose} - /> + {shown ? ( + { + setDeleteName(name); + setDeleteConfirmOpen(true); + }} + onClose={onClose} + /> + ) : null} @@ -246,7 +252,7 @@ function ProjectScriptEditorForm({ }: { environmentId: EnvironmentId; editScope: typeof AuthOrchestrationOperateScope | typeof AuthSettingsWriteScope; - request: ProjectScriptEditorRequest | null; + request: ProjectScriptEditorRequest; scripts: ReadonlyArray; validationError: string | null; onValidationError: (error: string | null) => void; @@ -260,52 +266,34 @@ function ProjectScriptEditorForm({ const canEditActions = useEnvironmentScope(environmentId, editScope); const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope); const formId = React.useId(); - const [name, setName] = useState(""); - const [command, setCommand] = useState(""); - const [icon, setIcon] = useState("play"); + const [name, setName] = useState(request.initial.name); + const [command, setCommand] = useState(request.initial.command); + const [icon, setIcon] = useState(request.initial.icon); const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions); - const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); - const [waitForSetup, setWaitForSetup] = useState(false); - const [runOnSettle, setRunOnSettle] = useState(false); - const [keybinding, setKeybinding] = useState(""); - const [previewUrl, setPreviewUrl] = useState(""); - const [autoOpenPreview, setAutoOpenPreview] = useState(false); - const [savingRequest, setSavingRequest] = useState(null); - const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null); - - const isEditing = request?.scriptId != null; - const isSaving = request !== null && savingRequest === request; - - useLayoutEffect( + const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState( + request.initial.runOnWorktreeCreate, + ); + const [waitForSetup, setWaitForSetup] = useState(request.initial.waitForSetup); + const [runOnSettle, setRunOnSettle] = useState(request.initial.runOnSettle); + const [keybinding, setKeybinding] = useState(request.initial.keybinding ?? ""); + const [previewUrl, setPreviewUrl] = useState(request.initial.previewUrl ?? ""); + const [autoOpenPreview, setAutoOpenPreview] = useState(request.initial.autoOpenPreview); + const [isSaving, setIsSaving] = useState(false); + const pendingSubmissionRef = useRef(null); + // A save that settles after this form is replaced must not close or error + // the request that replaced it. + useEffect( () => () => { - if (pendingSubmissionRef.current?.request === request) { - pendingSubmissionRef.current = null; - } + pendingSubmissionRef.current = null; }, - [request], + [], ); - const [formRequest, setFormRequest] = useState(null); - if (request !== formRequest) { - setFormRequest(request); - if (request) { - setName(request.initial.name); - setCommand(request.initial.command); - setIcon(request.initial.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate); - setWaitForSetup(request.initial.waitForSetup); - setRunOnSettle(request.initial.runOnSettle); - setKeybinding(request.initial.keybinding ?? ""); - setPreviewUrl(request.initial.previewUrl ?? ""); - setAutoOpenPreview(request.initial.autoOpenPreview); - setSavingRequest(null); - } - } + const isEditing = request.scriptId != null; const close = () => { pendingSubmissionRef.current = null; - setSavingRequest(null); + setIsSaving(false); setIconPickerOpen(false); onClose(); }; @@ -326,7 +314,7 @@ function ProjectScriptEditorForm({ const submit = async (event: FormEvent) => { event.preventDefault(); - if (!request || pendingSubmissionRef.current !== null) return; + if (pendingSubmissionRef.current !== null) return; if (!readEnvironmentScope(environmentId, editScope)) { setValidationError("This connection cannot change project actions."); return; @@ -381,9 +369,9 @@ function ProjectScriptEditorForm({ return; } - const submission = { request }; + const submission = {}; pendingSubmissionRef.current = submission; - setSavingRequest(request); + setIsSaving(true); setIconPickerOpen(false); try { const result = await onSubmit(request.scriptId, payload); @@ -404,7 +392,7 @@ function ProjectScriptEditorForm({ } if (pendingSubmissionRef.current === submission) { pendingSubmissionRef.current = null; - setSavingRequest(null); + setIsSaving(false); } }; diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 22a68eee9991b..fd6d26017d0a8 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -1,7 +1,7 @@ import { Spinner } from "~/components/ui/spinner"; import { AuthFilesystemReadScope, type ProjectContentMatch } from "@t3tools/contracts"; -import { useEffect, useState, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; import { useRenderedAppearance, useTheme } from "~/hooks/useTheme"; @@ -97,6 +97,10 @@ function EmptyContentSearchDialog() { ); } +const scrollSelectedRowIntoView = (row: HTMLElement | null) => { + row?.scrollIntoView({ block: "nearest" }); +}; + function OpenContentSearchDialog(props: { readonly onOpenChange: (open: boolean) => void; readonly target: ActiveProjectTarget; @@ -134,12 +138,6 @@ function OpenContentSearchDialog(props: { const visibleMatches = matches.slice(0, visibleCount); const groups = groupMatches(visibleMatches); - useEffect(() => { - document - .querySelector(`[data-content-search-result="${selectedIndex}"]`) - ?.scrollIntoView({ block: "nearest" }); - }, [selectedIndex]); - const observeLoadMoreSentinel = (sentinel: HTMLElement | null) => { if (!sentinel) return; const observer = new IntersectionObserver((entries) => { @@ -280,7 +278,9 @@ function OpenContentSearchDialog(props: {