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
15 changes: 13 additions & 2 deletions packages/server/src/infrastructure/fs/FsWorkspaceRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import type { Workspace as WorkspaceData } from '@braidhq/schema'
import type { WorkspaceRegistryFile } from './WorkspaceRegistryFile.js'
import { readFile, stat } from 'node:fs/promises'
import { resolve } from 'node:path'
import { NotFoundError, ValidationError, Workspace, type WorkspaceRepository } from '@braidhq/core'
import { createLogger, NotFoundError, ValidationError, Workspace, type WorkspaceRepository } from '@braidhq/core'
import { AbsolutePath, ProductManifest, WorkspaceId } from '@braidhq/schema'
import { parseMarkdownFrontmatter } from './frontmatter.js'
import { workspaceProductManifestPath } from './paths.js'

const log = createLogger('server').child({ mod: 'workspace-repo' })

export interface FsWorkspaceRepositoryDeps {
readonly registry: WorkspaceRegistryFile
}
Expand All @@ -16,11 +18,20 @@ export class FsWorkspaceRepository implements WorkspaceRepository {

constructor(private readonly deps: FsWorkspaceRepositoryDeps) {}

// One unreadable rootPath (deleted dir, broken PRODUCT.md, ...)
// must not starve the rest. The asymmetry with `load()` is intentional:
// aggregate views recover, single-entity lookups distinguish present
// from absent.
async list(): Promise<Workspace[]> {
const rootPaths = await this.deps.registry.list()
const workspaces: Workspace[] = []
for (const rootPath of rootPaths) {
workspaces.push(await this.load(rootPath))
try {
workspaces.push(await this.load(rootPath))
}
catch (err) {
log.warn({ err, rootPath }, 'skipping unreadable registry entry')
}
}
return workspaces
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@ describe('FsWorkspaceRepository', () => {
await expect(repository.load(rootPath)).rejects.toThrow(NotFoundError)
})

it('list skips registry entries whose workspace directory is missing', async () => {
const liveRoot = AbsolutePath.parse(await createWorkspaceDir({ name: 'alive' }))
const ghostRoot = AbsolutePath.parse('/tmp/braid-ghost-workspace-does-not-exist')
const registry = await makeRegistry()
// Stamp the stale entry directly; load+save would throw before saving.
await registry.add(ghostRoot)
await registry.add(liveRoot)

const repository = new FsWorkspaceRepository({ registry })
const all = await repository.list()

expect(all.map(w => w.productManifest.name)).toEqual(['alive'])
})

it('throws ValidationError when frontmatter invalid', async () => {
const dir = await mkdtemp(join(tmpdir(), 'braid-ws-'))
await writeFile(join(dir, 'PRODUCT.md'), '---\nname: ""\n---\n', 'utf-8')
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { WorkspaceDetailsSheet } from './components/WorkspaceDetailsSheet'
import { useBatchStatus, useWorkspaces } from './lib/queries'
import { useAuthGate } from './lib/useAuthGate'
import { GraphNavigationContext } from './lib/useGraphNavigation'
import { useResetOnRemoteChange } from './lib/useRemoteWorkspaces'
import { TabNavigationContext } from './lib/useTabNavigation'
import { readUrl, useUrlSync } from './lib/useUrlState'
import { useWorkspaceEvents } from './lib/useWorkspaceEvents'
Expand Down Expand Up @@ -44,6 +45,7 @@ function BootScreen() {
}

function AppInner() {
useResetOnRemoteChange()
const { data: workspaces } = useWorkspaces()
// Initial state is hydrated from the URL so refresh / deep links land back
// on the same workspace + surface.
Expand Down
23 changes: 21 additions & 2 deletions packages/studio/src/components/ListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ interface ListRowProps {
className?: string
/** Forwarded to the native `title` attribute; useful for tooltip text when the row is icon-only. */
title?: string | undefined
/**
* Optional left-edge identity stripe. Used by the multi-server sidebar
* to mark which remote a workspace belongs to. Sits inside the row's
* `<li>` alongside the active indicator so server identity stays
* visible even when the row isn't selected.
*/
stripeClassName?: string
stripeDim?: boolean
children: ReactNode
}

Expand All @@ -21,7 +29,7 @@ interface ListRowProps {
* left edge. Used by every selectable list in Studio so the visual language
* (hover transition, active bg, bar position) stays consistent.
*/
export function ListRow({ active, onClick, variant = 'content', className, title, children }: ListRowProps) {
export function ListRow({ active, onClick, variant = 'content', className, title, stripeClassName, stripeDim, children }: ListRowProps) {
const tokens = variant === 'sidebar'
? {
bar: 'inset-y-1',
Expand All @@ -40,8 +48,19 @@ export function ListRow({ active, onClick, variant = 'content', className, title
}
return (
<li className="relative">
{stripeClassName && (
<span
className={cn(
'absolute left-0 w-[2px] rounded-r-full',
tokens.bar,
stripeClassName,
stripeDim ? 'opacity-40' : 'opacity-90',
)}
aria-hidden
/>
)}
{active && (
<span className={cn('absolute left-0 w-[3px] rounded-r-full bg-primary', tokens.bar)} />
<span className={cn('absolute w-[3px] rounded-r-full bg-primary', stripeClassName ? 'left-[2px]' : 'left-0', tokens.bar)} />
)}
<button type="button" onClick={onClick} title={title} className={cn(tokens.button, className)}>
{children}
Expand Down
Loading
Loading