diff --git a/webview-ui/src/KickstartCluster/ClusterInput.tsx b/webview-ui/src/KickstartCluster/ClusterInput.tsx index 7c5bac006..5042f28e0 100644 --- a/webview-ui/src/KickstartCluster/ClusterInput.tsx +++ b/webview-ui/src/KickstartCluster/ClusterInput.tsx @@ -13,12 +13,12 @@ import { Subscription, ToVsCodeMsgDef, } from "../../../src/webview-contract/webviewDefinitions/kickstartCluster"; -import { TextWithDropdown } from "../components/TextWithDropdown"; +import { SearchableDropdown } from "../components/SearchableDropdown"; import { Maybe, isNothing, just, nothing } from "../utilities/maybe"; import { EventHandlers } from "../utilities/state"; import { Validatable, hasMessage, invalid, isValid, isValueSet, missing, unset, valid } from "../utilities/validation"; import styles from "./KickstartCluster.module.css"; -import { ActivityStageList, statusClass, statusIcon } from "../components/ActivityStageList"; +import { ActivityStageList, CollapsibleStageGroup, statusClass, statusIcon } from "../components/ActivityStageList"; import { CostEstimateResult, EventDef, FlowActivity, ScanResult } from "./helpers/state"; interface ClusterInputProps { @@ -169,7 +169,9 @@ export function ClusterInput(props: ClusterInputProps) { const providerScanStages = subscriptionScanStages.filter((s) => s.stage === "providers"); const regionScanStages = subscriptionScanStages.filter((s) => s.stage !== "providers"); const preflightStages = props.activity.preflight?.stages ?? []; - const visiblePreflightStages = preflightStages.filter((s) => s.stage !== "role"); + // Hide `role` (surfaced via the permission warning banner) and `providers` (already shown under + // the Subscription field during the scan) so the preflight block stays a compact check list. + const visiblePreflightStages = preflightStages.filter((s) => s.stage !== "role" && s.stage !== "providers"); const preflightHasFailure = preflightStages.some((s) => s.status === "failed"); const preflightRunning = preflightStages.length > 0 && props.preflightCanProceed === null; const preflightAttempted = preflightStages.length > 0 || props.preflightCanProceed !== null; @@ -460,13 +462,12 @@ export function ClusterInput(props: ClusterInputProps) { ); } return ( - ""} - allowAddItem={false} + selectedValue={currentLocation || null} + getValue={(s) => s} onSelect={handleLocationSelect} /> ); @@ -490,13 +491,12 @@ export function ClusterInput(props: ClusterInputProps) { ); } return ( - g.name)} - selectedItem={existingResourceGroup || null} - getAddItemText={() => ""} - allowAddItem={false} + selectedValue={existingResourceGroup || null} + getValue={(s) => s} onSelect={(value) => setExistingResourceGroup(value ?? "")} /> ); @@ -604,13 +604,12 @@ export function ClusterInput(props: ClusterInputProps) { - ""} - allowAddItem={false} + selectedValue={selectedSubscriptionName} + getValue={(s) => s} onSelect={handleSubscriptionSelect} /> {providerScanStages.length > 0 && ( @@ -730,7 +729,7 @@ export function ClusterInput(props: ClusterInputProps) { {preflightAttempted && visiblePreflightStages.length > 0 && (
- +
)} diff --git a/webview-ui/src/KickstartCluster/ExistingClusterInput.tsx b/webview-ui/src/KickstartCluster/ExistingClusterInput.tsx index fd2134216..990534521 100644 --- a/webview-ui/src/KickstartCluster/ExistingClusterInput.tsx +++ b/webview-ui/src/KickstartCluster/ExistingClusterInput.tsx @@ -13,7 +13,7 @@ import { } from "../../../src/webview-contract/webviewDefinitions/kickstartCluster"; import { statusClass, statusIcon } from "../components/ActivityStageList"; import { ProgressRing } from "../components/ProgressRing"; -import { TextWithDropdown } from "../components/TextWithDropdown"; +import { SearchableDropdown } from "../components/SearchableDropdown"; import { EventHandlers } from "../utilities/state"; import { Validatable, isValid, isValueSet, unset } from "../utilities/validation"; import { deriveAcrName, getValidatedAcrName, randomSuffix, renderValidationMessage } from "./ClusterInput"; @@ -211,13 +211,12 @@ export function ExistingClusterInput(props: ExistingClusterInputProps) { ); } return ( - ""} - allowAddItem={false} + selectedValue={props.selectedCluster ? clusterLabel(props.selectedCluster) : null} + getValue={(s) => s} onSelect={handleClusterSelect} /> ); @@ -243,13 +242,12 @@ export function ExistingClusterInput(props: ExistingClusterInputProps) { - ""} - allowAddItem={false} + selectedValue={effectiveAcrName} + getValue={(s) => s} onSelect={(value) => setSelectedAcrName(value)} /> {selectedAcr && {selectedAcr.loginServer}} @@ -346,13 +344,12 @@ export function ExistingClusterInput(props: ExistingClusterInputProps) { - ""} - allowAddItem={false} + selectedValue={selectedSubscriptionName} + getValue={(s) => s} onSelect={handleSubscriptionSelect} /> diff --git a/webview-ui/src/KickstartCluster/KickstartCluster.module.css b/webview-ui/src/KickstartCluster/KickstartCluster.module.css index e9eafd683..ac094776e 100644 --- a/webview-ui/src/KickstartCluster/KickstartCluster.module.css +++ b/webview-ui/src/KickstartCluster/KickstartCluster.module.css @@ -101,7 +101,7 @@ display: flex; align-items: flex-start; column-gap: 0.5rem; - margin-top: -0.25rem; + margin-top: -0.5rem; font-size: 0.9em; } diff --git a/webview-ui/src/KickstartGuidedSetup/GuidedSetupInput.tsx b/webview-ui/src/KickstartGuidedSetup/GuidedSetupInput.tsx index af1166c02..1272c77f0 100644 --- a/webview-ui/src/KickstartGuidedSetup/GuidedSetupInput.tsx +++ b/webview-ui/src/KickstartGuidedSetup/GuidedSetupInput.tsx @@ -29,8 +29,7 @@ import { ProjectType, ToVsCodeMsgDef, } from "../../../src/webview-contract/webviewDefinitions/kickstartGuidedSetup"; -import { CustomDropdown } from "../components/CustomDropdown"; -import { CustomDropdownOption } from "../components/CustomDropdownOption"; +import { SearchableDropdown } from "../components/SearchableDropdown"; import { Maybe, isNothing, just, nothing } from "../utilities/maybe"; import { EventHandlers } from "../utilities/state"; import { Validatable, hasMessage, isValid, isValueSet, missing, unset, valid } from "../utilities/validation"; @@ -214,31 +213,26 @@ export function GuidedSetupInput(props: GuidedSetupInputProps) { )} {props.githubRepos && props.githubRepos.length > 0 && ( - r.cloneUrl === repoUrl.value) ? repoUrl.value - : "" + : null } - onChange={(value) => { + getValue={(repo) => repo.cloneUrl} + toLabel={(repo) => { + const name = repo.fullName.split("/").slice(1).join("/") || repo.fullName; + return `${name}${repo.private ? " (private)" : ""}${ + repo.description ? ` — ${repo.description}` : "" + }`; + }} + onSelect={(value) => { if (value) setRepoUrl(valid(value)); }} - > - {props.githubRepos.map((repo) => { - const name = repo.fullName.split("/").slice(1).join("/") || repo.fullName; - return ( - - ); - })} - + /> )}
{l10n.t("or")}
diff --git a/webview-ui/src/components/ActivityStageList.module.css b/webview-ui/src/components/ActivityStageList.module.css index 647848969..2b16da2b4 100644 --- a/webview-ui/src/components/ActivityStageList.module.css +++ b/webview-ui/src/components/ActivityStageList.module.css @@ -33,7 +33,6 @@ display: block; color: var(--vscode-descriptionForeground); font-size: 0.9em; - margin-top: 0.15rem; } .checkPending { @@ -148,7 +147,7 @@ margin-top: 0.25rem; } -.fullErrorToggle { +.disclosureToggle { display: inline-flex; align-items: center; align-self: flex-start; @@ -162,12 +161,12 @@ font-size: 0.9em; } -.fullErrorToggle:hover { +.disclosureToggle:hover { color: var(--vscode-textLink-activeForeground); text-decoration: underline; } -.fullErrorChevron { +.disclosureChevron { font-size: 0.8em; } @@ -194,11 +193,12 @@ margin-top: 0.25rem; } -.entriesToggle { +.stageRetry { display: inline-flex; align-items: center; align-self: flex-start; column-gap: 0.35rem; + margin-top: 0.35rem; padding: 0; border: none; background: none; @@ -208,35 +208,37 @@ font-size: 0.9em; } -.entriesToggle:hover { +.stageRetry:hover { color: var(--vscode-textLink-activeForeground); text-decoration: underline; } -.entriesChevron { - font-size: 0.8em; +.stageRetryIcon { + font-size: 0.85em; } -.stageRetry { - display: inline-flex; - align-items: center; - align-self: flex-start; - column-gap: 0.35rem; - margin-top: 0.35rem; - padding: 0; - border: none; - background: none; - cursor: pointer; - color: var(--vscode-textLink-foreground); - font-family: inherit; - font-size: 0.9em; +.stageGroup { + display: flex; + flex-direction: column; + row-gap: 0.5rem; } -.stageRetry:hover { - color: var(--vscode-textLink-activeForeground); - text-decoration: underline; +.stageGroupHeader { + column-gap: 0.5rem; + color: var(--vscode-foreground); + font-size: inherit; + text-align: left; } -.stageRetryIcon { - font-size: 0.85em; +.stageGroupHeader:hover { + text-decoration: none; +} + +.stageGroupTitle { + font-weight: 600; +} + +.stageGroupSummary { + color: var(--vscode-descriptionForeground); + font-size: 0.9em; } diff --git a/webview-ui/src/components/ActivityStageList.tsx b/webview-ui/src/components/ActivityStageList.tsx index c3cb8159a..0256c4d63 100644 --- a/webview-ui/src/components/ActivityStageList.tsx +++ b/webview-ui/src/components/ActivityStageList.tsx @@ -71,19 +71,44 @@ function ProgressBar({ progress }: { progress: number }) { ); } +/** + * A chevron + label button that toggles a disclosure region. Shared by every collapsible in this + * file (stage full-error, capacity-check entries, and the preflight stage group) so the + * button/chevron markup and aria wiring live in exactly one place. + */ +function DisclosureButton({ + expanded, + onToggle, + label, + className, +}: { + expanded: boolean; + onToggle: () => void; + label: string; + className?: string; +}) { + return ( + + ); +} + function StageFullError({ fullError }: { fullError: string }) { const [expanded, setExpanded] = useState(false); return ( - + setExpanded((prev) => !prev)} + label={expanded ? l10n.t("Hide details") : l10n.t("More")} + /> {expanded && {fullError}} ); @@ -123,15 +148,11 @@ function CollapsibleEntries({ stage }: { stage: ActivitySnapshot }) { const [expanded, setExpanded] = useState(false); return ( - + setExpanded((prev) => !prev)} + label={expanded ? l10n.t("Hide Capacity Checks") : l10n.t("Show Capacity Checks")} + /> {expanded && ( {stage.entries.map((entry) => ( @@ -146,49 +167,130 @@ function CollapsibleEntries({ stage }: { stage: ActivitySnapshot }) { export function ActivityStageList({ stages, onRetryStage, + hideEntriesWhenSucceeded = false, }: { stages: ActivitySnapshot[]; onRetryStage?: (runId: number, stageId: string) => void; + /** + * When true, a succeeded stage renders only its title + one-line detail, hiding the leftover + * timing/action entries. Keeps compact check lists (e.g. preflight) from ballooning to several + * lines per check. Failed/warning stages always show their entries so problems stay visible. + */ + hideEntriesWhenSucceeded?: boolean; }) { if (stages.length === 0) { return null; } return (
    - {stages.map((stage) => ( -
  • - - - {stage.title} - {stage.detail && {stage.detail}} - {stage.fullError && } - {stage.entries.length > 0 && - (stage.collapsible && stage.entries.length > 1 ? ( - - ) : ( - - {stage.entries.map((entry) => ( - - ))} - - ))} - {onRetryStage && (stage.status === "failed" || stage.status === "warning") && ( - - )} - -
  • - ))} + {stages.map((stage) => { + const showEntries = !(hideEntriesWhenSucceeded && stage.status === "succeeded"); + return ( +
  • + + + {stage.title} + {stage.detail && {stage.detail}} + {stage.fullError && } + {showEntries && + stage.entries.length > 0 && + (stage.collapsible && stage.entries.length > 1 ? ( + + ) : ( + + {stage.entries.map((entry) => ( + + ))} + + ))} + {onRetryStage && (stage.status === "failed" || stage.status === "warning") && ( + + )} + +
  • + ); + })}
); } + +/** + * A titled, collapsible wrapper around an {@link ActivityStageList}. Auto-collapses to a one-line + * summary once every stage has succeeded; auto-expands while any stage is still running or has + * failed/warned so problems stay visible. The user can always toggle manually, and a manual choice + * sticks even as the underlying status changes. + */ +export function CollapsibleStageGroup({ + title, + stages, + onRetryStage, +}: { + title: string; + stages: ActivitySnapshot[]; + onRetryStage?: (runId: number, stageId: string) => void; +}) { + const [userOverride, setUserOverride] = useState(null); + + if (stages.length === 0) { + return null; + } + + const total = stages.length; + const passed = stages.filter((s) => s.status === "succeeded").length; + const hasProblem = stages.some((s) => s.status === "failed" || s.status === "warning"); + const running = stages.some((s) => s.status === "running" || s.status === "pending"); + const allPassed = passed === total; + + // Default: expanded while running or when something needs attention; collapsed once all pass. + const defaultExpanded = running || hasProblem || !allPassed; + const expanded = userOverride ?? defaultExpanded; + + const summary = hasProblem + ? l10n.t("{0} of {1} checks need attention", total - passed, total) + : running + ? l10n.t("Running checks… {0} of {1} passed", passed, total) + : l10n.t("{0} of {1} checks passed", passed, total); + + const summaryStatus: SetupStepStatus = hasProblem + ? stages.some((s) => s.status === "failed") + ? "failed" + : "warning" + : running + ? "running" + : "succeeded"; + + return ( +
+ + {expanded && } +
+ ); +} diff --git a/webview-ui/src/components/SearchableDropdown.module.css b/webview-ui/src/components/SearchableDropdown.module.css new file mode 100644 index 000000000..057b7036c --- /dev/null +++ b/webview-ui/src/components/SearchableDropdown.module.css @@ -0,0 +1,78 @@ +.dropdown { + position: relative; + display: inline-block; + width: 100%; +} + +.inputField { + position: relative; + width: 100%; +} + +.selectedValue { + width: 100%; + padding-right: 1.8rem !important; + box-sizing: border-box; +} + +.selectedValue:hover { + cursor: pointer; +} + +.arrowIcon { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; +} + +.listbox { + position: absolute; + width: 100%; + margin: 0; + padding: 0; + left: 0; + list-style: none; + background: var(--vscode-input-background); + border: 1px solid var(--vscode-focusBorder); + border-radius: 2px; + box-sizing: border-box; + max-height: 200px; + overflow-y: auto; + overflow-x: hidden; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); + z-index: 1; +} + +.dropUp { + bottom: 100%; + top: auto; + box-shadow: 0 -8px 16px rgba(0, 0, 0, 0.2); +} + +.hidden { + display: none; +} + +.listboxItem { + cursor: pointer; + padding: 2px 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.listboxItem:hover { + background: var(--vscode-list-hoverBackground); +} + +.highlighted { + background: var(--vscode-list-activeSelectionBackground); +} + +.empty { + padding: 2px 5px; + color: var(--vscode-descriptionForeground); + cursor: default; +} diff --git a/webview-ui/src/components/SearchableDropdown.tsx b/webview-ui/src/components/SearchableDropdown.tsx new file mode 100644 index 000000000..9d86a98bb --- /dev/null +++ b/webview-ui/src/components/SearchableDropdown.tsx @@ -0,0 +1,286 @@ +import { useEffect, useMemo, useRef, useState, KeyboardEvent as ReactKeyboardEvent } from "react"; +import styles from "./SearchableDropdown.module.css"; +import { Lazy, asLazy, isLoading, orDefault } from "../utilities/lazy"; +import { fuzzyMatch } from "../utilities/fuzzy"; +import { ProgressRing } from "./ProgressRing"; + +interface NormalizedOption { + value: string; + label: string; + sortKey: string; +} + +export interface SearchableDropdownProps { + id?: string; + className?: string; + // Raw items of any type. Lazy<> is preserved so async loading shows a spinner. + items: Lazy | T[]; + selectedValue: string | null; + onSelect: (value: string | null) => void; + disabled?: boolean; + placeholder?: string; + // Derive the stable identity (returned by onSelect) from an item. + getValue: (item: T) => string; + // Derive the display + fuzzy-search text. Defaults to getValue. + toLabel?: (item: T) => string; + // Derive the default sort key (applied when no search text, and as a tiebreaker on equal + // fuzzy scores). Defaults to toLabel/getValue. Sorted ascending, locale-aware. + sortKey?: (item: T) => string; +} + +export function SearchableDropdown(props: SearchableDropdownProps) { + const lazyItems = asLazy(props.items); + + if (isLoading(lazyItems)) { + return ; + } + + const rawItems = orDefault(lazyItems, [] as T[]); + return ; +} + +type InnerProps = SearchableDropdownProps & { rawItems: T[] }; + +function SearchableDropdownInner(props: InnerProps) { + const { rawItems, getValue, toLabel, sortKey, selectedValue, onSelect, disabled } = props; + + const [isOpen, setIsOpen] = useState(false); + const [dropUp, setDropUp] = useState(false); + const [searchText, setSearchText] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(0); + + const containerRef = useRef(null); + const listboxRef = useRef(null); + const inputRef = useRef(null); + + const options: NormalizedOption[] = useMemo( + () => + rawItems.map((item) => { + const label = (toLabel ?? getValue)(item); + return { + value: getValue(item), + label, + sortKey: (sortKey ?? (() => label))(item), + }; + }), + [rawItems, getValue, toLabel, sortKey], + ); + + const selectedOption = options.find((o) => o.value === selectedValue) ?? null; + + // When open the input shows the live search text; when closed it shows the selected label. + const inputText = isOpen ? searchText : (selectedOption?.label ?? ""); + + const filtered: NormalizedOption[] = useMemo(() => { + const matches = options + .map((option) => ({ option, match: fuzzyMatch(searchText, option.label) })) + .filter(({ match }) => match.matched); + matches.sort((a, b) => { + if (searchText !== "" && b.match.score !== a.match.score) { + return b.match.score - a.match.score; + } + return a.option.sortKey.localeCompare(b.option.sortKey); + }); + return matches.map(({ option }) => option); + }, [options, searchText]); + + // Clamp the highlight into range for the current filtered list (derived, not stored). + const safeHighlight = filtered.length === 0 ? 0 : Math.min(highlightedIndex, filtered.length - 1); + + // Close on outside click. + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + setSearchText(""); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Drop-up positioning when near the viewport bottom. + useEffect(() => { + if (isOpen && containerRef.current && listboxRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const menuHeight = listboxRef.current.offsetHeight; + const shouldDropUp = rect.bottom + menuHeight > window.innerHeight; + const t = window.setTimeout(() => setDropUp(shouldDropUp), 0); + return () => window.clearTimeout(t); + } + return; + }, [isOpen, filtered.length]); + + function scrollHighlightIntoView(index: number) { + listboxRef.current?.children[index]?.scrollIntoView({ block: "nearest" }); + } + + function openDropdown() { + if (disabled) return; + setSearchText(""); + const currentIndex = filtered.findIndex((o) => o.value === selectedValue); + setHighlightedIndex(currentIndex === -1 ? 0 : currentIndex); + setIsOpen(true); + } + + function closeDropdown() { + setIsOpen(false); + setSearchText(""); + } + + function commitSelection(option: NormalizedOption | undefined) { + if (!option) return; + onSelect(option.value); + closeDropdown(); + } + + function handleInputClick() { + if (isOpen) { + closeDropdown(); + } else { + openDropdown(); + inputRef.current?.focus(); + } + } + + function handleTextChange(e: React.FormEvent) { + if (!isOpen) setIsOpen(true); + setSearchText(e.currentTarget.value); + setHighlightedIndex(0); + } + + function handleKeyDown(e: ReactKeyboardEvent) { + if (disabled) return; + + if (!isOpen) { + if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter") { + openDropdown(); + e.preventDefault(); + } + return; + } + + switch (e.key) { + case "ArrowDown": { + if (filtered.length === 0) break; + { + const next = (safeHighlight + 1) % filtered.length; + setHighlightedIndex(next); + scrollHighlightIntoView(next); + } + e.preventDefault(); + break; + } + case "ArrowUp": { + if (filtered.length === 0) break; + { + const next = (safeHighlight - 1 + filtered.length) % filtered.length; + setHighlightedIndex(next); + scrollHighlightIntoView(next); + } + e.preventDefault(); + break; + } + case "Home": + if (filtered.length === 0) break; + setHighlightedIndex(0); + scrollHighlightIntoView(0); + e.preventDefault(); + break; + case "End": + if (filtered.length === 0) break; + setHighlightedIndex(filtered.length - 1); + scrollHighlightIntoView(filtered.length - 1); + e.preventDefault(); + break; + case "Enter": + commitSelection(filtered[safeHighlight]); + e.preventDefault(); + break; + case "Escape": + closeDropdown(); + e.preventDefault(); + break; + case "Tab": + closeDropdown(); + break; + } + } + + const listboxId = props.id ? `${props.id}-list` : undefined; + const displayListbox = isOpen; + + return ( +
+
+ + + + +
+ + {displayListbox && ( +
    + {filtered.length === 0 &&
  • No matches
  • } + {filtered.map((option, index) => ( +
  • { + e.preventDefault(); + commitSelection(option); + }} + onMouseEnter={() => setHighlightedIndex(index)} + > + {option.label} +
  • + ))} +
+ )} +
+ ); +}