mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
initial commit
This commit is contained in:
50
app/.gitignore
vendored
Normal file
50
app/.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# Electron
|
||||
/dist/
|
||||
36
app/README.md
Normal file
36
app/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
22
app/components.json
Normal file
22
app/components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
5
app/electron/.eslintrc.js
Normal file
5
app/electron/.eslintrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
},
|
||||
};
|
||||
146
app/electron/main.js
Normal file
146
app/electron/main.js
Normal file
@@ -0,0 +1,146 @@
|
||||
const { app, BrowserWindow, ipcMain, dialog } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs/promises");
|
||||
|
||||
let mainWindow = null;
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1024,
|
||||
minHeight: 700,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
titleBarStyle: "hiddenInset",
|
||||
backgroundColor: "#0a0a0a",
|
||||
});
|
||||
|
||||
// Load Next.js dev server in development or production build
|
||||
const isDev = !app.isPackaged;
|
||||
if (isDev) {
|
||||
mainWindow.loadURL("http://localhost:3000");
|
||||
// mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html"));
|
||||
}
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// IPC Handlers
|
||||
|
||||
// Dialog handlers
|
||||
ipcMain.handle("dialog:openDirectory", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openFile"],
|
||||
...options,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
// File system handlers
|
||||
ipcMain.handle("fs:readFile", async (_, filePath) => {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
return { success: true, content };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("fs:writeFile", async (_, filePath, content) => {
|
||||
try {
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("fs:mkdir", async (_, dirPath) => {
|
||||
try {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("fs:readdir", async (_, dirPath) => {
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
const result = entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory(),
|
||||
isFile: entry.isFile(),
|
||||
}));
|
||||
return { success: true, entries: result };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("fs:exists", async (_, filePath) => {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("fs:stat", async (_, filePath) => {
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
return {
|
||||
success: true,
|
||||
stats: {
|
||||
isDirectory: stats.isDirectory(),
|
||||
isFile: stats.isFile(),
|
||||
size: stats.size,
|
||||
mtime: stats.mtime,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// App data path
|
||||
ipcMain.handle("app:getPath", (_, name) => {
|
||||
return app.getPath(name);
|
||||
});
|
||||
|
||||
// IPC ping for testing communication
|
||||
ipcMain.handle("ping", () => {
|
||||
return "pong";
|
||||
});
|
||||
27
app/electron/preload.js
Normal file
27
app/electron/preload.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// the ipcRenderer without exposing the entire object
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// IPC test
|
||||
ping: () => ipcRenderer.invoke("ping"),
|
||||
|
||||
// Dialog APIs
|
||||
openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"),
|
||||
openFile: (options) => ipcRenderer.invoke("dialog:openFile", options),
|
||||
|
||||
// File system APIs
|
||||
readFile: (filePath) => ipcRenderer.invoke("fs:readFile", filePath),
|
||||
writeFile: (filePath, content) =>
|
||||
ipcRenderer.invoke("fs:writeFile", filePath, content),
|
||||
mkdir: (dirPath) => ipcRenderer.invoke("fs:mkdir", dirPath),
|
||||
readdir: (dirPath) => ipcRenderer.invoke("fs:readdir", dirPath),
|
||||
exists: (filePath) => ipcRenderer.invoke("fs:exists", filePath),
|
||||
stat: (filePath) => ipcRenderer.invoke("fs:stat", filePath),
|
||||
|
||||
// App APIs
|
||||
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
|
||||
});
|
||||
|
||||
// Also expose a flag to detect if we're in Electron
|
||||
contextBridge.exposeInMainWorld("isElectron", true);
|
||||
20
app/eslint.config.mjs
Normal file
20
app/eslint.config.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Electron files use CommonJS
|
||||
"electron/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
app/next.config.ts
Normal file
7
app/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
11940
app/package-lock.json
generated
Normal file
11940
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
app/package.json
Normal file
63
app/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "automaker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"dev:web": "next dev -p 3000",
|
||||
"dev:electron": "concurrently \"next dev -p 3000\" \"wait-on http://localhost:3000 && electron .\"",
|
||||
"build": "next build",
|
||||
"build:electron": "next build && electron-builder",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.556.0",
|
||||
"next": "16.0.7",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.7",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.automaker.app",
|
||||
"productName": "Automaker",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"electron/**/*",
|
||||
".next/**/*",
|
||||
"public/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
28
app/playwright.config.ts
Normal file
28
app/playwright.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
timeout: 10000,
|
||||
use: {
|
||||
baseURL: "http://localhost:3002",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "npm run dev -- -p 3002",
|
||||
url: "http://localhost:3002",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60000,
|
||||
},
|
||||
});
|
||||
7
app/postcss.config.mjs
Normal file
7
app/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
app/public/file.svg
Normal file
1
app/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
app/public/globe.svg
Normal file
1
app/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
app/public/next.svg
Normal file
1
app/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
app/public/vercel.svg
Normal file
1
app/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
app/public/window.svg
Normal file
1
app/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
BIN
app/src/app/favicon.ico
Normal file
BIN
app/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
150
app/src/app/globals.css
Normal file
150
app/src/app/globals.css
Normal file
@@ -0,0 +1,150 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.1 0 0);
|
||||
--foreground: oklch(0.95 0 0);
|
||||
--card: oklch(0.13 0 0);
|
||||
--card-foreground: oklch(0.95 0 0);
|
||||
--popover: oklch(0.13 0 0);
|
||||
--popover-foreground: oklch(0.95 0 0);
|
||||
--primary: oklch(0.55 0.25 265);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.2 0 0);
|
||||
--secondary-foreground: oklch(0.95 0 0);
|
||||
--muted: oklch(0.2 0 0);
|
||||
--muted-foreground: oklch(0.65 0 0);
|
||||
--accent: oklch(0.55 0.25 265);
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
--destructive: oklch(0.6 0.25 25);
|
||||
--border: oklch(0.25 0 0);
|
||||
--input: oklch(0.2 0 0);
|
||||
--ring: oklch(0.55 0.25 265);
|
||||
--chart-1: oklch(0.55 0.25 265);
|
||||
--chart-2: oklch(0.65 0.2 160);
|
||||
--chart-3: oklch(0.75 0.2 70);
|
||||
--chart-4: oklch(0.6 0.25 300);
|
||||
--chart-5: oklch(0.6 0.25 20);
|
||||
--sidebar: oklch(0.08 0 0);
|
||||
--sidebar-foreground: oklch(0.95 0 0);
|
||||
--sidebar-primary: oklch(0.55 0.25 265);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.2 0.05 265);
|
||||
--sidebar-accent-foreground: oklch(0.95 0 0);
|
||||
--sidebar-border: oklch(0.25 0 0);
|
||||
--sidebar-ring: oklch(0.55 0.25 265);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark mode */
|
||||
.dark ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: oklch(0.15 0 0);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.3 0 0);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.4 0 0);
|
||||
}
|
||||
|
||||
/* Electron title bar drag region */
|
||||
.titlebar-drag-region {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.titlebar-no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
34
app/src/app/layout.tsx
Normal file
34
app/src/app/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Automaker - Autonomous AI Development Studio",
|
||||
description: "Build software autonomously with intelligent orchestration",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased dark`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
89
app/src/app/page.tsx
Normal file
89
app/src/app/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { WelcomeView } from "@/components/views/welcome-view";
|
||||
import { BoardView } from "@/components/views/board-view";
|
||||
import { SpecView } from "@/components/views/spec-view";
|
||||
import { CodeView } from "@/components/views/code-view";
|
||||
import { AgentView } from "@/components/views/agent-view";
|
||||
import { SettingsView } from "@/components/views/settings-view";
|
||||
import { AnalysisView } from "@/components/views/analysis-view";
|
||||
import { AgentToolsView } from "@/components/views/agent-tools-view";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI, isElectron } from "@/lib/electron";
|
||||
|
||||
export default function Home() {
|
||||
const { currentView, setIpcConnected, theme } = useAppStore();
|
||||
|
||||
// Test IPC connection on mount
|
||||
useEffect(() => {
|
||||
const testConnection = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.ping();
|
||||
setIpcConnected(result === "pong" || result === "pong (mock)");
|
||||
} catch (error) {
|
||||
console.error("IPC connection failed:", error);
|
||||
setIpcConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
testConnection();
|
||||
}, [setIpcConnected]);
|
||||
|
||||
// Apply theme class to document
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else if (theme === "light") {
|
||||
root.classList.remove("dark");
|
||||
} else {
|
||||
// System theme
|
||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (isDark) {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
case "welcome":
|
||||
return <WelcomeView />;
|
||||
case "board":
|
||||
return <BoardView />;
|
||||
case "spec":
|
||||
return <SpecView />;
|
||||
case "code":
|
||||
return <CodeView />;
|
||||
case "agent":
|
||||
return <AgentView />;
|
||||
case "settings":
|
||||
return <SettingsView />;
|
||||
case "analysis":
|
||||
return <AnalysisView />;
|
||||
case "tools":
|
||||
return <AgentToolsView />;
|
||||
default:
|
||||
return <WelcomeView />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">{renderView()}</div>
|
||||
|
||||
{/* Environment indicator */}
|
||||
{!isElectron() && (
|
||||
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-yellow-500/10 text-yellow-500 text-xs rounded-full border border-yellow-500/20">
|
||||
Web Mode (Mock IPC)
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
220
app/src/components/layout/sidebar.tsx
Normal file
220
app/src/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import {
|
||||
FolderOpen,
|
||||
Plus,
|
||||
Settings,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Code,
|
||||
Bot,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
X,
|
||||
Moon,
|
||||
Sun,
|
||||
Search,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
export function Sidebar() {
|
||||
const {
|
||||
projects,
|
||||
currentProject,
|
||||
currentView,
|
||||
sidebarOpen,
|
||||
theme,
|
||||
setCurrentProject,
|
||||
setCurrentView,
|
||||
toggleSidebar,
|
||||
removeProject,
|
||||
setTheme,
|
||||
} = useAppStore();
|
||||
|
||||
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
|
||||
|
||||
const navItems = [
|
||||
{ id: "spec" as const, label: "Spec Editor", icon: FileText },
|
||||
{ id: "board" as const, label: "Kanban Board", icon: LayoutGrid },
|
||||
{ id: "code" as const, label: "Code View", icon: Code },
|
||||
{ id: "analysis" as const, label: "Analysis", icon: Search },
|
||||
{ id: "agent" as const, label: "Agent Chat", icon: Bot },
|
||||
{ id: "tools" as const, label: "Agent Tools", icon: Wrench },
|
||||
];
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex flex-col h-full bg-sidebar border-r border-sidebar-border transition-all duration-300",
|
||||
sidebarOpen ? "w-64" : "w-16"
|
||||
)}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between h-14 px-4 border-b border-sidebar-border titlebar-drag-region">
|
||||
{sidebarOpen && (
|
||||
<h1 className="text-lg font-bold text-sidebar-foreground">
|
||||
Automaker
|
||||
</h1>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className="titlebar-no-drag text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
data-testid="toggle-sidebar"
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Project Actions */}
|
||||
<div className="p-2 space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 text-sidebar-foreground hover:bg-sidebar-accent",
|
||||
!sidebarOpen && "justify-center px-2"
|
||||
)}
|
||||
onClick={() => setCurrentView("welcome")}
|
||||
data-testid="new-project-button"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{sidebarOpen && <span>New Project</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 text-sidebar-foreground hover:bg-sidebar-accent",
|
||||
!sidebarOpen && "justify-center px-2"
|
||||
)}
|
||||
onClick={() => setCurrentView("welcome")}
|
||||
data-testid="open-project-button"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
{sidebarOpen && <span>Open Project</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Projects List */}
|
||||
{sidebarOpen && projects.length > 0 && (
|
||||
<div className="flex-1 overflow-y-auto px-2">
|
||||
<p className="px-2 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Recent Projects
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 px-2 py-2 rounded-md cursor-pointer transition-colors",
|
||||
currentProject?.id === project.id
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "hover:bg-sidebar-accent/50 text-sidebar-foreground"
|
||||
)}
|
||||
onClick={() => setCurrentProject(project)}
|
||||
onMouseEnter={() => setHoveredProject(project.id)}
|
||||
onMouseLeave={() => setHoveredProject(null)}
|
||||
data-testid={`project-${project.id}`}
|
||||
>
|
||||
<Folder className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate text-sm">{project.name}</span>
|
||||
{hoveredProject === project.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeProject(project.id);
|
||||
}}
|
||||
data-testid={`remove-project-${project.id}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation - Only show when a project is open */}
|
||||
{currentProject && (
|
||||
<div className="border-t border-sidebar-border p-2 space-y-1">
|
||||
{sidebarOpen && (
|
||||
<p className="px-2 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Views
|
||||
</p>
|
||||
)}
|
||||
{navItems.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start gap-3",
|
||||
!sidebarOpen && "justify-center px-2",
|
||||
currentView === item.id
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
|
||||
)}
|
||||
onClick={() => setCurrentView(item.id)}
|
||||
data-testid={`nav-${item.id}`}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{sidebarOpen && <span>{item.label}</span>}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="mt-auto border-t border-sidebar-border p-2 space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 text-sidebar-foreground hover:bg-sidebar-accent",
|
||||
!sidebarOpen && "justify-center px-2"
|
||||
)}
|
||||
onClick={toggleTheme}
|
||||
data-testid="toggle-theme"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
{sidebarOpen && (
|
||||
<span>{theme === "dark" ? "Light Mode" : "Dark Mode"}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 text-sidebar-foreground hover:bg-sidebar-accent",
|
||||
!sidebarOpen && "justify-center px-2",
|
||||
currentView === "settings" && "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
)}
|
||||
onClick={() => setCurrentView("settings")}
|
||||
data-testid="settings-button"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
{sidebarOpen && <span>Settings</span>}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
60
app/src/components/ui/button.tsx
Normal file
60
app/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
app/src/components/ui/card.tsx
Normal file
92
app/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
143
app/src/components/ui/dialog.tsx
Normal file
143
app/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
21
app/src/components/ui/input.tsx
Normal file
21
app/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
app/src/components/ui/label.tsx
Normal file
24
app/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
139
app/src/components/ui/sheet.tsx
Normal file
139
app/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
66
app/src/components/ui/tabs.tsx
Normal file
66
app/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
465
app/src/components/views/agent-tools-view.tsx
Normal file
465
app/src/components/views/agent-tools-view.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Terminal,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Play,
|
||||
File,
|
||||
Pencil,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
interface ToolResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface ToolExecution {
|
||||
tool: string;
|
||||
input: string;
|
||||
result: ToolResult | null;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
export function AgentToolsView() {
|
||||
const { currentProject } = useAppStore();
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Read File Tool State
|
||||
const [readFilePath, setReadFilePath] = useState("");
|
||||
const [readFileResult, setReadFileResult] = useState<ToolResult | null>(null);
|
||||
const [isReadingFile, setIsReadingFile] = useState(false);
|
||||
|
||||
// Write File Tool State
|
||||
const [writeFilePath, setWriteFilePath] = useState("");
|
||||
const [writeFileContent, setWriteFileContent] = useState("");
|
||||
const [writeFileResult, setWriteFileResult] = useState<ToolResult | null>(null);
|
||||
const [isWritingFile, setIsWritingFile] = useState(false);
|
||||
|
||||
// Terminal Tool State
|
||||
const [terminalCommand, setTerminalCommand] = useState("ls");
|
||||
const [terminalResult, setTerminalResult] = useState<ToolResult | null>(null);
|
||||
const [isRunningCommand, setIsRunningCommand] = useState(false);
|
||||
|
||||
// Execute Read File
|
||||
const handleReadFile = useCallback(async () => {
|
||||
if (!readFilePath.trim()) return;
|
||||
|
||||
setIsReadingFile(true);
|
||||
setReadFileResult(null);
|
||||
|
||||
try {
|
||||
// Simulate agent requesting file read
|
||||
console.log(`[Agent Tool] Requesting to read file: ${readFilePath}`);
|
||||
|
||||
const result = await api.readFile(readFilePath);
|
||||
|
||||
if (result.success) {
|
||||
setReadFileResult({
|
||||
success: true,
|
||||
output: result.content,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] File read successful: ${readFilePath}`);
|
||||
} else {
|
||||
setReadFileResult({
|
||||
success: false,
|
||||
error: result.error || "Failed to read file",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] File read failed: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setReadFileResult({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} finally {
|
||||
setIsReadingFile(false);
|
||||
}
|
||||
}, [readFilePath, api]);
|
||||
|
||||
// Execute Write File
|
||||
const handleWriteFile = useCallback(async () => {
|
||||
if (!writeFilePath.trim() || !writeFileContent.trim()) return;
|
||||
|
||||
setIsWritingFile(true);
|
||||
setWriteFileResult(null);
|
||||
|
||||
try {
|
||||
// Simulate agent requesting file write
|
||||
console.log(`[Agent Tool] Requesting to write file: ${writeFilePath}`);
|
||||
|
||||
const result = await api.writeFile(writeFilePath, writeFileContent);
|
||||
|
||||
if (result.success) {
|
||||
setWriteFileResult({
|
||||
success: true,
|
||||
output: `File written successfully: ${writeFilePath}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] File write successful: ${writeFilePath}`);
|
||||
} else {
|
||||
setWriteFileResult({
|
||||
success: false,
|
||||
error: result.error || "Failed to write file",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] File write failed: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setWriteFileResult({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} finally {
|
||||
setIsWritingFile(false);
|
||||
}
|
||||
}, [writeFilePath, writeFileContent, api]);
|
||||
|
||||
// Execute Terminal Command
|
||||
const handleRunCommand = useCallback(async () => {
|
||||
if (!terminalCommand.trim()) return;
|
||||
|
||||
setIsRunningCommand(true);
|
||||
setTerminalResult(null);
|
||||
|
||||
try {
|
||||
// Simulate agent requesting terminal command execution
|
||||
console.log(`[Agent Tool] Requesting to run command: ${terminalCommand}`);
|
||||
|
||||
// In mock mode, simulate terminal output
|
||||
// In real Electron mode, this would use child_process
|
||||
const mockOutputs: Record<string, string> = {
|
||||
"ls": "app_spec.txt\nfeature_list.json\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
|
||||
"pwd": currentProject?.path || "/Users/demo/project",
|
||||
"echo hello": "hello",
|
||||
"whoami": "automaker-agent",
|
||||
"date": new Date().toString(),
|
||||
"cat package.json": '{\n "name": "demo-project",\n "version": "1.0.0"\n}',
|
||||
};
|
||||
|
||||
// Simulate command execution delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const output = mockOutputs[terminalCommand.toLowerCase()] ||
|
||||
`Command executed: ${terminalCommand}\n(Mock output - real execution requires Electron mode)`;
|
||||
|
||||
setTerminalResult({
|
||||
success: true,
|
||||
output: output,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`[Agent Tool] Command executed successfully: ${terminalCommand}`);
|
||||
} catch (error) {
|
||||
setTerminalResult({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} finally {
|
||||
setIsRunningCommand(false);
|
||||
}
|
||||
}, [terminalCommand, currentProject]);
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="agent-tools-no-project">
|
||||
<div className="text-center">
|
||||
<Wrench className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Open or create a project to test agent tools.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden" data-testid="agent-tools-view">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4 border-b">
|
||||
<Wrench className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Agent Tools</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Test file system and terminal tools for {currentProject.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools Grid */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{/* Read File Tool */}
|
||||
<Card data-testid="read-file-tool">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="w-5 h-5 text-blue-500" />
|
||||
<CardTitle className="text-lg">Read File</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Agent requests to read a file from the filesystem
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="read-file-path">File Path</Label>
|
||||
<Input
|
||||
id="read-file-path"
|
||||
placeholder="/path/to/file.txt"
|
||||
value={readFilePath}
|
||||
onChange={(e) => setReadFilePath(e.target.value)}
|
||||
data-testid="read-file-path-input"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleReadFile}
|
||||
disabled={isReadingFile || !readFilePath.trim()}
|
||||
className="w-full"
|
||||
data-testid="read-file-button"
|
||||
>
|
||||
{isReadingFile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Reading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Read
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{readFileResult && (
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 rounded-md border",
|
||||
readFileResult.success
|
||||
? "bg-green-500/10 border-green-500/20"
|
||||
: "bg-red-500/10 border-red-500/20"
|
||||
)}
|
||||
data-testid="read-file-result"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{readFileResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{readFileResult.success ? "Success" : "Failed"}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{readFileResult.success
|
||||
? readFileResult.output
|
||||
: readFileResult.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Write File Tool */}
|
||||
<Card data-testid="write-file-tool">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="w-5 h-5 text-green-500" />
|
||||
<CardTitle className="text-lg">Write File</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Agent requests to write content to a file
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="write-file-path">File Path</Label>
|
||||
<Input
|
||||
id="write-file-path"
|
||||
placeholder="/path/to/file.txt"
|
||||
value={writeFilePath}
|
||||
onChange={(e) => setWriteFilePath(e.target.value)}
|
||||
data-testid="write-file-path-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="write-file-content">Content</Label>
|
||||
<textarea
|
||||
id="write-file-content"
|
||||
placeholder="File content..."
|
||||
value={writeFileContent}
|
||||
onChange={(e) => setWriteFileContent(e.target.value)}
|
||||
className="w-full min-h-[100px] px-3 py-2 text-sm rounded-md border border-input bg-background resize-y"
|
||||
data-testid="write-file-content-input"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleWriteFile}
|
||||
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
|
||||
className="w-full"
|
||||
data-testid="write-file-button"
|
||||
>
|
||||
{isWritingFile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Writing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Write
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{writeFileResult && (
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 rounded-md border",
|
||||
writeFileResult.success
|
||||
? "bg-green-500/10 border-green-500/20"
|
||||
: "bg-red-500/10 border-red-500/20"
|
||||
)}
|
||||
data-testid="write-file-result"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{writeFileResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{writeFileResult.success ? "Success" : "Failed"}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{writeFileResult.success
|
||||
? writeFileResult.output
|
||||
: writeFileResult.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terminal Tool */}
|
||||
<Card data-testid="terminal-tool">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-purple-500" />
|
||||
<CardTitle className="text-lg">Run Terminal</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Agent requests to execute a terminal command
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="terminal-command">Command</Label>
|
||||
<Input
|
||||
id="terminal-command"
|
||||
placeholder="ls -la"
|
||||
value={terminalCommand}
|
||||
onChange={(e) => setTerminalCommand(e.target.value)}
|
||||
data-testid="terminal-command-input"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRunCommand}
|
||||
disabled={isRunningCommand || !terminalCommand.trim()}
|
||||
className="w-full"
|
||||
data-testid="run-terminal-button"
|
||||
>
|
||||
{isRunningCommand ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute Command
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{terminalResult && (
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 rounded-md border",
|
||||
terminalResult.success
|
||||
? "bg-green-500/10 border-green-500/20"
|
||||
: "bg-red-500/10 border-red-500/20"
|
||||
)}
|
||||
data-testid="terminal-result"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{terminalResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{terminalResult.success ? "Success" : "Failed"}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/50 text-green-400 p-2 rounded">
|
||||
$ {terminalCommand}
|
||||
{"\n"}
|
||||
{terminalResult.success
|
||||
? terminalResult.output
|
||||
: terminalResult.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tool Log Section */}
|
||||
<Card className="mt-6" data-testid="tool-log">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Tool Execution Log</CardTitle>
|
||||
<CardDescription>
|
||||
View agent tool requests and responses
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
Open your browser's developer console to see detailed agent tool logs.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>Read File - Agent requests file content from filesystem</li>
|
||||
<li>Write File - Agent writes content to specified path</li>
|
||||
<li>Run Terminal - Agent executes shell commands</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
app/src/components/views/agent-view.tsx
Normal file
199
app/src/components/views/agent-view.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Bot, Send, User, Loader2, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
let messageCounter = 0;
|
||||
const generateMessageId = () => `msg-${++messageCounter}`;
|
||||
|
||||
const getAgentResponse = (userInput: string): string => {
|
||||
const lowerInput = userInput.toLowerCase();
|
||||
|
||||
if (lowerInput.includes("todo") || lowerInput.includes("task")) {
|
||||
return "I can help you build a todo application! Let me ask a few questions:\n\n1. What tech stack would you prefer? (React, Vue, plain JavaScript)\n2. Do you need user authentication?\n3. Should tasks be stored locally or in a database?\n\nPlease share your preferences and I'll create a detailed spec.";
|
||||
}
|
||||
|
||||
if (lowerInput.includes("api") || lowerInput.includes("backend")) {
|
||||
return "Great! For building an API, I'll need to know:\n\n1. What type of data will it handle?\n2. Do you need authentication?\n3. What database would you like to use? (PostgreSQL, MongoDB, SQLite)\n4. Should I generate OpenAPI documentation?\n\nShare your requirements and I'll design the architecture.";
|
||||
}
|
||||
|
||||
if (lowerInput.includes("help") || lowerInput.includes("what can you do")) {
|
||||
return "I can help you with:\n\n• **Project Planning** - Define your app specification and features\n• **Code Generation** - Write code based on your requirements\n• **Testing** - Create and run tests for your features\n• **Code Review** - Analyze and improve existing code\n\nJust describe what you want to build, and I'll guide you through the process!";
|
||||
}
|
||||
|
||||
return `I understand you want to work on: "${userInput}"\n\nLet me analyze this and create a plan. In the full version, I would:\n\n1. Generate a detailed app_spec.txt\n2. Create feature_list.json with test cases\n3. Start implementing features one by one\n4. Run tests to verify each feature\n\nThis functionality requires API keys to be configured in Settings.`;
|
||||
};
|
||||
|
||||
export function AgentView() {
|
||||
const { currentProject } = useAppStore();
|
||||
const [messages, setMessages] = useState<Message[]>(() => [
|
||||
{
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
content:
|
||||
"Hello! I'm the Automaker Agent. I can help you build software autonomously. What would you like to create today?",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (!input.trim() || isProcessing) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: generateMessageId(),
|
||||
role: "user",
|
||||
content: input,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
const currentInput = input;
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput("");
|
||||
setIsProcessing(true);
|
||||
|
||||
// Simulate agent response (in a real implementation, this would call the AI API)
|
||||
setTimeout(() => {
|
||||
const assistantMessage: Message = {
|
||||
id: generateMessageId(),
|
||||
role: "assistant",
|
||||
content: getAgentResponse(currentInput),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
setIsProcessing(false);
|
||||
}, 1500);
|
||||
}, [input, isProcessing]);
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="agent-view-no-project">
|
||||
<div className="text-center">
|
||||
<Sparkles className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Open or create a project to start working with the AI agent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden" data-testid="agent-view">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4 border-b">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">AI Agent</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Autonomous development assistant for {currentProject.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4" data-testid="message-list">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-3",
|
||||
message.role === "user" && "flex-row-reverse"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
|
||||
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<User className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<Card
|
||||
className={cn(
|
||||
"max-w-[80%]",
|
||||
message.role === "user" && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs mt-2",
|
||||
message.role === "user"
|
||||
? "text-primary-foreground/70"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isProcessing && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">Thinking...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t p-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Describe what you want to build..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={isProcessing}
|
||||
data-testid="agent-input"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isProcessing}
|
||||
data-testid="send-message"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
361
app/src/components/views/analysis-view.tsx
Normal file
361
app/src/components/views/analysis-view.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAppStore, FileTreeNode, ProjectAnalysis } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
File,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Search,
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
FileCode,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
"node_modules",
|
||||
".git",
|
||||
".next",
|
||||
"dist",
|
||||
"build",
|
||||
".DS_Store",
|
||||
"*.log",
|
||||
".cache",
|
||||
"coverage",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".venv",
|
||||
"venv",
|
||||
".env",
|
||||
];
|
||||
|
||||
const shouldIgnore = (name: string) => {
|
||||
return IGNORE_PATTERNS.some((pattern) => {
|
||||
if (pattern.startsWith("*")) {
|
||||
return name.endsWith(pattern.slice(1));
|
||||
}
|
||||
return name === pattern;
|
||||
});
|
||||
};
|
||||
|
||||
const getExtension = (filename: string): string => {
|
||||
const parts = filename.split(".");
|
||||
return parts.length > 1 ? parts.pop() || "" : "";
|
||||
};
|
||||
|
||||
export function AnalysisView() {
|
||||
const {
|
||||
currentProject,
|
||||
projectAnalysis,
|
||||
isAnalyzing,
|
||||
setProjectAnalysis,
|
||||
setIsAnalyzing,
|
||||
clearAnalysis,
|
||||
} = useAppStore();
|
||||
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
// Recursively scan directory
|
||||
const scanDirectory = useCallback(
|
||||
async (path: string, depth: number = 0): Promise<FileTreeNode[]> => {
|
||||
if (depth > 10) return []; // Prevent infinite recursion
|
||||
|
||||
const api = getElectronAPI();
|
||||
try {
|
||||
const result = await api.readdir(path);
|
||||
if (!result.success || !result.entries) return [];
|
||||
|
||||
const nodes: FileTreeNode[] = [];
|
||||
const entries = result.entries.filter((e) => !shouldIgnore(e.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = `${path}/${entry.name}`;
|
||||
const node: FileTreeNode = {
|
||||
name: entry.name,
|
||||
path: fullPath,
|
||||
isDirectory: entry.isDirectory,
|
||||
extension: entry.isFile ? getExtension(entry.name) : undefined,
|
||||
};
|
||||
|
||||
if (entry.isDirectory) {
|
||||
// Recursively scan subdirectories
|
||||
node.children = await scanDirectory(fullPath, depth + 1);
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
// Sort: directories first, then files alphabetically
|
||||
nodes.sort((a, b) => {
|
||||
if (a.isDirectory && !b.isDirectory) return -1;
|
||||
if (!a.isDirectory && b.isDirectory) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return nodes;
|
||||
} catch (error) {
|
||||
console.error("Failed to scan directory:", path, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Count files and directories
|
||||
const countNodes = (
|
||||
nodes: FileTreeNode[]
|
||||
): { files: number; dirs: number; byExt: Record<string, number> } => {
|
||||
let files = 0;
|
||||
let dirs = 0;
|
||||
const byExt: Record<string, number> = {};
|
||||
|
||||
const traverse = (items: FileTreeNode[]) => {
|
||||
for (const item of items) {
|
||||
if (item.isDirectory) {
|
||||
dirs++;
|
||||
if (item.children) traverse(item.children);
|
||||
} else {
|
||||
files++;
|
||||
if (item.extension) {
|
||||
byExt[item.extension] = (byExt[item.extension] || 0) + 1;
|
||||
} else {
|
||||
byExt["(no extension)"] = (byExt["(no extension)"] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(nodes);
|
||||
return { files, dirs, byExt };
|
||||
};
|
||||
|
||||
// Run the analysis
|
||||
const runAnalysis = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsAnalyzing(true);
|
||||
clearAnalysis();
|
||||
|
||||
try {
|
||||
const fileTree = await scanDirectory(currentProject.path);
|
||||
const counts = countNodes(fileTree);
|
||||
|
||||
const analysis: ProjectAnalysis = {
|
||||
fileTree,
|
||||
totalFiles: counts.files,
|
||||
totalDirectories: counts.dirs,
|
||||
filesByExtension: counts.byExt,
|
||||
analyzedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setProjectAnalysis(analysis);
|
||||
} catch (error) {
|
||||
console.error("Analysis failed:", error);
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [currentProject, setIsAnalyzing, clearAnalysis, scanDirectory, setProjectAnalysis]);
|
||||
|
||||
// Toggle folder expansion
|
||||
const toggleFolder = (path: string) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
if (expandedFolders.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedFolders(newExpanded);
|
||||
};
|
||||
|
||||
// Render file tree node
|
||||
const renderNode = (node: FileTreeNode, depth: number = 0) => {
|
||||
const isExpanded = expandedFolders.has(node.path);
|
||||
|
||||
return (
|
||||
<div key={node.path} data-testid={`analysis-node-${node.name}`}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50 text-sm"
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => {
|
||||
if (node.isDirectory) {
|
||||
toggleFolder(node.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{node.isDirectory ? (
|
||||
<>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-primary shrink-0" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-4" />
|
||||
<File className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
{node.extension && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">.{node.extension}</span>
|
||||
)}
|
||||
</div>
|
||||
{node.isDirectory && isExpanded && node.children && (
|
||||
<div>{node.children.map((child) => renderNode(child, depth + 1))}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="analysis-view-no-project">
|
||||
<p className="text-muted-foreground">No project selected</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden" data-testid="analysis-view">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<Search className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Project Analysis</h1>
|
||||
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={runAnalysis}
|
||||
disabled={isAnalyzing}
|
||||
data-testid="analyze-project-button"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Analyze Project
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
{!projectAnalysis && !isAnalyzing ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Search className="w-16 h-16 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-lg font-semibold mb-2">No Analysis Yet</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
||||
Click "Analyze Project" to scan your codebase and get insights about its
|
||||
structure.
|
||||
</p>
|
||||
<Button onClick={runAnalysis} data-testid="analyze-project-button-empty">
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
Start Analysis
|
||||
</Button>
|
||||
</div>
|
||||
) : isAnalyzing ? (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary mb-4" />
|
||||
<p className="text-muted-foreground">Scanning project files...</p>
|
||||
</div>
|
||||
) : projectAnalysis ? (
|
||||
<div className="flex gap-4 h-full overflow-hidden">
|
||||
{/* Stats Panel */}
|
||||
<div className="w-80 shrink-0 overflow-y-auto space-y-4">
|
||||
<Card data-testid="analysis-stats">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
Statistics
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Analyzed {new Date(projectAnalysis.analyzedAt).toLocaleString()}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">Total Files</span>
|
||||
<span className="font-medium" data-testid="total-files">
|
||||
{projectAnalysis.totalFiles}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">Total Directories</span>
|
||||
<span className="font-medium" data-testid="total-directories">
|
||||
{projectAnalysis.totalDirectories}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card data-testid="files-by-extension">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4" />
|
||||
Files by Extension
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(projectAnalysis.filesByExtension)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 15)
|
||||
.map(([ext, count]) => (
|
||||
<div key={ext} className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground font-mono">
|
||||
{ext.startsWith("(") ? ext : `.${ext}`}
|
||||
</span>
|
||||
<span>{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* File Tree */}
|
||||
<Card className="flex-1 overflow-hidden">
|
||||
<CardHeader className="pb-2 border-b">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Folder className="w-4 h-4" />
|
||||
File Tree
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{projectAnalysis.totalFiles} files in {projectAnalysis.totalDirectories}{" "}
|
||||
directories
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 overflow-y-auto h-full" data-testid="analysis-file-tree">
|
||||
<div className="p-2">
|
||||
{projectAnalysis.fileTree.map((node) => renderNode(node))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
410
app/src/components/views/board-view.tsx
Normal file
410
app/src/components/views/board-view.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCorners,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAppStore, Feature } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { KanbanColumn } from "./kanban-column";
|
||||
import { KanbanCard } from "./kanban-card";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
|
||||
type ColumnId = Feature["status"];
|
||||
|
||||
const COLUMNS: { id: ColumnId; title: string; color: string }[] = [
|
||||
{ id: "backlog", title: "Backlog", color: "bg-zinc-500" },
|
||||
{ id: "planned", title: "Planned", color: "bg-blue-500" },
|
||||
{ id: "in_progress", title: "In Progress", color: "bg-yellow-500" },
|
||||
{ id: "review", title: "Review", color: "bg-purple-500" },
|
||||
{ id: "verified", title: "Verified", color: "bg-green-500" },
|
||||
{ id: "failed", title: "Failed", color: "bg-red-500" },
|
||||
];
|
||||
|
||||
export function BoardView() {
|
||||
const { currentProject, features, setFeatures, addFeature, updateFeature, moveFeature } =
|
||||
useAppStore();
|
||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [newFeature, setNewFeature] = useState({
|
||||
category: "",
|
||||
description: "",
|
||||
steps: [""],
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Load features from file
|
||||
const loadFeatures = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readFile(`${currentProject.path}/feature_list.json`);
|
||||
|
||||
if (result.success && result.content) {
|
||||
const parsed = JSON.parse(result.content);
|
||||
const featuresWithIds = parsed.map(
|
||||
(f: Omit<Feature, "id" | "status">, index: number) => ({
|
||||
...f,
|
||||
id: `feature-${index}-${Date.now()}`,
|
||||
status: f.passes ? "verified" : ("backlog" as ColumnId),
|
||||
})
|
||||
);
|
||||
setFeatures(featuresWithIds);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load features:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentProject, setFeatures]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeatures();
|
||||
}, [loadFeatures]);
|
||||
|
||||
// Save features to file
|
||||
const saveFeatures = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const toSave = features.map((f) => ({
|
||||
category: f.category,
|
||||
description: f.description,
|
||||
steps: f.steps,
|
||||
passes: f.status === "verified",
|
||||
}));
|
||||
await api.writeFile(
|
||||
`${currentProject.path}/feature_list.json`,
|
||||
JSON.stringify(toSave, null, 2)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to save features:", error);
|
||||
}
|
||||
}, [currentProject, features]);
|
||||
|
||||
// Save when features change
|
||||
useEffect(() => {
|
||||
if (features.length > 0) {
|
||||
saveFeatures();
|
||||
}
|
||||
}, [features, saveFeatures]);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
const feature = features.find((f) => f.id === active.id);
|
||||
if (feature) {
|
||||
setActiveFeature(feature);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveFeature(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const featureId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// Check if we dropped on a column
|
||||
const column = COLUMNS.find((c) => c.id === overId);
|
||||
if (column) {
|
||||
moveFeature(featureId, column.id);
|
||||
} else {
|
||||
// Dropped on another feature - find its column
|
||||
const overFeature = features.find((f) => f.id === overId);
|
||||
if (overFeature) {
|
||||
moveFeature(featureId, overFeature.status);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFeature = () => {
|
||||
addFeature({
|
||||
category: newFeature.category || "Uncategorized",
|
||||
description: newFeature.description,
|
||||
steps: newFeature.steps.filter((s) => s.trim()),
|
||||
passes: false,
|
||||
status: "backlog",
|
||||
});
|
||||
setNewFeature({ category: "", description: "", steps: [""] });
|
||||
setShowAddDialog(false);
|
||||
};
|
||||
|
||||
const handleUpdateFeature = () => {
|
||||
if (!editingFeature) return;
|
||||
|
||||
updateFeature(editingFeature.id, {
|
||||
category: editingFeature.category,
|
||||
description: editingFeature.description,
|
||||
steps: editingFeature.steps,
|
||||
});
|
||||
setEditingFeature(null);
|
||||
};
|
||||
|
||||
const getColumnFeatures = (columnId: ColumnId) => {
|
||||
return features.filter((f) => f.status === columnId);
|
||||
};
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project">
|
||||
<p className="text-muted-foreground">No project selected</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden" data-testid="board-view">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
||||
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadFeatures} data-testid="refresh-board">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowAddDialog(true)} data-testid="add-feature-button">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanban Columns */}
|
||||
<div className="flex-1 overflow-x-auto p-4">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-4 h-full min-w-max">
|
||||
{COLUMNS.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id);
|
||||
return (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
color={column.color}
|
||||
count={columnFeatures.length}
|
||||
>
|
||||
<SortableContext
|
||||
items={columnFeatures.map((f) => f.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{columnFeatures.map((feature) => (
|
||||
<KanbanCard
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
onEdit={() => setEditingFeature(feature)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</KanbanColumn>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeFeature && (
|
||||
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
|
||||
<CardHeader className="p-3">
|
||||
<CardTitle className="text-sm">{activeFeature.description}</CardTitle>
|
||||
<CardDescription className="text-xs">{activeFeature.category}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Add Feature Dialog */}
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent data-testid="add-feature-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Feature</DialogTitle>
|
||||
<DialogDescription>Create a new feature card for the Kanban board.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<Input
|
||||
id="category"
|
||||
placeholder="e.g., Core, UI, API"
|
||||
value={newFeature.category}
|
||||
onChange={(e) => setNewFeature({ ...newFeature, category: e.target.value })}
|
||||
data-testid="feature-category-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
placeholder="Describe the feature..."
|
||||
value={newFeature.description}
|
||||
onChange={(e) => setNewFeature({ ...newFeature, description: e.target.value })}
|
||||
data-testid="feature-description-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Steps</Label>
|
||||
{newFeature.steps.map((step, index) => (
|
||||
<Input
|
||||
key={index}
|
||||
placeholder={`Step ${index + 1}`}
|
||||
value={step}
|
||||
onChange={(e) => {
|
||||
const steps = [...newFeature.steps];
|
||||
steps[index] = e.target.value;
|
||||
setNewFeature({ ...newFeature, steps });
|
||||
}}
|
||||
data-testid={`feature-step-${index}-input`}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setNewFeature({ ...newFeature, steps: [...newFeature.steps, ""] })
|
||||
}
|
||||
data-testid="add-step-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowAddDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddFeature}
|
||||
disabled={!newFeature.description}
|
||||
data-testid="confirm-add-feature"
|
||||
>
|
||||
Add Feature
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Feature Dialog */}
|
||||
<Dialog open={!!editingFeature} onOpenChange={() => setEditingFeature(null)}>
|
||||
<DialogContent data-testid="edit-feature-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Feature</DialogTitle>
|
||||
<DialogDescription>Modify the feature details.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editingFeature && (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-category">Category</Label>
|
||||
<Input
|
||||
id="edit-category"
|
||||
value={editingFeature.category}
|
||||
onChange={(e) =>
|
||||
setEditingFeature({ ...editingFeature, category: e.target.value })
|
||||
}
|
||||
data-testid="edit-feature-category"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<Input
|
||||
id="edit-description"
|
||||
value={editingFeature.description}
|
||||
onChange={(e) =>
|
||||
setEditingFeature({ ...editingFeature, description: e.target.value })
|
||||
}
|
||||
data-testid="edit-feature-description"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Steps</Label>
|
||||
{editingFeature.steps.map((step, index) => (
|
||||
<Input
|
||||
key={index}
|
||||
value={step}
|
||||
onChange={(e) => {
|
||||
const steps = [...editingFeature.steps];
|
||||
steps[index] = e.target.value;
|
||||
setEditingFeature({ ...editingFeature, steps });
|
||||
}}
|
||||
data-testid={`edit-feature-step-${index}`}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
steps: [...editingFeature.steps, ""],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setEditingFeature(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateFeature} data-testid="confirm-edit-feature">
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
279
app/src/components/views/code-view.tsx
Normal file
279
app/src/components/views/code-view.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
File,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
RefreshCw,
|
||||
Code,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FileTreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children?: FileTreeNode[];
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
"node_modules",
|
||||
".git",
|
||||
".next",
|
||||
"dist",
|
||||
"build",
|
||||
".DS_Store",
|
||||
"*.log",
|
||||
];
|
||||
|
||||
const shouldIgnore = (name: string) => {
|
||||
return IGNORE_PATTERNS.some((pattern) => {
|
||||
if (pattern.startsWith("*")) {
|
||||
return name.endsWith(pattern.slice(1));
|
||||
}
|
||||
return name === pattern;
|
||||
});
|
||||
};
|
||||
|
||||
export function CodeView() {
|
||||
const { currentProject } = useAppStore();
|
||||
const [fileTree, setFileTree] = useState<FileTreeNode[]>([]);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load directory tree
|
||||
const loadTree = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readdir(currentProject.path);
|
||||
|
||||
if (result.success && result.entries) {
|
||||
const entries = result.entries
|
||||
.filter((e) => !shouldIgnore(e.name))
|
||||
.sort((a, b) => {
|
||||
// Directories first
|
||||
if (a.isDirectory && !b.isDirectory) return -1;
|
||||
if (!a.isDirectory && b.isDirectory) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
path: `${currentProject.path}/${e.name}`,
|
||||
isDirectory: e.isDirectory,
|
||||
}));
|
||||
|
||||
setFileTree(entries);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load file tree:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentProject]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTree();
|
||||
}, [loadTree]);
|
||||
|
||||
// Load subdirectory
|
||||
const loadSubdirectory = async (path: string): Promise<FileTreeNode[]> => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readdir(path);
|
||||
|
||||
if (result.success && result.entries) {
|
||||
return result.entries
|
||||
.filter((e) => !shouldIgnore(e.name))
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory && !b.isDirectory) return -1;
|
||||
if (!a.isDirectory && b.isDirectory) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
path: `${path}/${e.name}`,
|
||||
isDirectory: e.isDirectory,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load subdirectory:", error);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Load file content
|
||||
const loadFileContent = async (path: string) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readFile(path);
|
||||
|
||||
if (result.success && result.content) {
|
||||
setFileContent(result.content);
|
||||
setSelectedFile(path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load file:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle folder expansion
|
||||
const toggleFolder = async (node: FileTreeNode) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
|
||||
if (expandedFolders.has(node.path)) {
|
||||
newExpanded.delete(node.path);
|
||||
} else {
|
||||
newExpanded.add(node.path);
|
||||
|
||||
// Load children if not already loaded
|
||||
if (!node.children) {
|
||||
const children = await loadSubdirectory(node.path);
|
||||
// Update the tree with children
|
||||
const updateTree = (nodes: FileTreeNode[]): FileTreeNode[] => {
|
||||
return nodes.map((n) => {
|
||||
if (n.path === node.path) {
|
||||
return { ...n, children };
|
||||
}
|
||||
if (n.children) {
|
||||
return { ...n, children: updateTree(n.children) };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
};
|
||||
setFileTree(updateTree(fileTree));
|
||||
}
|
||||
}
|
||||
|
||||
setExpandedFolders(newExpanded);
|
||||
};
|
||||
|
||||
// Render file tree node
|
||||
const renderNode = (node: FileTreeNode, depth: number = 0) => {
|
||||
const isExpanded = expandedFolders.has(node.path);
|
||||
const isSelected = selectedFile === node.path;
|
||||
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
|
||||
isSelected && "bg-muted"
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => {
|
||||
if (node.isDirectory) {
|
||||
toggleFolder(node);
|
||||
} else {
|
||||
loadFileContent(node.path);
|
||||
}
|
||||
}}
|
||||
data-testid={`file-tree-item-${node.name}`}
|
||||
>
|
||||
{node.isDirectory ? (
|
||||
<>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-primary shrink-0" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-4" />
|
||||
<File className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span className="text-sm truncate">{node.name}</span>
|
||||
</div>
|
||||
{node.isDirectory && isExpanded && node.children && (
|
||||
<div>{node.children.map((child) => renderNode(child, depth + 1))}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="code-view-no-project">
|
||||
<p className="text-muted-foreground">No project selected</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="code-view-loading">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden" data-testid="code-view">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Code Explorer</h1>
|
||||
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadTree} data-testid="refresh-tree">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Split View */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* File Tree */}
|
||||
<div className="w-64 border-r overflow-y-auto" data-testid="file-tree">
|
||||
<div className="p-2">{fileTree.map((node) => renderNode(node))}</div>
|
||||
</div>
|
||||
|
||||
{/* Code Preview */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selectedFile ? (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="px-4 py-2 border-b bg-muted/30">
|
||||
<p className="text-sm font-mono text-muted-foreground truncate">
|
||||
{selectedFile.replace(currentProject.path, "")}
|
||||
</p>
|
||||
</div>
|
||||
<Card className="flex-1 m-4 overflow-hidden">
|
||||
<CardContent className="p-0 h-full">
|
||||
<pre className="p-4 h-full overflow-auto text-sm font-mono whitespace-pre-wrap">
|
||||
<code data-testid="code-content">{fileContent}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-muted-foreground">Select a file to view its contents</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
app/src/components/views/kanban-card.tsx
Normal file
101
app/src/components/views/kanban-card.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { GripVertical, Edit, Play, CheckCircle2, Circle } from "lucide-react";
|
||||
|
||||
interface KanbanCardProps {
|
||||
feature: Feature;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export function KanbanCard({ feature, onEdit }: KanbanCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: feature.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"cursor-grab active:cursor-grabbing transition-all",
|
||||
isDragging && "opacity-50 scale-105 shadow-lg"
|
||||
)}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
{...attributes}
|
||||
>
|
||||
<CardHeader className="p-3 pb-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
{...listeners}
|
||||
className="mt-0.5 cursor-grab touch-none"
|
||||
data-testid={`drag-handle-${feature.id}`}
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-sm leading-tight">{feature.description}</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">{feature.category}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-0">
|
||||
{/* Steps Preview */}
|
||||
{feature.steps.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
{feature.steps.slice(0, 3).map((step, index) => (
|
||||
<div key={index} className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
{feature.passes ? (
|
||||
<CheckCircle2 className="w-3 h-3 mt-0.5 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{step}</span>
|
||||
</div>
|
||||
))}
|
||||
{feature.steps.length > 3 && (
|
||||
<p className="text-xs text-muted-foreground pl-5">
|
||||
+{feature.steps.length - 3} more steps
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
data-testid={`edit-feature-${feature.id}`}
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-primary hover:text-primary"
|
||||
data-testid={`run-feature-${feature.id}`}
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
40
app/src/components/views/kanban-column.tsx
Normal file
40
app/src/components/views/kanban-column.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
count: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function KanbanColumn({ id, title, color, count, children }: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex flex-col w-72 h-full rounded-lg bg-muted/50 transition-colors",
|
||||
isOver && "bg-muted"
|
||||
)}
|
||||
data-testid={`kanban-column-${id}`}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center gap-2 p-3 border-b border-border">
|
||||
<div className={cn("w-3 h-3 rounded-full", color)} />
|
||||
<h3 className="font-medium text-sm flex-1">{title}</h3>
|
||||
<span className="text-xs text-muted-foreground bg-background px-2 py-0.5 rounded-full">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column Content */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
app/src/components/views/settings-view.tsx
Normal file
207
app/src/components/views/settings-view.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Settings, Key, Eye, EyeOff, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
|
||||
export function SettingsView() {
|
||||
const { apiKeys, setApiKeys, setCurrentView } = useAppStore();
|
||||
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
|
||||
const [googleKey, setGoogleKey] = useState(apiKeys.google);
|
||||
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
|
||||
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setAnthropicKey(apiKeys.anthropic);
|
||||
setGoogleKey(apiKeys.google);
|
||||
}, [apiKeys]);
|
||||
|
||||
const handleSave = () => {
|
||||
setApiKeys({
|
||||
anthropic: anthropicKey,
|
||||
google: googleKey,
|
||||
});
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
|
||||
const maskKey = (key: string) => {
|
||||
if (!key) return "";
|
||||
if (key.length <= 8) return "*".repeat(key.length);
|
||||
return key.slice(0, 4) + "*".repeat(key.length - 8) + key.slice(-4);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col" data-testid="settings-view">
|
||||
{/* Header */}
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="w-6 h-6" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your API keys and preferences
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* API Keys Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
API Keys
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your AI provider API keys. Keys are stored locally in your browser.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Anthropic API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="anthropic-key" className="flex items-center gap-2">
|
||||
Anthropic API Key
|
||||
{apiKeys.anthropic && (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="anthropic-key"
|
||||
type={showAnthropicKey ? "text" : "password"}
|
||||
value={anthropicKey}
|
||||
onChange={(e) => setAnthropicKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="pr-10"
|
||||
data-testid="anthropic-api-key-input"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => setShowAnthropicKey(!showAnthropicKey)}
|
||||
data-testid="toggle-anthropic-visibility"
|
||||
>
|
||||
{showAnthropicKey ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used for Claude AI features. Get your key at{" "}
|
||||
<a
|
||||
href="https://console.anthropic.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
console.anthropic.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Google API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="google-key" className="flex items-center gap-2">
|
||||
Google API Key (Gemini)
|
||||
{apiKeys.google && (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="google-key"
|
||||
type={showGoogleKey ? "text" : "password"}
|
||||
value={googleKey}
|
||||
onChange={(e) => setGoogleKey(e.target.value)}
|
||||
placeholder="AIza..."
|
||||
className="pr-10"
|
||||
data-testid="google-api-key-input"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => setShowGoogleKey(!showGoogleKey)}
|
||||
data-testid="toggle-google-visibility"
|
||||
>
|
||||
{showGoogleKey ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used for Gemini AI features. Get your key at{" "}
|
||||
<a
|
||||
href="https://makersuite.google.com/app/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
makersuite.google.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Security Notice */}
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 text-yellow-500 border border-yellow-500/20">
|
||||
<AlertCircle className="w-5 h-5 mt-0.5 shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Security Notice</p>
|
||||
<p className="text-xs opacity-80 mt-1">
|
||||
API keys are stored in your browser's local storage. Never share your API keys
|
||||
or commit them to version control.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
data-testid="save-settings"
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{saved ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Saved!
|
||||
</>
|
||||
) : (
|
||||
"Save Settings"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentView("welcome")}
|
||||
data-testid="back-to-home"
|
||||
>
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
app/src/components/views/spec-view.tsx
Normal file
128
app/src/components/views/spec-view.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Save, RefreshCw, FileText } from "lucide-react";
|
||||
|
||||
export function SpecView() {
|
||||
const { currentProject, appSpec, setAppSpec } = useAppStore();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Load spec from file
|
||||
const loadSpec = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readFile(`${currentProject.path}/app_spec.txt`);
|
||||
|
||||
if (result.success && result.content) {
|
||||
setAppSpec(result.content);
|
||||
setHasChanges(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load spec:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentProject, setAppSpec]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSpec();
|
||||
}, [loadSpec]);
|
||||
|
||||
// Save spec to file
|
||||
const saveSpec = async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
await api.writeFile(`${currentProject.path}/app_spec.txt`, appSpec);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save spec:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setAppSpec(value);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="spec-view-no-project">
|
||||
<p className="text-muted-foreground">No project selected</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="spec-view-loading">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden" data-testid="spec-view">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">App Specification</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentProject.path}/app_spec.txt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadSpec}
|
||||
disabled={isLoading}
|
||||
data-testid="reload-spec"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reload
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveSpec}
|
||||
disabled={!hasChanges || isSaving}
|
||||
data-testid="save-spec"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? "Saving..." : hasChanges ? "Save Changes" : "Saved"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 p-4 overflow-hidden">
|
||||
<Card className="h-full overflow-hidden">
|
||||
<textarea
|
||||
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
|
||||
value={appSpec}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Write your app specification here..."
|
||||
spellCheck={false}
|
||||
data-testid="spec-editor"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
282
app/src/components/views/welcome-view.tsx
Normal file
282
app/src/components/views/welcome-view.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { FolderOpen, Plus, Sparkles, Folder, Clock } from "lucide-react";
|
||||
|
||||
export function WelcomeView() {
|
||||
const { projects, addProject, setCurrentProject } = useAppStore();
|
||||
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState("");
|
||||
const [newProjectPath, setNewProjectPath] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleOpenProject = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const path = result.filePaths[0];
|
||||
const name = path.split("/").pop() || "Untitled Project";
|
||||
|
||||
const project = {
|
||||
id: `project-${Date.now()}`,
|
||||
name,
|
||||
path,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
addProject(project);
|
||||
setCurrentProject(project);
|
||||
}
|
||||
}, [addProject, setCurrentProject]);
|
||||
|
||||
const handleNewProject = () => {
|
||||
setNewProjectName("");
|
||||
setNewProjectPath("");
|
||||
setShowNewProjectDialog(true);
|
||||
};
|
||||
|
||||
const handleSelectDirectory = async () => {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.openDirectory();
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
setNewProjectPath(result.filePaths[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!newProjectName || !newProjectPath) return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const projectPath = `${newProjectPath}/${newProjectName}`;
|
||||
|
||||
// Create project directory
|
||||
await api.mkdir(projectPath);
|
||||
|
||||
// Create initial files
|
||||
await api.writeFile(
|
||||
`${projectPath}/app_spec.txt`,
|
||||
`<project_specification>
|
||||
<project_name>${newProjectName}</project_name>
|
||||
|
||||
<overview>
|
||||
Describe your project here...
|
||||
</overview>
|
||||
|
||||
<technology_stack>
|
||||
<!-- Define your tech stack -->
|
||||
</technology_stack>
|
||||
|
||||
<core_capabilities>
|
||||
<!-- List core features -->
|
||||
</core_capabilities>
|
||||
</project_specification>`
|
||||
);
|
||||
|
||||
await api.writeFile(
|
||||
`${projectPath}/feature_list.json`,
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
category: "Core",
|
||||
description: "First feature to implement",
|
||||
steps: ["Step 1: Define requirements", "Step 2: Implement", "Step 3: Test"],
|
||||
passes: false,
|
||||
},
|
||||
],
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const project = {
|
||||
id: `project-${Date.now()}`,
|
||||
name: newProjectName,
|
||||
path: projectPath,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
|
||||
addProject(project);
|
||||
setCurrentProject(project);
|
||||
setShowNewProjectDialog(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to create project:", error);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const recentProjects = [...projects]
|
||||
.sort((a, b) => {
|
||||
const dateA = a.lastOpened ? new Date(a.lastOpened).getTime() : 0;
|
||||
const dateB = b.lastOpened ? new Date(b.lastOpened).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8" data-testid="welcome-view">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-primary/10 mb-6">
|
||||
<Sparkles className="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-4">Welcome to Automaker</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-md">
|
||||
Your autonomous AI development studio. Build software with intelligent orchestration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-2xl w-full mb-12">
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={handleNewProject}
|
||||
data-testid="new-project-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-2">
|
||||
<Plus className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle>New Project</CardTitle>
|
||||
<CardDescription>
|
||||
Create a new project from scratch or use interactive mode
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className="w-full" data-testid="create-new-project">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Project
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={handleOpenProject}
|
||||
data-testid="open-project-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-2">
|
||||
<FolderOpen className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Open Project</CardTitle>
|
||||
<CardDescription>
|
||||
Open an existing project folder to continue working
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="secondary" className="w-full" data-testid="open-existing-project">
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
Browse Folder
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Projects */}
|
||||
{recentProjects.length > 0 && (
|
||||
<div className="max-w-2xl w-full">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
Recent Projects
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{recentProjects.map((project) => (
|
||||
<Card
|
||||
key={project.id}
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => setCurrentProject(project)}
|
||||
data-testid={`recent-project-${project.id}`}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Folder className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{project.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{project.path}</p>
|
||||
</div>
|
||||
{project.lastOpened && (
|
||||
<p className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(project.lastOpened).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Project Dialog */}
|
||||
<Dialog open={showNewProjectDialog} onOpenChange={setShowNewProjectDialog}>
|
||||
<DialogContent data-testid="new-project-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set up a new project directory with initial configuration files.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name">Project Name</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="my-awesome-project"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
data-testid="project-name-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-path">Parent Directory</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="project-path"
|
||||
placeholder="/path/to/projects"
|
||||
value={newProjectPath}
|
||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||
className="flex-1"
|
||||
data-testid="project-path-input"
|
||||
/>
|
||||
<Button variant="secondary" onClick={handleSelectDirectory} data-testid="browse-directory">
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowNewProjectDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateProject}
|
||||
disabled={!newProjectName || !newProjectPath || isCreating}
|
||||
data-testid="confirm-create-project"
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create Project"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
309
app/src/lib/electron.ts
Normal file
309
app/src/lib/electron.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// Type definitions for Electron IPC API
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
isFile: boolean;
|
||||
}
|
||||
|
||||
export interface FileStats {
|
||||
isDirectory: boolean;
|
||||
isFile: boolean;
|
||||
size: number;
|
||||
mtime: Date;
|
||||
}
|
||||
|
||||
export interface DialogResult {
|
||||
canceled: boolean;
|
||||
filePaths: string[];
|
||||
}
|
||||
|
||||
export interface FileResult {
|
||||
success: boolean;
|
||||
content?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WriteResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ReaddirResult {
|
||||
success: boolean;
|
||||
entries?: FileEntry[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StatResult {
|
||||
success: boolean;
|
||||
stats?: FileStats;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
ping: () => Promise<string>;
|
||||
openDirectory: () => Promise<DialogResult>;
|
||||
openFile: (options?: object) => Promise<DialogResult>;
|
||||
readFile: (filePath: string) => Promise<FileResult>;
|
||||
writeFile: (filePath: string, content: string) => Promise<WriteResult>;
|
||||
mkdir: (dirPath: string) => Promise<WriteResult>;
|
||||
readdir: (dirPath: string) => Promise<ReaddirResult>;
|
||||
exists: (filePath: string) => Promise<boolean>;
|
||||
stat: (filePath: string) => Promise<StatResult>;
|
||||
getPath: (name: string) => Promise<string>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI;
|
||||
isElectron?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock data for web development
|
||||
const mockFeatures = [
|
||||
{
|
||||
category: "Core",
|
||||
description: "Sample Feature",
|
||||
steps: ["Step 1", "Step 2"],
|
||||
passes: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Local storage keys
|
||||
const STORAGE_KEYS = {
|
||||
PROJECTS: "automaker_projects",
|
||||
CURRENT_PROJECT: "automaker_current_project",
|
||||
} as const;
|
||||
|
||||
// Mock file system using localStorage
|
||||
const mockFileSystem: Record<string, string> = {};
|
||||
|
||||
// Check if we're in Electron
|
||||
export const isElectron = (): boolean => {
|
||||
return typeof window !== "undefined" && window.isElectron === true;
|
||||
};
|
||||
|
||||
// Get the Electron API or a mock for web development
|
||||
export const getElectronAPI = (): ElectronAPI => {
|
||||
if (isElectron() && window.electronAPI) {
|
||||
return window.electronAPI;
|
||||
}
|
||||
|
||||
// Return mock API for web development
|
||||
return {
|
||||
ping: async () => "pong (mock)",
|
||||
|
||||
openDirectory: async () => {
|
||||
// In web mode, we'll use a prompt to simulate directory selection
|
||||
const path = prompt("Enter project directory path:", "/Users/demo/project");
|
||||
return {
|
||||
canceled: !path,
|
||||
filePaths: path ? [path] : [],
|
||||
};
|
||||
},
|
||||
|
||||
openFile: async () => {
|
||||
const path = prompt("Enter file path:");
|
||||
return {
|
||||
canceled: !path,
|
||||
filePaths: path ? [path] : [],
|
||||
};
|
||||
},
|
||||
|
||||
readFile: async (filePath: string) => {
|
||||
// Check mock file system
|
||||
if (mockFileSystem[filePath]) {
|
||||
return { success: true, content: mockFileSystem[filePath] };
|
||||
}
|
||||
// Return mock data based on file type
|
||||
if (filePath.endsWith("feature_list.json")) {
|
||||
return { success: true, content: JSON.stringify(mockFeatures, null, 2) };
|
||||
}
|
||||
if (filePath.endsWith("app_spec.txt")) {
|
||||
return {
|
||||
success: true,
|
||||
content: "<project_specification>\n <project_name>Demo Project</project_name>\n</project_specification>",
|
||||
};
|
||||
}
|
||||
return { success: false, error: "File not found (mock)" };
|
||||
},
|
||||
|
||||
writeFile: async (filePath: string, content: string) => {
|
||||
mockFileSystem[filePath] = content;
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
mkdir: async () => {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
readdir: async (dirPath: string) => {
|
||||
// Return mock directory structure based on path
|
||||
if (dirPath) {
|
||||
// Root level
|
||||
if (!dirPath.includes("/src") && !dirPath.includes("/tests") && !dirPath.includes("/public")) {
|
||||
return {
|
||||
success: true,
|
||||
entries: [
|
||||
{ name: "src", isDirectory: true, isFile: false },
|
||||
{ name: "tests", isDirectory: true, isFile: false },
|
||||
{ name: "public", isDirectory: true, isFile: false },
|
||||
{ name: "package.json", isDirectory: false, isFile: true },
|
||||
{ name: "tsconfig.json", isDirectory: false, isFile: true },
|
||||
{ name: "app_spec.txt", isDirectory: false, isFile: true },
|
||||
{ name: "feature_list.json", isDirectory: false, isFile: true },
|
||||
{ name: "README.md", isDirectory: false, isFile: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
// src directory
|
||||
if (dirPath.endsWith("/src")) {
|
||||
return {
|
||||
success: true,
|
||||
entries: [
|
||||
{ name: "components", isDirectory: true, isFile: false },
|
||||
{ name: "lib", isDirectory: true, isFile: false },
|
||||
{ name: "app", isDirectory: true, isFile: false },
|
||||
{ name: "index.ts", isDirectory: false, isFile: true },
|
||||
{ name: "utils.ts", isDirectory: false, isFile: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
// src/components directory
|
||||
if (dirPath.endsWith("/components")) {
|
||||
return {
|
||||
success: true,
|
||||
entries: [
|
||||
{ name: "Button.tsx", isDirectory: false, isFile: true },
|
||||
{ name: "Card.tsx", isDirectory: false, isFile: true },
|
||||
{ name: "Header.tsx", isDirectory: false, isFile: true },
|
||||
{ name: "Footer.tsx", isDirectory: false, isFile: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
// src/lib directory
|
||||
if (dirPath.endsWith("/lib")) {
|
||||
return {
|
||||
success: true,
|
||||
entries: [
|
||||
{ name: "api.ts", isDirectory: false, isFile: true },
|
||||
{ name: "helpers.ts", isDirectory: false, isFile: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
// src/app directory
|
||||
if (dirPath.endsWith("/app")) {
|
||||
return {
|
||||
success: true,
|
||||
entries: [
|
||||
{ name: "page.tsx", isDirectory: false, isFile: true },
|
||||
{ name: "layout.tsx", isDirectory: false, isFile: true },
|
||||
{ name: "globals.css", isDirectory: false, isFile: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
// tests directory
|
||||
if (dirPath.endsWith("/tests")) {
|
||||
return {
|
||||
success: true,
|
||||
entries: [
|
||||
{ name: "unit.test.ts", isDirectory: false, isFile: true },
|
||||
{ name: "e2e.spec.ts", isDirectory: false, isFile: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
// public directory
|
||||
if (dirPath.endsWith("/public")) {
|
||||
return {
|
||||
success: true,
|
||||
entries: [
|
||||
{ name: "favicon.ico", isDirectory: false, isFile: true },
|
||||
{ name: "logo.svg", isDirectory: false, isFile: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
// Default empty for other paths
|
||||
return { success: true, entries: [] };
|
||||
}
|
||||
return { success: true, entries: [] };
|
||||
},
|
||||
|
||||
exists: async (filePath: string) => {
|
||||
return mockFileSystem[filePath] !== undefined ||
|
||||
filePath.endsWith("feature_list.json") ||
|
||||
filePath.endsWith("app_spec.txt");
|
||||
},
|
||||
|
||||
stat: async () => {
|
||||
return {
|
||||
success: true,
|
||||
stats: {
|
||||
isDirectory: false,
|
||||
isFile: true,
|
||||
size: 1024,
|
||||
mtime: new Date(),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
getPath: async (name: string) => {
|
||||
if (name === "userData") {
|
||||
return "/mock/userData";
|
||||
}
|
||||
return `/mock/${name}`;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Utility functions for project management
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
lastOpened?: string;
|
||||
}
|
||||
|
||||
export const getStoredProjects = (): Project[] => {
|
||||
if (typeof window === "undefined") return [];
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.PROJECTS);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
};
|
||||
|
||||
export const saveProjects = (projects: Project[]): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_KEYS.PROJECTS, JSON.stringify(projects));
|
||||
};
|
||||
|
||||
export const getCurrentProject = (): Project | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.CURRENT_PROJECT);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
};
|
||||
|
||||
export const setCurrentProject = (project: Project | null): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (project) {
|
||||
localStorage.setItem(STORAGE_KEYS.CURRENT_PROJECT, JSON.stringify(project));
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEYS.CURRENT_PROJECT);
|
||||
}
|
||||
};
|
||||
|
||||
export const addProject = (project: Project): void => {
|
||||
const projects = getStoredProjects();
|
||||
const existing = projects.findIndex((p) => p.path === project.path);
|
||||
if (existing >= 0) {
|
||||
projects[existing] = { ...project, lastOpened: new Date().toISOString() };
|
||||
} else {
|
||||
projects.push({ ...project, lastOpened: new Date().toISOString() });
|
||||
}
|
||||
saveProjects(projects);
|
||||
};
|
||||
|
||||
export const removeProject = (projectId: string): void => {
|
||||
const projects = getStoredProjects().filter((p) => p.id !== projectId);
|
||||
saveProjects(projects);
|
||||
};
|
||||
6
app/src/lib/utils.ts
Normal file
6
app/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
223
app/src/store/app-store.ts
Normal file
223
app/src/store/app-store.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { Project } from "@/lib/electron";
|
||||
|
||||
export type ViewMode = "welcome" | "spec" | "board" | "code" | "agent" | "settings" | "analysis" | "tools";
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
export interface ApiKeys {
|
||||
anthropic: string;
|
||||
google: string;
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
passes: boolean;
|
||||
status: "backlog" | "planned" | "in_progress" | "review" | "verified" | "failed";
|
||||
}
|
||||
|
||||
export interface FileTreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children?: FileTreeNode[];
|
||||
size?: number;
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export interface ProjectAnalysis {
|
||||
fileTree: FileTreeNode[];
|
||||
totalFiles: number;
|
||||
totalDirectories: number;
|
||||
filesByExtension: Record<string, number>;
|
||||
analyzedAt: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
// Project state
|
||||
projects: Project[];
|
||||
currentProject: Project | null;
|
||||
|
||||
// View state
|
||||
currentView: ViewMode;
|
||||
sidebarOpen: boolean;
|
||||
|
||||
// Theme
|
||||
theme: ThemeMode;
|
||||
|
||||
// Features/Kanban
|
||||
features: Feature[];
|
||||
|
||||
// App spec
|
||||
appSpec: string;
|
||||
|
||||
// IPC status
|
||||
ipcConnected: boolean;
|
||||
|
||||
// API Keys
|
||||
apiKeys: ApiKeys;
|
||||
|
||||
// Project Analysis
|
||||
projectAnalysis: ProjectAnalysis | null;
|
||||
isAnalyzing: boolean;
|
||||
}
|
||||
|
||||
export interface AppActions {
|
||||
// Project actions
|
||||
setProjects: (projects: Project[]) => void;
|
||||
addProject: (project: Project) => void;
|
||||
removeProject: (projectId: string) => void;
|
||||
setCurrentProject: (project: Project | null) => void;
|
||||
|
||||
// View actions
|
||||
setCurrentView: (view: ViewMode) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
|
||||
// Theme actions
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
|
||||
// Feature actions
|
||||
setFeatures: (features: Feature[]) => void;
|
||||
updateFeature: (id: string, updates: Partial<Feature>) => void;
|
||||
addFeature: (feature: Omit<Feature, "id">) => void;
|
||||
removeFeature: (id: string) => void;
|
||||
moveFeature: (id: string, newStatus: Feature["status"]) => void;
|
||||
|
||||
// App spec actions
|
||||
setAppSpec: (spec: string) => void;
|
||||
|
||||
// IPC actions
|
||||
setIpcConnected: (connected: boolean) => void;
|
||||
|
||||
// API Keys actions
|
||||
setApiKeys: (keys: Partial<ApiKeys>) => void;
|
||||
|
||||
// Analysis actions
|
||||
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
|
||||
setIsAnalyzing: (isAnalyzing: boolean) => void;
|
||||
clearAnalysis: () => void;
|
||||
|
||||
// Reset
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState: AppState = {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
currentView: "welcome",
|
||||
sidebarOpen: true,
|
||||
theme: "dark",
|
||||
features: [],
|
||||
appSpec: "",
|
||||
ipcConnected: false,
|
||||
apiKeys: {
|
||||
anthropic: "",
|
||||
google: "",
|
||||
},
|
||||
projectAnalysis: null,
|
||||
isAnalyzing: false,
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState & AppActions>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// Project actions
|
||||
setProjects: (projects) => set({ projects }),
|
||||
|
||||
addProject: (project) => {
|
||||
const projects = get().projects;
|
||||
const existing = projects.findIndex((p) => p.path === project.path);
|
||||
if (existing >= 0) {
|
||||
const updated = [...projects];
|
||||
updated[existing] = { ...project, lastOpened: new Date().toISOString() };
|
||||
set({ projects: updated });
|
||||
} else {
|
||||
set({
|
||||
projects: [...projects, { ...project, lastOpened: new Date().toISOString() }],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
removeProject: (projectId) => {
|
||||
set({ projects: get().projects.filter((p) => p.id !== projectId) });
|
||||
},
|
||||
|
||||
setCurrentProject: (project) => {
|
||||
set({ currentProject: project });
|
||||
if (project) {
|
||||
set({ currentView: "board" });
|
||||
} else {
|
||||
set({ currentView: "welcome" });
|
||||
}
|
||||
},
|
||||
|
||||
// View actions
|
||||
setCurrentView: (view) => set({ currentView: view }),
|
||||
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
|
||||
// Theme actions
|
||||
setTheme: (theme) => set({ theme }),
|
||||
|
||||
// Feature actions
|
||||
setFeatures: (features) => set({ features }),
|
||||
|
||||
updateFeature: (id, updates) => {
|
||||
set({
|
||||
features: get().features.map((f) =>
|
||||
f.id === id ? { ...f, ...updates } : f
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
addFeature: (feature) => {
|
||||
const id = `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
set({ features: [...get().features, { ...feature, id }] });
|
||||
},
|
||||
|
||||
removeFeature: (id) => {
|
||||
set({ features: get().features.filter((f) => f.id !== id) });
|
||||
},
|
||||
|
||||
moveFeature: (id, newStatus) => {
|
||||
set({
|
||||
features: get().features.map((f) =>
|
||||
f.id === id ? { ...f, status: newStatus } : f
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
// App spec actions
|
||||
setAppSpec: (spec) => set({ appSpec: spec }),
|
||||
|
||||
// IPC actions
|
||||
setIpcConnected: (connected) => set({ ipcConnected: connected }),
|
||||
|
||||
// API Keys actions
|
||||
setApiKeys: (keys) => set({ apiKeys: { ...get().apiKeys, ...keys } }),
|
||||
|
||||
// Analysis actions
|
||||
setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }),
|
||||
setIsAnalyzing: (isAnalyzing) => set({ isAnalyzing }),
|
||||
clearAnalysis: () => set({ projectAnalysis: null, isAnalyzing: false }),
|
||||
|
||||
// Reset
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{
|
||||
name: "automaker-storage",
|
||||
partialize: (state) => ({
|
||||
projects: state.projects,
|
||||
theme: state.theme,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
apiKeys: state.apiKeys,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
217
app/tests/agent-tools.spec.ts
Normal file
217
app/tests/agent-tools.spec.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Agent Tools", () => {
|
||||
test("can navigate to agent tools view when project is open", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await page.getByTestId("project-name-input").fill("Test Project");
|
||||
await page.getByTestId("project-path-input").fill("/test/path");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Wait for board view to load
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Navigate to agent tools
|
||||
await page.getByTestId("nav-tools").click();
|
||||
|
||||
// Verify agent tools view is displayed
|
||||
await expect(page.getByTestId("agent-tools-view")).toBeVisible();
|
||||
});
|
||||
|
||||
test("agent tools view shows all three tool cards", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await page.getByTestId("project-name-input").fill("Test Project");
|
||||
await page.getByTestId("project-path-input").fill("/test/path");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Navigate to agent tools
|
||||
await page.getByTestId("nav-tools").click();
|
||||
|
||||
// Verify all three tool cards are visible
|
||||
await expect(page.getByTestId("read-file-tool")).toBeVisible();
|
||||
await expect(page.getByTestId("write-file-tool")).toBeVisible();
|
||||
await expect(page.getByTestId("terminal-tool")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("Read File Tool", () => {
|
||||
test("agent can request to read file and receive content", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await page.getByTestId("project-name-input").fill("Test Project");
|
||||
await page.getByTestId("project-path-input").fill("/test/path");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Navigate to agent tools
|
||||
await page.getByTestId("nav-tools").click();
|
||||
|
||||
// Enter a file path
|
||||
await page.getByTestId("read-file-path-input").fill("/test/path/feature_list.json");
|
||||
|
||||
// Click execute
|
||||
await page.getByTestId("read-file-button").click();
|
||||
|
||||
// Wait for result
|
||||
await expect(page.getByTestId("read-file-result")).toBeVisible();
|
||||
|
||||
// Verify success message
|
||||
await expect(page.getByTestId("read-file-result")).toContainText("Success");
|
||||
});
|
||||
|
||||
test("read file tool shows input field for file path", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await page.getByTestId("project-name-input").fill("Test Project");
|
||||
await page.getByTestId("project-path-input").fill("/test/path");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Navigate to agent tools
|
||||
await page.getByTestId("nav-tools").click();
|
||||
|
||||
// Verify input field exists
|
||||
await expect(page.getByTestId("read-file-path-input")).toBeVisible();
|
||||
await expect(page.getByTestId("read-file-button")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Write File Tool", () => {
|
||||
test("agent can request to write file and file is written", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await page.getByTestId("project-name-input").fill("Test Project");
|
||||
await page.getByTestId("project-path-input").fill("/test/path");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Navigate to agent tools
|
||||
await page.getByTestId("nav-tools").click();
|
||||
|
||||
// Enter file path and content
|
||||
await page.getByTestId("write-file-path-input").fill("/test/path/new-file.txt");
|
||||
await page.getByTestId("write-file-content-input").fill("Hello from agent!");
|
||||
|
||||
// Click execute
|
||||
await page.getByTestId("write-file-button").click();
|
||||
|
||||
// Wait for result
|
||||
await expect(page.getByTestId("write-file-result")).toBeVisible();
|
||||
|
||||
// Verify success message
|
||||
await expect(page.getByTestId("write-file-result")).toContainText("Success");
|
||||
await expect(page.getByTestId("write-file-result")).toContainText("File written successfully");
|
||||
});
|
||||
|
||||
test("write file tool shows path and content inputs", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await page.getByTestId("project-name-input").fill("Test Project");
|
||||
await page.getByTestId("project-path-input").fill("/test/path");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Navigate to agent tools
|
||||
await page.getByTestId("nav-tools").click();
|
||||
|
||||
// Verify input fields exist
|
||||
await expect(page.getByTestId("write-file-path-input")).toBeVisible();
|
||||
await expect(page.getByTestId("write-file-content-input")).toBeVisible();
|
||||
await expect(page.getByTestId("write-file-button")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Terminal Tool", () => {
|
||||
test("agent can request to run terminal command and receive stdout", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await page.getByTestId("project-name-input").fill("Test Project");
|
||||
await page.getByTestId("project-path-input").fill("/test/path");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Navigate to agent tools
|
||||
await page.getByTestId("nav-tools").click();
|
||||
|
||||
// Enter command (default is 'ls')
|
||||
await page.getByTestId("terminal-command-input").fill("ls");
|
||||
|
||||
// Click execute
|
||||
await page.getByTestId("run-terminal-button").click();
|
||||
|
||||
// Wait for result
|
||||
await expect(page.getByTestId("terminal-result")).toBeVisible();
|
||||
|
||||
// Verify success and output
|
||||
await expect(page.getByTestId("terminal-result")).toContainText("Success");
|
||||
await expect(page.getByTestId("terminal-result")).toContainText("$ ls");
|
||||
});
|
||||
|
||||
test("terminal tool shows command input field", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await page.getByTestId("project-name-input").fill("Test Project");
|
||||
await page.getByTestId("project-path-input").fill("/test/path");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Navigate to agent tools
|
||||
await page.getByTestId("nav-tools").click();
|
||||
|
||||
// Verify input field exists
|
||||
await expect(page.getByTestId("terminal-command-input")).toBeVisible();
|
||||
await expect(page.getByTestId("run-terminal-button")).toBeVisible();
|
||||
});
|
||||
|
||||
test("terminal tool can run pwd command", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await page.getByTestId("project-name-input").fill("Test Project");
|
||||
await page.getByTestId("project-path-input").fill("/test/path");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Navigate to agent tools
|
||||
await page.getByTestId("nav-tools").click();
|
||||
|
||||
// Enter pwd command
|
||||
await page.getByTestId("terminal-command-input").fill("pwd");
|
||||
|
||||
// Click execute
|
||||
await page.getByTestId("run-terminal-button").click();
|
||||
|
||||
// Wait for result
|
||||
await expect(page.getByTestId("terminal-result")).toBeVisible();
|
||||
|
||||
// Verify success
|
||||
await expect(page.getByTestId("terminal-result")).toContainText("Success");
|
||||
});
|
||||
});
|
||||
|
||||
test("tool log section is visible", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await page.getByTestId("project-name-input").fill("Test Project");
|
||||
await page.getByTestId("project-path-input").fill("/test/path");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Navigate to agent tools
|
||||
await page.getByTestId("nav-tools").click();
|
||||
|
||||
// Verify tool log section is visible
|
||||
await expect(page.getByTestId("tool-log")).toBeVisible();
|
||||
});
|
||||
});
|
||||
166
app/tests/analysis.spec.ts
Normal file
166
app/tests/analysis.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Project Analysis", () => {
|
||||
test("can navigate to analysis view when project is open", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
|
||||
await page.getByTestId("project-name-input").fill("Analysis Test Project");
|
||||
await page.getByTestId("project-path-input").fill("/test/analysis/project");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Wait for board view to load
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Click on Analysis in sidebar
|
||||
await page.getByTestId("nav-analysis").click();
|
||||
|
||||
// Verify analysis view is displayed
|
||||
await expect(page.getByTestId("analysis-view")).toBeVisible();
|
||||
});
|
||||
|
||||
test("analysis view shows 'No Analysis Yet' message initially", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
|
||||
await page.getByTestId("project-name-input").fill("Analysis Test Project2");
|
||||
await page.getByTestId("project-path-input").fill("/test/analysis/project2");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Navigate to analysis view
|
||||
await page.getByTestId("nav-analysis").click();
|
||||
await expect(page.getByTestId("analysis-view")).toBeVisible();
|
||||
|
||||
// Verify no analysis message
|
||||
await expect(page.getByText("No Analysis Yet")).toBeVisible();
|
||||
await expect(page.getByText('Click "Analyze Project"')).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows 'Analyze Project' button", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
|
||||
await page.getByTestId("project-name-input").fill("Analysis Test Project3");
|
||||
await page.getByTestId("project-path-input").fill("/test/analysis/project3");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Navigate to analysis view
|
||||
await page.getByTestId("nav-analysis").click();
|
||||
await expect(page.getByTestId("analysis-view")).toBeVisible();
|
||||
|
||||
// Verify analyze button is visible
|
||||
await expect(page.getByTestId("analyze-project-button")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can run project analysis", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
|
||||
await page.getByTestId("project-name-input").fill("Analysis Test Project4");
|
||||
await page.getByTestId("project-path-input").fill("/test/analysis/project4");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Navigate to analysis view
|
||||
await page.getByTestId("nav-analysis").click();
|
||||
await expect(page.getByTestId("analysis-view")).toBeVisible();
|
||||
|
||||
// Click analyze button
|
||||
await page.getByTestId("analyze-project-button").click();
|
||||
|
||||
// Wait for analysis to complete and stats to appear
|
||||
await expect(page.getByTestId("analysis-stats")).toBeVisible();
|
||||
|
||||
// Verify statistics are displayed
|
||||
await expect(page.getByTestId("total-files")).toBeVisible();
|
||||
await expect(page.getByTestId("total-directories")).toBeVisible();
|
||||
});
|
||||
|
||||
test("analysis shows file tree after running", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
|
||||
await page.getByTestId("project-name-input").fill("Analysis Test Project5");
|
||||
await page.getByTestId("project-path-input").fill("/test/analysis/project5");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Navigate to analysis view
|
||||
await page.getByTestId("nav-analysis").click();
|
||||
await expect(page.getByTestId("analysis-view")).toBeVisible();
|
||||
|
||||
// Click analyze button
|
||||
await page.getByTestId("analyze-project-button").click();
|
||||
|
||||
// Wait for analysis to complete
|
||||
await expect(page.getByTestId("analysis-file-tree")).toBeVisible();
|
||||
|
||||
// Verify file tree is displayed
|
||||
await expect(page.getByTestId("analysis-file-tree")).toBeVisible();
|
||||
});
|
||||
|
||||
test("analysis shows files by extension breakdown", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
|
||||
await page.getByTestId("project-name-input").fill("Analysis Test Project6");
|
||||
await page.getByTestId("project-path-input").fill("/test/analysis/project6");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Navigate to analysis view
|
||||
await page.getByTestId("nav-analysis").click();
|
||||
await expect(page.getByTestId("analysis-view")).toBeVisible();
|
||||
|
||||
// Click analyze button
|
||||
await page.getByTestId("analyze-project-button").click();
|
||||
|
||||
// Wait for analysis to complete
|
||||
await expect(page.getByTestId("files-by-extension")).toBeVisible();
|
||||
|
||||
// Verify files by extension card is displayed
|
||||
await expect(page.getByTestId("files-by-extension")).toBeVisible();
|
||||
});
|
||||
|
||||
test("file tree displays correct structure with directories and files", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project first
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
|
||||
await page.getByTestId("project-name-input").fill("Analysis Test Project7");
|
||||
await page.getByTestId("project-path-input").fill("/test/analysis/project7");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Navigate to analysis view
|
||||
await page.getByTestId("nav-analysis").click();
|
||||
await expect(page.getByTestId("analysis-view")).toBeVisible();
|
||||
|
||||
// Click analyze button
|
||||
await page.getByTestId("analyze-project-button").click();
|
||||
|
||||
// Wait for file tree to be populated
|
||||
await expect(page.getByTestId("analysis-file-tree")).toBeVisible();
|
||||
|
||||
// Verify src directory is in the tree (mock data provides this)
|
||||
await expect(page.getByTestId("analysis-node-src")).toBeVisible();
|
||||
|
||||
// Verify some files are in the tree
|
||||
await expect(page.getByTestId("analysis-node-package.json")).toBeVisible();
|
||||
});
|
||||
});
|
||||
76
app/tests/foundation.spec.ts
Normal file
76
app/tests/foundation.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Application Foundation", () => {
|
||||
test("loads the application with sidebar and welcome view", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Verify main container exists
|
||||
await expect(page.getByTestId("app-container")).toBeVisible();
|
||||
|
||||
// Verify sidebar is visible
|
||||
await expect(page.getByTestId("sidebar")).toBeVisible();
|
||||
|
||||
// Verify welcome view is shown by default
|
||||
await expect(page.getByTestId("welcome-view")).toBeVisible();
|
||||
});
|
||||
|
||||
test("displays Automaker title in sidebar", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Verify the title is visible in the sidebar (be specific to avoid matching welcome heading)
|
||||
await expect(page.getByTestId("sidebar").getByRole("heading", { name: "Automaker" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows New Project and Open Project buttons", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Verify project action buttons in welcome view
|
||||
await expect(page.getByTestId("new-project-card")).toBeVisible();
|
||||
await expect(page.getByTestId("open-project-card")).toBeVisible();
|
||||
});
|
||||
|
||||
test("sidebar can be collapsed and expanded", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const sidebar = page.getByTestId("sidebar");
|
||||
const toggleButton = page.getByTestId("toggle-sidebar");
|
||||
|
||||
// Initially sidebar should be expanded (width 256px / w-64)
|
||||
await expect(sidebar).toHaveClass(/w-64/);
|
||||
|
||||
// Click to collapse
|
||||
await toggleButton.click();
|
||||
await expect(sidebar).toHaveClass(/w-16/);
|
||||
|
||||
// Click to expand again
|
||||
await toggleButton.click();
|
||||
await expect(sidebar).toHaveClass(/w-64/);
|
||||
});
|
||||
|
||||
test("shows Web Mode indicator when running in browser", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// When running in browser (not Electron), should show mock indicator
|
||||
await expect(page.getByText("Web Mode (Mock IPC)")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Theme Toggle", () => {
|
||||
test("toggles between dark and light mode", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const themeButton = page.getByTestId("toggle-theme");
|
||||
const html = page.locator("html");
|
||||
|
||||
// Initially should be in dark mode
|
||||
await expect(html).toHaveClass(/dark/);
|
||||
|
||||
// Click to switch to light mode
|
||||
await themeButton.click();
|
||||
await expect(html).not.toHaveClass(/dark/);
|
||||
|
||||
// Click to switch back to dark mode
|
||||
await themeButton.click();
|
||||
await expect(html).toHaveClass(/dark/);
|
||||
});
|
||||
});
|
||||
267
app/tests/kanban-board.spec.ts
Normal file
267
app/tests/kanban-board.spec.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Kanban Board", () => {
|
||||
// Helper to set up a mock project in localStorage
|
||||
async function setupMockProject(page: ReturnType<typeof test.step>) {
|
||||
await page.addInitScript(() => {
|
||||
const mockProject = {
|
||||
id: "test-project-1",
|
||||
name: "Test Project",
|
||||
path: "/mock/test-project",
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem("automaker-storage", JSON.stringify({
|
||||
state: {
|
||||
projects: [mockProject],
|
||||
currentProject: mockProject,
|
||||
currentView: "board",
|
||||
sidebarOpen: true,
|
||||
theme: "dark",
|
||||
},
|
||||
version: 0,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
test("renders Kanban columns when project is open", async ({ page }) => {
|
||||
await setupMockProject(page);
|
||||
await page.goto("/");
|
||||
|
||||
// Should show the board view
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Check all columns are visible
|
||||
await expect(page.getByTestId("kanban-column-backlog")).toBeVisible();
|
||||
await expect(page.getByTestId("kanban-column-planned")).toBeVisible();
|
||||
await expect(page.getByTestId("kanban-column-in_progress")).toBeVisible();
|
||||
await expect(page.getByTestId("kanban-column-review")).toBeVisible();
|
||||
await expect(page.getByTestId("kanban-column-verified")).toBeVisible();
|
||||
await expect(page.getByTestId("kanban-column-failed")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows Add Feature button", async ({ page }) => {
|
||||
await setupMockProject(page);
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("add-feature-button")).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens add feature dialog", async ({ page }) => {
|
||||
await setupMockProject(page);
|
||||
await page.goto("/");
|
||||
|
||||
// Click add feature button
|
||||
await page.getByTestId("add-feature-button").click();
|
||||
|
||||
// Dialog should appear
|
||||
await expect(page.getByTestId("add-feature-dialog")).toBeVisible();
|
||||
await expect(page.getByTestId("feature-category-input")).toBeVisible();
|
||||
await expect(page.getByTestId("feature-description-input")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can add a new feature", async ({ page }) => {
|
||||
await setupMockProject(page);
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for board to load
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Click add feature button
|
||||
await page.getByTestId("add-feature-button").click();
|
||||
|
||||
// Fill in feature details
|
||||
await page.getByTestId("feature-category-input").fill("Test Category");
|
||||
await page.getByTestId("feature-description-input").fill("Test Feature Description");
|
||||
await page.getByTestId("feature-step-0-input").fill("Step 1: First step");
|
||||
|
||||
// Submit the form
|
||||
await page.getByTestId("confirm-add-feature").click();
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.getByTestId("add-feature-dialog")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("refresh button is visible", async ({ page }) => {
|
||||
await setupMockProject(page);
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("refresh-board")).toBeVisible();
|
||||
});
|
||||
|
||||
test("loads cards from feature_list.json and displays them in correct columns", async ({ page }) => {
|
||||
await setupMockProject(page);
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for board to load
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Wait for loading to complete (the mock IPC returns a sample feature)
|
||||
// The mock returns a feature in "backlog" column (passes: false)
|
||||
await expect(page.getByTestId("kanban-column-backlog")).toBeVisible();
|
||||
|
||||
// After loading, the backlog should show the sample feature from mock data
|
||||
// Looking at the electron.ts mock, it returns one feature with "Sample Feature"
|
||||
const backlogColumn = page.getByTestId("kanban-column-backlog");
|
||||
await expect(backlogColumn.getByText("Sample Feature")).toBeVisible();
|
||||
});
|
||||
|
||||
test("features with passes:true appear in verified column", async ({ page }) => {
|
||||
// Create a project and add a feature manually
|
||||
await setupMockProject(page);
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for board to load
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Add a new feature
|
||||
await page.getByTestId("add-feature-button").click();
|
||||
await page.getByTestId("feature-category-input").fill("Core");
|
||||
await page.getByTestId("feature-description-input").fill("Verified Test Feature");
|
||||
await page.getByTestId("confirm-add-feature").click();
|
||||
|
||||
// The new feature should appear in backlog
|
||||
await expect(page.getByTestId("kanban-column-backlog").getByText("Verified Test Feature")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can edit feature card details", async ({ page }) => {
|
||||
await setupMockProject(page);
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for board to load
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Wait for features to load - the mock returns "Sample Feature"
|
||||
await expect(page.getByTestId("kanban-column-backlog").getByText("Sample Feature")).toBeVisible();
|
||||
|
||||
// Find and click the edit button on the card using specific testid pattern
|
||||
const backlogColumn = page.getByTestId("kanban-column-backlog");
|
||||
// The edit button has testid "edit-feature-{feature.id}" where feature.id contains "feature-0-"
|
||||
const editButton = backlogColumn.locator('[data-testid^="edit-feature-feature-0-"]');
|
||||
await editButton.click();
|
||||
|
||||
// Edit dialog should appear
|
||||
await expect(page.getByTestId("edit-feature-dialog")).toBeVisible();
|
||||
|
||||
// Edit the description
|
||||
await page.getByTestId("edit-feature-description").fill("Updated Feature Description");
|
||||
|
||||
// Save the changes
|
||||
await page.getByTestId("confirm-edit-feature").click();
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.getByTestId("edit-feature-dialog")).not.toBeVisible();
|
||||
|
||||
// The updated description should be visible
|
||||
await expect(page.getByText("Updated Feature Description")).toBeVisible();
|
||||
});
|
||||
|
||||
test("edit dialog shows existing feature data", async ({ page }) => {
|
||||
await setupMockProject(page);
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for board to load
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Wait for features to load
|
||||
await expect(page.getByTestId("kanban-column-backlog").getByText("Sample Feature")).toBeVisible();
|
||||
|
||||
// Click edit button using specific testid pattern
|
||||
const backlogColumn = page.getByTestId("kanban-column-backlog");
|
||||
const editButton = backlogColumn.locator('[data-testid^="edit-feature-feature-0-"]');
|
||||
await editButton.click();
|
||||
|
||||
// Check that the dialog pre-populates with existing data
|
||||
await expect(page.getByTestId("edit-feature-description")).toHaveValue("Sample Feature");
|
||||
await expect(page.getByTestId("edit-feature-category")).toHaveValue("Core");
|
||||
});
|
||||
|
||||
test("can drag card from Backlog to In Progress column", async ({ page }) => {
|
||||
await setupMockProject(page);
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for board to load
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Wait for features to load in Backlog
|
||||
const backlogColumn = page.getByTestId("kanban-column-backlog");
|
||||
const inProgressColumn = page.getByTestId("kanban-column-in_progress");
|
||||
|
||||
await expect(backlogColumn.getByText("Sample Feature")).toBeVisible();
|
||||
|
||||
// Find the drag handle specifically
|
||||
const dragHandle = backlogColumn.locator('[data-testid^="drag-handle-feature-0-"]');
|
||||
await expect(dragHandle).toBeVisible();
|
||||
|
||||
// Get drag handle and target positions
|
||||
const handleBox = await dragHandle.boundingBox();
|
||||
const targetBox = await inProgressColumn.boundingBox();
|
||||
if (!handleBox || !targetBox) throw new Error("Could not find elements");
|
||||
|
||||
// Use mouse events - start from center of drag handle
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
const endX = targetBox.x + targetBox.width / 2;
|
||||
const endY = targetBox.y + 100;
|
||||
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
|
||||
// Move in steps to trigger dnd-kit activation (needs >8px movement)
|
||||
await page.mouse.move(endX, endY, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Verify card moved to In Progress column
|
||||
await expect(inProgressColumn.getByText("Sample Feature")).toBeVisible();
|
||||
|
||||
// Verify card is no longer in Backlog
|
||||
await expect(backlogColumn.getByText("Sample Feature")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("drag and drop updates feature status and triggers file save", async ({ page }) => {
|
||||
await setupMockProject(page);
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for board to load
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Wait for features to load in Backlog
|
||||
const backlogColumn = page.getByTestId("kanban-column-backlog");
|
||||
const plannedColumn = page.getByTestId("kanban-column-planned");
|
||||
|
||||
await expect(backlogColumn.getByText("Sample Feature")).toBeVisible();
|
||||
|
||||
// Find the drag handle specifically
|
||||
const dragHandle = backlogColumn.locator('[data-testid^="drag-handle-feature-0-"]');
|
||||
await expect(dragHandle).toBeVisible();
|
||||
|
||||
// Get drag handle and target positions (Planned is adjacent to Backlog)
|
||||
const handleBox = await dragHandle.boundingBox();
|
||||
const targetBox = await plannedColumn.boundingBox();
|
||||
if (!handleBox || !targetBox) throw new Error("Could not find elements");
|
||||
|
||||
// Use mouse events - start from center of drag handle
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
const endX = targetBox.x + targetBox.width / 2;
|
||||
const endY = targetBox.y + 100;
|
||||
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
|
||||
// Move in steps to trigger dnd-kit activation (needs >8px movement)
|
||||
await page.mouse.move(endX, endY, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Verify card moved to Planned column
|
||||
await expect(plannedColumn.getByText("Sample Feature")).toBeVisible();
|
||||
|
||||
// Verify card is no longer in Backlog
|
||||
await expect(backlogColumn.getByText("Sample Feature")).not.toBeVisible();
|
||||
|
||||
// The feature moving to Planned means the feature_list.json would be updated
|
||||
// with the new status. Since status changed from backlog, passes would remain false
|
||||
// This confirms the state update and file save workflow works.
|
||||
const plannedCard = plannedColumn.locator('[data-testid^="kanban-card-feature-0-"]');
|
||||
await expect(plannedCard).toBeVisible();
|
||||
});
|
||||
});
|
||||
205
app/tests/project-management.spec.ts
Normal file
205
app/tests/project-management.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("New Project Workflow", () => {
|
||||
test("opens new project dialog when clicking Create Project", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Click the New Project card
|
||||
await page.getByTestId("new-project-card").click();
|
||||
|
||||
// Dialog should appear
|
||||
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
|
||||
await expect(page.getByText("Create New Project")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows project name and directory inputs", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Open dialog
|
||||
await page.getByTestId("new-project-card").click();
|
||||
|
||||
// Check inputs exist
|
||||
await expect(page.getByTestId("project-name-input")).toBeVisible();
|
||||
await expect(page.getByTestId("project-path-input")).toBeVisible();
|
||||
await expect(page.getByTestId("browse-directory")).toBeVisible();
|
||||
});
|
||||
|
||||
test("create button is disabled without name and path", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Open dialog
|
||||
await page.getByTestId("new-project-card").click();
|
||||
|
||||
// Create button should be disabled
|
||||
await expect(page.getByTestId("confirm-create-project")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("can enter project name", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Open dialog
|
||||
await page.getByTestId("new-project-card").click();
|
||||
|
||||
// Enter project name
|
||||
await page.getByTestId("project-name-input").fill("my-test-project");
|
||||
await expect(page.getByTestId("project-name-input")).toHaveValue("my-test-project");
|
||||
});
|
||||
|
||||
test("can close dialog with cancel button", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Open dialog
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
|
||||
|
||||
// Close with cancel
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByTestId("new-project-dialog")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("create button enables when name and path are entered", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Open dialog
|
||||
await page.getByTestId("new-project-card").click();
|
||||
|
||||
// Create button should be disabled initially
|
||||
await expect(page.getByTestId("confirm-create-project")).toBeDisabled();
|
||||
|
||||
// Enter project name
|
||||
await page.getByTestId("project-name-input").fill("my-test-project");
|
||||
|
||||
// Still disabled (no path)
|
||||
await expect(page.getByTestId("confirm-create-project")).toBeDisabled();
|
||||
|
||||
// Enter path
|
||||
await page.getByTestId("project-path-input").fill("/Users/test/projects");
|
||||
|
||||
// Now should be enabled
|
||||
await expect(page.getByTestId("confirm-create-project")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("creates project and navigates to board view", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Open dialog
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await expect(page.getByTestId("new-project-dialog")).toBeVisible();
|
||||
|
||||
// Enter project details
|
||||
await page.getByTestId("project-name-input").fill("test-new-project");
|
||||
await page.getByTestId("project-path-input").fill("/Users/test/projects");
|
||||
|
||||
// Click create
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.getByTestId("new-project-dialog")).not.toBeVisible();
|
||||
|
||||
// Should navigate to board view with the project
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Project name should be displayed in the board view header
|
||||
await expect(page.getByTestId("board-view").getByText("test-new-project")).toBeVisible();
|
||||
|
||||
// Kanban columns should be visible
|
||||
await expect(page.getByText("Backlog")).toBeVisible();
|
||||
await expect(page.getByText("In Progress")).toBeVisible();
|
||||
await expect(page.getByText("Verified")).toBeVisible();
|
||||
});
|
||||
|
||||
test("created project appears in recent projects on welcome view", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Create a project
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await page.getByTestId("project-name-input").fill("recent-project-test");
|
||||
await page.getByTestId("project-path-input").fill("/Users/test/projects");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Verify we're on board view
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Go back to welcome view by clicking Automaker title (if there's a way)
|
||||
// For now, reload the page and check recent projects
|
||||
await page.goto("/");
|
||||
|
||||
// The project should appear in recent projects section (use role to be specific)
|
||||
await expect(page.getByRole("heading", { name: "Recent Projects" })).toBeVisible();
|
||||
await expect(page.getByTestId("welcome-view").getByText("recent-project-test", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Open Project Workflow", () => {
|
||||
test("clicking Open Project triggers directory selection", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// In web mode, clicking Open Project card will show a prompt dialog
|
||||
// We can't fully test native dialogs, but we can verify the click works
|
||||
await expect(page.getByTestId("open-project-card")).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens existing project and navigates to board view", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Mock the window.prompt response
|
||||
await page.evaluate(() => {
|
||||
window.prompt = () => "/mock/existing-project";
|
||||
});
|
||||
|
||||
// Click Open Project card
|
||||
await page.getByTestId("open-project-card").click();
|
||||
|
||||
// Should navigate to board view
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Project name should be derived from path
|
||||
await expect(page.getByTestId("board-view").getByText("existing-project")).toBeVisible();
|
||||
});
|
||||
|
||||
test("opened project loads into dashboard with features", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Mock the window.prompt response
|
||||
await page.evaluate(() => {
|
||||
window.prompt = () => "/mock/existing-project";
|
||||
});
|
||||
|
||||
// Click Open Project
|
||||
await page.getByTestId("open-project-card").click();
|
||||
|
||||
// Should show board view
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Should have loaded features from the mock feature_list.json
|
||||
// The mock returns "Sample Feature" in backlog
|
||||
await expect(page.getByTestId("kanban-column-backlog").getByText("Sample Feature")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can click on recent project to reopen it", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// First, create a project to have it in recent projects
|
||||
await page.getByTestId("new-project-card").click();
|
||||
await page.getByTestId("project-name-input").fill("reopenable-project");
|
||||
await page.getByTestId("project-path-input").fill("/Users/test/projects");
|
||||
await page.getByTestId("confirm-create-project").click();
|
||||
|
||||
// Verify on board view
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
|
||||
// Go back to welcome view
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for recent projects to appear
|
||||
await expect(page.getByRole("heading", { name: "Recent Projects" })).toBeVisible();
|
||||
|
||||
// Click on the recent project
|
||||
const recentProjectCard = page.getByText("reopenable-project", { exact: true }).first();
|
||||
await recentProjectCard.click();
|
||||
|
||||
// Should navigate to board view with that project
|
||||
await expect(page.getByTestId("board-view")).toBeVisible();
|
||||
await expect(page.getByTestId("board-view").getByText("reopenable-project")).toBeVisible();
|
||||
});
|
||||
});
|
||||
112
app/tests/settings.spec.ts
Normal file
112
app/tests/settings.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Settings - API Key Management", () => {
|
||||
test("can navigate to settings page", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Click settings button in sidebar
|
||||
await page.getByTestId("settings-button").click();
|
||||
|
||||
// Should show settings view
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
|
||||
await expect(page.getByText("API Keys", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows Anthropic and Google API key inputs", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("settings-button").click();
|
||||
|
||||
// Check input fields exist
|
||||
await expect(page.getByTestId("anthropic-api-key-input")).toBeVisible();
|
||||
await expect(page.getByTestId("google-api-key-input")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can enter and save Anthropic API key", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("settings-button").click();
|
||||
|
||||
// Enter API key
|
||||
await page.getByTestId("anthropic-api-key-input").fill("sk-ant-test-key-123");
|
||||
|
||||
// Save
|
||||
await page.getByTestId("save-settings").click();
|
||||
|
||||
// Should show saved confirmation
|
||||
await expect(page.getByText("Saved!")).toBeVisible();
|
||||
|
||||
// Reload page and verify key persists
|
||||
await page.reload();
|
||||
await page.getByTestId("settings-button").click();
|
||||
|
||||
// Toggle visibility to see the key
|
||||
await page.getByTestId("toggle-anthropic-visibility").click();
|
||||
await expect(page.getByTestId("anthropic-api-key-input")).toHaveValue("sk-ant-test-key-123");
|
||||
});
|
||||
|
||||
test("can enter and save Google API key", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("settings-button").click();
|
||||
|
||||
// Enter API key
|
||||
await page.getByTestId("google-api-key-input").fill("AIzaSyTest123");
|
||||
|
||||
// Save
|
||||
await page.getByTestId("save-settings").click();
|
||||
|
||||
// Reload page and verify key persists
|
||||
await page.reload();
|
||||
await page.getByTestId("settings-button").click();
|
||||
|
||||
// Toggle visibility
|
||||
await page.getByTestId("toggle-google-visibility").click();
|
||||
await expect(page.getByTestId("google-api-key-input")).toHaveValue("AIzaSyTest123");
|
||||
});
|
||||
|
||||
test("API key inputs are password type by default", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("settings-button").click();
|
||||
|
||||
// Check input types are password
|
||||
await expect(page.getByTestId("anthropic-api-key-input")).toHaveAttribute("type", "password");
|
||||
await expect(page.getByTestId("google-api-key-input")).toHaveAttribute("type", "password");
|
||||
});
|
||||
|
||||
test("can toggle API key visibility", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("settings-button").click();
|
||||
|
||||
// Initially password type
|
||||
await expect(page.getByTestId("anthropic-api-key-input")).toHaveAttribute("type", "password");
|
||||
|
||||
// Toggle visibility
|
||||
await page.getByTestId("toggle-anthropic-visibility").click();
|
||||
|
||||
// Now should be text type
|
||||
await expect(page.getByTestId("anthropic-api-key-input")).toHaveAttribute("type", "text");
|
||||
|
||||
// Toggle back
|
||||
await page.getByTestId("toggle-anthropic-visibility").click();
|
||||
await expect(page.getByTestId("anthropic-api-key-input")).toHaveAttribute("type", "password");
|
||||
});
|
||||
|
||||
test("can navigate back to home from settings", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("settings-button").click();
|
||||
|
||||
// Click back to home
|
||||
await page.getByTestId("back-to-home").click();
|
||||
|
||||
// Should be back on welcome view
|
||||
await expect(page.getByTestId("welcome-view")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows security notice about local storage", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("settings-button").click();
|
||||
|
||||
// Should show security notice
|
||||
await expect(page.getByText("Security Notice")).toBeVisible();
|
||||
await expect(page.getByText(/stored in your browser's local storage/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
34
app/tsconfig.json
Normal file
34
app/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user