Skip to content
Merged
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
50 changes: 50 additions & 0 deletions apps/studio/electron.vite.config.new-ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Electron-vite dev config for running the Electron app with the new @studio/ui renderer.
* Builds main + preload in development mode; skips the renderer since it's handled
* by the @studio/ui Vite dev server (pointed to via ELECTRON_RENDERER_URL).
*/

import { resolve } from 'path';
import { defineConfig } from 'electron-vite';

export default defineConfig( {
main: {
plugins: [],
resolve: {
alias: {
src: resolve( __dirname, 'src' ),
'@studio/common': resolve( __dirname, '../../tools/common' ),
'@wp-playground/blueprints/blueprint-schema-validator': resolve(
__dirname,
'../../node_modules/@wp-playground/blueprints/blueprint-schema-validator.js'
),
},
},
define: {
'process.env.NODE_ENV': JSON.stringify( process.env.NODE_ENV ),
COMMIT_HASH: JSON.stringify( 'dev' ),
},
build: {
externalizeDeps: {
exclude: [ '@studio/common' ],
},
rollupOptions: {
input: {
index: resolve( __dirname, 'src/index.ts' ),
},
output: {
entryFileNames: '[name].js',
},
external: [ /^@php-wasm\/.*/ ],
},
},
},
preload: {
build: {
externalizeDeps: { exclude: [ '@sentry/electron' ] },
lib: {
entry: resolve( __dirname, 'src/preload.ts' ),
},
},
},
} );
2 changes: 1 addition & 1 deletion apps/studio/src/components/content-tab-assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps
<TelexIcon />
<span className="text-frame-text">
{ createInterpolateElement(
__( 'Build blocks with <button>Telex <ArrowIcon /></button>' ),
__( 'Build blocks with <button>Telex <ArrowIcon/></button>' ),
{
button: (
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,7 @@ const SyncConnectedSiteSection = ( {
<div className="text-frame-text">
{ createInterpolateElement(
__(
'<siteUrlButton /> appears to be deleted or is currently unreachable. <button>Get help ↗</button>'
'<siteUrlButton/> appears to be deleted or is currently unreachable. <button>Get help ↗</button>'
),
{
button: (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function StudioCliToggle( { value, onChange }: StudioCLIToggleProps ) {
<div className="a8c-body-small text-frame-text-secondary">
{ createInterpolateElement(
__(
'Use the <code>studio</code> command in any terminal to manage sites, run WP-CLI commands, and control your local environment. <learn_more_link />'
'Use the <code>studio</code> command in any terminal to manage sites, run WP-CLI commands, and control your local environment. <learn_more_link/>'
),
{
code: <code className="bg-black/10 rounded px-1 py-0.5 text-xs" />,
Expand Down
73 changes: 73 additions & 0 deletions apps/ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# @studio/ui

A new UI layer for Studio that runs as the Electron renderer.

The UI is built around a portable connector pattern, so the same React app could be wired to a different backend (e.g. a REST API for a hosted/web version) without changing the UI code. For now, only the Electron IPC connector is shipped.

## Architecture

### Connector pattern

The `Connector` interface (`data/core/types.ts`) defines the data operations the UI needs:

```
data/core/
types.ts # Connector interface + domain types (SiteDetails, AuthUser)
connector-context.tsx # React Context provider + useConnector() hook
query-client.ts # TanStack Query client with localStorage persistence
connectors/
ipc/index.ts # Electron IPC implementation
```

Components access data through the `useConnector()` hook, which pulls the active connector from React Context. This keeps all UI code environment-agnostic.

The interface includes both a data surface (`getSites`, `createSite`, `deleteSite`, `startSite`, `stopSite`) and an auth surface (`requiresAuth`, `isAuthenticated`, `authenticate`, `logout`, `getAuthUser`). The auth surface is reserved for future non-Electron connectors -- the IPC connector sets `requiresAuth: false` and delegates to the desktop app.

### Data fetching with TanStack Query

Query hooks in `data/queries/` wrap connector methods with TanStack Query for caching, deduplication, and cache invalidation. The query client uses localStorage persistence (24h max age) mirroring the wp-calypso setup.

```typescript
function useSites() {
const connector = useConnector();
return useQuery({
queryKey: ['sites'],
queryFn: () => connector.getSites(),
});
}
```

Mutations invalidate related queries on success, keeping the UI in sync without manual refetching.

### Routing with TanStack Router

Routes are **code-based** (not file-based), following the wp-calypso hosting dashboard pattern. Routes are defined with `createRoute()` calls under `router/` and assembled into a route tree in `router/router.tsx`.

The router context carries both the `QueryClient` and `Connector`, enabling route-level data prefetching in `beforeLoad` hooks.

### Component structure

Components use a folder-per-component pattern with CSS Modules:

```
components/
sidebar-layout/
index.tsx
style.module.css
site-list/
index.tsx
style.module.css
onboarding-layout/
index.tsx
style.module.css
```

UI is built with `@wordpress/ui` and `@wordpress/theme` from the WordPress Design System, plus `@wordpress/icons` for iconography.

## Development

Run the full Electron app with the new UI as the renderer:

```bash
npm run start:new
```
12 changes: 12 additions & 0 deletions apps/ui/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
34 changes: 34 additions & 0 deletions apps/ui/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@studio/ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@studio/common": "file:../../tools/common",
"@tanstack/query-sync-storage-persister": "^5.96.2",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-persist-client": "^5.96.2",
"@tanstack/react-router": "^1.120.14",
"@wordpress/i18n": "^6.9.0",
"@wordpress/icons": "^11.8.0",
"@wordpress/private-apis": "^1.10.0",
"@wordpress/react-i18n": "^4.41.0",
"@wordpress/theme": "0.10.0",
"@wordpress/ui": "0.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.3.27",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^5.1.4",
"typescript": "~5.9.3",
"vite": "^7.3.1"
}
}
36 changes: 36 additions & 0 deletions apps/ui/src/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { defaultI18n } from '@wordpress/i18n';
import { I18nProvider } from '@wordpress/react-i18n';
import { privateApis } from '@wordpress/theme';
import { ConnectorProvider, queryClient } from '@/data/core';
import { usePrefersColorScheme } from '@/hooks/use-prefers-color-scheme';
import { unlock } from '@/lock-unlock';
import { createAppRouter } from '@/router/router';
import '@wordpress/theme/design-tokens.css';
import '@/index.css';
import type { Connector } from '@/data/core';

const { ThemeProvider } = unlock( privateApis );

interface AppProps {
connector: Connector;
}

export function App( { connector }: AppProps ) {
const router = createAppRouter( { queryClient, connector } );
const colorScheme = usePrefersColorScheme();
const themeColor = colorScheme === 'dark' ? { bg: '#1e1e1e' } : undefined;

return (
<ConnectorProvider connector={ connector }>
<QueryClientProvider client={ queryClient }>
<I18nProvider i18n={ defaultI18n }>
<ThemeProvider isRoot color={ themeColor }>
<RouterProvider router={ router } />
</ThemeProvider>
</I18nProvider>
</QueryClientProvider>
</ConnectorProvider>
);
}
11 changes: 11 additions & 0 deletions apps/ui/src/components/onboarding-layout/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Stack } from '@wordpress/ui';
import styles from './style.module.css';
import type { ReactNode } from 'react';

export function OnboardingLayout( { children }: { children: ReactNode } ) {
return (
<Stack align="center" justify="center" className={ styles.root }>
<div className={ styles.content }>{ children }</div>
</Stack>
);
}
9 changes: 9 additions & 0 deletions apps/ui/src/components/onboarding-layout/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.root {
height: 100vh;
}

.content {
max-width: 640px;
width: 100%;
padding: var(--wpds-dimension-padding-3xl);
}
15 changes: 15 additions & 0 deletions apps/ui/src/components/sidebar-layout/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { SiteList } from '@/components/site-list';
import styles from './style.module.css';
import type { ReactNode } from 'react';

export function SidebarLayout( { children }: { children: ReactNode } ) {
return (
<div className={ styles.root }>
<aside className={ styles.sidebar }>
<div className={ styles.separator } />
<SiteList />
</aside>
<main className={ styles.main }>{ children }</main>
</div>
);
}
24 changes: 24 additions & 0 deletions apps/ui/src/components/sidebar-layout/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.root {
display: flex;
height: 100vh;
}

.sidebar {
width: 260px;
flex-shrink: 0;
background-color: var(--wpds-color-bg-surface-neutral-weak);
display: flex;
flex-direction: column;
justify-content: flex-end;
}

.separator {
margin-block-start: auto;
height: 1px;
background-color: var(--wpds-color-stroke-surface-neutral);
}

.main {
flex: 1;
overflow: auto;
}
46 changes: 46 additions & 0 deletions apps/ui/src/components/site-list/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { __ } from '@wordpress/i18n';
import { Icon, chevronDown, chevronRight, plus } from '@wordpress/icons';
import { Button, Collapsible, Stack } from '@wordpress/ui';
import { useState } from 'react';
import { useSites } from '@/data/queries/use-sites';
import styles from './style.module.css';

export function SiteList() {
const { data: sites, isLoading } = useSites();
const [ open, setOpen ] = useState( true );

return (
<Collapsible.Root open={ open } onOpenChange={ setOpen } className={ styles.root }>
<Stack direction="row" align="center" justify="space-between">
<Collapsible.Trigger
render={
<Button variant="minimal" tone="neutral" size="compact">
<span className={ styles.title }>{ __( 'Projects' ) }</span>
<Icon icon={ open ? chevronDown : chevronRight } size={ 18 } />
</Button>
}
/>
<Button variant="minimal" tone="neutral" size="compact">
{ __( 'Stop all' ) }
</Button>
</Stack>
<Collapsible.Panel>
{ isLoading ? (
<p className={ styles.loading }>{ __( 'Loading…' ) }</p>
) : (
<ul className={ styles.list }>
{ sites?.map( ( site ) => (
<li key={ site.id } className={ styles.item }>
{ site.name }
</li>
) ) }
</ul>
) }
<Button variant="minimal" tone="neutral" size="compact">
<Icon icon={ plus } size={ 18 } />
<span>{ __( 'Add site' ) }</span>
</Button>
</Collapsible.Panel>
</Collapsible.Root>
);
}
33 changes: 33 additions & 0 deletions apps/ui/src/components/site-list/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
.root {
padding: var(--wpds-dimension-padding-md);
}

.title {
font-size: var(--wpds-font-size-xs);
font-weight: var(--wpds-font-weight-medium);
text-transform: uppercase;
letter-spacing: 0.05em;
}

.loading {
padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md);
color: var(--wpds-color-fg-content-neutral-weak);
}

.list {
list-style: none;
padding: 0;
margin: 0;
}

.item {
padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-md);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.item:hover {
color: var(--wpds-color-fg-content-neutral);
}
Loading
Loading