Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/studio/src/components/form-path-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useI18n } from '@wordpress/react-i18n';
import FolderIcon from 'src/components/folder-icon';
import { cx } from 'src/lib/cx';
import { SiteFormError } from './site-form-error';

export interface FormPathInputComponentProps {
value: string;
onClick: () => void;
error?: string;
doesPathContainWordPress: boolean;
id?: string;
}

export function FormPathInputComponent( {
value,
onClick,
error,
doesPathContainWordPress,
id,
}: FormPathInputComponentProps ) {
const { __ } = useI18n();
return (
<div className="flex flex-col gap-2">
<button
aria-invalid={ !! error }
/**
* The below `aria-describedby` presumes the error message always
* relates to the local path input, which is true currently as it is the
* only data validation in place. If we ever introduce additional data
* validation we need to expand the robustness of this
* `aria-describedby` attribute so that it only targets relevant error
* messages.
*/
aria-describedby={ error ? 'site-path-error' : undefined }
type="button"
aria-label={ `${ value }, ${ __( 'Select different local path' ) }` }
className={ cx(
'flex flex-row items-stretch rounded-sm border border-frame-border focus:border-frame-theme focus:shadow-[0_0_0_0.5px] focus:shadow-a8c-blue-50 outline-none transition-shadow transition-linear duration-100 [&_.local-path-icon]:focus:border-l-frame-theme [&:disabled]:cursor-not-allowed',
error && 'border-red-500 [&_.local-path-icon]:border-l-red-500'
) }
data-testid="select-path-button"
onClick={ onClick }
id={ id }
>
<div aria-hidden="true" tabIndex={ -1 } className="w-full text-left pl-3 py-3 min-h-10">
{ value }
</div>
<div
aria-hidden="true"
className="local-path-icon flex items-center py-[9px] px-2.5 self-center"
>
<FolderIcon className="text-frame-text-secondary" />
</div>
</button>
<SiteFormError
error={ error }
tipMessage={
doesPathContainWordPress
? __( 'The existing WordPress site at this path will be added.' )
: ''
}
/>
<input type="hidden" data-testid="local-path-input" value={ value } />
</div>
);
}
37 changes: 37 additions & 0 deletions apps/studio/src/components/site-form-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Icon } from '@wordpress/components';
import { cautionFilled, tip } from '@wordpress/icons';
import { cx } from 'src/lib/cx';

export interface SiteFormErrorProps {
error?: string;
tipMessage?: string;
className?: string;
}

export const SiteFormError = ( { error, tipMessage = '', className = '' }: SiteFormErrorProps ) => {
return (
( error || tipMessage ) && (
<div
id="site-path-error"
role="alert"
aria-atomic="true"
className={ cx(
'flex items-start gap-1 text-xs',
error ? 'text-red-500' : 'text-frame-text-secondary',
className
) }
>
<Icon
className={ cx(
'shrink-0 basis-4',
error ? 'fill-red-500' : 'fill-frame-text-secondary'
) }
icon={ error ? cautionFilled : tip }
width={ 16 }
height={ 16 }
/>
<p>{ error ? error : tipMessage }</p>
</div>
Comment on lines +32 to +34
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SiteFormError calls __( tipMessage ) even though callers (e.g., FormPathInputComponent) already pass a translated string via __(...). This results in translating an already-translated string (and using translated text as a key), which can break localization. Consider either (a) requiring callers to pass an untranslated key and translating inside, or (b) treating tipMessage as already-localized and rendering it directly without calling __ again.

Copilot uses AI. Check for mistakes.
)
);
};
32 changes: 22 additions & 10 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ import { supportedEditorConfig, SupportedEditor } from 'src/modules/user-setting
import { getUserEditor, getUserTerminal } from 'src/modules/user-settings/lib/ipc-handlers';
import { winFindEditorPath } from 'src/modules/user-settings/lib/win-editor-path';
import { SiteServer, stopAllServers as triggerStopAllServers } from 'src/site-server';
import { DEFAULT_SITE_PATH, getSiteThumbnailPath } from 'src/storage/paths';
import { getSiteThumbnailPath, resolveDefaultSiteDirectory } from 'src/storage/paths';
import {
loadUserData,
lockAppdata,
Expand Down Expand Up @@ -149,6 +149,8 @@ export {
saveUserLocale,
saveUserTerminal,
showUserSettings,
getDefaultSiteDirectory,
saveDefaultSiteDirectory,
} from 'src/modules/user-settings/lib/ipc-handlers';

export async function getAgentInstructionsStatus(
Expand Down Expand Up @@ -635,10 +637,14 @@ export async function showSaveAsDialog( event: IpcMainInvokeEvent, options: Save
throw new Error( `No window found for sender of showSaveAsDialog message: ${ event.frameId }` );
}

const defaultPath =
options.defaultPath === nodePath.basename( options.defaultPath ?? '' )
? nodePath.join( DEFAULT_SITE_PATH, options.defaultPath )
: options.defaultPath;
let defaultPath = options.defaultPath;
if (
typeof options.defaultPath === 'string' &&
options.defaultPath === nodePath.basename( options.defaultPath )
) {
const defaultSiteDirectory = await resolveDefaultSiteDirectory();
defaultPath = nodePath.join( defaultSiteDirectory, options.defaultPath );
}
const { canceled, filePath } = await dialog.showSaveDialog( parentWindow, {
defaultPath,
...options,
Expand Down Expand Up @@ -672,9 +678,11 @@ export async function showOpenFolderDialog(
};
}

const defaultPath =
defaultDialogPath !== '' ? defaultDialogPath : await resolveDefaultSiteDirectory();
const { canceled, filePaths } = await dialog.showOpenDialog( parentWindow, {
title,
defaultPath: defaultDialogPath !== '' ? defaultDialogPath : DEFAULT_SITE_PATH,
defaultPath,
properties: [
'openDirectory',
'createDirectory', // allow user to create new directories; macOS only
Expand Down Expand Up @@ -718,7 +726,8 @@ export async function copySite(
}
const sourceSite = sourceServer.details;

const finalSitePath = nodePath.join( DEFAULT_SITE_PATH, sanitizeFolderName( siteName ) );
const defaultSiteDirectory = await resolveDefaultSiteDirectory();
const finalSitePath = nodePath.join( defaultSiteDirectory, sanitizeFolderName( siteName ) );

console.log( `Copying site '${ sourceSite.name }' to '${ siteName }'` );

Expand Down Expand Up @@ -891,7 +900,8 @@ export async function generateProposedSitePath(
_event: IpcMainInvokeEvent,
siteName: string
): Promise< FolderDialogResponse > {
const path = nodePath.join( DEFAULT_SITE_PATH, sanitizeFolderName( siteName ) );
const defaultSiteDirectory = await resolveDefaultSiteDirectory();
const path = nodePath.join( defaultSiteDirectory, sanitizeFolderName( siteName ) );

try {
return {
Expand Down Expand Up @@ -926,9 +936,10 @@ export async function generateSiteNameFromList(
_event: IpcMainInvokeEvent,
usedSites: SiteDetails[]
): Promise< string > {
const defaultSiteDirectory = await resolveDefaultSiteDirectory();
return generateSiteName(
usedSites.map( ( s ) => s.name ),
DEFAULT_SITE_PATH
defaultSiteDirectory
);
}

Expand All @@ -937,10 +948,11 @@ export async function generateNumberedNameFromList(
baseName: string,
usedSites: SiteDetails[]
): Promise< string > {
const defaultSiteDirectory = await resolveDefaultSiteDirectory();
return generateNumberedName(
baseName,
usedSites.map( ( s ) => s.name ),
DEFAULT_SITE_PATH
defaultSiteDirectory
);
}

Expand Down
106 changes: 3 additions & 103 deletions apps/studio/src/modules/add-site/components/create-site-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ import { SupportedPHPVersion, SupportedPHPVersions } from '@studio/common/types/
import { Icon, SelectControl, Notice } from '@wordpress/components';
import { createInterpolateElement } from '@wordpress/element';
import { __, sprintf, _n } from '@wordpress/i18n';
import { tip, cautionFilled, chevronRight, chevronDown, chevronLeft } from '@wordpress/icons';
import { cautionFilled, chevronRight, chevronDown, chevronLeft } from '@wordpress/icons';
import { useI18n } from '@wordpress/react-i18n';
import { FormEvent, useState, useEffect, useCallback, useMemo, useRef, RefObject } from 'react';
import Button from 'src/components/button';
import FolderIcon from 'src/components/folder-icon';
import { FormPathInputComponent } from 'src/components/form-path-input';
import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more';
import PasswordControl from 'src/components/password-control';
import { SiteFormError } from 'src/components/site-form-error';
import TextControlComponent from 'src/components/text-control';
import { WPVersionSelector } from 'src/components/wp-version-selector';
import { cx } from 'src/lib/cx';
Expand Down Expand Up @@ -58,107 +59,6 @@ interface CreateSiteFormProps {
formRef?: RefObject< HTMLFormElement >;
}

interface FormPathInputComponentProps {
value: string;
onClick: () => void;
error?: string;
doesPathContainWordPress: boolean;
id?: string;
}

interface SiteFormErrorProps {
error?: string;
tipMessage?: string;
className?: string;
}

const SiteFormError = ( { error, tipMessage = '', className = '' }: SiteFormErrorProps ) => {
return (
( error || tipMessage ) && (
<div
id={ error ? 'error-message' : 'tip-message' }
role="alert"
aria-atomic="true"
className={ cx(
'flex items-start gap-1 text-xs',
error ? 'text-red-500' : 'text-frame-text-secondary',
className
) }
>
<Icon
className={ cx(
'shrink-0 basis-4',
error ? 'fill-red-500' : 'fill-frame-text-secondary'
) }
icon={ error ? cautionFilled : tip }
width={ 16 }
height={ 16 }
/>
<p>{ error ? error : __( tipMessage ) }</p>
</div>
)
);
};

function FormPathInputComponent( {
value,
onClick,
error,
doesPathContainWordPress,
id,
}: FormPathInputComponentProps ) {
const { __ } = useI18n();
return (
<div className="flex flex-col gap-2">
<button
aria-invalid={ !! error }
/**
* The below `aria-describedby` presumes the error message always
* relates to the local path input, which is true currently as it is the
* only data validation in place. If we ever introduce additional data
* validation we need to expand the robustness of this
* `aria-describedby` attribute so that it only targets relevant error
* messages.
*/
aria-describedby={ error ? 'site-path-error' : undefined }
type="button"
aria-label={ `${ value }, ${ __( 'Select different local path' ) }` }
className={ cx(
'flex flex-row items-stretch rounded-sm border border-frame-border focus:border-frame-theme focus:shadow-[0_0_0_0.5px] focus:shadow-frame-theme outline-none transition-shadow transition-linear duration-100 [&_.local-path-icon]:focus:border-l-frame-theme [&:disabled]:cursor-not-allowed',
error && 'border-red-500 [&_.local-path-icon]:border-l-red-500'
) }
data-testid="select-path-button"
onClick={ onClick }
id={ id }
>
<div
aria-hidden="true"
tabIndex={ -1 }
className="w-full text-left pl-3 py-3 min-h-10"
onChange={ () => {} }
>
{ value }
</div>
<div
aria-hidden="true"
className="local-path-icon flex items-center py-[9px] px-2.5 self-center"
>
<FolderIcon className="text-frame-text-secondary" />
</div>
</button>
<SiteFormError
error={ error }
tipMessage={
doesPathContainWordPress
? __( 'The existing WordPress site at this path will be added.' )
: ''
}
/>
<input type="hidden" data-testid="local-path-input" value={ value } />
</div>
);
}

export const CreateSiteForm = ( {
defaultValues = {},
onSelectPath,
Expand Down
Loading