diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 80304cd..476b4de 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -9,7 +9,8 @@ export default defineConfig({ renderer: { resolve: { alias: { - '@renderer': resolve('src/renderer/src') + '@renderer': resolve('src/renderer/src'), + '@shared': resolve('src/shared') } }, plugins: [react(), tailwindcss()] diff --git a/src/main/ddev.ts b/src/main/ddev.ts new file mode 100644 index 0000000..fabce72 --- /dev/null +++ b/src/main/ddev.ts @@ -0,0 +1,110 @@ +import { execFile } from 'child_process' +import { promisify } from 'util' +import type { DdevProjectDetail, DdevProjectSummary } from '../shared/types' + +const execFileAsync = promisify(execFile) + +// GUI apps launched from Finder/Dock on macOS don't inherit the shell's PATH, +// so Homebrew-installed `ddev` at /opt/homebrew/bin can be invisible even +// though it works fine when launched from a terminal (`pnpm dev`). +const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin'] +const ENV_WITH_DDEV_PATH = { + ...process.env, + PATH: [...EXTRA_PATH_DIRS, process.env.PATH].join(':') +} + +export class DdevCliError extends Error { + constructor(message: string) { + super(message) + this.name = 'DdevCliError' + } +} + +interface DdevJsonEnvelope { + level: string + msg: T | string + raw?: T + time: string +} + +async function execDdev(args: string[]): Promise> { + let stdout: string + try { + ;({ stdout } = await execFileAsync('ddev', [...args, '--json-output'], { + maxBuffer: 32 * 1024 * 1024, + env: ENV_WITH_DDEV_PATH + })) + } catch (error) { + const execError = error as { stdout?: string; stderr?: string; message: string } + const parsed = tryParseLastJsonLine>(execError.stdout ?? '') + if (parsed?.msg && typeof parsed.msg === 'string') { + throw new DdevCliError(parsed.msg) + } + if (execError.message.includes('ENOENT')) { + throw new DdevCliError('ddev is not installed or not on PATH') + } + throw new DdevCliError(execError.stderr?.trim() || execError.message) + } + + const envelope = tryParseLastJsonLine>(stdout) + if (!envelope) { + throw new DdevCliError(`Could not parse ddev output: ${stdout.slice(0, 500)}`) + } + if (envelope.level === 'fatal' || envelope.level === 'error') { + throw new DdevCliError(typeof envelope.msg === 'string' ? envelope.msg : 'ddev command failed') + } + return envelope +} + +// Read commands (list/describe): the payload lives under `raw`. +async function runDdevRead(args: string[]): Promise { + const envelope = await execDdev(args) + if (envelope.raw === undefined) { + throw new DdevCliError( + typeof envelope.msg === 'string' ? envelope.msg : 'ddev returned no data' + ) + } + return envelope.raw +} + +// Mutation commands (start/stop/restart): success is just a non-fatal `msg`, no `raw`. +async function runDdevCommand(args: string[]): Promise { + await execDdev(args) +} + +// ddev sometimes prints non-JSON progress lines before the final JSON envelope +// (opt-in prompts, deprecation notices); the envelope is always the last line. +function tryParseLastJsonLine(output: string): T | null { + const lines = output.trim().split('\n') + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim() + if (!line.startsWith('{')) continue + try { + return JSON.parse(line) as T + } catch { + continue + } + } + return null +} + +export async function listProjects(): Promise { + const raw = await runDdevRead(['list']) + return raw ?? [] +} + +export async function describeProject(name: string): Promise { + return runDdevRead(['describe', name]) +} + +export async function startProject(name: string): Promise { + await runDdevCommand(['start', name]) +} + +export async function stopProject(name: string): Promise { + await runDdevCommand(['stop', name]) +} + +export async function restartProject(name: string): Promise { + await runDdevCommand(['restart', name]) +} diff --git a/src/main/index.ts b/src/main/index.ts index ff8cca9..061c8c9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,6 +2,7 @@ import { app, shell, BrowserWindow } from 'electron' import { join } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' +import { registerProjectsIpc } from './ipc/projects' function createWindow(): void { // Create the browser window. @@ -50,6 +51,8 @@ app.whenReady().then(() => { optimizer.watchWindowShortcuts(window) }) + registerProjectsIpc() + createWindow() app.on('activate', function () { diff --git a/src/main/ipc/projects.ts b/src/main/ipc/projects.ts new file mode 100644 index 0000000..7b4bc1a --- /dev/null +++ b/src/main/ipc/projects.ts @@ -0,0 +1,10 @@ +import { ipcMain } from 'electron' +import { describeProject, listProjects, restartProject, startProject, stopProject } from '../ddev' + +export function registerProjectsIpc(): void { + ipcMain.handle('projects:list', () => listProjects()) + ipcMain.handle('projects:describe', (_event, name: string) => describeProject(name)) + ipcMain.handle('projects:start', (_event, name: string) => startProject(name)) + ipcMain.handle('projects:stop', (_event, name: string) => stopProject(name)) + ipcMain.handle('projects:restart', (_event, name: string) => restartProject(name)) +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a153669..291c976 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,8 +1,9 @@ import { ElectronAPI } from '@electron-toolkit/preload' +import type { Api } from './index' declare global { interface Window { electron: ElectronAPI - api: unknown + api: Api } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2d18524..c1ad00f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,8 +1,20 @@ -import { contextBridge } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' +import type { DdevProjectDetail, DdevProjectSummary } from '../shared/types' // Custom APIs for renderer -const api = {} +const api = { + projects: { + list: (): Promise => ipcRenderer.invoke('projects:list'), + describe: (name: string): Promise => + ipcRenderer.invoke('projects:describe', name), + start: (name: string): Promise => ipcRenderer.invoke('projects:start', name), + stop: (name: string): Promise => ipcRenderer.invoke('projects:stop', name), + restart: (name: string): Promise => ipcRenderer.invoke('projects:restart', name) + } +} + +export type Api = typeof api // Use `contextBridge` APIs to expose Electron APIs to // renderer only if context isolation is enabled, otherwise diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index b245d28..fda93c3 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -1,10 +1,35 @@ -import { describe, expect, it } from 'vitest' -import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' +vi.stubGlobal('api', { + projects: { + list: vi.fn().mockResolvedValue([]), + describe: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + restart: vi.fn() + } +}) + +function renderApp(): ReturnType { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + ) +} + describe('App', () => { it('renders the app title', () => { - render() + renderApp() expect(screen.getByText('Aurora Dockside')).toBeInTheDocument() }) + + it('shows an empty state when there are no DDEV projects', async () => { + renderApp() + await waitFor(() => expect(screen.getByText(/No DDEV projects found/)).toBeInTheDocument()) + }) }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 01c15db..fd113aa 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,12 +1,29 @@ +import { ProjectDetail } from './components/projects/ProjectDetail' +import { ProjectList } from './components/projects/ProjectList' +import { useAppStore } from './stores/appStore' + function App(): React.JSX.Element { + const selectedProjectName = useAppStore((s) => s.selectedProjectName) + return ( -
-
-

Aurora Dockside

-

- DDEV project management, coming up. -

-
+
+ +
+ {selectedProjectName ? ( + + ) : ( +
+ Select a project to see its details. +
+ )} +
) } diff --git a/src/renderer/src/components/projects/ProjectDetail.tsx b/src/renderer/src/components/projects/ProjectDetail.tsx new file mode 100644 index 0000000..7fd05ee --- /dev/null +++ b/src/renderer/src/components/projects/ProjectDetail.tsx @@ -0,0 +1,142 @@ +import { Play, RotateCw, Square } from 'lucide-react' +import { + useProjectDetail, + useRestartProject, + useStartProject, + useStopProject +} from '../../hooks/useDdev' +import { StatusBadge } from './StatusBadge' + +export function ProjectDetail({ name }: { name: string }): React.JSX.Element { + const { data: project, isLoading, isError, error } = useProjectDetail(name) + const startProject = useStartProject() + const stopProject = useStopProject() + const restartProject = useRestartProject() + + const isBusy = startProject.isPending || stopProject.isPending || restartProject.isPending + + if (isLoading) { + return
Loading {name}…
+ } + + if (isError || !project) { + return ( +
+ {error instanceof Error ? error.message : `Failed to load ${name}.`} +
+ ) + } + + const isRunning = project.status === 'running' + + return ( +
+
+
+
+

{project.name}

+ +
+

{project.approot}

+
+
+ + + +
+
+ +
+

URLs

+
    + {project.urls.map((url) => ( +
  • + + {url} + +
  • + ))} +
+
+ +
+

+ Services +

+
+ + + + + + + + + + {Object.values(project.services).map((service) => ( + + + + + + ))} + +
ServiceStatusImage
{service.short_name} + + + {service.image} +
+
+
+ +
+

+ Database credentials +

+
+
Type
+
+ {project.dbinfo.database_type} {project.dbinfo.database_version} +
+
Database
+
{project.dbinfo.dbname}
+
Username
+
{project.dbinfo.username}
+
Password
+
{project.dbinfo.password}
+
Port
+
{project.dbinfo.published_port}
+
+
+
+ ) +} diff --git a/src/renderer/src/components/projects/ProjectList.tsx b/src/renderer/src/components/projects/ProjectList.tsx new file mode 100644 index 0000000..a6dc33d --- /dev/null +++ b/src/renderer/src/components/projects/ProjectList.tsx @@ -0,0 +1,57 @@ +import { clsx } from 'clsx' +import { useProjects } from '../../hooks/useDdev' +import { useAppStore } from '../../stores/appStore' +import { StatusBadge } from './StatusBadge' + +export function ProjectList(): React.JSX.Element { + const { data: projects, isLoading, isError, error } = useProjects() + const selectedProjectName = useAppStore((s) => s.selectedProjectName) + const selectProject = useAppStore((s) => s.selectProject) + + if (isLoading) { + return
Loading projects…
+ } + + if (isError) { + return ( +
+ {error instanceof Error ? error.message : 'Failed to load DDEV projects.'} +
+ ) + } + + if (!projects || projects.length === 0) { + return ( +
+ No DDEV projects found. Run ddev start in a project directory to see it here. +
+ ) + } + + return ( +
    + {projects.map((project) => ( +
  • + +
  • + ))} +
+ ) +} diff --git a/src/renderer/src/components/projects/StatusBadge.tsx b/src/renderer/src/components/projects/StatusBadge.tsx new file mode 100644 index 0000000..9578448 --- /dev/null +++ b/src/renderer/src/components/projects/StatusBadge.tsx @@ -0,0 +1,22 @@ +import { clsx } from 'clsx' + +const STATUS_STYLES: Record = { + running: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400', + stopped: 'bg-neutral-200 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400', + paused: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400' +} + +const DEFAULT_STYLE = 'bg-neutral-200 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400' + +export function StatusBadge({ status }: { status: string }): React.JSX.Element { + return ( + + {status} + + ) +} diff --git a/src/renderer/src/hooks/useDdev.ts b/src/renderer/src/hooks/useDdev.ts new file mode 100644 index 0000000..a3f720a --- /dev/null +++ b/src/renderer/src/hooks/useDdev.ts @@ -0,0 +1,53 @@ +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult +} from '@tanstack/react-query' +import type { DdevProjectDetail, DdevProjectSummary } from '@shared/types' + +const PROJECTS_KEY = ['projects'] as const +const projectDetailKey = (name: string): readonly [string, string] => ['project', name] as const + +export function useProjects(): UseQueryResult { + return useQuery({ + queryKey: PROJECTS_KEY, + queryFn: () => window.api.projects.list(), + refetchInterval: 5000 + }) +} + +export function useProjectDetail(name: string | null): UseQueryResult { + return useQuery({ + queryKey: name ? projectDetailKey(name) : ['project', 'none'], + queryFn: () => window.api.projects.describe(name!), + enabled: name !== null, + refetchInterval: 5000 + }) +} + +function useProjectAction( + action: (name: string) => Promise +): UseMutationResult { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: action, + onSuccess: (_data, name) => { + queryClient.invalidateQueries({ queryKey: PROJECTS_KEY }) + queryClient.invalidateQueries({ queryKey: projectDetailKey(name) }) + } + }) +} + +export function useStartProject(): UseMutationResult { + return useProjectAction((name) => window.api.projects.start(name)) +} + +export function useStopProject(): UseMutationResult { + return useProjectAction((name) => window.api.projects.stop(name)) +} + +export function useRestartProject(): UseMutationResult { + return useProjectAction((name) => window.api.projects.restart(name)) +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 5905ed1..fa76016 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -2,10 +2,21 @@ import './assets/main.css' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false + } + } +}) + createRoot(document.getElementById('root')!).render( - + + + ) diff --git a/src/renderer/src/stores/appStore.ts b/src/renderer/src/stores/appStore.ts new file mode 100644 index 0000000..8678080 --- /dev/null +++ b/src/renderer/src/stores/appStore.ts @@ -0,0 +1,11 @@ +import { create } from 'zustand' + +interface AppState { + selectedProjectName: string | null + selectProject: (name: string | null) => void +} + +export const useAppStore = create((set) => ({ + selectedProjectName: null, + selectProject: (name): void => set({ selectedProjectName: name }) +})) diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..460b4fe --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,78 @@ +export type ProjectStatus = 'running' | 'stopped' | 'paused' | 'starting' | 'stopping' | string + +export interface DdevProjectSummary { + name: string + status: ProjectStatus + status_desc: string + type: string + approot: string + shortroot: string + docroot: string + primary_url: string + httpurl: string + httpsurl: string + mutagen_enabled: boolean + mutagen_status?: string +} + +export interface DdevServiceHostPortMapping { + exposed_port: string + host_port: string +} + +export interface DdevService { + short_name: string + full_name: string + status: string + image: string + exposed_ports: string + host_ports: string + host_ports_mapping: DdevServiceHostPortMapping[] + http_url?: string + https_url?: string + host_http_url?: string + host_https_url?: string + virtual_host?: string + 'describe-info'?: string + 'describe-url-port'?: string +} + +export interface DdevDbInfo { + database_type: string + database_version: string + dbPort: string + dbname: string + host: string + password: string + published_port: number + username: string +} + +export interface DdevProjectDetail extends DdevProjectSummary { + database_type: string + database_version: string + dbinfo: DdevDbInfo + hostname: string + hostnames: string[] + httpURLs: string[] + httpsURLs: string[] + urls: string[] + php_version?: string + nodejs_version?: string + webserver_type?: string + performance_mode?: string + router: string + router_status?: string + services: Record + xdebug_enabled: boolean +} + +export interface DdevCommandResult { + ok: boolean + message: string +} + +export interface DdevError { + ok: false + message: string +} diff --git a/tsconfig.web.json b/tsconfig.web.json index 49b84f9..04c5d2a 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -17,6 +17,9 @@ "paths": { "@renderer/*": [ "src/renderer/src/*" + ], + "@shared/*": [ + "src/shared/*" ] } } diff --git a/vitest.config.ts b/vitest.config.ts index 0eec852..c53639d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,8 @@ export default defineConfig({ plugins: [react()], resolve: { alias: { - '@renderer': resolve('src/renderer/src') + '@renderer': resolve('src/renderer/src'), + '@shared': resolve('src/shared') } }, test: {