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
25 changes: 17 additions & 8 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,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 @@ -137,6 +137,8 @@ export {
saveUserLocale,
saveUserTerminal,
showUserSettings,
getDefaultSiteDirectory,
saveDefaultSiteDirectory,
} from 'src/modules/user-settings/lib/ipc-handlers';

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

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

const defaultSiteDirectory = await resolveDefaultSiteDirectory();
const { canceled, filePaths } = await dialog.showOpenDialog( parentWindow, {
title,
defaultPath: defaultDialogPath !== '' ? defaultDialogPath : DEFAULT_SITE_PATH,
defaultPath: defaultDialogPath !== '' ? defaultDialogPath : defaultSiteDirectory,
properties: [
'openDirectory',
'createDirectory', // allow user to create new directories; macOS only
Expand Down Expand Up @@ -691,7 +696,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 @@ -889,7 +895,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 @@ -924,9 +931,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 @@ -935,10 +943,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useI18n } from '@wordpress/react-i18n';
import Button from 'src/components/button';
import { SettingsFormField } from './settings-form-field';

interface DefaultDirectoryPickerProps {
directory?: string;
isLoading: boolean;
isSelecting: boolean;
onPick: () => void;
}

export const DefaultDirectoryPicker = ( {
directory,
isLoading,
isSelecting,
onPick,
}: DefaultDirectoryPickerProps ) => {
const { __ } = useI18n();

return (
<SettingsFormField label={ __( 'Default site directory' ) }>
<div className="flex flex-col gap-2">
<p className="a8c-body-small text-a8c-gray-700 break-words">
{ isLoading ? __( 'Loading...' ) : directory ?? '' }
</p>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={ onPick }
disabled={ isLoading || isSelecting }
data-testid="preferences-change-default-directory-button"
>
{ __( 'Change folder' ) }
</Button>
</div>
</div>
</SettingsFormField>
);
};
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { SupportedLocale } from '@studio/common/lib/locale';
import { useI18n } from '@wordpress/react-i18n';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import Button from 'src/components/button';
import { useFeatureFlags } from 'src/hooks/use-feature-flags';
import { isWindowsStore } from 'src/lib/app-globals';
import { McpSettings } from 'src/modules/mcp/components/mcp-settings';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { EditorPicker } from 'src/modules/user-settings/components/editor-picker';
import { LanguagePicker } from 'src/modules/user-settings/components/language-picker';
import { StudioCliToggle } from 'src/modules/user-settings/components/studio-cli-toggle';
Expand All @@ -21,6 +22,7 @@ import {
useGetStudioCliIsInstalledQuery,
useSaveStudioCliIsInstalledMutation,
} from 'src/stores/installed-apps-api';
import { DefaultDirectoryPicker } from './default-directory-picker';

export const PreferencesTab = ( { onClose }: { onClose: () => void } ) => {
const { __ } = useI18n();
Expand All @@ -40,6 +42,10 @@ export const PreferencesTab = ( { onClose }: { onClose: () => void } ) => {
const [ dirtyEditor, setDirtyEditor ] = useState< SupportedEditor | null >();
const [ dirtyTerminal, setDirtyTerminal ] = useState< SupportedTerminal >();
const [ dirtyIsCliInstalled, setDirtyIsCliInstalled ] = useState< boolean >();
const [ storedDefaultSiteDirectory, setStoredDefaultSiteDirectory ] = useState< string >();
const [ defaultSiteDirectory, setDefaultSiteDirectory ] = useState< string >();
const [ isLoadingDefaultSiteDirectory, setIsLoadingDefaultSiteDirectory ] = useState( true );
const [ isSelectingDefaultDirectory, setIsSelectingDefaultDirectory ] = useState( false );

const savePreferences = async () => {
if ( dirtyLocale ) {
Expand All @@ -54,6 +60,13 @@ export const PreferencesTab = ( { onClose }: { onClose: () => void } ) => {
if ( dirtyIsCliInstalled !== undefined ) {
await saveCliIsInstalled( dirtyIsCliInstalled );
}
const isDefaultDirectoryDirty =
storedDefaultSiteDirectory !== undefined &&
defaultSiteDirectory !== undefined &&
storedDefaultSiteDirectory !== defaultSiteDirectory;
if ( isDefaultDirectoryDirty && defaultSiteDirectory ) {
await getIpcApi().saveDefaultSiteDirectory( defaultSiteDirectory );
}
onClose();
};

Expand All @@ -67,8 +80,45 @@ export const PreferencesTab = ( { onClose }: { onClose: () => void } ) => {
[ dirtyEditor, editor ],
[ dirtyTerminal, terminal ],
[ dirtyIsCliInstalled, isCliInstalled ],
[ defaultSiteDirectory, storedDefaultSiteDirectory ],
].some( ( [ a, b ] ) => a !== undefined && a !== b );

useEffect( () => {
let isMounted = true;
void ( async () => {
try {
const directory = await getIpcApi().getDefaultSiteDirectory();
if ( ! isMounted ) {
return;
}
setStoredDefaultSiteDirectory( directory );
setDefaultSiteDirectory( directory );
} finally {
if ( isMounted ) {
setIsLoadingDefaultSiteDirectory( false );
}
}
} )();
return () => {
isMounted = false;
};
}, [] );

const handleChangeDefaultDirectory = async () => {
setIsSelectingDefaultDirectory( true );
try {
const response = await getIpcApi().showOpenFolderDialog(
__( 'Select default site directory' ),
defaultSiteDirectory ?? ''
);
if ( response?.path ) {
setDefaultSiteDirectory( response.path );
}
} finally {
setIsSelectingDefaultDirectory( false );
}
};

return (
<>
<LanguagePicker value={ localeSelection } onChange={ setDirtyLocale } />
Expand All @@ -84,6 +134,12 @@ export const PreferencesTab = ( { onClose }: { onClose: () => void } ) => {
{ enableAgentSuite && <McpSettings /> }
</>
) }
<DefaultDirectoryPicker
directory={ defaultSiteDirectory }
isLoading={ isLoadingDefaultSiteDirectory }
isSelecting={ isSelectingDefaultDirectory }
onPick={ handleChangeDefaultDirectory }
/>
<div className="mt-auto pt-2 flex justify-end gap-3">
<Button
variant="tertiary"
Expand Down
11 changes: 11 additions & 0 deletions apps/studio/src/modules/user-settings/lib/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getUserLocaleWithFallback } from 'src/lib/locale-node';
import { SUPPORTED_EDITORS, SupportedEditor } from 'src/modules/user-settings/lib/editor';
import { SupportedTerminal } from 'src/modules/user-settings/lib/terminal';
import { UserSettingsTabName } from 'src/modules/user-settings/user-settings-types';
import { ensureWritableDirectory, resolveDefaultSiteDirectory } from 'src/storage/paths';
import { loadUserData, updateAppdata } from 'src/storage/user-data';

export function getInstalledAppsAndTerminals(): InstalledApps {
Expand Down Expand Up @@ -49,6 +50,16 @@ export async function saveUserEditor( event: IpcMainInvokeEvent, editor: Support
await updateAppdata( { preferredEditor: editor } );
}

export async function getDefaultSiteDirectory(): Promise< string > {
return resolveDefaultSiteDirectory();
}

export async function saveDefaultSiteDirectory( event: IpcMainInvokeEvent, directory: string ) {
await ensureWritableDirectory( directory );
await sendIpcEventToRenderer( 'user-preference-changed' );
await updateAppdata( { defaultSiteDirectory: directory } );
}

export async function getUserLocale() {
return getUserLocaleWithFallback();
}
Expand Down
3 changes: 3 additions & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ const api: IpcApi = {
saveUserLocale: ( locale ) => ipcRendererInvoke( 'saveUserLocale', locale ),
getSentryUserId: () => ipcRendererInvoke( 'getSentryUserId' ),
getUserLocale: () => ipcRendererInvoke( 'getUserLocale' ),
getDefaultSiteDirectory: () => ipcRendererInvoke( 'getDefaultSiteDirectory' ),
saveDefaultSiteDirectory: ( directory ) =>
ipcRendererInvoke( 'saveDefaultSiteDirectory', directory ),
showUserSettings: ( tabName ) => ipcRendererInvoke( 'showUserSettings', tabName ),
startServer: ( id ) => ipcRendererInvoke( 'startServer', id ),
stopServer: ( id ) => ipcRendererInvoke( 'stopServer', id ),
Expand Down
43 changes: 42 additions & 1 deletion apps/studio/src/storage/paths.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fs from 'fs';
import fsPromises from 'fs/promises';
import path from 'path';
import { LOCKFILE_NAME } from '@studio/common/constants';

Expand Down Expand Up @@ -34,7 +36,7 @@ export function getUserDataCertificatesPath(): string {
return path.join( getAppDataPath(), getAppName(), 'certificates' );
}

export const DEFAULT_SITE_PATH = path.join(
const defaultSitePath = path.join(
( process.env.E2E && process.env.E2E_HOME_PATH
? process.env.E2E_HOME_PATH
: app?.getPath( 'home' ) ) || '',
Expand Down Expand Up @@ -116,3 +118,42 @@ function getAppName(): string {
}
return app.getName();
}

async function ensurePathIsDirectory( directory: string ) {
const stats = await fsPromises.stat( directory );
if ( ! stats.isDirectory() ) {
throw new Error( 'Selected path is not a directory.' );
}
}

async function ensurePathIsWritable( directory: string ) {
await fsPromises.access( directory, fs.constants.W_OK );
}

export async function ensureWritableDirectory( directory: string ) {
await ensurePathIsDirectory( directory );
await ensurePathIsWritable( directory );
}

async function getStoredDefaultSiteDirectory(): Promise< string | undefined > {
const { loadUserData } = await import( 'src/storage/user-data' );
const userData = await loadUserData();
return userData.defaultSiteDirectory;
}

export async function resolveDefaultSiteDirectory(): Promise< string > {
const storedPath = await getStoredDefaultSiteDirectory();
if ( storedPath ) {
try {
await ensureWritableDirectory( storedPath );
return storedPath;
} catch ( error ) {
console.warn(
'Stored default site directory is unavailable, falling back to built-in path.',
error
);
}
}

return defaultSitePath;
}
1 change: 1 addition & 0 deletions apps/studio/src/storage/storage-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface UserData {
preferredEditor?: SupportedEditor;
betaFeatures?: BetaFeatures;
stopSitesOnQuit?: boolean;
defaultSiteDirectory?: string;
}

export interface PersistedUserData extends Omit< UserData, 'sites' > {
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/storage/user-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@ type UserDataSafeKeys =
| 'lastSeenVersion'
| 'preferredTerminal'
| 'preferredEditor'
| 'betaFeatures';
| 'betaFeatures'
| 'defaultSiteDirectory';

type PartialUserDataWithSafeKeysToUpdate = Partial< Pick< UserData, UserDataSafeKeys > >;

Expand Down
2 changes: 1 addition & 1 deletion apps/studio/src/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ vi.mock( 'src/storage/paths', () => ( {
getCliPath: vi.fn().mockReturnValue( '/mock/cli/path' ),
getBundledNodeBinaryPath: vi.fn().mockReturnValue( '/mock/node/binary' ),
getSiteThumbnailPath: vi.fn().mockReturnValue( '/mock/thumbnail.png' ),
DEFAULT_SITE_PATH: '/mock/default/site/path',
resolveDefaultSiteDirectory: vi.fn().mockResolvedValue( '/mock/default/site/path' ),
} ) );
vi.mock( 'src/modules/cli/lib/execute-command', () => {
const mockEventEmitter = {
Expand Down
Loading
Loading