Skip to content
Open
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
63 changes: 60 additions & 3 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { useState, useCallback } from 'react'
import { FlowprintEditor, useTheme, useSymbolSearch } from '@ruminaider/flowprint-editor'
import { useState, useCallback, useMemo, useEffect } from 'react'
import {
FlowprintEditor,
MigrationBanner,
MigrationModal,
useTheme,
useSymbolSearch,
} from '@ruminaider/flowprint-editor'
import type { RulesDataMap } from '@ruminaider/flowprint-editor'
import '@ruminaider/flowprint-editor/styles.css'
import type { FlowprintDocument } from '@ruminaider/flowprint-schema'
import { isMajorBump } from '@ruminaider/flowprint-schema'
import { Header } from './components/Header'
import { WelcomeScreen } from './components/WelcomeScreen'
import { NewBlueprintWizard } from './components/NewBlueprintWizard'
Expand All @@ -21,6 +28,7 @@ export function App() {
const [settingsOpen, setSettingsOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rulesDataMap, setRulesDataMap] = useState<RulesDataMap>({})
const [migrationAccepted, setMigrationAccepted] = useState(false)

const settingsHook = useSettings()
const { settings } = settingsHook
Expand Down Expand Up @@ -106,6 +114,35 @@ export function App() {
void settingsHook.updateSettings({ theme: next })
}, [settings.theme, settingsHook])

// Use the migration result from whichever hook opened the file
const activeMigrationResult =
fileManager.migrationResult ?? projectDirectory.migrationResult ?? null
const dismissMigration = useCallback(() => {
fileManager.clearMigrationResult()
projectDirectory.clearMigrationResult()
}, [fileManager, projectDirectory])
Comment on lines +117 to +123
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

activeMigrationResult keeps showing a file's migration state after switching to a project — should we clear fileManager.migrationResult or dismiss the banner on project switch?

Finding type: Logical Bugs | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In packages/app/src/App.tsx around lines 117 to 123, the activeMigrationResult logic
prefers fileManager.migrationResult which can remain set after opening a project,
causing the UI to reflect the wrong document migration state. Add a useEffect that
watches projectDirectory.migrationResult and when it becomes non-null (or when a project
is loaded) call fileManager.clearMigrationResult() so the project's migrationResult
takes precedence; keep the existing dismissMigration behavior. This ensures the
banner/modal/read-only/save-disabled UI reflects the currently active document (project
vs file).

Heads up!

Your free trial ends in 2 days.
To keep getting your PRs reviewed by Baz, update your team's subscription


const needsMigrationModal = useMemo(() => {
if (!activeMigrationResult || activeMigrationResult.status !== 'migrated') return false
const hasRequiredOrNotable = activeMigrationResult.changelog.entries.some(
(e) => e.required || e.notable,
)
const isMajor = isMajorBump(
activeMigrationResult.fromVersion,
activeMigrationResult.toVersion,
)
return hasRequiredOrNotable || isMajor
}, [activeMigrationResult])

const isForcedUpgrade = useMemo(() => {
if (!activeMigrationResult || activeMigrationResult.status !== 'migrated') return false
return isMajorBump(activeMigrationResult.fromVersion, activeMigrationResult.toVersion)
}, [activeMigrationResult])

useEffect(() => {
setMigrationAccepted(false)
}, [activeMigrationResult])

const handleClose = useCallback(() => {
if (fileManager.dirty) {
if (!window.confirm('You have unsaved changes. Discard and return to the welcome screen?')) {
Expand All @@ -116,7 +153,8 @@ export function App() {
fileManager.setDirty(false)
setRulesDataMap({})
setError(null)
}, [fileManager])
dismissMigration()
}, [fileManager, dismissMigration])

return (
<div
Expand Down Expand Up @@ -199,14 +237,33 @@ export function App() {
setSettingsOpen(true)
}}
onClose={handleClose}
saveDisabled={
activeMigrationResult?.status === 'future_version' ||
(needsMigrationModal && !migrationAccepted)
}
/>
{activeMigrationResult && activeMigrationResult.status !== 'current' && (
<MigrationBanner result={activeMigrationResult} onDismiss={dismissMigration} />
)}
{needsMigrationModal && activeMigrationResult?.status === 'migrated' && (
<MigrationModal
open={!migrationAccepted}
changelog={activeMigrationResult.changelog}
Comment on lines +240 to +251
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

migration.status === 'error' isn't included in saveDisabled/readOnly guards — should we disable Save/edit or require user confirmation for error migrations?

Finding type: Logical Bugs | Severity: 🔴 High


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In packages/app/src/App.tsx around lines 240 to 266, the render logic that sets
Header.saveDisabled and FlowprintEditor.readOnly only checks for
activeMigrationResult.status === 'future_version' and the migrated-modal gating, but
ignores activeMigrationResult.status === 'error'. Treat migration errors like other
non-current migration states: update the conditions that compute saveDisabled and
readOnly to also be true when activeMigrationResult?.status === 'error', and ensure the
MigrationBanner/Modal is shown for error cases (so the user must dismiss/acknowledge)
rather than allowing silent edits. Make the checks explicit and centralized (reuse
needsMigrationModal / isForcedUpgrade or add a small helper) so both the Header prop and
FlowprintEditor.readOnly remain consistent for error, future_version, and migrated
states.

Heads up!

Your free trial ends in 2 days.
To keep getting your PRs reviewed by Baz, update your team's subscription

forced={isForcedUpgrade}
onAccept={() => setMigrationAccepted(true)}
/>
)}
<div style={{ flex: 1, minHeight: 0 }}>
<FlowprintEditor
value={doc}
onChange={handleChange}
theme={settings.theme}
symbolSearch={symbolSearch ?? undefined}
rulesDataMap={rulesDataMap}
readOnly={
activeMigrationResult?.status === 'future_version' ||
(needsMigrationModal && !migrationAccepted)
}
showYamlPreview
showExportButton
style={{ width: '100%', height: '100%' }}
Expand Down
13 changes: 10 additions & 3 deletions packages/app/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface HeaderProps {
onSaveAs: () => void
onSettings: () => void
onClose?: () => void
saveDisabled?: boolean
}

const THEME_LABELS: Record<ThemeMode, string> = {
Expand All @@ -34,9 +35,11 @@ const btnStyle: React.CSSProperties = {
function HeaderButton({
onClick,
children,
disabled,
}: {
onClick: () => void
children: React.ReactNode
disabled?: boolean
}) {
const [hovered, setHovered] = useState(false)
return (
Expand All @@ -45,9 +48,12 @@ function HeaderButton({
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
disabled={disabled}
style={{
...btnStyle,
background: hovered ? '#252434' : '#1C1B25',
background: hovered && !disabled ? '#252434' : '#1C1B25',
opacity: disabled ? 0.4 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
}}
>
{children}
Expand All @@ -67,6 +73,7 @@ export function Header({
onSaveAs,
onSettings,
onClose,
saveDisabled,
}: HeaderProps) {
return (
<header
Expand Down Expand Up @@ -107,8 +114,8 @@ export function Header({
{supportsOpenProject && onOpenProject && (
<HeaderButton onClick={onOpenProject}>Open Project</HeaderButton>
)}
<HeaderButton onClick={onSave}>Save</HeaderButton>
<HeaderButton onClick={onSaveAs}>Save As</HeaderButton>
<HeaderButton onClick={onSave} disabled={saveDisabled}>Save</HeaderButton>
<HeaderButton onClick={onSaveAs} disabled={saveDisabled}>Save As</HeaderButton>
<HeaderButton onClick={onSettings}>Settings</HeaderButton>
<HeaderButton onClick={onCycleTheme}>
Theme: {THEME_LABELS[themeMode]}
Expand Down
18 changes: 11 additions & 7 deletions packages/app/src/hooks/useFileManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { FlowprintDocument } from '@ruminaider/flowprint-schema'
import { validateYaml, serialize } from '@ruminaider/flowprint-schema'
import { validate, migrate, serialize } from '@ruminaider/flowprint-schema'
import { parse } from 'yaml'
import { useFileManager } from './useFileManager'

vi.mock('@ruminaider/flowprint-schema', () => ({
validateYaml: vi.fn(),
validate: vi.fn(),
migrate: vi.fn(),
serialize: vi.fn(),
}))

Expand Down Expand Up @@ -111,8 +112,9 @@ describe('useFileManager', () => {
beforeEach(() => {
vi.clearAllMocks()

// Default: validation passes
vi.mocked(validateYaml).mockReturnValue({
// Default: migration returns current, validation passes
vi.mocked(migrate).mockReturnValue({ status: 'current', doc: MOCK_DOC })
vi.mocked(validate).mockReturnValue({
valid: true,
errors: [],
})
Expand Down Expand Up @@ -169,7 +171,8 @@ describe('useFileManager', () => {
types: [{ description: 'Flowprint YAML', accept: { 'text/yaml': ['.yaml', '.yml'] } }],
multiple: false,
})
expect(validateYaml).toHaveBeenCalledWith(VALID_YAML)
expect(migrate).toHaveBeenCalledWith(MOCK_DOC)
expect(validate).toHaveBeenCalledWith(MOCK_DOC)
expect(onDocLoaded).toHaveBeenCalledWith(MOCK_DOC, 'flow.flowprint.yaml')
expect(result.current.fileName).toBe('flow.flowprint.yaml')
expect(result.current.dirty).toBe(false)
Expand All @@ -188,7 +191,8 @@ describe('useFileManager', () => {
await result.current.openFile()
})

expect(validateYaml).toHaveBeenCalled()
expect(migrate).toHaveBeenCalledWith(MOCK_DOC)
expect(validate).toHaveBeenCalledWith(MOCK_DOC)
expect(onDocLoaded).toHaveBeenCalledWith(MOCK_DOC, 'fallback.flowprint.yaml')
expect(result.current.fileName).toBe('fallback.flowprint.yaml')
expect(result.current.dirty).toBe(false)
Expand All @@ -202,7 +206,7 @@ describe('useFileManager', () => {
const onDocLoaded = vi.fn()
const onError = vi.fn()

vi.mocked(validateYaml).mockReturnValue({
vi.mocked(validate).mockReturnValue({
valid: false,
errors: [
{ path: '/schema', message: 'Missing required property: schema', severity: 'error' },
Expand Down
95 changes: 75 additions & 20 deletions packages/app/src/hooks/useFileManager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useRef, useCallback, useMemo } from 'react'
import { parse } from 'yaml'
import { validateYaml, serialize } from '@ruminaider/flowprint-schema'
import type { FlowprintDocument } from '@ruminaider/flowprint-schema'
import { validate, migrate, serialize } from '@ruminaider/flowprint-schema'
import type { FlowprintDocument, MigrationResult } from '@ruminaider/flowprint-schema'

export interface UseFileManagerOptions {
doc: FlowprintDocument
Expand All @@ -19,6 +19,8 @@ export interface UseFileManagerReturn {
dirty: boolean
setDirty(dirty: boolean): void
supportsNativeFS: boolean
migrationResult: MigrationResult | null
clearMigrationResult(): void
}

const YAML_PICKER_TYPES: FilePickerAcceptType[] = [
Expand All @@ -40,6 +42,7 @@ export function useFileManager(options: UseFileManagerOptions): UseFileManagerRe
const [filePath, setFilePath] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [hasFileHandle, setHasFileHandle] = useState(false)
const [migrationResult, setMigrationResult] = useState<MigrationResult | null>(null)
const handleRef = useRef<FileSystemFileHandle | null>(null)

const supportsNativeFS = useMemo(() => hasNativeFS(), [])
Expand All @@ -62,21 +65,44 @@ export function useFileManager(options: UseFileManagerOptions): UseFileManagerRe
const file = await handle.getFile()
const text = await file.text()

const result = validateYaml(text)
if (!result.valid) {
const firstError = result.errors[0]
const msg = firstError
? `Invalid flowprint file: ${firstError.message} (at ${firstError.path})`
: 'Invalid flowprint file'
throw new Error(msg)
let parsed: FlowprintDocument
try {
parsed = parse(text) as FlowprintDocument
} catch (err) {
throw new Error(
`Failed to parse YAML: ${err instanceof Error ? err.message : String(err)}`,
)
}

const migration = migrate(parsed)
setMigrationResult(migration)

Comment on lines +68 to +79
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openFileNative, openFileFallback, and openProject duplicate the parse/migrate/validate pipeline (also repeated in useProjectDirectory.ts lines 172‑204) — should we extract it into prepareDocument(text) returning { migration, docToLoad }?

Finding types: Conciseness Code Dedup and Conventions | Severity: 🟠 Medium


Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents:

In packages/app/src/hooks/useFileManager.ts around lines 68-156, the logic inside
openFileNative and openFileFallback that parses YAML, calls migrate, chooses docToLoad,
and runs structural validation is duplicated. Extract that sequence into a new helper
function named prepareDocument(text: string): { migration: MigrationResult, docToLoad:
FlowprintDocument } (export it from this file or a new shared module) that: 1) parses
the YAML or throws a clear parse error, 2) runs migrate and returns the migration
result, 3) picks the correct docToLoad based on migration.status, and 4) runs validate
unless status === 'future_version' and throws on validation errors. Replace the
duplicated blocks in both openFileNative and openFileFallback with a call to
prepareDocument(text) and use its returned migration and docToLoad, and update
imports/types (MigrationResult, FlowprintDocument) accordingly so the same helper can
also be reused by packages/app/src/hooks/useProjectDirectory.ts (lines 172‑204) where
the same YAML migration+validation block is duplicated verbatim.

Heads up!

Your free trial ends in 2 days.
To keep getting your PRs reviewed by Baz, update your team's subscription

// Determine which doc to load
const docToLoad =
migration.status === 'migrated'
? migration.doc
: migration.status === 'error'
? migration.originalDoc
: parsed

// Run structural validation (skip for future_version — tool can't validate future schemas)
if (migration.status !== 'future_version') {
const validation = validate(docToLoad)
if (!validation.valid) {
const firstError = validation.errors.find((e) => e.severity === 'error')
if (firstError) {
throw new Error(
`Invalid flowprint file: ${firstError.message} (at ${firstError.path})`,
)
}
}
}

const parsed = parse(text) as FlowprintDocument
setHandle(handle)
setFileName(file.name)
setFilePath(file.name)
setDirty(false)
onDocLoaded(parsed, file.name)
onDocLoaded(docToLoad, file.name)
}, [onDocLoaded, setHandle])

const openFileFallback = useCallback((): Promise<void> => {
Expand All @@ -96,21 +122,44 @@ export function useFileManager(options: UseFileManagerOptions): UseFileManagerRe
void file
.text()
.then((text) => {
const result = validateYaml(text)
if (!result.valid) {
const firstError = result.errors[0]
const msg = firstError
? `Invalid flowprint file: ${firstError.message} (at ${firstError.path})`
: 'Invalid flowprint file'
throw new Error(msg)
let parsed: FlowprintDocument
try {
parsed = parse(text) as FlowprintDocument
} catch (err) {
throw new Error(
`Failed to parse YAML: ${err instanceof Error ? err.message : String(err)}`,
)
}

const migration = migrate(parsed)
setMigrationResult(migration)

// Determine which doc to load
const docToLoad =
migration.status === 'migrated'
? migration.doc
: migration.status === 'error'
? migration.originalDoc
: parsed

// Run structural validation (skip for future_version — tool can't validate future schemas)
if (migration.status !== 'future_version') {
const validation = validate(docToLoad)
if (!validation.valid) {
const firstError = validation.errors.find((e) => e.severity === 'error')
if (firstError) {
throw new Error(
`Invalid flowprint file: ${firstError.message} (at ${firstError.path})`,
)
}
}
}

const parsed = parse(text) as FlowprintDocument
setHandle(null)
setFileName(file.name)
setFilePath(null)
setDirty(false)
onDocLoaded(parsed, file.name)
onDocLoaded(docToLoad, file.name)
resolve()
})
.catch((err: unknown) => {
Expand Down Expand Up @@ -206,6 +255,10 @@ export function useFileManager(options: UseFileManagerOptions): UseFileManagerRe
}
}, [writeToHandle, saveFileAs, onError])

const clearMigrationResult = useCallback(() => {
setMigrationResult(null)
}, [])

return {
openFile,
saveFile,
Expand All @@ -216,5 +269,7 @@ export function useFileManager(options: UseFileManagerOptions): UseFileManagerRe
dirty,
setDirty,
supportsNativeFS,
migrationResult,
clearMigrationResult,
}
}
Loading
Loading