diff --git a/apps/emdash-desktop/src/main/core/preview-servers/controller.ts b/apps/emdash-desktop/src/main/core/preview-servers/controller.ts index ddb58198f0..6fe1969d76 100644 --- a/apps/emdash-desktop/src/main/core/preview-servers/controller.ts +++ b/apps/emdash-desktop/src/main/core/preview-servers/controller.ts @@ -6,10 +6,17 @@ export const previewServersController = createRPCController({ listForWorkspace: async (args: { projectId: string; workspaceId: string }) => previewServerService.listForWorkspace(args), + listAll: async () => previewServerService.listAll(), + forwardManual: async (request: ManualPreviewServerRequest) => previewServerService.forwardManual(request), stop: async (id: string) => previewServerService.stop(id), + stopForWorkspace: async (args: { projectId: string; workspaceId: string }) => + previewServerService.stopForWorkspace(args.projectId, args.workspaceId), + + stopAll: async () => previewServerService.stopAll(), + restart: async (id: string) => previewServerService.restart(id), }); diff --git a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts index 281534bbd6..23704f02cc 100644 --- a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts +++ b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts @@ -590,4 +590,40 @@ describe('PreviewServerService', () => { 'preview:ssh:auto:project-1:workspace-1:connection-1:5173', ]); }); + + it('lists and stops previews across all projects and workspaces', async () => { + const context = createService(); + const local = await context.service.registerDetectedTarget({ + projectId: 'project-1', + workspaceId: 'workspace-1', + transport: 'local', + source: { kind: 'terminal-output', terminalId: 'terminal-1' }, + protocol: 'http:', + host: 'localhost', + port: 5173, + urlPath: '/', + }); + const forwarded = await context.service.registerDetectedTarget({ + projectId: 'project-2', + workspaceId: 'workspace-2', + connectionId: 'connection-1', + transport: 'ssh', + proxy: fakeProxy(), + source: { kind: 'terminal-output', terminalId: 'terminal-2' }, + protocol: 'http:', + port: 5174, + urlPath: '/', + }); + + expect(context.service.listAll()).toEqual([local, forwarded]); + + await context.service.stopAll(); + + expect(context.service.listAll()).toEqual([]); + expect(context.events).toContainEqual({ type: 'remove', id: local.id }); + expect(context.events).toContainEqual({ type: 'remove', id: forwarded.id }); + expect(context.closedTunnelIds).toEqual([ + 'preview:ssh:auto:project-2:workspace-2:connection-1:5174', + ]); + }); }); diff --git a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts index 25e8359d67..23af920e18 100644 --- a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts +++ b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts @@ -182,6 +182,10 @@ export class PreviewServerService { ); } + listAll(): PreviewServer[] { + return Array.from(this.servers.values()); + } + async handleTerminalSourceClosed(input: TerminalSourceClosedInput): Promise { if (input.transport === 'local') { await this.stopForTerminal(input); @@ -370,6 +374,11 @@ export class PreviewServerService { await Promise.all(ids.map((id) => this.stop(id))); } + async stopAll(): Promise { + const ids = Array.from(this.servers.keys()); + await Promise.all(ids.map((id) => this.stop(id))); + } + private registerLocalTarget( target: Extract ): DirectPreviewServer { diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx new file mode 100644 index 0000000000..cbb555be16 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx @@ -0,0 +1,246 @@ +import { ExternalLinkIcon, GlobeIcon, SquareIcon } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import type { ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import { + getProjectStore, + projectDisplayName, +} from '@renderer/features/projects/stores/project-selectors'; +import { + formatPreviewServerLabel, + previewServerStatusLabel, +} from '@renderer/features/tasks/components/preview-servers/preview-server-format'; +import { + asProvisioned, + getTaskManagerStore, + taskDisplayName, +} from '@renderer/features/tasks/stores/task-selectors'; +import { useToast } from '@renderer/lib/hooks/use-toast'; +import { events, rpc } from '@renderer/lib/ipc'; +import { useShowModal } from '@renderer/lib/modal/modal-provider'; +import { Badge } from '@renderer/lib/ui/badge'; +import { Button } from '@renderer/lib/ui/button'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@renderer/lib/ui/tooltip'; +import { cn } from '@renderer/utils/utils'; +import { previewServerEventChannel } from '@shared/core/preview-servers/events'; +import type { PreviewServer } from '@shared/core/preview-servers/types'; +import { previewServerUrl } from '@shared/core/preview-servers/types'; + +export const PreviewServersSettingsCard = observer(function PreviewServersSettingsCard() { + const [servers, setServers] = useState([]); + const showConfirm = useShowModal('confirmActionModal'); + const { toast } = useToast(); + + useEffect(() => { + let disposed = false; + void rpc.previewServers.listAll().then((list) => { + if (!disposed) setServers(sortServers(list)); + }); + const unsubscribe = events.on(previewServerEventChannel, (event) => { + setServers((current) => { + const next = new Map(current.map((server) => [server.id, server])); + if (event.type === 'upsert') { + next.set(event.server.id, event.server); + } else { + next.delete(event.id); + } + return sortServers(Array.from(next.values())); + }); + }); + return () => { + disposed = true; + unsubscribe(); + }; + }, []); + + return ( +
+
+
+

Preview servers

+

+ Detected servers and port forwards across all projects and tasks. +

+
+ {servers.length > 0 ? ( + + ) : null} +
+ + {servers.length === 0 ? ( +
+ +
No preview servers running
+

+ Servers detected in task terminals and SSH port forwards show up here. +

+
+ ) : ( +
+ {servers.map((server) => ( + + ))} +
+ )} +
+ ); +}); + +function ServerActionButton({ + label, + children, + disabled, + className, + onClick, +}: { + label: string; + children: ReactNode; + disabled?: boolean; + className?: string; + onClick: () => void; +}) { + return ( + + + + {children} + + } + /> + {label} + + + ); +} + +function PreviewServerStatusBadge({ server }: { server: PreviewServer }) { + const kind = server.status.kind; + const isBusy = kind === 'starting' || kind === 'reconnecting'; + + return ( + + + {previewServerStatusLabel(server)} + + ); +} + +const PreviewServerSettingsRow = observer(function PreviewServerSettingsRow({ + server, +}: { + server: PreviewServer; +}) { + const url = previewServerUrl(server); + const canOpen = server.status.kind === 'ready' && url !== null; + const projectName = projectDisplayName(getProjectStore(server.projectId)); + const taskName = taskNameForWorkspace(server.projectId, server.workspaceId); + const usedBy = [projectName, taskName].filter(Boolean).join(' ยท '); + const label = formatPreviewServerLabel(server); + + return ( +
+
+ +
+
+
+

{label}

+ +
+
+

{url ?? 'No local URL'}

+ {usedBy !== '' ?

Used by: {usedBy}

: null} + {server.status.kind === 'failed' ? ( +

{server.status.message}

+ ) : null} +
+
+
+ { + if (canOpen) void rpc.app.openExternal(url); + }} + > + + + void rpc.previewServers.stop(server.id)} + > + + +
+
+ ); +}); + +function taskNameForWorkspace(projectId: string, workspaceId: string): string | undefined { + const manager = getTaskManagerStore(projectId); + if (!manager) return undefined; + for (const store of manager.tasks.values()) { + if (asProvisioned(store)?.workspaceId === workspaceId) return taskDisplayName(store); + } + return undefined; +} + +function sortServers(servers: PreviewServer[]): PreviewServer[] { + return [...servers].sort((a, b) => { + if (a.projectId !== b.projectId) return a.projectId.localeCompare(b.projectId); + const aPort = a.kind === 'forwarded' ? a.remotePort : a.port; + const bPort = b.kind === 'forwarded' ? b.remotePort : b.port; + if (aPort !== bPort) return aPort - bPort; + return a.id.localeCompare(b.id); + }); +} diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx index 965e2ce02e..9aca3490e2 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx @@ -10,6 +10,7 @@ import IntegrationsCard from './IntegrationsCard'; import InterfaceSettingsCard from './InterfaceSettingsCard'; import KeyboardSettingsCard from './KeyboardSettingsCard'; import NotificationSettingsCard from './NotificationSettingsCard'; +import { PreviewServersSettingsCard } from './PreviewServersSettingsCard'; import RepositorySettingsCard from './RepositorySettingsCard'; import ResourceMonitorSettingsCard from './ResourceMonitorSettingsCard'; import SidebarMetadataSettingsCard from './SidebarMetadataSettingsCard'; @@ -89,9 +90,10 @@ function ConnectionsSettingsPage() { + ); } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts index 6857368330..f5fc4d287c 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts @@ -1,4 +1,4 @@ -import type { PreviewServer } from '@shared/core/preview-servers/types'; +import type { PreviewServer, PreviewServerStatus } from '@shared/core/preview-servers/types'; export function formatPreviewUrl(url: string): string { try { @@ -32,7 +32,11 @@ export function previewServerStatusLabel(server: PreviewServer): string { } export function previewServerStatusClasses(server: PreviewServer): string { - switch (server.status.kind) { + return previewServerStatusKindClasses(server.status.kind); +} + +export function previewServerStatusKindClasses(kind: PreviewServerStatus['kind']): string { + switch (kind) { case 'ready': return 'bg-background-info text-foreground-info hover:bg-background-info-hover'; case 'starting': @@ -42,3 +46,12 @@ export function previewServerStatusClasses(server: PreviewServer): string { return 'bg-background-destructive text-foreground-destructive hover:bg-destructive/20'; } } + +export function previewServersSummaryStatusKind( + servers: PreviewServer[] +): PreviewServerStatus['kind'] { + if (servers.some((server) => server.status.kind === 'failed')) return 'failed'; + if (servers.some((server) => server.status.kind === 'reconnecting')) return 'reconnecting'; + if (servers.some((server) => server.status.kind === 'starting')) return 'starting'; + return 'ready'; +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-menu-items.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-menu-items.tsx new file mode 100644 index 0000000000..b6010ad87c --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-menu-items.tsx @@ -0,0 +1,82 @@ +import { Clipboard, ExternalLink, Globe, RefreshCcw, Square } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import { + usePreviewServers, + useWorkspaceViewModel, +} from '@renderer/features/tasks/task-view-context'; +import { rpc } from '@renderer/lib/ipc'; +import { DropdownMenuItem, DropdownMenuSeparator } from '@renderer/lib/ui/dropdown-menu'; +import { MicroLabel } from '@renderer/lib/ui/label'; +import type { PreviewServer } from '@shared/core/preview-servers/types'; +import { previewServerUrl } from '@shared/core/preview-servers/types'; + +export const PreviewServerMenuItems = observer(function PreviewServerMenuItems({ + server, +}: { + server: PreviewServer; +}) { + const previews = usePreviewServers(); + const taskView = useWorkspaceViewModel(); + const url = previewServerUrl(server); + const hasUrl = url !== null; + const canOpen = server.status.kind === 'ready' && hasUrl; + + return ( + <> +
+ Preview +
+ {url ?? 'No local URL'} +
+ {server.kind === 'forwarded' ? ( +
+ {server.localPort === undefined + ? `Remote ${server.remotePort}` + : `Remote ${server.remotePort} to local ${server.localPort}`} +
+ ) : null} + {server.status.kind === 'failed' ? ( +
{server.status.message}
+ ) : null} +
+ + { + if (canOpen && url) { + taskView.paneLayout.open('browser', { initialUrl: url }); + taskView.setFocusedRegion('main'); + } + }} + > + + Open in Emdash Browser + + canOpen && url && void rpc.app.openExternal(url)} + > + + Open in System Browser + + url && void rpc.app.clipboardWriteText(url)} + > + + Copy URL + + + {server.kind === 'forwarded' ? ( + void previews.restart(server.id)}> + + Restart Forward + + ) : null} + void previews.stop(server.id)}> + + Stop + + + ); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx index 45422abeea..12629e1352 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx @@ -1,26 +1,10 @@ -import { - ChevronDown, - Clipboard, - ExternalLink, - Globe, - Loader2, - RefreshCcw, - Square, -} from 'lucide-react'; +import { ChevronDown, Globe, Loader2 } from 'lucide-react'; import { observer } from 'mobx-react-lite'; -import { - usePreviewServers, - useWorkspaceViewModel, -} from '@renderer/features/tasks/task-view-context'; -import { rpc } from '@renderer/lib/ipc'; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@renderer/lib/ui/dropdown-menu'; -import { MicroLabel } from '@renderer/lib/ui/label'; import { cn } from '@renderer/utils/utils'; import type { PreviewServer } from '@shared/core/preview-servers/types'; import { previewServerUrl } from '@shared/core/preview-servers/types'; @@ -29,20 +13,18 @@ import { previewServerStatusClasses, previewServerStatusLabel, } from './preview-server-format'; +import { PreviewServerMenuItems } from './preview-server-menu-items'; export const PreviewServerPill = observer(function PreviewServerPill({ server, }: { server: PreviewServer; }) { - const previews = usePreviewServers(); - const taskView = useWorkspaceViewModel(); const url = previewServerUrl(server); - const hasUrl = url !== null; - const canOpen = server.status.kind === 'ready' && hasUrl; - const title = hasUrl - ? `${previewServerStatusLabel(server)} at ${url}` - : `${previewServerStatusLabel(server)} for ${formatPreviewServerLabel(server)}`; + const title = + url !== null + ? `${previewServerStatusLabel(server)} at ${url}` + : `${previewServerStatusLabel(server)} for ${formatPreviewServerLabel(server)}`; return ( @@ -68,60 +50,7 @@ export const PreviewServerPill = observer(function PreviewServerPill({ -
- Preview -
- {url ?? 'No local URL'} -
- {server.kind === 'forwarded' ? ( -
- {server.localPort === undefined - ? `Remote ${server.remotePort}` - : `Remote ${server.remotePort} to local ${server.localPort}`} -
- ) : null} - {server.status.kind === 'failed' ? ( -
{server.status.message}
- ) : null} -
- - { - if (canOpen && url) { - taskView.paneLayout.open('browser', { initialUrl: url }); - taskView.setFocusedRegion('main'); - } - }} - > - - Open in Emdash Browser - - canOpen && url && void rpc.app.openExternal(url)} - > - - Open in System Browser - - url && void rpc.app.clipboardWriteText(url)} - > - - Copy URL - - - {server.kind === 'forwarded' ? ( - void previews.restart(server.id)}> - - Restart Forward - - ) : null} - void previews.stop(server.id)}> - - Stop - +
); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pills.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pills.tsx index 3e977031bb..58a7cfbb7e 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pills.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pills.tsx @@ -2,6 +2,7 @@ import { observer } from 'mobx-react-lite'; import { usePreviewServers, useWorkspace } from '@renderer/features/tasks/task-view-context'; import { ManualForwardButton } from './manual-forward-button'; import { PreviewServerPill } from './preview-server-pill'; +import { PreviewServersBadge } from './preview-servers-badge'; export const PreviewServerPills = observer(function PreviewServerPills() { const previews = usePreviewServers(); @@ -13,9 +14,11 @@ export const PreviewServerPills = observer(function PreviewServerPills() { return ( <> - {servers.map((server) => ( - - ))} + {servers.length > 1 ? ( + + ) : ( + servers.map((server) => ) + )} {isRemoteWorkspace ? : null} ); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-servers-badge.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-servers-badge.tsx new file mode 100644 index 0000000000..66569c238b --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-servers-badge.tsx @@ -0,0 +1,113 @@ +import { ChevronDown, Globe, Loader2, Square } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import { usePreviewServers } from '@renderer/features/tasks/task-view-context'; +import { useToast } from '@renderer/lib/hooks/use-toast'; +import { useShowModal } from '@renderer/lib/modal/modal-provider'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@renderer/lib/ui/dropdown-menu'; +import { MicroLabel } from '@renderer/lib/ui/label'; +import { cn } from '@renderer/utils/utils'; +import type { PreviewServer } from '@shared/core/preview-servers/types'; +import { + formatPreviewServerLabel, + previewServersSummaryStatusKind, + previewServerStatusKindClasses, +} from './preview-server-format'; +import { PreviewServerMenuItems } from './preview-server-menu-items'; + +export const PreviewServersBadge = observer(function PreviewServersBadge({ + servers, +}: { + servers: PreviewServer[]; +}) { + const previews = usePreviewServers(); + const showConfirm = useShowModal('confirmActionModal'); + const { toast } = useToast(); + const summaryKind = previewServersSummaryStatusKind(servers); + const isBusy = summaryKind === 'starting' || summaryKind === 'reconnecting'; + + return ( + + + } + > + {isBusy ? ( + + ) : ( + + )} + {servers.length} servers + + + +
+ Preview Servers +
+ + {servers.map((server) => ( + + + {server.status.kind === 'starting' || server.status.kind === 'reconnecting' ? ( + + ) : ( + + )} + {formatPreviewServerLabel(server)} + + + + + + ))} + + { + const count = servers.length; + showConfirm({ + title: 'Stop workspace preview servers?', + description: `This will stop ${count === 1 ? 'the running preview server' : `all ${count} running preview servers`} in this workspace.`, + confirmLabel: 'Stop All', + variant: 'destructive', + onSuccess: () => { + void previews.stopAll().catch((error: unknown) => { + toast({ + title: 'Failed to stop preview servers', + description: String(error), + variant: 'destructive', + }); + }); + }, + }); + }} + > + + Stop All Servers + +
+
+ ); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts index 78376875d2..dc55469435 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts @@ -14,6 +14,7 @@ const rpcMocks = vi.hoisted(() => ({ forwardManual: vi.fn(), restart: vi.fn(), stop: vi.fn(), + stopForWorkspace: vi.fn(), })); vi.mock('@renderer/lib/ipc', () => ({ @@ -70,6 +71,7 @@ describe('PreviewServerStore', () => { rpcMocks.forwardManual.mockReset(); rpcMocks.restart.mockReset(); rpcMocks.stop.mockReset(); + rpcMocks.stopForWorkspace.mockReset(); }); it('loads preview servers for a workspace and exposes addressable URLs', async () => { @@ -181,6 +183,31 @@ describe('PreviewServerStore', () => { store.dispose(); }); + it('stops all servers for the workspace and clears local state', async () => { + rpcMocks.listForWorkspace.mockResolvedValueOnce([ + directServer(), + forwardedServer({ id: 'forwarded-1' }), + ]); + rpcMocks.stopForWorkspace.mockResolvedValueOnce(undefined); + + const store = new PreviewServerStore({ + projectId: 'project-1', + workspaceId: 'workspace-1', + }); + await store.serversResource.load(); + expect(store.servers).toHaveLength(2); + + await store.stopAll(); + + expect(rpcMocks.stopForWorkspace).toHaveBeenCalledWith({ + projectId: 'project-1', + workspaceId: 'workspace-1', + }); + expect(store.servers).toEqual([]); + + store.dispose(); + }); + it('does not upsert manual forwards that return an error result', async () => { rpcMocks.forwardManual.mockResolvedValueOnce({ success: false, diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts index 8f55201f23..d18a6784f9 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts @@ -114,6 +114,14 @@ export class PreviewServerStore implements IDisposable { this.serversResource.setValue(next); } + async stopAll(): Promise { + await rpc.previewServers.stopForWorkspace({ + projectId: this.projectId, + workspaceId: this.workspaceId, + }); + this.serversResource.setValue(new Map()); + } + dispose(): void { this.serversResource.dispose(); } diff --git a/apps/emdash-desktop/src/renderer/lib/commands/app-commands.ts b/apps/emdash-desktop/src/renderer/lib/commands/app-commands.ts index 242fdb9fe8..9eca775d8d 100644 --- a/apps/emdash-desktop/src/renderer/lib/commands/app-commands.ts +++ b/apps/emdash-desktop/src/renderer/lib/commands/app-commands.ts @@ -1,5 +1,6 @@ import { applyHistoryEntry } from '@renderer/lib/components/nav-buttons'; import { toast } from '@renderer/lib/hooks/use-toast'; +import { rpc } from '@renderer/lib/ipc'; import { toggleSettingsView } from '@renderer/lib/layout/settings-toggle'; import { showModal } from '@renderer/lib/modal/modal-provider'; import { appState } from '@renderer/lib/stores/app-state'; @@ -90,6 +91,29 @@ function createAppCommandProvider(): CommandProvider { }, }); + const stopAllPreviewServersDef = appDef('app.stopAllPreviewServers'); + commands.push({ + id: stopAllPreviewServersDef.id, + label: stopAllPreviewServersDef.label, + description: stopAllPreviewServersDef.description, + shortcutKey: stopAllPreviewServersDef.shortcutKey, + group: stopAllPreviewServersDef.group, + execute() { + void rpc.previewServers + .stopAll() + .then(() => { + toast({ title: 'Stopped all preview servers' }); + }) + .catch((error: unknown) => { + toast({ + title: 'Failed to stop preview servers', + description: String(error), + variant: 'destructive', + }); + }); + }, + }); + commands.push({ id: toggleThemeDef.id, label: toggleThemeDef.label, diff --git a/apps/emdash-desktop/src/shared/commands.ts b/apps/emdash-desktop/src/shared/commands.ts index d8a93b1591..1ed03bd601 100644 --- a/apps/emdash-desktop/src/shared/commands.ts +++ b/apps/emdash-desktop/src/shared/commands.ts @@ -64,6 +64,14 @@ export const APP_COMMAND_DEFS = defineCommandDefs([ group: 'App', iconKey: 'message-square-share', }, + { + id: 'app.stopAllPreviewServers', + label: 'Stop All Preview Servers', + description: 'Stop preview servers and port forwards across all projects and tasks', + scope: 'app', + group: 'App', + iconKey: 'globe', + }, { id: 'app.toggleTheme', label: 'Toggle Theme',