diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
new file mode 100644
index 00000000..644576bd
--- /dev/null
+++ b/.github/workflows/e2e-tests.yml
@@ -0,0 +1,91 @@
+name: E2E Tests
+
+on:
+ pull_request:
+ branches:
+ - "*"
+ push:
+ branches:
+ - main
+ - master
+
+jobs:
+ e2e:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: package-lock.json
+
+ - name: Install dependencies
+ # Use npm install instead of npm ci to correctly resolve platform-specific
+ # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
+ run: npm install
+
+ - name: Install Linux native bindings
+ # Workaround for npm optional dependencies bug (npm/cli#4828)
+ # Explicitly install Linux bindings needed for build tools
+ run: |
+ npm install --no-save --force \
+ @rollup/rollup-linux-x64-gnu@4.53.3 \
+ @tailwindcss/oxide-linux-x64-gnu@4.1.17
+
+ - name: Install Playwright browsers
+ run: npx playwright install --with-deps chromium
+ working-directory: apps/app
+
+ - name: Build server
+ run: npm run build --workspace=apps/server
+
+ - name: Start backend server
+ run: npm run start --workspace=apps/server &
+ env:
+ PORT: 3008
+ NODE_ENV: test
+
+ - name: Wait for backend server
+ run: |
+ echo "Waiting for backend server to be ready..."
+ for i in {1..30}; do
+ if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then
+ echo "Backend server is ready!"
+ exit 0
+ fi
+ echo "Waiting... ($i/30)"
+ sleep 1
+ done
+ echo "Backend server failed to start!"
+ exit 1
+
+ - name: Run E2E tests
+ # Playwright automatically starts the Next.js frontend via webServer config
+ # (see apps/app/playwright.config.ts) - no need to start it manually
+ run: npm run test --workspace=apps/app
+ env:
+ CI: true
+ NEXT_PUBLIC_SERVER_URL: http://localhost:3008
+ NEXT_PUBLIC_SKIP_SETUP: "true"
+
+ - name: Upload Playwright report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: playwright-report
+ path: apps/app/playwright-report/
+ retention-days: 7
+
+ - name: Upload test results
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: test-results
+ path: apps/app/test-results/
+ retention-days: 7
diff --git a/apps/app/package.json b/apps/app/package.json
index a5bafda6..ad9100db 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -27,15 +27,19 @@
"postinstall": "electron-builder install-app-deps",
"start": "next start",
"lint": "eslint",
+ "pretest": "node scripts/setup-e2e-fixtures.js",
"test": "playwright test",
"test:headed": "playwright test --headed",
"dev:electron:wsl": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron . --no-sandbox --disable-gpu\"",
"dev:electron:wsl:gpu": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA electron . --no-sandbox --disable-gpu-sandbox\""
},
"dependencies": {
+ "@codemirror/lang-xml": "^6.1.0",
+ "@codemirror/theme-one-dark": "^6.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@lezer/highlight": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -46,6 +50,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
+ "@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
@@ -122,7 +127,9 @@
{
"from": "../../.env",
"to": ".env",
- "filter": ["**/*"]
+ "filter": [
+ "**/*"
+ ]
}
],
"mac": {
diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts
index d01cb870..d4d0f866 100644
--- a/apps/app/playwright.config.ts
+++ b/apps/app/playwright.config.ts
@@ -30,6 +30,10 @@ export default defineConfig({
url: `http://localhost:${port}`,
reuseExistingServer: !process.env.CI,
timeout: 120000,
+ env: {
+ ...process.env,
+ NEXT_PUBLIC_SKIP_SETUP: "true",
+ },
},
}),
});
diff --git a/apps/app/scripts/setup-e2e-fixtures.js b/apps/app/scripts/setup-e2e-fixtures.js
new file mode 100644
index 00000000..63ad5a02
--- /dev/null
+++ b/apps/app/scripts/setup-e2e-fixtures.js
@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+
+/**
+ * Setup script for E2E test fixtures
+ * Creates the necessary test fixture directories and files before running Playwright tests
+ */
+
+import * as fs from "fs";
+import * as path from "path";
+import { fileURLToPath } from "url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+// Resolve workspace root (apps/app/scripts -> workspace root)
+const WORKSPACE_ROOT = path.resolve(__dirname, "../../..");
+const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
+const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");
+
+const SPEC_CONTENT = `
+ Test Project A
+ A test fixture project for Playwright testing
+
+ - TypeScript
+ - React
+
+
+`;
+
+function setupFixtures() {
+ console.log("Setting up E2E test fixtures...");
+ console.log(`Workspace root: ${WORKSPACE_ROOT}`);
+ console.log(`Fixture path: ${FIXTURE_PATH}`);
+
+ // Create fixture directory
+ const specDir = path.dirname(SPEC_FILE_PATH);
+ if (!fs.existsSync(specDir)) {
+ fs.mkdirSync(specDir, { recursive: true });
+ console.log(`Created directory: ${specDir}`);
+ }
+
+ // Create app_spec.txt
+ fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT);
+ console.log(`Created fixture file: ${SPEC_FILE_PATH}`);
+
+ console.log("E2E test fixtures setup complete!");
+}
+
+setupFixtures();
diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx
index 06dfd503..29a74578 100644
--- a/apps/app/src/app/page.tsx
+++ b/apps/app/src/app/page.tsx
@@ -222,12 +222,6 @@ function HomeContent() {
return (
- {/* Environment indicator */}
- {isMounted && !isElectron() && (
-
- Web Mode
-
- )}
);
}
@@ -242,13 +236,6 @@ function HomeContent() {
{renderView()}
- {/* Environment indicator - only show after mount to prevent hydration issues */}
- {isMounted && !isElectron() && (
-
- Web Mode
-
- )}
-
{/* Hidden streamer panel - opens with "\" key, pushes content */}
("");
+ const [pathInput, setPathInput] = useState
("");
const [parentPath, setParentPath] = useState(null);
const [directories, setDirectories] = useState([]);
const [drives, setDrives] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [warning, setWarning] = useState("");
+ const pathInputRef = useRef(null);
const browseDirectory = async (dirPath?: string) => {
setLoading(true);
@@ -79,6 +83,7 @@ export function FileBrowserDialog({
if (result.success) {
setCurrentPath(result.currentPath);
+ setPathInput(result.currentPath);
setParentPath(result.parentPath);
setDirectories(result.directories);
setDrives(result.drives || []);
@@ -99,6 +104,7 @@ export function FileBrowserDialog({
useEffect(() => {
if (!open) {
setCurrentPath("");
+ setPathInput("");
setParentPath(null);
setDirectories([]);
setError("");
@@ -131,6 +137,20 @@ export function FileBrowserDialog({
browseDirectory(drivePath);
};
+ const handleGoToPath = () => {
+ const trimmedPath = pathInput.trim();
+ if (trimmedPath) {
+ browseDirectory(trimmedPath);
+ }
+ };
+
+ const handlePathInputKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleGoToPath();
+ }
+ };
+
const handleSelect = () => {
if (currentPath) {
onSelect(currentPath);
@@ -152,6 +172,31 @@ export function FileBrowserDialog({
+ {/* Direct path input */}
+
+ setPathInput(e.target.value)}
+ onKeyDown={handlePathInputKeyDown}
+ className="flex-1 font-mono text-sm"
+ data-testid="path-input"
+ disabled={loading}
+ />
+
+
+ Go
+
+
+
{/* Drives selector (Windows only) */}
{drives.length > 0 && (
@@ -251,8 +296,8 @@ export function FileBrowserDialog({
- Click on a folder to navigate. Select the current folder or navigate
- to a subfolder.
+ Paste a full path above, or click on folders to navigate. Press
+ Enter or click Go to jump to a path.
diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx
index 500f2ab6..26ad3e69 100644
--- a/apps/app/src/components/layout/sidebar.tsx
+++ b/apps/app/src/components/layout/sidebar.tsx
@@ -216,6 +216,16 @@ export function Sidebar() {
setSpecCreatingForProject,
} = useAppStore();
+ // Environment variable flags for hiding sidebar items
+ // Note: Next.js requires static access to process.env variables (no dynamic keys)
+ const hideTerminal = process.env.NEXT_PUBLIC_HIDE_TERMINAL === "true";
+ const hideWiki = process.env.NEXT_PUBLIC_HIDE_WIKI === "true";
+ const hideRunningAgents =
+ process.env.NEXT_PUBLIC_HIDE_RUNNING_AGENTS === "true";
+ const hideContext = process.env.NEXT_PUBLIC_HIDE_CONTEXT === "true";
+ const hideSpecEditor = process.env.NEXT_PUBLIC_HIDE_SPEC_EDITOR === "true";
+ const hideAiProfiles = process.env.NEXT_PUBLIC_HIDE_AI_PROFILES === "true";
+
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
@@ -949,54 +959,75 @@ export function Sidebar() {
}
}, [emptyTrash, trashedProjects.length]);
- const navSections: NavSection[] = [
- {
- label: "Project",
- items: [
- {
- id: "board",
- label: "Kanban Board",
- icon: LayoutGrid,
- shortcut: shortcuts.board,
- },
- {
- id: "agent",
- label: "Agent Runner",
- icon: Bot,
- shortcut: shortcuts.agent,
- },
- ],
- },
- {
- label: "Tools",
- items: [
- {
- id: "spec",
- label: "Spec Editor",
- icon: FileText,
- shortcut: shortcuts.spec,
- },
- {
- id: "context",
- label: "Context",
- icon: BookOpen,
- shortcut: shortcuts.context,
- },
- {
- id: "profiles",
- label: "AI Profiles",
- icon: UserCircle,
- shortcut: shortcuts.profiles,
- },
- {
- id: "terminal",
- label: "Terminal",
- icon: Terminal,
- shortcut: shortcuts.terminal,
- },
- ],
- },
- ];
+ const navSections: NavSection[] = useMemo(() => {
+ const allToolsItems: NavItem[] = [
+ {
+ id: "spec",
+ label: "Spec Editor",
+ icon: FileText,
+ shortcut: shortcuts.spec,
+ },
+ {
+ id: "context",
+ label: "Context",
+ icon: BookOpen,
+ shortcut: shortcuts.context,
+ },
+ {
+ id: "profiles",
+ label: "AI Profiles",
+ icon: UserCircle,
+ shortcut: shortcuts.profiles,
+ },
+ {
+ id: "terminal",
+ label: "Terminal",
+ icon: Terminal,
+ shortcut: shortcuts.terminal,
+ },
+ ];
+
+ // Filter out hidden items
+ const visibleToolsItems = allToolsItems.filter((item) => {
+ if (item.id === "spec" && hideSpecEditor) {
+ return false;
+ }
+ if (item.id === "context" && hideContext) {
+ return false;
+ }
+ if (item.id === "profiles" && hideAiProfiles) {
+ return false;
+ }
+ if (item.id === "terminal" && hideTerminal) {
+ return false;
+ }
+ return true;
+ });
+
+ return [
+ {
+ label: "Project",
+ items: [
+ {
+ id: "board",
+ label: "Kanban Board",
+ icon: LayoutGrid,
+ shortcut: shortcuts.board,
+ },
+ {
+ id: "agent",
+ label: "Agent Runner",
+ icon: Bot,
+ shortcut: shortcuts.agent,
+ },
+ ],
+ },
+ {
+ label: "Tools",
+ items: visibleToolsItems,
+ },
+ ];
+ }, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]);
// Handle selecting the currently highlighted project
const selectHighlightedProject = useCallback(() => {
@@ -1627,108 +1658,112 @@ export function Sidebar() {
{/* Course Promo Badge */}
{/* Wiki Link */}
-
-
setCurrentView("wiki")}
- className={cn(
- "group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
- isActiveRoute("wiki")
- ? "bg-sidebar-accent/50 text-foreground border border-sidebar-border"
- : "text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50",
- sidebarOpen ? "justify-start" : "justify-center"
- )}
- title={!sidebarOpen ? "Wiki" : undefined}
- data-testid="wiki-link"
- >
- {isActiveRoute("wiki") && (
-
- )}
-
+ setCurrentView("wiki")}
className={cn(
- "w-4 h-4 shrink-0 transition-colors",
+ "group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
isActiveRoute("wiki")
- ? "text-brand-500"
- : "group-hover:text-brand-400"
- )}
- />
-
- Wiki
-
- {!sidebarOpen && (
-
- Wiki
-
- )}
-
-
- {/* Running Agents Link */}
-
-
setCurrentView("running-agents")}
- className={cn(
- "group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
- isActiveRoute("running-agents")
- ? "bg-sidebar-accent/50 text-foreground border border-sidebar-border"
- : "text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50",
- sidebarOpen ? "justify-start" : "justify-center"
- )}
- title={!sidebarOpen ? "Running Agents" : undefined}
- data-testid="running-agents-link"
- >
- {isActiveRoute("running-agents") && (
-
- )}
-
+ )}
+
- {/* Running agents count badge - shown in collapsed state */}
- {!sidebarOpen && runningAgentsCount > 0 && (
+
+ Wiki
+
+ {!sidebarOpen && (
+
+ Wiki
+
+ )}
+
+
+ )}
+ {/* Running Agents Link */}
+ {!hideRunningAgents && (
+
+
setCurrentView("running-agents")}
+ className={cn(
+ "group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
+ isActiveRoute("running-agents")
+ ? "bg-sidebar-accent/50 text-foreground border border-sidebar-border"
+ : "text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50",
+ sidebarOpen ? "justify-start" : "justify-center"
+ )}
+ title={!sidebarOpen ? "Running Agents" : undefined}
+ data-testid="running-agents-link"
+ >
+ {isActiveRoute("running-agents") && (
+
+ )}
+
+
+ {/* Running agents count badge - shown in collapsed state */}
+ {!sidebarOpen && runningAgentsCount > 0 && (
+
+ {runningAgentsCount > 99 ? "99" : runningAgentsCount}
+
+ )}
+
+
+ Running Agents
+
+ {/* Running agents count badge - shown in expanded state */}
+ {sidebarOpen && runningAgentsCount > 0 && (
{runningAgentsCount > 99 ? "99" : runningAgentsCount}
)}
-
-
+ Running Agents
+
)}
- >
- Running Agents
-
- {/* Running agents count badge - shown in expanded state */}
- {sidebarOpen && runningAgentsCount > 0 && (
-
- {runningAgentsCount > 99 ? "99" : runningAgentsCount}
-
- )}
- {!sidebarOpen && (
-
- Running Agents
-
- )}
-
-
+
+
+ )}
{/* Settings Link */}
- if (text.slice(i, i + 4) === "", i + 4);
- if (end !== -1) {
- tokens.push({ type: "comment", value: text.slice(i, end + 3) });
- i = end + 3;
- continue;
- }
- }
+ // Strings and content
+ { tag: t.string, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
+ { tag: t.content, color: "var(--foreground)" },
- // CDATA:
- if (text.slice(i, i + 9) === "", i + 9);
- if (end !== -1) {
- tokens.push({ type: "cdata", value: text.slice(i, end + 3) });
- i = end + 3;
- continue;
- }
- }
+ // Comments
+ { tag: t.comment, color: "var(--muted-foreground)", fontStyle: "italic" },
- // DOCTYPE:
- if (text.slice(i, i + 9).toUpperCase() === "", i + 9);
- if (end !== -1) {
- tokens.push({ type: "doctype", value: text.slice(i, end + 1) });
- i = end + 1;
- continue;
- }
- }
+ // Special
+ { tag: t.processingInstruction, color: "var(--muted-foreground)" },
+ { tag: t.documentMeta, color: "var(--muted-foreground)" },
+]);
- // Tag: < ... >
- if (text[i] === "<") {
- // Find the end of the tag
- let tagEnd = i + 1;
- let inString: string | null = null;
+// Editor theme using CSS variables
+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",
+ },
+ ".cm-line": {
+ padding: "0",
+ },
+ "&.cm-focused": {
+ outline: "none",
+ },
+ ".cm-gutters": {
+ display: "none",
+ },
+ ".cm-placeholder": {
+ color: "var(--muted-foreground)",
+ fontStyle: "italic",
+ },
+});
- while (tagEnd < text.length) {
- const char = text[tagEnd];
-
- if (inString) {
- if (char === inString && text[tagEnd - 1] !== "\\") {
- inString = null;
- }
- } else {
- if (char === '"' || char === "'") {
- inString = char;
- } else if (char === ">") {
- tagEnd++;
- break;
- }
- }
- tagEnd++;
- }
-
- const tagContent = text.slice(i, tagEnd);
- const tagTokens = tokenizeTag(tagContent);
- tokens.push(...tagTokens);
- i = tagEnd;
- continue;
- }
-
- // Text content between tags
- const nextTag = text.indexOf("<", i);
- if (nextTag === -1) {
- tokens.push({ type: "text", value: text.slice(i) });
- break;
- } else if (nextTag > i) {
- tokens.push({ type: "text", value: text.slice(i, nextTag) });
- i = nextTag;
- }
- }
-
- return tokens;
-}
-
-function tokenizeTag(tag: string): Token[] {
- const tokens: Token[] = [];
- let i = 0;
-
- // Opening bracket (< or or )
- if (tag.startsWith("")) {
- tokens.push({ type: "tag-bracket", value: "" });
- i = 2;
- } else if (tag.startsWith("")) {
- tokens.push({ type: "tag-bracket", value: "" });
- i = 2;
- } else {
- tokens.push({ type: "tag-bracket", value: "<" });
- i = 1;
- }
-
- // Skip whitespace
- while (i < tag.length && /\s/.test(tag[i])) {
- tokens.push({ type: "text", value: tag[i] });
- i++;
- }
-
- // Tag name
- let tagName = "";
- while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
- tagName += tag[i];
- i++;
- }
- if (tagName) {
- tokens.push({ type: "tag-name", value: tagName });
- }
-
- // Attributes and closing
- while (i < tag.length) {
- // Skip whitespace
- if (/\s/.test(tag[i])) {
- let ws = "";
- while (i < tag.length && /\s/.test(tag[i])) {
- ws += tag[i];
- i++;
- }
- tokens.push({ type: "text", value: ws });
- continue;
- }
-
- // Closing bracket
- if (tag[i] === ">" || tag.slice(i, i + 2) === "/>" || tag.slice(i, i + 2) === "?>") {
- tokens.push({ type: "tag-bracket", value: tag.slice(i) });
- break;
- }
-
- // Attribute name
- let attrName = "";
- while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
- attrName += tag[i];
- i++;
- }
- if (attrName) {
- tokens.push({ type: "attribute-name", value: attrName });
- }
-
- // Skip whitespace around =
- while (i < tag.length && /\s/.test(tag[i])) {
- tokens.push({ type: "text", value: tag[i] });
- i++;
- }
-
- // Equals sign
- if (tag[i] === "=") {
- tokens.push({ type: "attribute-equals", value: "=" });
- i++;
- }
-
- // Skip whitespace after =
- while (i < tag.length && /\s/.test(tag[i])) {
- tokens.push({ type: "text", value: tag[i] });
- i++;
- }
-
- // Attribute value
- if (tag[i] === '"' || tag[i] === "'") {
- const quote = tag[i];
- let value = quote;
- i++;
- while (i < tag.length && tag[i] !== quote) {
- value += tag[i];
- i++;
- }
- if (i < tag.length) {
- value += tag[i];
- i++;
- }
- tokens.push({ type: "attribute-value", value });
- }
- }
-
- return tokens;
-}
+// Combine all extensions
+const extensions: Extension[] = [
+ xml(),
+ syntaxHighlighting(syntaxColors),
+ editorTheme,
+];
export function XmlSyntaxEditor({
value,
@@ -212,78 +96,24 @@ export function XmlSyntaxEditor({
className,
"data-testid": testId,
}: XmlSyntaxEditorProps) {
- const textareaRef = useRef(null);
- const highlightRef = useRef(null);
-
- // Sync scroll between textarea and highlight layer
- const handleScroll = useCallback(() => {
- if (textareaRef.current && highlightRef.current) {
- highlightRef.current.scrollTop = textareaRef.current.scrollTop;
- highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
- }
- }, []);
-
- // Handle tab key for indentation
- const handleKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === "Tab") {
- e.preventDefault();
- const textarea = e.currentTarget;
- const start = textarea.selectionStart;
- const end = textarea.selectionEnd;
- const newValue =
- value.substring(0, start) + " " + value.substring(end);
- onChange(newValue);
- // Reset cursor position after state update
- requestAnimationFrame(() => {
- textarea.selectionStart = textarea.selectionEnd = start + 2;
- });
- }
- },
- [value, onChange]
- );
-
- // Memoize the highlighted content
- const highlightedContent = useMemo(() => {
- const tokens = tokenizeXml(value);
-
- return tokens.map((token, index) => {
- const className = `xml-${token.type}`;
- // React handles escaping automatically, just render the raw value
- return (
-
- {token.value}
-
- );
- });
- }, [value]);
-
return (
-
- {/* Syntax highlighted layer (read-only, behind textarea) */}
-
- {value ? (
- {highlightedContent}
- ) : (
- {placeholder}
- )}
-
-
- {/* Actual textarea (transparent text, handles input) */}
-
);
diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx
index bd4bf17e..b39bb953 100644
--- a/apps/app/src/components/views/board-view.tsx
+++ b/apps/app/src/components/views/board-view.tsx
@@ -1533,7 +1533,7 @@ export function BoardView() {
? features.filter(
(f) =>
f.description.toLowerCase().includes(normalizedQuery) ||
- f.category.toLowerCase().includes(normalizedQuery)
+ f.category?.toLowerCase().includes(normalizedQuery)
)
: features;
diff --git a/apps/app/src/components/views/spec-view.tsx b/apps/app/src/components/views/spec-view.tsx
index 66847fc2..ea9fe36d 100644
--- a/apps/app/src/components/views/spec-view.tsx
+++ b/apps/app/src/components/views/spec-view.tsx
@@ -1004,7 +1004,7 @@ export function SpecView() {
{/* Editor */}
-
+
()(
@@ -106,12 +109,14 @@ export const useSetupStore = create()(
// Setup flow
setCurrentStep: (step) => set({ currentStep: step }),
- completeSetup: () => set({ setupComplete: true, currentStep: "complete" }),
+ completeSetup: () =>
+ set({ setupComplete: true, currentStep: "complete" }),
- resetSetup: () => set({
- ...initialState,
- isFirstRun: false, // Don't reset first run flag
- }),
+ resetSetup: () =>
+ set({
+ ...initialState,
+ isFirstRun: false, // Don't reset first run flag
+ }),
setIsFirstRun: (isFirstRun) => set({ isFirstRun }),
@@ -120,16 +125,18 @@ export const useSetupStore = create()(
setClaudeAuthStatus: (status) => set({ claudeAuthStatus: status }),
- setClaudeInstallProgress: (progress) => set({
- claudeInstallProgress: {
- ...get().claudeInstallProgress,
- ...progress,
- },
- }),
+ setClaudeInstallProgress: (progress) =>
+ set({
+ claudeInstallProgress: {
+ ...get().claudeInstallProgress,
+ ...progress,
+ },
+ }),
- resetClaudeInstallProgress: () => set({
- claudeInstallProgress: { ...initialInstallProgress },
- }),
+ resetClaudeInstallProgress: () =>
+ set({
+ claudeInstallProgress: { ...initialInstallProgress },
+ }),
// Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
diff --git a/apps/app/tests/spec-editor-persistence.spec.ts b/apps/app/tests/spec-editor-persistence.spec.ts
new file mode 100644
index 00000000..06dc78e5
--- /dev/null
+++ b/apps/app/tests/spec-editor-persistence.spec.ts
@@ -0,0 +1,472 @@
+import { test, expect, Page } from "@playwright/test";
+import * as fs from "fs";
+import * as path from "path";
+
+// Resolve the workspace root - handle both running from apps/app and from root
+function getWorkspaceRoot(): string {
+ const cwd = process.cwd();
+ if (cwd.includes("apps/app")) {
+ return path.resolve(cwd, "../..");
+ }
+ return cwd;
+}
+
+const WORKSPACE_ROOT = getWorkspaceRoot();
+const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
+const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");
+
+// Original spec content for resetting between tests
+const ORIGINAL_SPEC_CONTENT = `
+ Test Project A
+ A test fixture project for Playwright testing
+
+ - TypeScript
+ - React
+
+
+`;
+
+/**
+ * Reset the fixture's app_spec.txt to original content
+ */
+function resetFixtureSpec() {
+ const dir = path.dirname(SPEC_FILE_PATH);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ fs.writeFileSync(SPEC_FILE_PATH, ORIGINAL_SPEC_CONTENT);
+}
+
+/**
+ * Set up localStorage with a project pointing to our test fixture
+ * Note: In CI, setup wizard is also skipped via NEXT_PUBLIC_SKIP_SETUP env var
+ */
+async function setupProjectWithFixture(page: Page, projectPath: string) {
+ await page.addInitScript((path: string) => {
+ const mockProject = {
+ id: "test-project-fixture",
+ name: "projectA",
+ path: path,
+ lastOpened: new Date().toISOString(),
+ };
+
+ const mockState = {
+ state: {
+ projects: [mockProject],
+ currentProject: mockProject,
+ currentView: "board",
+ theme: "dark",
+ sidebarOpen: true,
+ apiKeys: { anthropic: "", google: "" },
+ chatSessions: [],
+ chatHistoryOpen: false,
+ maxConcurrency: 3,
+ },
+ version: 0,
+ };
+
+ localStorage.setItem("automaker-storage", JSON.stringify(mockState));
+
+ // Also mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
+ const setupState = {
+ state: {
+ isFirstRun: false,
+ setupComplete: true,
+ currentStep: "complete",
+ skipClaudeSetup: false,
+ },
+ version: 0,
+ };
+ localStorage.setItem("automaker-setup", JSON.stringify(setupState));
+ }, projectPath);
+}
+
+/**
+ * Navigate to spec editor via sidebar
+ */
+async function navigateToSpecEditor(page: Page) {
+ // Click on the Spec Editor nav item in the sidebar
+ const specNavButton = page.locator('[data-testid="nav-spec"]');
+ await specNavButton.waitFor({ state: "visible", timeout: 10000 });
+ await specNavButton.click();
+
+ // Wait for the spec view to be visible
+ await page.waitForSelector('[data-testid="spec-view"]', { timeout: 10000 });
+}
+
+/**
+ * Get the CodeMirror editor content
+ */
+async function getEditorContent(page: Page): Promise {
+ // CodeMirror uses a contenteditable div with class .cm-content
+ const content = await page
+ .locator('[data-testid="spec-editor"] .cm-content')
+ .textContent();
+ return content || "";
+}
+
+/**
+ * Set the CodeMirror editor content by selecting all and typing
+ */
+async function setEditorContent(page: Page, content: string) {
+ // Click on the editor to focus it
+ const editor = page.locator('[data-testid="spec-editor"] .cm-content');
+ await editor.click();
+
+ // Wait for focus
+ await page.waitForTimeout(200);
+
+ // Select all content (Cmd+A on Mac, Ctrl+A on others)
+ const isMac = process.platform === "darwin";
+ await page.keyboard.press(isMac ? "Meta+a" : "Control+a");
+
+ // Wait for selection
+ await page.waitForTimeout(100);
+
+ // Delete the selected content first
+ await page.keyboard.press("Backspace");
+
+ // Wait for deletion
+ await page.waitForTimeout(100);
+
+ // Type the new content
+ await page.keyboard.type(content, { delay: 10 });
+
+ // Wait for typing to complete
+ await page.waitForTimeout(200);
+}
+
+/**
+ * Click the save button
+ */
+async function clickSaveButton(page: Page) {
+ const saveButton = page.locator('[data-testid="save-spec"]');
+ await saveButton.click();
+
+ // Wait for the button text to change to "Saved" indicating save is complete
+ await page.waitForFunction(
+ () => {
+ const btn = document.querySelector('[data-testid="save-spec"]');
+ return btn?.textContent?.includes("Saved");
+ },
+ { timeout: 5000 }
+ );
+}
+
+test.describe("Spec Editor Persistence", () => {
+ test.beforeEach(async () => {
+ // Reset the fixture spec file to original content before each test
+ resetFixtureSpec();
+ });
+
+ test.afterEach(async () => {
+ // Clean up - reset the spec file after each test
+ resetFixtureSpec();
+ });
+
+ test("should open project, edit spec, save, and persist changes after refresh", async ({
+ page,
+ }) => {
+ // Use the resolved fixture path
+ const fixturePath = FIXTURE_PATH;
+
+ // Step 1: Set up the project in localStorage pointing to our fixture
+ await setupProjectWithFixture(page, fixturePath);
+
+ // Step 2: Navigate to the app
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+
+ // Step 3: Verify we're on the dashboard with the project loaded
+ // The sidebar should show the project selector
+ const sidebar = page.locator('[data-testid="sidebar"]');
+ await sidebar.waitFor({ state: "visible", timeout: 10000 });
+
+ // Step 4: Click on the Spec Editor in the sidebar
+ await navigateToSpecEditor(page);
+
+ // Step 5: Wait for the spec editor to load
+ const specEditor = page.locator('[data-testid="spec-editor"]');
+ await specEditor.waitFor({ state: "visible", timeout: 10000 });
+
+ // Step 6: Wait for CodeMirror to initialize (it has a .cm-content element)
+ await page.waitForSelector('[data-testid="spec-editor"] .cm-content', {
+ timeout: 10000,
+ });
+
+ // Small delay to ensure editor is fully initialized
+ await page.waitForTimeout(500);
+
+ // Step 7: Modify the editor content to "hello world"
+ await setEditorContent(page, "hello world");
+
+ // Step 8: Click the save button
+ await clickSaveButton(page);
+
+ // Step 9: Refresh the page
+ await page.reload();
+ await page.waitForLoadState("networkidle");
+
+ // Step 10: Navigate back to the spec editor
+ // After reload, we need to wait for the app to initialize
+ await page.waitForSelector('[data-testid="sidebar"]', { timeout: 10000 });
+
+ // Navigate to spec editor again
+ await navigateToSpecEditor(page);
+
+ // Wait for CodeMirror to be ready
+ await page.waitForSelector('[data-testid="spec-editor"] .cm-content', {
+ timeout: 10000,
+ });
+
+ // Small delay to ensure editor content is loaded
+ await page.waitForTimeout(500);
+
+ // Step 11: Verify the content was persisted
+ const persistedContent = await getEditorContent(page);
+ expect(persistedContent.trim()).toBe("hello world");
+ });
+
+ test("should handle opening project via Open Project button and file browser", async ({
+ page,
+ }) => {
+ // This test covers the flow of:
+ // 1. Clicking Open Project button
+ // 2. Using the file browser to navigate to the fixture directory
+ // 3. Opening the project
+ // 4. Editing the spec
+
+ // Set up without a current project to test the open project flow
+ await page.addInitScript(() => {
+ const mockState = {
+ state: {
+ projects: [],
+ currentProject: null,
+ currentView: "welcome",
+ theme: "dark",
+ sidebarOpen: true,
+ apiKeys: { anthropic: "", google: "" },
+ chatSessions: [],
+ chatHistoryOpen: false,
+ maxConcurrency: 3,
+ },
+ version: 0,
+ };
+ localStorage.setItem("automaker-storage", JSON.stringify(mockState));
+
+ // Mark setup as complete
+ const setupState = {
+ state: {
+ isFirstRun: false,
+ setupComplete: true,
+ currentStep: "complete",
+ skipClaudeSetup: false,
+ },
+ version: 0,
+ };
+ localStorage.setItem("automaker-setup", JSON.stringify(setupState));
+ });
+
+ // Navigate to the app
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+
+ // Wait for the sidebar to be visible
+ const sidebar = page.locator('[data-testid="sidebar"]');
+ await sidebar.waitFor({ state: "visible", timeout: 10000 });
+
+ // Click the Open Project button
+ const openProjectButton = page.locator(
+ '[data-testid="open-project-button"]'
+ );
+
+ // Check if the button is visible (it might not be in collapsed sidebar)
+ const isButtonVisible = await openProjectButton
+ .isVisible()
+ .catch(() => false);
+
+ if (isButtonVisible) {
+ await openProjectButton.click();
+
+ // The file browser dialog should open
+ // Note: In web mode, this might use the FileBrowserDialog component
+ // which makes requests to the backend server at /api/fs/browse
+
+ // Wait a bit to see if a dialog appears
+ await page.waitForTimeout(1000);
+
+ // Check if a dialog is visible
+ const dialog = page.locator('[role="dialog"]');
+ const dialogVisible = await dialog.isVisible().catch(() => false);
+
+ if (dialogVisible) {
+ // If file browser dialog is open, we need to navigate to the fixture path
+ // This depends on the current directory structure
+
+ // For now, let's verify the dialog appeared and close it
+ // A full test would navigate through directories
+ console.log("File browser dialog opened successfully");
+
+ // Press Escape to close the dialog
+ await page.keyboard.press("Escape");
+ }
+ }
+
+ // For a complete e2e test with file browsing, we'd need to:
+ // 1. Navigate through the directory tree
+ // 2. Select the projectA directory
+ // 3. Click "Select Current Folder"
+
+ // Since this involves actual file system navigation,
+ // and depends on the backend server being properly configured,
+ // we'll verify the basic UI elements are present
+
+ expect(sidebar).toBeTruthy();
+ });
+});
+
+test.describe("Spec Editor - Full Open Project Flow", () => {
+ test.beforeEach(async () => {
+ // Reset the fixture spec file to original content before each test
+ resetFixtureSpec();
+ });
+
+ test.afterEach(async () => {
+ // Clean up - reset the spec file after each test
+ resetFixtureSpec();
+ });
+
+ test("should open project via file browser, edit spec, and persist", async ({
+ page,
+ }) => {
+ // Navigate to app first
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+
+ // Set up localStorage state (without a current project, but mark setup complete)
+ // Using evaluate instead of addInitScript so it only runs once
+ // Note: In CI, setup wizard is also skipped via NEXT_PUBLIC_SKIP_SETUP env var
+ await page.evaluate(() => {
+ const mockState = {
+ state: {
+ projects: [],
+ currentProject: null,
+ currentView: "welcome",
+ theme: "dark",
+ sidebarOpen: true,
+ apiKeys: { anthropic: "", google: "" },
+ chatSessions: [],
+ chatHistoryOpen: false,
+ maxConcurrency: 3,
+ },
+ version: 0,
+ };
+ localStorage.setItem("automaker-storage", JSON.stringify(mockState));
+
+ // Mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
+ const setupState = {
+ state: {
+ isFirstRun: false,
+ setupComplete: true,
+ currentStep: "complete",
+ skipClaudeSetup: false,
+ },
+ version: 0,
+ };
+ localStorage.setItem("automaker-setup", JSON.stringify(setupState));
+ });
+
+ // Reload to apply the localStorage state
+ await page.reload();
+ await page.waitForLoadState("networkidle");
+
+ // Wait for sidebar
+ await page.waitForSelector('[data-testid="sidebar"]', { timeout: 10000 });
+
+ // Click the Open Project button
+ const openProjectButton = page.locator(
+ '[data-testid="open-project-button"]'
+ );
+ await openProjectButton.waitFor({ state: "visible", timeout: 10000 });
+ await openProjectButton.click();
+
+ // Wait for the file browser dialog to open
+ const dialogTitle = page.locator('text="Select Project Directory"');
+ await dialogTitle.waitFor({ state: "visible", timeout: 10000 });
+
+ // Wait for the dialog to fully load (loading to complete)
+ await page.waitForFunction(
+ () => !document.body.textContent?.includes("Loading directories..."),
+ { timeout: 10000 }
+ );
+
+ // Use the path input to directly navigate to the fixture directory
+ const pathInput = page.locator('[data-testid="path-input"]');
+ await pathInput.waitFor({ state: "visible", timeout: 5000 });
+
+ // Clear the input and type the full path to the fixture
+ await pathInput.fill(FIXTURE_PATH);
+
+ // Click the Go button to navigate to the path
+ const goButton = page.locator('[data-testid="go-to-path-button"]');
+ await goButton.click();
+
+ // Wait for loading to complete
+ await page.waitForFunction(
+ () => !document.body.textContent?.includes("Loading directories..."),
+ { timeout: 10000 }
+ );
+
+ // Verify we're in the right directory by checking the path display
+ const pathDisplay = page.locator(".font-mono.text-sm.truncate");
+ await expect(pathDisplay).toContainText("projectA");
+
+ // Click "Select Current Folder" button
+ const selectFolderButton = page.locator(
+ 'button:has-text("Select Current Folder")'
+ );
+ await selectFolderButton.click();
+
+ // Wait for dialog to close and project to load
+ await page.waitForFunction(
+ () => !document.querySelector('[role="dialog"]'),
+ { timeout: 10000 }
+ );
+ await page.waitForTimeout(500);
+
+ // Navigate to spec editor
+ const specNav = page.locator('[data-testid="nav-spec"]');
+ await specNav.waitFor({ state: "visible", timeout: 10000 });
+ await specNav.click();
+
+ // Wait for spec view with the editor (not the empty state)
+ await page.waitForSelector('[data-testid="spec-view"]', { timeout: 10000 });
+ await page.waitForSelector('[data-testid="spec-editor"] .cm-content', {
+ timeout: 10000,
+ });
+ await page.waitForTimeout(500);
+
+ // Edit the content
+ await setEditorContent(page, "hello world");
+
+ // Click save button
+ await clickSaveButton(page);
+
+ // Refresh and verify persistence
+ await page.reload();
+ await page.waitForLoadState("networkidle");
+
+ // Navigate back to spec editor
+ await specNav.waitFor({ state: "visible", timeout: 10000 });
+ await specNav.click();
+
+ await page.waitForSelector('[data-testid="spec-editor"] .cm-content', {
+ timeout: 10000,
+ });
+ await page.waitForTimeout(500);
+
+ // Verify the content persisted
+ const persistedContent = await getEditorContent(page);
+ expect(persistedContent.trim()).toBe("hello world");
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index 4813729a..1926a8c0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,9 +19,12 @@
"hasInstallScript": true,
"license": "Unlicense",
"dependencies": {
+ "@codemirror/lang-xml": "^6.1.0",
+ "@codemirror/theme-one-dark": "^6.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@lezer/highlight": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -32,6 +35,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
+ "@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
@@ -9863,6 +9867,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/types": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
@@ -9887,6 +9900,113 @@
"node": ">=18"
}
},
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
+ "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.10.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz",
+ "integrity": "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.4.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-xml": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
+ "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.4.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/xml": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.11.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
+ "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.1.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.9.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
+ "integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.35.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.5.11",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
+ "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
+ "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/theme-one-dark": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
+ "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.39.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
+ "integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.5.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
"node_modules/@electron/get": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
@@ -10890,6 +11010,47 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lezer/common": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz",
+ "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz",
+ "integrity": "sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/xml": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
+ "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+ "license": "MIT"
+ },
"node_modules/@next/env": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
@@ -11767,6 +11928,59 @@
"@types/node": "*"
}
},
+ "node_modules/@uiw/codemirror-extensions-basic-setup": {
+ "version": "4.25.4",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.4.tgz",
+ "integrity": "sha512-YzNwkm0AbPv1EXhCHYR5v0nqfemG2jEB0Z3Att4rBYqKrlG7AA9Rhjc3IyBaOzsBu18wtrp9/+uhTyu7TXSRng==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@codemirror/autocomplete": ">=6.0.0",
+ "@codemirror/commands": ">=6.0.0",
+ "@codemirror/language": ">=6.0.0",
+ "@codemirror/lint": ">=6.0.0",
+ "@codemirror/search": ">=6.0.0",
+ "@codemirror/state": ">=6.0.0",
+ "@codemirror/view": ">=6.0.0"
+ }
+ },
+ "node_modules/@uiw/react-codemirror": {
+ "version": "4.25.4",
+ "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.4.tgz",
+ "integrity": "sha512-ipO067oyfUw+DVaXhQCxkB0ZD9b7RnY+ByrprSYSKCHaULvJ3sqWYC/Zen6zVQ8/XC4o5EPBfatGiX20kC7XGA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.6",
+ "@codemirror/commands": "^6.1.0",
+ "@codemirror/state": "^6.1.1",
+ "@codemirror/theme-one-dark": "^6.0.0",
+ "@uiw/codemirror-extensions-basic-setup": "4.25.4",
+ "codemirror": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.11.0",
+ "@codemirror/state": ">=6.0.0",
+ "@codemirror/theme-one-dark": ">=6.0.0",
+ "@codemirror/view": ">=6.0.0",
+ "codemirror": ">=6.0.0",
+ "react": ">=17.0.0",
+ "react-dom": ">=17.0.0"
+ }
+ },
"node_modules/@vitest/coverage-v8": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.15.tgz",
@@ -12250,6 +12464,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/codemirror": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -12317,6 +12546,12 @@
"node": ">= 0.10"
}
},
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -14669,6 +14904,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/style-mod": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+ "license": "MIT"
+ },
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -15587,6 +15828,12 @@
}
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/test/fixtures/projectA/.gitkeep b/test/fixtures/projectA/.gitkeep
new file mode 100644
index 00000000..c2d306af
--- /dev/null
+++ b/test/fixtures/projectA/.gitkeep
@@ -0,0 +1,2 @@
+# This file ensures the test fixture directory is tracked by git.
+# The .automaker directory is created at test runtime.