Skip to content
1 change: 1 addition & 0 deletions src/server/infra/transport/handlers/config-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class ConfigHandler {
this.transport.onRequest<void, ConfigGetEnvironmentResponse>(ConfigEvents.GET_ENVIRONMENT, () => {
const config = getCurrentConfig()
return {
gitRemoteBaseUrl: config.gitRemoteBaseUrl,
iamBaseUrl: config.iamBaseUrl,
isDevelopment: isDevelopment(),
webAppUrl: config.webAppUrl,
Expand Down
37 changes: 36 additions & 1 deletion src/server/infra/transport/handlers/locations-handler.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import {spawn} from 'node:child_process'
import {join} from 'node:path'

import type {ProjectLocationDTO} from '../../../../shared/transport/types/dto.js'
import type {IContextTreeService} from '../../../core/interfaces/context-tree/i-context-tree-service.js'
import type {IProjectRegistry} from '../../../core/interfaces/project/i-project-registry.js'
import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js'

import {LocationsEvents, type LocationsGetResponse} from '../../../../shared/transport/events/locations-events.js'
import {
LocationsEvents,
type LocationsGetResponse,
type LocationsRevealRequest,
type LocationsRevealResponse,
} from '../../../../shared/transport/events/locations-events.js'
import {BRV_DIR, CONTEXT_TREE_DIR} from '../../../constants.js'
import {type ProjectPathResolver} from './handler-types.js'
import {resolveRevealCommand} from './reveal-command.js'

export interface LocationsHandlerDeps {
contextTreeService: IContextTreeService
Expand Down Expand Up @@ -49,6 +56,10 @@ export class LocationsHandler {
return {locations: []}
}
})

this.transport.onRequest<LocationsRevealRequest, LocationsRevealResponse>(LocationsEvents.REVEAL, async (data) =>
this.handleReveal(data),
)
}

private async buildLocations(currentProjectPath?: string): Promise<ProjectLocationDTO[]> {
Expand Down Expand Up @@ -96,4 +107,28 @@ export class LocationsHandler {
return (all.get(b.projectPath)?.registeredAt ?? 0) - (all.get(a.projectPath)?.registeredAt ?? 0)
})
}

private async handleReveal(data: LocationsRevealRequest): Promise<LocationsRevealResponse> {
const {projectPath} = data
if (!projectPath) throw new Error('projectPath is required')

// Only allow revealing paths that are registered projects — the client
// controls this argument, so we must not trust it blindly.
const registered = this.projectRegistry.getAll()
if (!registered.has(projectPath)) {
throw new Error('Project is not registered.')
}

const exists = await this.pathExists(projectPath).catch(() => false)
if (!exists) throw new Error('Project folder no longer exists.')

const {args, command} = resolveRevealCommand(process.platform, projectPath)
Comment thread
wzlng marked this conversation as resolved.
const child = spawn(command, args, {detached: true, stdio: 'ignore', windowsHide: true})
child.on('error', () => {
/* best-effort — nothing to report back once the ack resolved */
})
child.unref()

return {projectPath}
}
}
14 changes: 14 additions & 0 deletions src/server/infra/transport/handlers/reveal-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Resolves the OS-native command for revealing a path in the system file manager.
* Extracted so the branching can be unit-tested without spawning real processes.
*/
export type RevealCommand = {
args: string[]
command: string
}

export function resolveRevealCommand(platformName: NodeJS.Platform, targetPath: string): RevealCommand {
if (platformName === 'darwin') return {args: [targetPath], command: 'open'}
if (platformName === 'win32') return {args: [targetPath], command: 'explorer'}
return {args: [targetPath], command: 'xdg-open'}
}
7 changes: 6 additions & 1 deletion src/server/infra/webui/webui-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ export function createWebUiMiddleware({getConfig, webuiDistDir}: CreateWebUiMidd
"connect-src 'self' ws: wss:",
"font-src 'self' data: https://fonts.gstatic.com",
"frame-ancestors 'none'",
"img-src 'self' data:",
// `img-src https:` is deliberately broad: user avatars come from an
// open-ended set of OAuth provider CDNs (Google, GitHub, Gravatar,
// self-hosted identity providers, …) that can't be enumerated ahead
// of time. Images can't execute code, so the attack surface is just
// pixel exfiltration / tracking, which we accept for this use case.
"img-src 'self' data: https:",
Comment thread
wzlng marked this conversation as resolved.
"object-src 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
Expand Down
1 change: 1 addition & 0 deletions src/shared/transport/events/config-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const ConfigEvents = {
} as const

export interface ConfigGetEnvironmentResponse {
gitRemoteBaseUrl: string
iamBaseUrl: string
isDevelopment: boolean
webAppUrl: string
Expand Down
9 changes: 9 additions & 0 deletions src/shared/transport/events/locations-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@ import type {ProjectLocationDTO} from '../types/dto.js'

export const LocationsEvents = {
GET: 'locations:get',
REVEAL: 'locations:reveal',
} as const

export interface LocationsGetResponse {
locations: ProjectLocationDTO[]
}

export interface LocationsRevealRequest {
projectPath: string
}

export interface LocationsRevealResponse {
projectPath: string
}
2 changes: 1 addition & 1 deletion src/webui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function App() {
return (
<AppProviders>
<RouterProvider router={router} />
<Toaster />
<Toaster position="top-center" />
</AppProviders>
)
}
44 changes: 44 additions & 0 deletions src/webui/components/status-dot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {cn} from '@campfirein/byterover-packages/lib/utils'

type Tone = 'amber' | 'destructive' | 'info' | 'success'

type Props = {
className?: string
/** When true, wrap the dot in a `ping`-animated halo to draw attention. */
pulsing?: boolean
tone: Tone
}

const TONE_BG: Record<Tone, string> = {
amber: 'bg-amber-500',
destructive: 'bg-destructive',
info: 'bg-blue-500',
success: 'bg-primary-foreground',
}

/**
* Small colored dot for status annotations.
*
* - Default: a single solid dot — use for permanent indicators
* (e.g. "connected", "X unread").
* - `pulsing`: dot wrapped in a `ping`-animated halo — use for exceptions
* that want attention (e.g. "configuration required").
*
* Default size is 6px; pass `size-*` via `className` to resize. `className`
* can also add border/position utilities (e.g. overlaying as a corner badge
* on an icon).
*/
export function StatusDot({className, pulsing = false, tone}: Props) {
const bg = TONE_BG[tone]

if (pulsing) {
return (
<span className={cn('relative inline-flex size-1.5 shrink-0', className)}>
<span className={cn('absolute inline-flex size-full animate-ping rounded-full opacity-75', bg)} />
<span className={cn('relative inline-flex size-full rounded-full', bg)} />
</span>
)
}

return <span className={cn('inline-block size-1.5 shrink-0 rounded-full', bg, className)} />
}
6 changes: 3 additions & 3 deletions src/webui/features/auth/components/auth-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function UnauthorizedTrigger() {

return (
<>
<Button className="h-auto rounded-full p-0 ml-2.5" onClick={() => setIsOpen(true)} variant="ghost">
<Button className="h-auto rounded-full p-0" onClick={() => setIsOpen(true)} variant="ghost">
<Avatar>
<AvatarFallback className="bg-transparent">
<User className="size-4 shrink-0" />
Expand All @@ -49,7 +49,7 @@ function AuthorizedMenu() {

if (!user) {
return (
<Button className="ml-2.5" disabled size="sm" variant="outline">
<Button disabled size="sm" variant="outline">
<User className="size-4 shrink-0 text-muted-foreground" />
<span>Signed in</span>
</Button>
Expand Down Expand Up @@ -89,7 +89,7 @@ export function AuthMenu() {

if (isLoadingInitial) {
return (
<Button className="ml-2.5" disabled size="icon" variant="outline">
<Button disabled size="icon" variant="outline">
<User className="size-4 shrink-0 text-muted-foreground animate-pulse" />
</Button>
)
Expand Down
4 changes: 2 additions & 2 deletions src/webui/features/auth/components/login-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function LoginDialog({onOpenChange, open}: LoginDialogProps) {

const unsubscribe = subscribeToLoginCompleted((data) => {
if (data.success && data.user) {
toast.success(`Logged in as ${data.user.email}`, {position: 'top-center'})
toast.success(`Logged in as ${data.user.email}`)
queryClient.invalidateQueries({queryKey: getAuthStateQueryOptions().queryKey})
onOpenChange(false)
} else {
Expand All @@ -76,7 +76,7 @@ export function LoginDialog({onOpenChange, open}: LoginDialogProps) {
const result = await queryClient.fetchQuery(getAuthStateQueryOptions())
if (cancelled) return
if (result.isAuthorized && result.user) {
toast.success(`Logged in as ${result.user.email}`, {position: 'top-center'})
toast.success(`Logged in as ${result.user.email}`)
setLoggingIn(false)
onOpenChange(false)
}
Expand Down
Loading
Loading