Add core DDEV project management (list/describe/start/stop/restart)
Wire up the full stack for step 2 of the build plan: a ddev.ts CLI wrapper in the main process (execFile + JSON envelope parsing, with a PATH fallback since GUI apps on macOS don't inherit Homebrew's PATH), IPC handlers exposed through a typed preload window.api surface, TanStack Query hooks for polling/mutations, and a two-pane UI (project list sidebar + detail panel with URLs, services, and DB credentials). Verified end-to-end against a real scratch DDEV project: typecheck, lint, and tests pass, and the running app correctly displays live project data and reflects state changes (start/stop) via polling.
This commit is contained in:
@@ -9,7 +9,8 @@ export default defineConfig({
|
|||||||
renderer: {
|
renderer: {
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@renderer': resolve('src/renderer/src')
|
'@renderer': resolve('src/renderer/src'),
|
||||||
|
'@shared': resolve('src/shared')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [react(), tailwindcss()]
|
plugins: [react(), tailwindcss()]
|
||||||
|
|||||||
@@ -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<T> {
|
||||||
|
level: string
|
||||||
|
msg: T | string
|
||||||
|
raw?: T
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execDdev<T>(args: string[]): Promise<DdevJsonEnvelope<T>> {
|
||||||
|
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<DdevJsonEnvelope<T>>(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<DdevJsonEnvelope<T>>(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<T>(args: string[]): Promise<T> {
|
||||||
|
const envelope = await execDdev<T>(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<void> {
|
||||||
|
await execDdev<never>(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<T>(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<DdevProjectSummary[]> {
|
||||||
|
const raw = await runDdevRead<DdevProjectSummary[] | null>(['list'])
|
||||||
|
return raw ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function describeProject(name: string): Promise<DdevProjectDetail> {
|
||||||
|
return runDdevRead<DdevProjectDetail>(['describe', name])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startProject(name: string): Promise<void> {
|
||||||
|
await runDdevCommand(['start', name])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopProject(name: string): Promise<void> {
|
||||||
|
await runDdevCommand(['stop', name])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartProject(name: string): Promise<void> {
|
||||||
|
await runDdevCommand(['restart', name])
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { app, shell, BrowserWindow } from 'electron'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
|
import { registerProjectsIpc } from './ipc/projects'
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
@@ -50,6 +51,8 @@ app.whenReady().then(() => {
|
|||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
registerProjectsIpc()
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
app.on('activate', function () {
|
app.on('activate', function () {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
Vendored
+2
-1
@@ -1,8 +1,9 @@
|
|||||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||||
|
import type { Api } from './index'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electron: ElectronAPI
|
electron: ElectronAPI
|
||||||
api: unknown
|
api: Api
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -1,8 +1,20 @@
|
|||||||
import { contextBridge } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
import { electronAPI } from '@electron-toolkit/preload'
|
import { electronAPI } from '@electron-toolkit/preload'
|
||||||
|
import type { DdevProjectDetail, DdevProjectSummary } from '../shared/types'
|
||||||
|
|
||||||
// Custom APIs for renderer
|
// Custom APIs for renderer
|
||||||
const api = {}
|
const api = {
|
||||||
|
projects: {
|
||||||
|
list: (): Promise<DdevProjectSummary[]> => ipcRenderer.invoke('projects:list'),
|
||||||
|
describe: (name: string): Promise<DdevProjectDetail> =>
|
||||||
|
ipcRenderer.invoke('projects:describe', name),
|
||||||
|
start: (name: string): Promise<void> => ipcRenderer.invoke('projects:start', name),
|
||||||
|
stop: (name: string): Promise<void> => ipcRenderer.invoke('projects:stop', name),
|
||||||
|
restart: (name: string): Promise<void> => ipcRenderer.invoke('projects:restart', name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Api = typeof api
|
||||||
|
|
||||||
// Use `contextBridge` APIs to expose Electron APIs to
|
// Use `contextBridge` APIs to expose Electron APIs to
|
||||||
// renderer only if context isolation is enabled, otherwise
|
// renderer only if context isolation is enabled, otherwise
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import App from './App'
|
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<typeof render> {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
it('renders the app title', () => {
|
it('renders the app title', () => {
|
||||||
render(<App />)
|
renderApp()
|
||||||
expect(screen.getByText('Aurora Dockside')).toBeInTheDocument()
|
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())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
function App(): React.JSX.Element {
|
||||||
|
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen items-center justify-center bg-neutral-50 text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100">
|
<div className="flex h-screen w-screen bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100">
|
||||||
<div className="text-center">
|
<aside className="flex w-72 flex-shrink-0 flex-col border-r border-neutral-200 dark:border-neutral-800">
|
||||||
<h1 className="text-2xl font-semibold">Aurora Dockside</h1>
|
<div className="border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||||
<p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
<h1 className="text-sm font-semibold">Aurora Dockside</h1>
|
||||||
DDEV project management, coming up.
|
</div>
|
||||||
</p>
|
<div className="flex-1 overflow-y-auto">
|
||||||
</div>
|
<ProjectList />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
{selectedProjectName ? (
|
||||||
|
<ProjectDetail name={selectedProjectName} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-neutral-500">
|
||||||
|
Select a project to see its details.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <div className="p-6 text-sm text-neutral-500">Loading {name}…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !project) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{error instanceof Error ? error.message : `Failed to load ${name}.`}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRunning = project.status === 'running'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold">{project.name}</h2>
|
||||||
|
<StatusBadge status={project.status} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">{project.approot}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isRunning || isBusy}
|
||||||
|
onClick={() => startProject.mutate(project.name)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Play size={14} /> Start
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isRunning || isBusy}
|
||||||
|
onClick={() => stopProject.mutate(project.name)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-600 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Square size={14} /> Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isRunning || isBusy}
|
||||||
|
onClick={() => restartProject.mutate(project.name)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<RotateCw size={14} /> Restart
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-sm font-semibold text-neutral-500 dark:text-neutral-400">URLs</h3>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{project.urls.map((url) => (
|
||||||
|
<li key={url}>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-sm font-semibold text-neutral-500 dark:text-neutral-400">
|
||||||
|
Services
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 font-medium">Service</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Status</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Image</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.values(project.services).map((service) => (
|
||||||
|
<tr
|
||||||
|
key={service.short_name}
|
||||||
|
className="border-t border-neutral-200 dark:border-neutral-800"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 font-medium">{service.short_name}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<StatusBadge status={service.status} />
|
||||||
|
</td>
|
||||||
|
<td className="truncate px-3 py-2 text-neutral-500 dark:text-neutral-400">
|
||||||
|
{service.image}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-sm font-semibold text-neutral-500 dark:text-neutral-400">
|
||||||
|
Database credentials
|
||||||
|
</h3>
|
||||||
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||||
|
<dt className="text-neutral-500 dark:text-neutral-400">Type</dt>
|
||||||
|
<dd>
|
||||||
|
{project.dbinfo.database_type} {project.dbinfo.database_version}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-neutral-500 dark:text-neutral-400">Database</dt>
|
||||||
|
<dd>{project.dbinfo.dbname}</dd>
|
||||||
|
<dt className="text-neutral-500 dark:text-neutral-400">Username</dt>
|
||||||
|
<dd>{project.dbinfo.username}</dd>
|
||||||
|
<dt className="text-neutral-500 dark:text-neutral-400">Password</dt>
|
||||||
|
<dd>{project.dbinfo.password}</dd>
|
||||||
|
<dt className="text-neutral-500 dark:text-neutral-400">Port</dt>
|
||||||
|
<dd>{project.dbinfo.published_port}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <div className="p-4 text-sm text-neutral-500">Loading projects…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{error instanceof Error ? error.message : 'Failed to load DDEV projects.'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projects || projects.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-sm text-neutral-500">
|
||||||
|
No DDEV projects found. Run <code>ddev start</code> in a project directory to see it here.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col gap-1 p-2">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<li key={project.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectProject(project.name)}
|
||||||
|
className={clsx(
|
||||||
|
'flex w-full flex-col gap-1 rounded-lg px-3 py-2 text-left transition-colors',
|
||||||
|
selectedProjectName === project.name
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/40'
|
||||||
|
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{project.name}</span>
|
||||||
|
<StatusBadge status={project.status} />
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{project.type} · {project.shortroot}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium capitalize',
|
||||||
|
STATUS_STYLES[status] ?? DEFAULT_STYLE
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<DdevProjectSummary[], Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: PROJECTS_KEY,
|
||||||
|
queryFn: () => window.api.projects.list(),
|
||||||
|
refetchInterval: 5000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjectDetail(name: string | null): UseQueryResult<DdevProjectDetail, Error> {
|
||||||
|
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<void>
|
||||||
|
): UseMutationResult<void, Error, string> {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: action,
|
||||||
|
onSuccess: (_data, name) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
|
||||||
|
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStartProject(): UseMutationResult<void, Error, string> {
|
||||||
|
return useProjectAction((name) => window.api.projects.start(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStopProject(): UseMutationResult<void, Error, string> {
|
||||||
|
return useProjectAction((name) => window.api.projects.stop(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRestartProject(): UseMutationResult<void, Error, string> {
|
||||||
|
return useProjectAction((name) => window.api.projects.restart(name))
|
||||||
|
}
|
||||||
@@ -2,10 +2,21 @@ import './assets/main.css'
|
|||||||
|
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
selectedProjectName: string | null
|
||||||
|
selectProject: (name: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
|
selectedProjectName: null,
|
||||||
|
selectProject: (name): void => set({ selectedProjectName: name })
|
||||||
|
}))
|
||||||
@@ -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<string, DdevService>
|
||||||
|
xdebug_enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DdevCommandResult {
|
||||||
|
ok: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DdevError {
|
||||||
|
ok: false
|
||||||
|
message: string
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@renderer/*": [
|
"@renderer/*": [
|
||||||
"src/renderer/src/*"
|
"src/renderer/src/*"
|
||||||
|
],
|
||||||
|
"@shared/*": [
|
||||||
|
"src/shared/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@renderer': resolve('src/renderer/src')
|
'@renderer': resolve('src/renderer/src'),
|
||||||
|
'@shared': resolve('src/shared')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in New Issue
Block a user