Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
},
"dependencies": {
"@automaker/dependency-resolver": "1.0.0",
"@automaker/spec-parser": "1.0.0",
"@automaker/types": "1.0.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,14 @@ export function ProjectSwitcherItem({
const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath;

// Create a sanitized project name for test ID (lowercase, hyphens instead of spaces)
const sanitizedName = project.name.toLowerCase().replace(/\s+/g, '-');

return (
<button
onClick={onClick}
onContextMenu={onContextMenu}
data-testid={`project-switcher-project-${sanitizedName}`}
className={cn(
'group w-full aspect-square rounded-xl flex items-center justify-center relative overflow-hidden',
'transition-all duration-200 ease-out',
Expand All @@ -60,7 +64,6 @@ export function ProjectSwitcherItem({
'hover:scale-105 active:scale-95'
)}
title={project.name}
data-testid={`project-switcher-${project.id}`}
>
{hasCustomIcon ? (
<img
Expand Down
53 changes: 5 additions & 48 deletions apps/ui/src/components/ui/xml-syntax-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import CodeMirror from '@uiw/react-codemirror';
import { xml } from '@codemirror/lang-xml';
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { cn } from '@/lib/utils';

interface XmlSyntaxEditorProps {
Expand All @@ -14,52 +11,19 @@ interface XmlSyntaxEditorProps {
'data-testid'?: string;
}

// Syntax highlighting that uses CSS variables from the app's theme system
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
const syntaxColors = HighlightStyle.define([
// XML tags - use primary color
{ tag: t.tagName, color: 'var(--primary)' },
{ tag: t.angleBracket, color: 'var(--muted-foreground)' },

// Attributes
{ tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },

// Strings and content
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.content, color: 'var(--foreground)' },

// Comments
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },

// Special
{ tag: t.processingInstruction, color: 'var(--muted-foreground)' },
{ tag: t.documentMeta, color: 'var(--muted-foreground)' },
]);

// Editor theme using CSS variables
// Simple editor theme - inherits text color from parent
const editorTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '0.875rem',
fontFamily: 'ui-monospace, monospace',
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'ui-monospace, monospace',
},
'.cm-content': {
padding: '1rem',
minHeight: '100%',
caretColor: 'var(--primary)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
'.cm-activeLine': {
backgroundColor: 'transparent',
Expand All @@ -73,15 +37,8 @@ const editorTheme = EditorView.theme({
'.cm-gutters': {
display: 'none',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
});

// Combine all extensions
const extensions: Extension[] = [xml(), syntaxHighlighting(syntaxColors), editorTheme];

export function XmlSyntaxEditor({
value,
onChange,
Expand All @@ -94,16 +51,16 @@ export function XmlSyntaxEditor({
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
extensions={[xml(), editorTheme]}
theme="none"
placeholder={placeholder}
className="h-full [&_.cm-editor]:h-full"
className="h-full [&_.cm-editor]:h-full [&_.cm-content]:text-foreground"
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightSelectionMatches: true,
autocompletion: true,
highlightSelectionMatches: false,
autocompletion: false,
bracketMatching: true,
indentOnInput: true,
}}
Expand Down
91 changes: 84 additions & 7 deletions apps/ui/src/components/views/spec-view.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,43 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { RefreshCw } from 'lucide-react';
import { useAppStore } from '@/store/app-store';

// Extracted hooks
import { useSpecLoading, useSpecSave, useSpecGeneration } from './spec-view/hooks';
import { useSpecLoading, useSpecSave, useSpecGeneration, useSpecParser } from './spec-view/hooks';

// Extracted components
import { SpecHeader, SpecEditor, SpecEmptyState } from './spec-view/components';
import {
SpecHeader,
SpecEditor,
SpecEmptyState,
SpecViewMode,
SpecEditMode,
SpecModeTabs,
} from './spec-view/components';

// Extracted dialogs
import { CreateSpecDialog, RegenerateSpecDialog } from './spec-view/dialogs';

// Types
import type { SpecViewMode as SpecViewModeType } from './spec-view/types';

export function SpecView() {
const { currentProject, appSpec } = useAppStore();

// View mode state - default to 'view'
const [mode, setMode] = useState<SpecViewModeType>('view');

// Actions panel state (for tablet/mobile)
const [showActionsPanel, setShowActionsPanel] = useState(false);

// Loading state
const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading();

// Save state
const { isSaving, hasChanges, saveSpec, handleChange, setHasChanges } = useSpecSave();
const { isSaving, hasChanges, saveSpec, handleChange } = useSpecSave();

// Parse the spec XML
const { isValid: isParseValid, parsedSpec, errors: parseErrors } = useSpecParser(appSpec);

// Generation state and handlers
const {
Expand Down Expand Up @@ -70,8 +86,17 @@ export function SpecView() {
handleSync,
} = useSpecGeneration({ loadSpec });

// Reset hasChanges when spec is reloaded
// (This is needed because loadSpec updates appSpec in the store)
// Handle mode change - if parse is invalid, force source mode
const handleModeChange = useCallback(
(newMode: SpecViewModeType) => {
if ((newMode === 'view' || newMode === 'edit') && !isParseValid) {
// Can't switch to view/edit if parse is invalid
return;
}
setMode(newMode);
},
[isParseValid]
);

// No project selected
if (!currentProject) {
Expand Down Expand Up @@ -126,6 +151,29 @@ export function SpecView() {
);
}

// Render content based on mode
const renderContent = () => {
// If the XML is invalid, we can only show the source editor.
// The tabs for other modes are disabled, but this is an extra safeguard.
if (!isParseValid) {
return <SpecEditor value={appSpec} onChange={handleChange} />;
}

switch (mode) {
case 'view':
// When isParseValid is true, parsedSpec is guaranteed to be non-null.
return <SpecViewMode spec={parsedSpec!} />;
case 'edit':
return <SpecEditMode spec={parsedSpec!} onChange={handleChange} />;
case 'source':
default:
return <SpecEditor value={appSpec} onChange={handleChange} />;
}
};

const isProcessing =
isRegenerating || isGenerationRunning || isCreating || isGeneratingFeatures || isSyncing;

// Main view - spec exists
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="spec-view">
Expand All @@ -145,9 +193,38 @@ export function SpecView() {
onSaveClick={saveSpec}
showActionsPanel={showActionsPanel}
onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)}
showSaveButton={true}
/>

<SpecEditor value={appSpec} onChange={handleChange} />
{/* Mode tabs and content container */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mode tabs bar - inside the content area, centered */}
{!isProcessing && (
<div className="flex items-center justify-center px-4 py-2 border-b border-border bg-muted/30 relative">
<SpecModeTabs
mode={mode}
onModeChange={handleModeChange}
isParseValid={isParseValid}
disabled={isProcessing}
/>
{/* Show parse error indicator - positioned to the right */}
{!isParseValid && parseErrors.length > 0 && (
<span className="absolute right-4 text-xs text-destructive">
XML has errors - fix in Source mode
</span>
)}
</div>
)}

{/* Show parse error banner if in source mode with errors */}
{!isParseValid && parseErrors.length > 0 && mode === 'source' && (
<div className="px-4 py-2 bg-destructive/10 border-b border-destructive/20 text-sm text-destructive">
<span className="font-medium">XML Parse Errors:</span> {parseErrors.join(', ')}
</div>
)}

{renderContent()}
</div>

<RegenerateSpecDialog
open={showRegenerateDialog}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Plus, X, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { useRef, useState, useEffect } from 'react';

interface ArrayFieldEditorProps {
values: string[];
onChange: (values: string[]) => void;
placeholder?: string;
addLabel?: string;
emptyMessage?: string;
}

interface ItemWithId {
id: string;
value: string;
}

function generateId(): string {
return crypto.randomUUID();
}

export function ArrayFieldEditor({
values,
onChange,
placeholder = 'Enter value...',
addLabel = 'Add Item',
emptyMessage = 'No items added yet.',
}: ArrayFieldEditorProps) {
// Track items with stable IDs
const [items, setItems] = useState<ItemWithId[]>(() =>
values.map((value) => ({ id: generateId(), value }))
);

// Track if we're making an internal change to avoid sync loops
const isInternalChange = useRef(false);

// Sync external values to internal items when values change externally
useEffect(() => {
if (isInternalChange.current) {
isInternalChange.current = false;
return;
}

// External change - rebuild items with new IDs
setItems(values.map((value) => ({ id: generateId(), value })));
}, [values]);

const handleAdd = () => {
const newItems = [...items, { id: generateId(), value: '' }];
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map((item) => item.value));
};

const handleRemove = (id: string) => {
const newItems = items.filter((item) => item.id !== id);
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map((item) => item.value));
};

const handleChange = (id: string, value: string) => {
const newItems = items.map((item) => (item.id === id ? { ...item, value } : item));
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map((item) => item.value));
};

return (
<div className="space-y-2">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">{emptyMessage}</p>
) : (
<div className="space-y-2">
{items.map((item) => (
<Card key={item.id} className="p-2">
<div className="flex items-center gap-2">
<GripVertical className="w-4 h-4 text-muted-foreground shrink-0 cursor-grab" />
<Input
value={item.value}
onChange={(e) => handleChange(item.id, e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemove(item.id)}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
</Card>
))}
</div>
)}
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
<Plus className="w-4 h-4" />
{addLabel}
</Button>
</div>
);
}
Loading