From d21449c1c3c606f97394211189a66ea2a791a12a Mon Sep 17 00:00:00 2001 From: rohan Date: Thu, 26 Feb 2026 16:03:11 +0530 Subject: [PATCH 1/6] feat: secret referencing ux and autocompletion Signed-off-by: rohan --- .../apps/[app]/_components/AppFolderRow.tsx | 2 +- .../apps/[app]/_components/AppSecretRow.tsx | 48 +- .../apps/[app]/_components/AppSecrets.tsx | 612 ++++++------ .../apps/[app]/_components/EnvFolder.tsx | 2 +- .../[environment]/[[...path]]/page.tsx | 600 +++++++----- frontend/components/common/MaskedTextarea.tsx | 131 ++- .../environments/folders/SecretFolderRow.tsx | 2 +- .../environments/secrets/SecretRow.tsx | 74 +- .../secrets/BrokenReferencesDialog.tsx | 59 ++ .../secrets/ReferenceAutocompleteDropdown.tsx | 108 +++ .../secrets/SecretReferenceHighlight.tsx | 30 + frontend/contexts/secretReferenceContext.tsx | 19 + frontend/hooks/useOrgSecretKeys.ts | 133 +++ .../hooks/useSecretReferenceAutocomplete.ts | 220 +++++ frontend/utils/secretReferences.ts | 870 ++++++++++++++++++ 15 files changed, 2358 insertions(+), 552 deletions(-) create mode 100644 frontend/components/secrets/BrokenReferencesDialog.tsx create mode 100644 frontend/components/secrets/ReferenceAutocompleteDropdown.tsx create mode 100644 frontend/components/secrets/SecretReferenceHighlight.tsx create mode 100644 frontend/contexts/secretReferenceContext.tsx create mode 100644 frontend/hooks/useOrgSecretKeys.ts create mode 100644 frontend/hooks/useSecretReferenceAutocomplete.ts create mode 100644 frontend/utils/secretReferences.ts diff --git a/frontend/app/[team]/apps/[app]/_components/AppFolderRow.tsx b/frontend/app/[team]/apps/[app]/_components/AppFolderRow.tsx index 38bd11164..1480a06cd 100644 --- a/frontend/app/[team]/apps/[app]/_components/AppFolderRow.tsx +++ b/frontend/app/[team]/apps/[app]/_components/AppFolderRow.tsx @@ -32,7 +32,7 @@ const AppFolderRowBase = ({ appFolder, pathname }: AppFolderRowProps) => { open ? 'font-bold' : 'font-medium' )} > - + {fullPath} @@ -64,6 +68,7 @@ const EnvSecretComponent = ({ updateEnvValue: (id: string, envId: string, value: string | undefined) => void addEnvValue: (appSecretId: string, environment: EnvironmentType) => void deleteEnvValue: (appSecretId: string, environment: EnvironmentType) => void + currentSecretKey?: string }) => { const pathname = usePathname() const { activeOrganisation: organisation } = useContext(organisationContext) @@ -76,6 +81,27 @@ const EnvSecretComponent = ({ valueIsNew || !serverEnvSecret || isEmptyValue || false ) + // Secret reference autocomplete + const textareaRef = useRef(null) + const handleValueChange = useCallback( + (v: string) => updateEnvValue(appSecretId, clientEnvSecret.env.id!, v), + [updateEnvValue, appSecretId, clientEnvSecret.env.id] + ) + + const autocomplete = useSecretReferenceAutocomplete({ + value: clientEnvSecret.secret?.value ?? '', + isRevealed: showValue, + textareaRef, + onChange: handleValueChange, + currentSecretKey, + }) + + const secretValue = clientEnvSecret.secret?.value ?? '' + const highlightContent = + showValue && secretValue.includes('${') ? ( + + ) : undefined + const isBoolean = clientEnvSecret?.secret ? ['true', 'false'].includes(clientEnvSecret.secret.value.toLowerCase()) : false @@ -218,17 +244,33 @@ const EnvSecretComponent = ({ )} updateEnvValue(appSecretId, clientEnvSecret.env.id!, v)} + onChange={(v) => { + handleValueChange(v) + autocomplete.handleChange() + }} + onKeyDown={autocomplete.handleKeyDown} + onSelect={autocomplete.handleSelect} + onBlur={autocomplete.handleBlur} + onFocus={autocomplete.handleFocus} isRevealed={showValue} expanded={true} + highlightContent={highlightContent} /> + {clientEnvSecret.secret !== null && (
diff --git a/frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx b/frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx index 0560f5ca1..49318585e 100644 --- a/frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx +++ b/frontend/app/[team]/apps/[app]/_components/AppSecrets.tsx @@ -52,6 +52,14 @@ import { useWarnIfUnsavedChanges } from '@/hooks/warnUnsavedChanges' import { AppDynamicSecretRow } from '@/ee/components/secrets/dynamic/AppDynamicSecretRow' import { AppFolderRow } from './AppFolderRow' import { AppSecretRowSkeleton } from './AppSecretRowSkeleton' +import { SecretReferenceContext } from '@/contexts/secretReferenceContext' +import { + validateSecretReferences, + ReferenceContext, + ReferenceValidationError, +} from '@/utils/secretReferences' +import { BrokenReferencesDialog } from '@/components/secrets/BrokenReferencesDialog' +import { useOrgSecretKeys } from '@/hooks/useOrgSecretKeys' export const AppSecrets = ({ team, app }: { team: string; app: string }) => { const { activeOrganisation: organisation } = useContext(organisationContext) @@ -110,6 +118,8 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { const [isLoading, setIsLoading] = useState(false) const importDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) + const refWarningDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) + const [refWarnings, setRefWarnings] = useState([]) const savingAndFetching = bulkUpdatePending || isLoading @@ -148,6 +158,64 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { unsavedChanges ? 0 : 10000 // Poll every 10 seconds ) + // Fetch and decrypt all org secret keys for cross-app reference autocomplete/validation + const { orgApps: allOrgApps } = useOrgSecretKeys() + + // Build stabilized reference context for autocomplete + const secretKeysFingerprint = clientAppSecrets.map((s) => s.key).join('\0') + const referenceContext: ReferenceContext = useMemo(() => { + const secretKeys = clientAppSecrets.map((s) => s.key).filter((k) => k !== '') + + const envSecretKeys: Record = {} + for (const env of appEnvironments ?? []) { + envSecretKeys[env.name.toLowerCase()] = clientAppSecrets + .filter((s) => s.envs.some((e) => e.env.id === env.id && e.secret !== null)) + .map((s) => s.key) + .filter((k) => k !== '') + } + + const envNames = (appEnvironments ?? []).map((e) => e.name) + + const folderPaths = appFolders.map((f) => `${f.path}/${f.name}`.replace(/^\/+/, '')) + + // Exclude the current app from cross-app suggestions + const currentAppName = data?.apps?.[0]?.name?.toLowerCase() + const orgApps = allOrgApps.filter((a) => a.name.toLowerCase() !== currentAppName) + + // Extract folder-qualified and root-level secret keys for the current app + const currentAppData = allOrgApps.find((a) => a.name.toLowerCase() === currentAppName) + const folderSecretKeys = currentAppData?.folderKeys ?? {} + const envRootKeys = currentAppData?.envRootKeys ?? {} + + const deletedKeys = clientAppSecrets + .filter((s) => appSecretsToDelete.includes(s.id)) + .map((s) => s.key) + + // Build env ID mapping for navigation + const envIds: Record = {} + for (const env of appEnvironments ?? []) { + envIds[env.name.toLowerCase()] = env.id + } + + const secretIdLookup = currentAppData?.secretIdLookup ?? {} + + return { + teamSlug: team, + appId: app, + envIds, + secretIdLookup, + secretKeys, + envSecretKeys, + envRootKeys, + envNames, + folderPaths, + folderSecretKeys, + orgApps, + deletedKeys, + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [secretKeysFingerprint, appEnvironments, appFolders, allOrgApps, data, appSecretsToDelete]) + const filteredFolders = useMemo(() => { if (searchQuery === '') return appFolders const re = new RegExp(escapeRegExp(searchQuery), 'i') @@ -375,6 +443,21 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { return false } + // Validate secret references + const activeSecrets = clientAppSecrets.filter((s) => !appSecretsToDelete.includes(s.id)) + const refErrors = validateSecretReferences( + activeSecrets, + appEnvironments ?? [], + referenceContext, + appSecretsToDelete + ) + if (refErrors.length > 0) { + setRefWarnings(refErrors) + refWarningDialogRef.current?.openModal() + setIsLoading(false) + return false + } + await handleBulkUpdateSecrets() setIsLoading(false) @@ -382,6 +465,15 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { toast.success('Changes successfully deployed.') } + const handleSaveWithBrokenRefs = async () => { + refWarningDialogRef.current?.closeModal() + setRefWarnings([]) + setIsLoading(true) + await handleBulkUpdateSecrets() + setIsLoading(false) + toast.success('Changes successfully deployed.') + } + const handleAddNewClientSecret = (initialKey?: string) => { const keyToUse = initialKey ?? '' const envs: EnvironmentType[] = appEnvironments @@ -676,7 +768,7 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { >
{envFolder.env.name}
-
+
{fullPath}
@@ -688,286 +780,294 @@ export const AppSecrets = ({ team, app }: { team: string; app: string }) => { } return ( -
-
-
-

Secrets

-

- An overview of Secrets across all Environments in this App. Expand a row in the table - below to compare and manage values across all Environments. -

+ +
+
+
+

Secrets

+

+ An overview of Secrets across all Environments in this App. Expand a row in the table + below to compare and manage values across all Environments. +

+
-
-
-
-
- +
+
+
+ +
+ setSearchQuery(e.target.value)} + /> + setSearchQuery('')} + />
- setSearchQuery(e.target.value)} - /> - setSearchQuery('')} - /> -
-
-
- {unsavedChanges && ( - + )} + + {syncsData?.syncs && userCanReadSyncs && ( +
+ +
+ )} + + - )} - - {syncsData?.syncs && userCanReadSyncs && ( -
- -
- )} - - +
-
- {appEnvironments && ( - + )} + + - )} - - {(filteredSecrets.length > 0 || filteredDynamicSecrets.length > 0) && ( -
-
- - -
-
- - {userCanCreateSecrets && ( - - )} + + +
+
+ + {userCanCreateSecrets && ( + + )} +
-
- )} - - {clientAppSecrets.length > 0 || appFolders.length > 0 || searchQuery ? ( - <> - {filteredSecrets.length > 0 || filteredFolders.length > 0 ? ( -
- - - - - {appEnvironments?.map((env: EnvironmentType) => ( -
- key - - - - + )} + + {clientAppSecrets.length > 0 || appFolders.length > 0 || searchQuery ? ( + <> + {filteredSecrets.length > 0 || filteredFolders.length > 0 ? ( +
+ + + + + {appEnvironments?.map((env: EnvironmentType) => ( + + ))} + + + + {filteredFolders.map((appFolder) => ( + ))} - - - - {filteredFolders.map((appFolder) => ( - - ))} - {filteredDynamicSecrets.map((appDynamicSecret) => ( - - ))} + {filteredDynamicSecrets.map((appDynamicSecret) => ( + + ))} - {filteredSecrets.map((appSecret, index) => ( - - ))} - -
+ key + + + +
-
- ) : ( -
- - -
- } + {filteredSecrets.map((appSecret, index) => ( + + ))} + +
+
+ ) : ( +
+ + +
+ } + > + {userCanCreateSecrets && ( +
+ +
+ )} + +
+ )} + + + ) : isLoading || fetching || (appSecrets?.length > 0 && clientAppSecrets.length === 0) ? ( +
+ + - {userCanCreateSecrets && ( -
- -
- )} - - - )} - - - ) : isLoading || fetching || (appSecrets?.length > 0 && clientAppSecrets.length === 0) ? ( -
-
- - - - {['Development', 'Staging', 'Production'].map((envName) => ( - + + {['Development', 'Staging', 'Production'].map((envName) => ( + + ))} + + + + {[...Array(13)].map((_, index) => ( + ))} - - - - {[...Array(13)].map((_, index) => ( - - ))} - -
- key - - +
+ key + +
-
- ) : userCanReadEnvironments && userCanReadSecrets ? ( -
- + + +
+ } + > +
+ + +
+ +
+ ) : ( + - +
} > -
- - -
+ <> - - ) : ( - - - - } - > - <> - - )} - + )} + + ) } diff --git a/frontend/app/[team]/apps/[app]/_components/EnvFolder.tsx b/frontend/app/[team]/apps/[app]/_components/EnvFolder.tsx index efd87d17d..18f924d88 100644 --- a/frontend/app/[team]/apps/[app]/_components/EnvFolder.tsx +++ b/frontend/app/[team]/apps/[app]/_components/EnvFolder.tsx @@ -27,7 +27,7 @@ const EnvFolderBase = ({ envFolder, pathname }: EnvFolderProp) => { >
{envFolder.env.name}
-
+
{fullPath}
diff --git a/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx b/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx index 5500af33b..10c23b1b9 100644 --- a/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx +++ b/frontend/app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx @@ -82,6 +82,15 @@ import { CreateDynamicSecretDialog } from '@/ee/components/secrets/dynamic/Creat import { DynamicSecretRow } from '@/ee/components/secrets/dynamic/DynamicSecretRow' import { PlanLabel } from '@/components/settings/organisation/PlanLabel' import { UpsellDialog } from '@/components/settings/organisation/UpsellDialog' +import { SecretReferenceContext } from '@/contexts/secretReferenceContext' +import { + ReferenceContext, + ReferenceValidationError, + secretIdKey, + validateSecretReferences, +} from '@/utils/secretReferences' +import { BrokenReferencesDialog } from '@/components/secrets/BrokenReferencesDialog' +import { useOrgSecretKeys } from '@/hooks/useOrgSecretKeys' export default function EnvironmentPath({ params, @@ -114,6 +123,8 @@ export default function EnvironmentPath({ const importDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) const dynamicSecretDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) const upsellDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) + const refWarningDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) + const [refWarnings, setRefWarnings] = useState([]) const [sort, setSort] = useState('-created') @@ -199,6 +210,8 @@ export default function EnvironmentPath({ useWarnIfUnsavedChanges(unsavedChanges) + const { orgApps: allOrgApps } = useOrgSecretKeys() + const { data: appEnvsData } = useQuery(GetAppEnvironments, { variables: { appId: params.app, @@ -220,6 +233,79 @@ export default function EnvironmentPath({ return data?.folders ?? [] }, [data?.folders]) + const environment = data?.appEnvironments[0] as EnvironmentType + + const referenceContext: ReferenceContext = useMemo(() => { + const secretKeys = clientSecrets.map((s) => s.key).filter((k) => k !== '') + + const appName = environment?.app?.name + const currentAppData = allOrgApps.find((a) => a.name.toLowerCase() === appName?.toLowerCase()) + + const envSecretKeys: Record = { + ...(currentAppData?.envSecretKeys ?? {}), + } + if (environment?.name) { + envSecretKeys[environment.name.toLowerCase()] = secretKeys + } + + const envNames = (appEnvsData?.appEnvironments ?? []).map((e: EnvironmentType) => e.name) + + const localFolderPaths = folders.map((f: SecretFolderType) => + `${f.path}/${f.name}`.replace(/^\/+/, '') + ) + const orgFolderPaths = Object.keys(currentAppData?.folderKeys ?? {}) + const folderPaths = [...new Set([...localFolderPaths, ...orgFolderPaths])] + + const folderSecretKeys = currentAppData?.folderKeys ?? {} + const envRootKeys: Record = { + ...(currentAppData?.envRootKeys ?? {}), + } + // Override current env with client-side root keys when viewing root + if (environment?.name && secretPath === '/') { + envRootKeys[environment.name.toLowerCase()] = secretKeys + } + + const orgApps = allOrgApps.filter((a) => a.name.toLowerCase() !== appName?.toLowerCase()) + + const deletedKeys = clientSecrets + .filter((s) => secretsToDelete.includes(s.id)) + .map((s) => s.key) + + // Build env ID mapping for navigation + const envIds: Record = {} + for (const env of appEnvsData?.appEnvironments ?? []) { + envIds[(env as EnvironmentType).name.toLowerCase()] = (env as EnvironmentType).id + } + + const secretIdLookup: Record = { + ...(currentAppData?.secretIdLookup ?? {}), + } + // Override with client-side secret IDs for current env (includes newly decrypted secrets) + if (environment?.name) { + for (const s of clientSecrets) { + if (s.id && !s.id.startsWith('new-')) { + secretIdLookup[secretIdKey(environment.name, s.path || '/', s.key)] = s.id + } + } + } + + return { + teamSlug: params.team, + appId: params.app, + envId: params.environment, + envIds, + secretIdLookup, + secretKeys, + envSecretKeys, + envRootKeys, + envNames, + folderPaths, + folderSecretKeys, + orgApps, + deletedKeys, + } + }, [clientSecrets, environment, appEnvsData, allOrgApps, folders, secretsToDelete, params]) + const savingAndFetching = isLoading || loading const [bulkProcessSecrets] = useMutation(BulkProcessSecrets) @@ -231,7 +317,6 @@ export default function EnvironmentPath({ return `/${params.team}/apps/${params.app}/environments/${env.id}${secretPath}` } - const environment = data?.appEnvironments[0] as EnvironmentType //const dynamicSecrets: DynamicSecretType[] = data?.dynamicSecrets ?? [] const envLinks = @@ -575,6 +660,26 @@ export default function EnvironmentPath({ return false } + // Validate secret references + const activeSecrets = clientSecrets + .filter((s) => !secretsToDelete.includes(s.id)) + .map((s) => ({ + key: s.key, + envs: [ + { + env: { id: environment.id, name: environment.name }, + secret: { value: s.value }, + }, + ], + })) + const refErrors = validateSecretReferences(activeSecrets, [], referenceContext, secretsToDelete) + if (refErrors.length > 0) { + setRefWarnings(refErrors) + refWarningDialogRef.current?.openModal() + setIsloading(false) + return false + } + await handleBulkUpdateSecrets() setTimeout(() => setIsloading(false), 500) @@ -582,6 +687,15 @@ export default function EnvironmentPath({ toast.success('Changes successfully deployed.') } + const handleSaveWithBrokenRefs = async () => { + refWarningDialogRef.current?.closeModal() + setRefWarnings([]) + setIsloading(true) + await handleBulkUpdateSecrets() + setTimeout(() => setIsloading(false), 500) + toast.success('Changes successfully deployed.') + } + const handleDiscardChanges = () => { setClientSecrets(serverSecrets) setSecretsToDelete([]) @@ -918,261 +1032,273 @@ export default function EnvironmentPath({ } return ( -
- {keyring !== null && !loading && ( -
-
-
- {envLinks.length > 1 ? ( - - {({ open }) => ( - <> - -
-

{environment.name}

- -
-
- - -
- {envLinks.map((link: { label: string; href: string }) => ( - - {({ active }) => ( - -
{link.label}
- - - )} -
- ))} + +
+ {keyring !== null && !loading && ( +
+
+
+ {envLinks.length > 1 ? ( + + {({ open }) => ( + <> + +
+

{environment.name}

+
- - - - )} -
- ) : ( -

{environment.name}

- )} -
- -
-
-
- {unsavedChanges && ( - - )} -
-
-
-
-
-
-
- -
- setSearchQuery(e.target.value)} - /> - + + +
+ {envLinks.map((link: { label: string; href: string }) => ( + + {({ active }) => ( + +
{link.label}
+ + + )} +
+ ))} +
+
+
+ )} - role="button" - onClick={() => setSearchQuery('')} - /> -
-
- +
+ ) : ( +

{environment.name}

+ )} +
+
- -
+
{unsavedChanges && ( - - )} - - {data.envSyncs && userCanReadSyncs && ( -
- -
+ )} - -
- - - - {!noSecrets && ( -
-
- key +
+
+
+
+
+ +
+ setSearchQuery(e.target.value)} + /> + setSearchQuery('')} + /> +
+
+ +
-
- value -
- + +
+ {unsavedChanges && ( - -
+ )} + + {data.envSyncs && userCanReadSyncs && ( +
+ +
+ )} + +
- )} -
-
- - - - - {organisation && - filteredFolders.map((folder: SecretFolderType) => ( - - ))} - - {environment && - filteredDynamicSecrets.map((secret) => ( - - ))} - - {organisation && - filteredAndSortedSecrets.map((secret, index: number) => ( -
-
- {index + 1} + + + + + {!noSecrets && ( +
+
+ key
- -
- ))} - - {noSecrets && ( - - {searchQuery ? : } +
+ value +
+ + + +
- } - > - {searchQuery ? ( - userCanCreateSecrets && - normalizeKey(searchQuery) && ( - - ) - ) : ( - - )} - {!searchQuery && ( -
- +
+ )} +
+ +
+ + + + + {organisation && + filteredFolders.map((folder: SecretFolderType) => ( + + ))} + + {environment && + filteredDynamicSecrets.map((secret) => ( + + ))} + + {organisation && + filteredAndSortedSecrets.map((secret, index: number) => ( +
+
+ {index + 1} +
+
- )} - - )} + ))} + + {noSecrets && ( + + {searchQuery ? : } +
+ } + > + {searchQuery ? ( + userCanCreateSecrets && + normalizeKey(searchQuery) && ( + + ) + ) : ( + + )} + {!searchQuery && ( +
+ +
+ )} + + )} +
-
- )} -
+ )} +
+ ) } diff --git a/frontend/components/common/MaskedTextarea.tsx b/frontend/components/common/MaskedTextarea.tsx index b7c8160fe..0fb2da531 100644 --- a/frontend/components/common/MaskedTextarea.tsx +++ b/frontend/components/common/MaskedTextarea.tsx @@ -1,55 +1,108 @@ -import React, { useRef, useEffect } from 'react' +import React, { useCallback, useRef, forwardRef } from 'react' import clsx from 'clsx' interface MaskedTextareaProps { value?: string onChange?: (value: string) => void onFocus?: () => void + onBlur?: () => void + onKeyDown?: (e: React.KeyboardEvent) => void + onSelect?: (e: React.SyntheticEvent) => void isRevealed: boolean expanded: boolean placeholder?: string className?: string disabled?: boolean rowsAutoGrow?: boolean + highlightContent?: React.ReactNode } -export const MaskedTextarea: React.FC = ({ - value = '', - onChange, - onFocus, - isRevealed, - expanded, - placeholder, - className, - disabled, - rowsAutoGrow = true, -}) => { - const textareaRef = useRef(null) - const lineCount = value.split('\n').length - const rows = expanded ? (rowsAutoGrow ? Math.min(Math.max(lineCount, 1), 40) : 1) : 1 - - const handleChange = (e: React.ChangeEvent) => { - onChange?.(e.target.value) +export const MaskedTextarea = forwardRef( + ( + { + value = '', + onChange, + onFocus, + onBlur, + onKeyDown, + onSelect, + isRevealed, + expanded, + placeholder, + className, + disabled, + rowsAutoGrow = true, + highlightContent, + }, + ref + ) => { + const highlightRef = useRef(null) + const lineCount = value.split('\n').length + const rows = expanded ? (rowsAutoGrow ? Math.min(Math.max(lineCount, 1), 40) : 1) : 1 + const hasHighlight = isRevealed && highlightContent != null + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onChange?.(e.target.value) + }, + [onChange] + ) + + const handleScroll = useCallback((e: React.UIEvent) => { + if (highlightRef.current) { + highlightRef.current.scrollTop = e.currentTarget.scrollTop + highlightRef.current.scrollLeft = e.currentTarget.scrollLeft + } + }, []) + + const sharedClasses = clsx( + className, + 'resize-none', + rows === 1 ? 'whitespace-nowrap' : 'whitespace-pre-wrap break-all' + ) + + const textarea = ( +