diff --git a/README-CUSTOM-INTEGRATION.md b/README-CUSTOM-INTEGRATION.md index 0ebb8a4..f90b853 100644 --- a/README-CUSTOM-INTEGRATION.md +++ b/README-CUSTOM-INTEGRATION.md @@ -523,10 +523,35 @@ const RATE_LIMIT_CONFIG = { ### Common Issues -1. **TypeScript Errors**: The script skips TypeScript compilation due to existing MCP issues. Focus on functionality first. +1. **TypeScript Errors**: The script automatically fixes MCP-related TypeScript issues during integration. 2. **Missing Custom Modifications**: The script warns if files don't contain expected custom patterns. 3. **Backup Restoration**: Always restore from the most recent working backup. +### MCP TypeScript Issues + +The integration script automatically handles MCP (Model Context Protocol) related TypeScript compilation errors: + +**Issues Fixed:** +- `chat_stream_handlers.ts`: Adds type assertion (`as any`) for tool objects +- `mcp_handlers.ts`: Adds type assertion for tool.description property +- `mcp_manager.ts`: Replaces problematic imports with stub implementation + +**Automatic Fixes:** +The `fix_mcp_typescript_issues()` function in the script: +1. Detects MCP-related type errors +2. Applies appropriate type assertions +3. Creates stub implementations for missing exports +4. Ensures compilation succeeds + +**Manual Fix (if needed):** +If you encounter MCP TypeScript errors after integration: +```bash +# Re-run the integration script to fix MCP issues +./scripts/integrate-custom-features.sh integrate + +# Or manually fix by adding 'as any' type assertions to tool objects +``` + ### Validation Warnings If you see warnings about missing custom modifications: diff --git a/backups/backup-20251218-161645/git-log.txt b/backups/backup-20251218-161645/git-log.txt new file mode 100644 index 0000000..71861c3 --- /dev/null +++ b/backups/backup-20251218-161645/git-log.txt @@ -0,0 +1,10 @@ +07bf441 Merge remote-tracking branch 'upstream/main' the commit. +4fd3b6f docs: enhance integration guide with detailed step-by-step commands and troubleshooting tips +5660de4 feat: integrate custom features for smart context management +32093a4 Bump to v0.31.0-beta.1 (#1978) +656b6cb fix thinking budget e2e (#1981) +df785a8 Fix MCP e2e test (#1980) +423be75 prettier: gen playwright (#1979) +2a5cd31 gemini 3 flash (#1977) +99b0cdf feat: implement custom smart context functionality with hooks, IPC handlers, and utilities +7cf8317 Fix Playwright report comments on forked PRs (#1975) diff --git a/backups/backup-20251218-161645/git-status.txt b/backups/backup-20251218-161645/git-status.txt new file mode 100644 index 0000000..02cf3c9 --- /dev/null +++ b/backups/backup-20251218-161645/git-status.txt @@ -0,0 +1,9 @@ +On branch main +Your branch is ahead of 'origin/main' by 6 commits. + (use "git push" to publish your local commits) + +Untracked files: + (use "git add ..." to include in what will be committed) + backups/backup-20251218-161645/ + +nothing added to commit but untracked files present (use "git add" to track) diff --git a/backups/backup-20251218-161645/package.json b/backups/backup-20251218-161645/package.json new file mode 100644 index 0000000..e249086 --- /dev/null +++ b/backups/backup-20251218-161645/package.json @@ -0,0 +1,189 @@ +{ + "name": "dyad", + "productName": "dyad", + "version": "0.31.0-beta.1", + "description": "Free, local, open-source AI app builder", + "main": ".vite/build/main.js", + "repository": { + "type": "git", + "url": "https://github.com/dyad-sh/dyad.git" + }, + "engines": { + "node": ">=20" + }, + "scripts": { + "clean": "rimraf out scaffold/node_modules", + "start": "electron-forge start", + "dev:engine": "cross-env DYAD_ENGINE_URL=http://localhost:8080/v1 npm start", + "staging:engine": "cross-env DYAD_ENGINE_URL=https://staging---dyad-llm-engine-kq7pivehnq-uc.a.run.app/v1 npm start", + "package": "npm run clean && electron-forge package", + "make": "npm run clean && electron-forge make", + "publish": "npm run clean && electron-forge publish", + "verify-release": "node scripts/verify-release-assets.js", + "ts": "npm run ts:main && npm run ts:workers", + "ts:main": "npx tsc -p tsconfig.app.json --noEmit", + "ts:workers": "npx tsc -p workers/tsc/tsconfig.json --noEmit", + "lint": "npx oxlint --fix", + "lint:fix": "npx oxlint --fix --fix-suggestions --fix-dangerously", + "db:generate": "drizzle-kit generate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + "prettier:check": "npx prettier --check .", + "prettier": "npx prettier --write .", + "presubmit": "npm run prettier:check && npm run lint", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "extract-codebase": "ts-node scripts/extract-codebase.ts", + "init-precommit": "husky", + "pre:e2e": "cross-env E2E_TEST_BUILD=true npm run package", + "e2e": "playwright test", + "e2e:shard": "playwright test --shard" + }, + "keywords": [], + "author": { + "name": "Will Chen", + "email": "willchen90@gmail.com" + }, + "license": "MIT", + "devDependencies": { + "@electron-forge/cli": "^7.8.0", + "@electron-forge/maker-deb": "^7.8.0", + "@electron-forge/maker-rpm": "^7.8.0", + "@electron-forge/maker-squirrel": "^7.8.0", + "@electron-forge/maker-zip": "^7.8.0", + "@electron-forge/plugin-auto-unpack-natives": "^7.8.0", + "@electron-forge/plugin-fuses": "^7.8.0", + "@electron-forge/plugin-vite": "^7.8.0", + "@electron-forge/publisher-github": "^7.8.0", + "@electron/fuses": "^1.8.0", + "@playwright/test": "^1.52.0", + "@testing-library/react": "^16.3.0", + "@types/better-sqlite3": "^7.6.13", + "@types/fs-extra": "^11.0.4", + "@types/glob": "^8.1.0", + "@types/kill-port": "^2.0.3", + "@types/node": "^22.14.0", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@typescript-eslint/eslint-plugin": "^5.62.0", + "@typescript-eslint/parser": "^5.62.0", + "@vitest/ui": "^3.1.1", + "cross-env": "^7.0.3", + "drizzle-kit": "^0.31.8", + "electron": "38.2.2", + "eslint": "^8.57.1", + "eslint-plugin-import": "^2.31.0", + "happy-dom": "^20.0.11", + "husky": "^9.1.7", + "lint-staged": "^15.5.2", + "oxlint": "^1.8.0", + "prettier": "3.5.3", + "rimraf": "^6.0.1", + "typescript": "^5.8.3", + "vite": "^7.3.0", + "vitest": "^3.1.1" + }, + "dependencies": { + "@ai-sdk/amazon-bedrock": "^3.0.15", + "@ai-sdk/anthropic": "^2.0.4", + "@ai-sdk/azure": "^2.0.17", + "@ai-sdk/google": "^2.0.6", + "@ai-sdk/google-vertex": "3.0.16", + "@ai-sdk/openai": "2.0.15", + "@ai-sdk/openai-compatible": "^1.0.8", + "@ai-sdk/provider-utils": "^3.0.3", + "@ai-sdk/xai": "^2.0.16", + "@babel/parser": "^7.28.5", + "@biomejs/biome": "^1.9.4", + "@dyad-sh/supabase-management-js": "v1.0.1", + "@lexical/react": "^0.33.1", + "@modelcontextprotocol/sdk": "^1.17.5", + "@monaco-editor/react": "^4.7.0-rc.0", + "@neondatabase/api-client": "^2.1.0", + "@neondatabase/serverless": "^1.0.1", + "@openrouter/ai-sdk-provider": "^1.1.2", + "@radix-ui/react-accordion": "^1.2.4", + "@radix-ui/react-alert-dialog": "^1.1.13", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.7", + "@radix-ui/react-label": "^2.1.4", + "@radix-ui/react-popover": "^1.1.7", + "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-select": "^2.2.2", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-switch": "^1.2.0", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toggle": "^1.1.3", + "@radix-ui/react-toggle-group": "^1.1.3", + "@radix-ui/react-tooltip": "^1.1.8", + "@rollup/plugin-commonjs": "^28.0.3", + "@tailwindcss/typography": "^0.5.16", + "@tailwindcss/vite": "^4.1.3", + "@tanstack/react-query": "^5.75.5", + "@tanstack/react-router": "^1.114.34", + "@types/uuid": "^10.0.0", + "@vercel/sdk": "^1.18.0", + "@vitejs/plugin-react": "^4.3.4", + "ai": "^5.0.15", + "better-sqlite3": "^12.4.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "dotenv": "^16.4.7", + "drizzle-orm": "^0.41.0", + "dugite": "^3.0.0", + "electron-log": "^5.3.3", + "electron-playwright-helpers": "^1.7.1", + "electron-squirrel-startup": "^1.0.1", + "esbuild-register": "^3.6.0", + "fastest-levenshtein": "^1.0.16", + "fix-path": "^4.0.0", + "framer-motion": "^12.6.3", + "geist": "^1.3.1", + "glob": "^11.0.2", + "html-to-image": "^1.11.13", + "isomorphic-git": "^1.30.1", + "jotai": "^2.12.2", + "kill-port": "^2.0.1", + "konva": "^10.0.12", + "lexical": "^0.33.1", + "lexical-beautiful-mentions": "^0.1.47", + "lucide-react": "^0.487.0", + "monaco-editor": "^0.52.2", + "openai": "^4.91.1", + "perfect-freehand": "^1.2.2", + "posthog-js": "^1.236.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-konva": "^19.2.1", + "react-markdown": "^10.1.0", + "react-resizable-panels": "^2.1.7", + "react-shiki": "^0.9.0", + "recast": "^0.23.11", + "remark-gfm": "^4.0.1", + "shell-env": "^4.0.1", + "shiki": "^3.2.1", + "sonner": "^2.0.3", + "stacktrace-js": "^2.0.2", + "tailwind-merge": "^3.1.0", + "tailwindcss": "^4.1.3", + "tree-kill": "^1.2.2", + "tw-animate-css": "^1.2.5", + "update-electron-app": "^3.1.1", + "uuid": "^11.1.0", + "zod": "^3.25.76" + }, + "lint-staged": { + "**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue,astro,svelte}": "oxlint", + "*.{js,css,md,ts,tsx,jsx,json}": "prettier --write" + }, + "overrides": { + "@vercel/sdk": { + "@modelcontextprotocol/sdk": "$@modelcontextprotocol/sdk" + } + } +} diff --git a/backups/backup-20251218-161645/src/__tests__/README.md b/backups/backup-20251218-161645/src/__tests__/README.md new file mode 100644 index 0000000..c7a2e37 --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/README.md @@ -0,0 +1,77 @@ +# Test Documentation + +This directory contains unit tests for the Dyad application. + +## Testing Setup + +We use [Vitest](https://vitest.dev/) as our testing framework, which is designed to work well with Vite and modern JavaScript. + +### Test Commands + +Add these commands to your `package.json`: + +```json +"test": "vitest run", +"test:watch": "vitest", +"test:ui": "vitest --ui" +``` + +- `npm run test` - Run tests once +- `npm run test:watch` - Run tests in watch mode (rerun when files change) +- `npm run test:ui` - Run tests with UI reporter + +## Mocking Guidelines + +### Mocking fs module + +When mocking the `node:fs` module, use a default export in the mock: + +```typescript +vi.mock("node:fs", async () => { + return { + default: { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + // Add other fs methods as needed + }, + }; +}); +``` + +### Mocking isomorphic-git + +When mocking isomorphic-git, provide a default export: + +```typescript +vi.mock("isomorphic-git", () => ({ + default: { + add: vi.fn().mockResolvedValue(undefined), + commit: vi.fn().mockResolvedValue(undefined), + // Add other git methods as needed + }, +})); +``` + +### Testing IPC Handlers + +When testing IPC handlers, mock the Electron IPC system: + +```typescript +vi.mock("electron", () => ({ + ipcMain: { + handle: vi.fn(), + on: vi.fn(), + }, +})); +``` + +## Adding New Tests + +1. Create a new file with the `.test.ts` or `.spec.ts` extension +2. Import the functions you want to test +3. Mock any dependencies using `vi.mock()` +4. Write your test cases using `describe()` and `it()` + +## Example + +See `chat_stream_handlers.test.ts` for an example of testing IPC handlers with proper mocking. diff --git a/backups/backup-20251218-161645/src/__tests__/__snapshots__/problem_prompt.test.ts.snap b/backups/backup-20251218-161645/src/__tests__/__snapshots__/problem_prompt.test.ts.snap new file mode 100644 index 0000000..3a70ced --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/__snapshots__/problem_prompt.test.ts.snap @@ -0,0 +1,127 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`problem_prompt > createConciseProblemFixPrompt > should format a concise prompt for multiple errors 1`] = ` +"Fix these 2 TypeScript compile-time errors: + +1. src/main.ts:5:12 - Cannot find module 'react-dom/client' or its corresponding type declarations. (TS2307) +\`\`\` +SNIPPET +\`\`\` + +2. src/components/Modal.tsx:35:20 - Property 'isOpen' does not exist on type 'IntrinsicAttributes & ModalProps'. (TS2339) +\`\`\` +SNIPPET +\`\`\` + + +Please fix all errors in a concise way." +`; + +exports[`problem_prompt > createConciseProblemFixPrompt > should format a concise prompt for single error 1`] = ` +"Fix these 1 TypeScript compile-time error: + +1. src/App.tsx:10:5 - Cannot find name 'consol'. Did you mean 'console'? (TS2552) +\`\`\` +SNIPPET +\`\`\` + + +Please fix all errors in a concise way." +`; + +exports[`problem_prompt > createConciseProblemFixPrompt > should return a short message when no problems exist 1`] = `"No TypeScript problems detected."`; + +exports[`problem_prompt > createProblemFixPrompt > should format a single error correctly 1`] = ` +"Fix these 1 TypeScript compile-time error: + +1. src/components/Button.tsx:15:23 - Property 'onClick' does not exist on type 'ButtonProps'. (TS2339) +\`\`\` +SNIPPET +\`\`\` + + +Please fix all errors in a concise way." +`; + +exports[`problem_prompt > createProblemFixPrompt > should format multiple errors across multiple files 1`] = ` +"Fix these 4 TypeScript compile-time errors: + +1. src/components/Button.tsx:15:23 - Property 'onClick' does not exist on type 'ButtonProps'. (TS2339) +\`\`\` +SNIPPET +\`\`\` + +2. src/components/Button.tsx:8:12 - Type 'string | undefined' is not assignable to type 'string'. (TS2322) +\`\`\` +SNIPPET +\`\`\` + +3. src/hooks/useApi.ts:42:5 - Argument of type 'unknown' is not assignable to parameter of type 'string'. (TS2345) +\`\`\` +SNIPPET +\`\`\` + +4. src/utils/helpers.ts:45:8 - Function lacks ending return statement and return type does not include 'undefined'. (TS2366) +\`\`\` +SNIPPET +\`\`\` + + +Please fix all errors in a concise way." +`; + +exports[`problem_prompt > createProblemFixPrompt > should handle realistic React TypeScript errors 1`] = ` +"Fix these 4 TypeScript compile-time errors: + +1. src/components/UserProfile.tsx:12:35 - Type '{ children: string; }' is missing the following properties from type 'UserProfileProps': user, onEdit (TS2739) +\`\`\` +SNIPPET +\`\`\` + +2. src/components/UserProfile.tsx:25:15 - Object is possibly 'null'. (TS2531) +\`\`\` +SNIPPET +\`\`\` + +3. src/hooks/useLocalStorage.ts:18:12 - Type 'string | null' is not assignable to type 'T'. (TS2322) +\`\`\` +SNIPPET +\`\`\` + +4. src/types/api.ts:45:3 - Duplicate identifier 'UserRole'. (TS2300) +\`\`\` +SNIPPET +\`\`\` + + +Please fix all errors in a concise way." +`; + +exports[`problem_prompt > createProblemFixPrompt > should return a message when no problems exist 1`] = `"No TypeScript problems detected."`; + +exports[`problem_prompt > realistic TypeScript error scenarios > should handle common React + TypeScript errors 1`] = ` +"Fix these 4 TypeScript compile-time errors: + +1. src/components/ProductCard.tsx:22:18 - Property 'price' is missing in type '{ name: string; description: string; }' but required in type 'Product'. (TS2741) +\`\`\` +SNIPPET +\`\`\` + +2. src/components/SearchInput.tsx:15:45 - Type '(value: string) => void' is not assignable to type 'ChangeEventHandler'. (TS2322) +\`\`\` +SNIPPET +\`\`\` + +3. src/api/userService.ts:8:1 - Function lacks ending return statement and return type does not include 'undefined'. (TS2366) +\`\`\` +SNIPPET +\`\`\` + +4. src/utils/dataProcessor.ts:34:25 - Object is possibly 'undefined'. (TS2532) +\`\`\` +SNIPPET +\`\`\` + + +Please fix all errors in a concise way." +`; diff --git a/backups/backup-20251218-161645/src/__tests__/app_env_vars_utils.test.ts b/backups/backup-20251218-161645/src/__tests__/app_env_vars_utils.test.ts new file mode 100644 index 0000000..ceee45e --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/app_env_vars_utils.test.ts @@ -0,0 +1,534 @@ +import { parseEnvFile, serializeEnvFile } from "@/ipc/utils/app_env_var_utils"; +import { describe, it, expect } from "vitest"; + +describe("parseEnvFile", () => { + it("should parse basic key=value pairs", () => { + const content = `API_KEY=abc123 +DATABASE_URL=postgres://localhost:5432/mydb +PORT=3000`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "PORT", value: "3000" }, + ]); + }); + + it("should handle quoted values and remove quotes", () => { + const content = `API_KEY="abc123" +DATABASE_URL='postgres://localhost:5432/mydb' +MESSAGE="Hello World"`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "MESSAGE", value: "Hello World" }, + ]); + }); + + it("should skip empty lines", () => { + const content = `API_KEY=abc123 + +DATABASE_URL=postgres://localhost:5432/mydb + + +PORT=3000`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "PORT", value: "3000" }, + ]); + }); + + it("should skip comment lines", () => { + const content = `# This is a comment +API_KEY=abc123 +# Another comment +DATABASE_URL=postgres://localhost:5432/mydb +# PORT=3000 (commented out) +DEBUG=true`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "DEBUG", value: "true" }, + ]); + }); + + it("should handle values with spaces", () => { + const content = `MESSAGE="Hello World" +DESCRIPTION='This is a long description' +TITLE=My App Title`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "MESSAGE", value: "Hello World" }, + { key: "DESCRIPTION", value: "This is a long description" }, + { key: "TITLE", value: "My App Title" }, + ]); + }); + + it("should handle values with special characters", () => { + const content = `PASSWORD="p@ssw0rd!#$%" +URL="https://example.com/api?key=123&secret=456" +REGEX="^[a-zA-Z0-9]+$"`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "PASSWORD", value: "p@ssw0rd!#$%" }, + { key: "URL", value: "https://example.com/api?key=123&secret=456" }, + { key: "REGEX", value: "^[a-zA-Z0-9]+$" }, + ]); + }); + + it("should handle empty values", () => { + const content = `EMPTY_VAR= +QUOTED_EMPTY="" +ANOTHER_VAR=value`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "EMPTY_VAR", value: "" }, + { key: "QUOTED_EMPTY", value: "" }, + { key: "ANOTHER_VAR", value: "value" }, + ]); + }); + + it("should handle values with equals signs", () => { + const content = `EQUATION="2+2=4" +CONNECTION_STRING="server=localhost;user=admin;password=secret"`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "EQUATION", value: "2+2=4" }, + { + key: "CONNECTION_STRING", + value: "server=localhost;user=admin;password=secret", + }, + ]); + }); + + it("should trim whitespace around keys and values", () => { + const content = ` API_KEY = abc123 + DATABASE_URL = "postgres://localhost:5432/mydb" + PORT = 3000 `; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "PORT", value: "3000" }, + ]); + }); + + it("should skip malformed lines without equals sign", () => { + const content = `API_KEY=abc123 +MALFORMED_LINE +DATABASE_URL=postgres://localhost:5432/mydb +ANOTHER_MALFORMED +PORT=3000`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "PORT", value: "3000" }, + ]); + }); + + it("should skip lines with equals sign at the beginning", () => { + const content = `API_KEY=abc123 +=invalid_line +DATABASE_URL=postgres://localhost:5432/mydb`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + ]); + }); + + it("should handle mixed quote types in values", () => { + const content = `MESSAGE="He said 'Hello World'" +COMMAND='echo "Hello World"'`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "MESSAGE", value: "He said 'Hello World'" }, + { key: "COMMAND", value: 'echo "Hello World"' }, + ]); + }); + + it("should handle empty content", () => { + const result = parseEnvFile(""); + expect(result).toEqual([]); + }); + + it("should handle content with only comments and empty lines", () => { + const content = `# Comment 1 + +# Comment 2 + +# Comment 3`; + + const result = parseEnvFile(content); + expect(result).toEqual([]); + }); + + it("should handle values that start with hash symbol when quoted", () => { + const content = `HASH_VALUE="#hashtag" +COMMENT_LIKE="# This looks like a comment but it's a value" +ACTUAL_COMMENT=value +# This is an actual comment`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "HASH_VALUE", value: "#hashtag" }, + { + key: "COMMENT_LIKE", + value: "# This looks like a comment but it's a value", + }, + { key: "ACTUAL_COMMENT", value: "value" }, + ]); + }); + + it("should skip comments that look like key=value pairs", () => { + const content = `API_KEY=abc123 +# SECRET_KEY=should_be_ignored +DATABASE_URL=postgres://localhost:5432/mydb +# PORT=3000 +DEBUG=true`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "DEBUG", value: "true" }, + ]); + }); + + it("should handle values containing comment symbols", () => { + const content = `GIT_COMMIT_MSG="feat: add new feature # closes #123" +SQL_QUERY="SELECT * FROM users WHERE id = 1 # Get user by ID" +MARKDOWN_HEADING="# Main Title" +SHELL_COMMENT="echo 'hello' # prints hello"`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "GIT_COMMIT_MSG", value: "feat: add new feature # closes #123" }, + { + key: "SQL_QUERY", + value: "SELECT * FROM users WHERE id = 1 # Get user by ID", + }, + { key: "MARKDOWN_HEADING", value: "# Main Title" }, + { key: "SHELL_COMMENT", value: "echo 'hello' # prints hello" }, + ]); + }); + + it("should handle inline comments after key=value pairs", () => { + const content = `API_KEY=abc123 # This is the API key +DATABASE_URL=postgres://localhost:5432/mydb # Database connection +PORT=3000 # Server port +DEBUG=true # Enable debug mode`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123 # This is the API key" }, + { + key: "DATABASE_URL", + value: "postgres://localhost:5432/mydb # Database connection", + }, + { key: "PORT", value: "3000 # Server port" }, + { key: "DEBUG", value: "true # Enable debug mode" }, + ]); + }); + + it("should handle quoted values with inline comments", () => { + const content = `MESSAGE="Hello World" # Greeting message +PASSWORD="secret#123" # Password with hash +URL="https://example.com#section" # URL with fragment`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "MESSAGE", value: "Hello World" }, + { key: "PASSWORD", value: "secret#123" }, + { key: "URL", value: "https://example.com#section" }, + ]); + }); + + it("should handle complex mixed comment scenarios", () => { + const content = `# Configuration file +API_KEY=abc123 +# Database settings +DATABASE_URL="postgres://localhost:5432/mydb" +# PORT=5432 (commented out) +DATABASE_NAME=myapp + +# Feature flags +FEATURE_A=true # Enable feature A +FEATURE_B="false" # Disable feature B +# FEATURE_C=true (disabled) + +# URLs with fragments +HOMEPAGE="https://example.com#home" +DOCS_URL=https://docs.example.com#getting-started # Documentation link`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "DATABASE_NAME", value: "myapp" }, + { key: "FEATURE_A", value: "true # Enable feature A" }, + { key: "FEATURE_B", value: "false" }, + { key: "HOMEPAGE", value: "https://example.com#home" }, + { + key: "DOCS_URL", + value: "https://docs.example.com#getting-started # Documentation link", + }, + ]); + }); +}); + +describe("serializeEnvFile", () => { + it("should serialize basic key=value pairs", () => { + const envVars = [ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "PORT", value: "3000" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`API_KEY=abc123 +DATABASE_URL=postgres://localhost:5432/mydb +PORT=3000`); + }); + + it("should quote values with spaces", () => { + const envVars = [ + { key: "MESSAGE", value: "Hello World" }, + { key: "DESCRIPTION", value: "This is a long description" }, + { key: "SIMPLE", value: "no_spaces" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`MESSAGE="Hello World" +DESCRIPTION="This is a long description" +SIMPLE=no_spaces`); + }); + + it("should quote values with special characters", () => { + const envVars = [ + { key: "PASSWORD", value: "p@ssw0rd!#$%" }, + { key: "URL", value: "https://example.com/api?key=123&secret=456" }, + { key: "SIMPLE", value: "simple123" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`PASSWORD="p@ssw0rd!#$%" +URL="https://example.com/api?key=123&secret=456" +SIMPLE=simple123`); + }); + + it("should escape quotes in values", () => { + const envVars = [ + { key: "MESSAGE", value: 'He said "Hello World"' }, + { key: "COMMAND", value: 'echo "test"' }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`MESSAGE="He said \\"Hello World\\"" +COMMAND="echo \\"test\\""`); + }); + + it("should handle empty values", () => { + const envVars = [ + { key: "EMPTY_VAR", value: "" }, + { key: "ANOTHER_VAR", value: "value" }, + { key: "ALSO_EMPTY", value: "" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`EMPTY_VAR= +ANOTHER_VAR=value +ALSO_EMPTY=`); + }); + + it("should quote values with hash symbols", () => { + const envVars = [ + { key: "PASSWORD", value: "secret#123" }, + { key: "COMMENT", value: "This has # in it" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`PASSWORD="secret#123" +COMMENT="This has # in it"`); + }); + + it("should quote values with single quotes", () => { + const envVars = [ + { key: "MESSAGE", value: "Don't worry" }, + { key: "SQL", value: "SELECT * FROM 'users'" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`MESSAGE="Don't worry" +SQL="SELECT * FROM 'users'"`); + }); + + it("should handle values with equals signs", () => { + const envVars = [ + { key: "EQUATION", value: "2+2=4" }, + { + key: "CONNECTION_STRING", + value: "server=localhost;user=admin;password=secret", + }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`EQUATION="2+2=4" +CONNECTION_STRING="server=localhost;user=admin;password=secret"`); + }); + + it("should handle mixed scenarios", () => { + const envVars = [ + { key: "SIMPLE", value: "value" }, + { key: "WITH_SPACES", value: "hello world" }, + { key: "WITH_QUOTES", value: 'say "hello"' }, + { key: "EMPTY", value: "" }, + { key: "SPECIAL_CHARS", value: "p@ssw0rd!#$%" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`SIMPLE=value +WITH_SPACES="hello world" +WITH_QUOTES="say \\"hello\\"" +EMPTY= +SPECIAL_CHARS="p@ssw0rd!#$%"`); + }); + + it("should handle empty array", () => { + const result = serializeEnvFile([]); + expect(result).toBe(""); + }); + + it("should handle complex escaped quotes", () => { + const envVars = [ + { key: "COMPLEX", value: "This is \"complex\" with 'mixed' quotes" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`COMPLEX="This is \\"complex\\" with 'mixed' quotes"`); + }); + + it("should handle values that start with hash symbol", () => { + const envVars = [ + { key: "HASHTAG", value: "#trending" }, + { key: "COMMENT_LIKE", value: "# This looks like a comment" }, + { key: "MARKDOWN_HEADING", value: "# Main Title" }, + { key: "NORMAL_VALUE", value: "no_hash_here" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`HASHTAG="#trending" +COMMENT_LIKE="# This looks like a comment" +MARKDOWN_HEADING="# Main Title" +NORMAL_VALUE=no_hash_here`); + }); + + it("should handle values containing comment symbols", () => { + const envVars = [ + { key: "GIT_COMMIT", value: "feat: add feature # closes #123" }, + { key: "SQL_QUERY", value: "SELECT * FROM users # Get all users" }, + { key: "SHELL_CMD", value: "echo 'hello' # prints hello" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`GIT_COMMIT="feat: add feature # closes #123" +SQL_QUERY="SELECT * FROM users # Get all users" +SHELL_CMD="echo 'hello' # prints hello"`); + }); + + it("should handle URLs with fragments that contain hash symbols", () => { + const envVars = [ + { key: "HOMEPAGE", value: "https://example.com#home" }, + { key: "DOCS_URL", value: "https://docs.example.com#getting-started" }, + { key: "API_ENDPOINT", value: "https://api.example.com/v1#section" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`HOMEPAGE="https://example.com#home" +DOCS_URL="https://docs.example.com#getting-started" +API_ENDPOINT="https://api.example.com/v1#section"`); + }); + + it("should handle values with hash symbols and other special characters", () => { + const envVars = [ + { key: "COMPLEX_PASSWORD", value: "p@ssw0rd#123!&" }, + { key: "REGEX_PATTERN", value: "^[a-zA-Z0-9#]+$" }, + { + key: "MARKDOWN_CONTENT", + value: "# Title\n\nSome content with = and & symbols", + }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`COMPLEX_PASSWORD="p@ssw0rd#123!&" +REGEX_PATTERN="^[a-zA-Z0-9#]+$" +MARKDOWN_CONTENT="# Title\n\nSome content with = and & symbols"`); + }); +}); + +describe("parseEnvFile and serializeEnvFile integration", () => { + it("should be able to parse what it serializes", () => { + const originalEnvVars = [ + { key: "API_KEY", value: "abc123" }, + { key: "MESSAGE", value: "Hello World" }, + { key: "PASSWORD", value: 'secret"123' }, + { key: "EMPTY", value: "" }, + { key: "SPECIAL", value: "p@ssw0rd!#$%" }, + ]; + + const serialized = serializeEnvFile(originalEnvVars); + const parsed = parseEnvFile(serialized); + + expect(parsed).toEqual(originalEnvVars); + }); + + it("should handle round-trip with complex values", () => { + const originalEnvVars = [ + { key: "URL", value: "https://example.com/api?key=123&secret=456" }, + { key: "REGEX", value: "^[a-zA-Z0-9]+$" }, + { key: "COMMAND", value: 'echo "Hello World"' }, + { key: "EQUATION", value: "2+2=4" }, + ]; + + const serialized = serializeEnvFile(originalEnvVars); + const parsed = parseEnvFile(serialized); + + expect(parsed).toEqual(originalEnvVars); + }); + + it("should handle round-trip with comment-like values", () => { + const originalEnvVars = [ + { key: "HASHTAG", value: "#trending" }, + { + key: "COMMENT_LIKE", + value: "# This looks like a comment but it's a value", + }, + { key: "GIT_COMMIT", value: "feat: add feature # closes #123" }, + { key: "URL_WITH_FRAGMENT", value: "https://example.com#section" }, + { key: "MARKDOWN_HEADING", value: "# Main Title" }, + { key: "COMPLEX_VALUE", value: "password#123=secret&token=abc" }, + ]; + + const serialized = serializeEnvFile(originalEnvVars); + const parsed = parseEnvFile(serialized); + + expect(parsed).toEqual(originalEnvVars); + }); +}); diff --git a/backups/backup-20251218-161645/src/__tests__/chat_stream_handlers.test.ts b/backups/backup-20251218-161645/src/__tests__/chat_stream_handlers.test.ts new file mode 100644 index 0000000..5ddf638 --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/chat_stream_handlers.test.ts @@ -0,0 +1,1213 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { + getDyadWriteTags, + getDyadRenameTags, + getDyadAddDependencyTags, + getDyadDeleteTags, +} from "../ipc/utils/dyad_tag_parser"; + +import { processFullResponseActions } from "../ipc/processors/response_processor"; +import { + removeDyadTags, + hasUnclosedDyadWrite, +} from "../ipc/handlers/chat_stream_handlers"; +import fs from "node:fs"; +import { db } from "../db"; +import { cleanFullResponse } from "../ipc/utils/cleanFullResponse"; +import { gitAdd, gitRemove, gitCommit } from "../ipc/utils/git_utils"; + +// Mock fs with default export +vi.mock("node:fs", async () => { + return { + default: { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn().mockReturnValue(false), // Default to false to avoid creating temp directory + renameSync: vi.fn(), + unlinkSync: vi.fn(), + lstatSync: vi.fn().mockReturnValue({ isDirectory: () => false }), + promises: { + readFile: vi.fn().mockResolvedValue(""), + }, + }, + existsSync: vi.fn().mockReturnValue(false), // Also mock the named export + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), + lstatSync: vi.fn().mockReturnValue({ isDirectory: () => false }), + promises: { + readFile: vi.fn().mockResolvedValue(""), + }, + }; +}); + +// Mock Git utils +vi.mock("../ipc/utils/git_utils", () => ({ + gitAdd: vi.fn(), + gitCommit: vi.fn(), + gitRemove: vi.fn(), + gitRenameBranch: vi.fn(), + gitCurrentBranch: vi.fn(), + gitLog: vi.fn(), + gitInit: vi.fn(), + gitPush: vi.fn(), + gitSetRemoteUrl: vi.fn(), + gitStatus: vi.fn().mockResolvedValue([]), + getGitUncommittedFiles: vi.fn().mockResolvedValue([]), +})); + +// Mock paths module to control getDyadAppPath +vi.mock("../paths/paths", () => ({ + getDyadAppPath: vi.fn().mockImplementation((appPath) => { + return `/mock/user/data/path/${appPath}`; + }), + getUserDataPath: vi.fn().mockReturnValue("/mock/user/data/path"), +})); + +// Mock db +vi.mock("../db", () => ({ + db: { + query: { + chats: { + findFirst: vi.fn(), + }, + messages: { + findFirst: vi.fn(), + }, + }, + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(undefined), + })), + })), + }, +})); + +describe("getDyadAddDependencyTags", () => { + it("should return an empty array when no dyad-add-dependency tags are found", () => { + const result = getDyadAddDependencyTags("No dyad-add-dependency tags here"); + expect(result).toEqual([]); + }); + + it("should return an array of dyad-add-dependency tags", () => { + const result = getDyadAddDependencyTags( + ``, + ); + expect(result).toEqual(["uuid"]); + }); + + it("should return all the packages in the dyad-add-dependency tags", () => { + const result = getDyadAddDependencyTags( + ``, + ); + expect(result).toEqual(["pkg1", "pkg2"]); + }); + + it("should return all the packages in the dyad-add-dependency tags", () => { + const result = getDyadAddDependencyTags( + `txt beforetext after`, + ); + expect(result).toEqual(["pkg1", "pkg2"]); + }); + + it("should return all the packages in multiple dyad-add-dependency tags", () => { + const result = getDyadAddDependencyTags( + `txt beforetxt betweentext after`, + ); + expect(result).toEqual(["pkg1", "pkg2", "pkg3"]); + }); +}); +describe("getDyadWriteTags", () => { + it("should return an empty array when no dyad-write tags are found", () => { + const result = getDyadWriteTags("No dyad-write tags here"); + expect(result).toEqual([]); + }); + + it("should return a dyad-write tag", () => { + const result = + getDyadWriteTags(` +import React from "react"; +console.log("TodoItem"); +`); + expect(result).toEqual([ + { + path: "src/components/TodoItem.tsx", + description: "Creating a component for individual todo items", + content: `import React from "react"; +console.log("TodoItem");`, + }, + ]); + }); + + it("should strip out code fence (if needed) from a dyad-write tag", () => { + const result = + getDyadWriteTags(` +\`\`\`tsx +import React from "react"; +console.log("TodoItem"); +\`\`\` + +`); + expect(result).toEqual([ + { + path: "src/components/TodoItem.tsx", + description: "Creating a component for individual todo items", + content: `import React from "react"; +console.log("TodoItem");`, + }, + ]); + }); + + it("should handle missing description", () => { + const result = getDyadWriteTags(` + +import React from 'react'; + + `); + expect(result).toEqual([ + { + path: "src/pages/locations/neighborhoods/louisville/Highlands.tsx", + description: undefined, + content: `import React from 'react';`, + }, + ]); + }); + + it("should handle extra space", () => { + const result = getDyadWriteTags( + cleanFullResponse(` + +import React from 'react'; + + `), + ); + expect(result).toEqual([ + { + path: "src/pages/locations/neighborhoods/louisville/Highlands.tsx", + description: "Updating Highlands neighborhood page to use <a> tags.", + content: `import React from 'react';`, + }, + ]); + }); + + it("should handle nested tags", () => { + const result = getDyadWriteTags( + cleanFullResponse(` + BEFORE TAG + +import React from 'react'; + +AFTER TAG + `), + ); + expect(result).toEqual([ + { + path: "src/pages/locations/neighborhoods/louisville/Highlands.tsx", + description: "Updating Highlands neighborhood page to use <a> tags.", + content: `import React from 'react';`, + }, + ]); + }); + + it("should handle nested tags after preprocessing", () => { + // Simulate the preprocessing step that cleanFullResponse would do + const inputWithNestedTags = ` + BEFORE TAG + +import React from 'react'; + +AFTER TAG + `; + + const cleanedInput = cleanFullResponse(inputWithNestedTags); + + const result = getDyadWriteTags(cleanedInput); + expect(result).toEqual([ + { + path: "src/pages/locations/neighborhoods/louisville/Highlands.tsx", + description: "Updating Highlands neighborhood page to use <a> tags.", + content: `import React from 'react';`, + }, + ]); + }); + + it("should handle multiple nested tags after preprocessing", () => { + const inputWithMultipleNestedTags = `content`; + + // This simulates what cleanFullResponse should do + const cleanedInput = cleanFullResponse(inputWithMultipleNestedTags); + const result = getDyadWriteTags(cleanedInput); + expect(result).toEqual([ + { + path: "src/file.tsx", + description: "Testing <div> and <span> and <a> tags.", + content: `content`, + }, + ]); + }); + + it("should handle nested tags in multiple attributes", () => { + const inputWithNestedInMultipleAttrs = `content`; + + // This simulates what cleanFullResponse should do + const cleanedInput = cleanFullResponse(inputWithNestedInMultipleAttrs); + + const result = getDyadWriteTags(cleanedInput); + expect(result).toEqual([ + { + path: "src/<component>.tsx", + description: "Testing <div> tags.", + content: `content`, + }, + ]); + }); + + it("should return an array of dyad-write tags", () => { + const result = getDyadWriteTags( + `I'll create a simple todo list app using React, TypeScript, and shadcn/ui components. Let's get started! + +First, I'll create the necessary files for our todo list application: + + +export interface Todo { + id: string; + text: string; + completed: boolean; + createdAt: Date; +} + + + +import React from "react"; +import { Trash2, Check, X } from "lucide-react"; +import { Todo } from "../types/todo"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface TodoItemProps { + todo: Todo; + onToggle: (id: string) => void; + onDelete: (id: string) => void; +} + +const TodoItem: React.FC = ({ todo, onToggle, onDelete }) => { + return ( +
+
+ + + {todo.text} + +
+ +
+ ); +}; + +export default TodoItem; +
+ + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PlusCircle } from "lucide-react"; + +interface TodoFormProps { + onAddTodo: (text: string) => void; +} + +const TodoForm: React.FC = ({ onAddTodo }) => { + const [text, setText] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (text.trim()) { + onAddTodo(text.trim()); + setText(""); + } + }; + + return ( +
+ setText(e.target.value)} + placeholder="Add a new task..." + className="flex-1" + /> + +
+ ); +}; + +export default TodoForm; +
+ + +import React from "react"; +import { Todo } from "../types/todo"; +import TodoItem from "./TodoItem"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +interface TodoListProps { + todos: Todo[]; + onToggle: (id: string) => void; + onDelete: (id: string) => void; +} + +const TodoList: React.FC = ({ todos, onToggle, onDelete }) => { + if (todos.length === 0) { + return ( +
+ No tasks yet. Add one above! +
+ ); + } + + return ( + +
+ {todos.map((todo) => ( + + ))} +
+
+ ); +}; + +export default TodoList; +
+ + +import React from "react"; +import { Todo } from "../types/todo"; +import { Card, CardContent } from "@/components/ui/card"; + +interface TodoStatsProps { + todos: Todo[]; +} + +const TodoStats: React.FC = ({ todos }) => { + const total = todos.length; + const completed = todos.filter((todo) => todo.completed).length; + const pending = total - completed; + const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0; + + return ( +
+ + +
+

Total Tasks

+

{total}

+
+
+
+ + + +
+

Completed

+

{completed}

+
+
+
+ + + +
+

Pending

+

{pending}

+
+
+
+ + + +
+
+ Progress + {percentComplete}% +
+
+
+
+
+ + +
+ ); +}; + +export default TodoStats; + + + +import React, { useState, useEffect } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { Todo } from "../types/todo"; +import TodoForm from "../components/TodoForm"; +import TodoList from "../components/TodoList"; +import TodoStats from "../components/TodoStats"; +import { Toaster } from "@/components/ui/toaster"; +import { useToast } from "@/components/ui/use-toast"; + +const Index: React.FC = () => { + const [todos, setTodos] = useState(() => { + const savedTodos = localStorage.getItem("todos"); + if (savedTodos) { + try { + // Parse the saved todos and convert string dates back to Date objects + return JSON.parse(savedTodos).map((todo: any) => ({ + ...todo, + createdAt: new Date(todo.createdAt) + })); + } catch (e) { + console.error("Failed to parse saved todos", e); + return []; + } + } + return []; + }); + + const { toast } = useToast(); + + // Save todos to localStorage whenever they change + useEffect(() => { + localStorage.setItem("todos", JSON.stringify(todos)); + }, [todos]); + + const handleAddTodo = (text: string) => { + const newTodo: Todo = { + id: uuidv4(), + text, + completed: false, + createdAt: new Date() + }; + + setTodos([...todos, newTodo]); + toast({ + title: "Task added", + description: "Your new task has been added successfully.", + }); + }; + + const handleToggleTodo = (id: string) => { + setTodos( + todos.map((todo) => + todo.id === id ? { ...todo, completed: !todo.completed } : todo + ) + ); + + const todo = todos.find(t => t.id === id); + if (todo) { + toast({ + title: todo.completed ? "Task marked as incomplete" : "Task completed", + description: todo.text, + }); + } + }; + + const handleDeleteTodo = (id: string) => { + const todoToDelete = todos.find(todo => todo.id === id); + setTodos(todos.filter((todo) => todo.id !== id)); + + if (todoToDelete) { + toast({ + title: "Task deleted", + description: todoToDelete.text, + variant: "destructive", + }); + } + }; + + return ( +
+

Todo List

+

+ Keep track of your tasks and stay organized +

+ + + + + +
+ ); +}; + +export default Index; +
+ + + + +declare module 'uuid' { + export function v4(): string; +} + + +I've created a complete todo list application with the ability to add, complete, and delete tasks. The app includes statistics and uses local storage to persist data.`, + ); + expect(result.length).toEqual(7); + }); +}); + +describe("getDyadRenameTags", () => { + it("should return an empty array when no dyad-rename tags are found", () => { + const result = getDyadRenameTags("No dyad-rename tags here"); + expect(result).toEqual([]); + }); + + it("should return an array of dyad-rename tags", () => { + const result = getDyadRenameTags( + ` + `, + ); + expect(result).toEqual([ + { + from: "src/components/UserProfile.jsx", + to: "src/components/ProfileCard.jsx", + }, + { from: "src/utils/helpers.js", to: "src/utils/utils.js" }, + ]); + }); +}); + +describe("getDyadDeleteTags", () => { + it("should return an empty array when no dyad-delete tags are found", () => { + const result = getDyadDeleteTags("No dyad-delete tags here"); + expect(result).toEqual([]); + }); + + it("should return an array of dyad-delete paths", () => { + const result = getDyadDeleteTags( + ` + `, + ); + expect(result).toEqual([ + "src/components/Analytics.jsx", + "src/utils/unused.js", + ]); + }); +}); + +describe("processFullResponse", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock db query response + vi.mocked(db.query.chats.findFirst).mockResolvedValue({ + id: 1, + appId: 1, + title: "Test Chat", + createdAt: new Date(), + app: { + id: 1, + name: "Mock App", + path: "mock-app-path", + createdAt: new Date(), + updatedAt: new Date(), + }, + messages: [], + } as any); + + vi.mocked(db.query.messages.findFirst).mockResolvedValue({ + id: 1, + chatId: 1, + role: "assistant", + content: "some content", + createdAt: new Date(), + approvalState: null, + commitHash: null, + } as any); + + // Default mock for existsSync to return true + vi.mocked(fs.existsSync).mockReturnValue(true); + }); + + it("should return empty object when no dyad-write tags are found", async () => { + const result = await processFullResponseActions( + "No dyad-write tags here", + 1, + { + chatSummary: undefined, + messageId: 1, + }, + ); + expect(result).toEqual({ + updatedFiles: false, + extraFiles: undefined, + extraFilesError: undefined, + }); + expect(fs.mkdirSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it("should process dyad-write tags and create files", async () => { + // Set up fs mocks to succeed + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const response = `console.log('Hello');`; + + const result = await processFullResponseActions(response, 1, { + chatSummary: undefined, + messageId: 1, + }); + + expect(fs.mkdirSync).toHaveBeenCalledWith( + "/mock/user/data/path/mock-app-path/src", + { recursive: true }, + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + "/mock/user/data/path/mock-app-path/src/file1.js", + "console.log('Hello');", + ); + expect(gitAdd).toHaveBeenCalledWith( + expect.objectContaining({ + filepath: "src/file1.js", + }), + ); + expect(gitCommit).toHaveBeenCalled(); + expect(result).toEqual({ updatedFiles: true }); + }); + + it("should handle file system errors gracefully", async () => { + // Set up the mock to throw an error on mkdirSync + vi.mocked(fs.mkdirSync).mockImplementationOnce(() => { + throw new Error("Mock filesystem error"); + }); + + const response = `This will fail`; + + const result = await processFullResponseActions(response, 1, { + chatSummary: undefined, + messageId: 1, + }); + + expect(result).toHaveProperty("error"); + expect(result.error).toContain("Mock filesystem error"); + }); + + it("should process multiple dyad-write tags and commit all files", async () => { + // Clear previous mock calls + vi.clearAllMocks(); + + // Set up fs mocks to succeed + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const response = ` + console.log('First file'); + export const add = (a, b) => a + b; + + import React from 'react'; + export const Button = ({ children }) => ; + + `; + + const result = await processFullResponseActions(response, 1, { + chatSummary: undefined, + messageId: 1, + }); + + // Check that directories were created for each file path + expect(fs.mkdirSync).toHaveBeenCalledWith( + "/mock/user/data/path/mock-app-path/src", + { recursive: true }, + ); + expect(fs.mkdirSync).toHaveBeenCalledWith( + "/mock/user/data/path/mock-app-path/src/utils", + { recursive: true }, + ); + expect(fs.mkdirSync).toHaveBeenCalledWith( + "/mock/user/data/path/mock-app-path/src/components", + { recursive: true }, + ); + + // Using toHaveBeenNthCalledWith to check each specific call + expect(fs.writeFileSync).toHaveBeenNthCalledWith( + 1, + "/mock/user/data/path/mock-app-path/src/file1.js", + "console.log('First file');", + ); + expect(fs.writeFileSync).toHaveBeenNthCalledWith( + 2, + "/mock/user/data/path/mock-app-path/src/utils/file2.js", + "export const add = (a, b) => a + b;", + ); + expect(fs.writeFileSync).toHaveBeenNthCalledWith( + 3, + "/mock/user/data/path/mock-app-path/src/components/Button.tsx", + "import React from 'react';\n export const Button = ({ children }) => ;", + ); + + // Verify git operations were called for each file + expect(gitAdd).toHaveBeenCalledWith( + expect.objectContaining({ + filepath: "src/file1.js", + }), + ); + expect(gitAdd).toHaveBeenCalledWith( + expect.objectContaining({ + filepath: "src/utils/file2.js", + }), + ); + expect(gitAdd).toHaveBeenCalledWith( + expect.objectContaining({ + filepath: "src/components/Button.tsx", + }), + ); + + // Verify commit was called once after all files were added + expect(gitCommit).toHaveBeenCalledTimes(1); + expect(result).toEqual({ updatedFiles: true }); + }); + + it("should process dyad-rename tags and rename files", async () => { + // Set up fs mocks to succeed + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + vi.mocked(fs.renameSync).mockImplementation(() => undefined); + + const response = ``; + + const result = await processFullResponseActions(response, 1, { + chatSummary: undefined, + messageId: 1, + }); + + expect(fs.mkdirSync).toHaveBeenCalledWith( + "/mock/user/data/path/mock-app-path/src/components", + { recursive: true }, + ); + expect(fs.renameSync).toHaveBeenCalledWith( + "/mock/user/data/path/mock-app-path/src/components/OldComponent.jsx", + "/mock/user/data/path/mock-app-path/src/components/NewComponent.jsx", + ); + expect(gitAdd).toHaveBeenCalledWith( + expect.objectContaining({ + filepath: "src/components/NewComponent.jsx", + }), + ); + expect(gitRemove).toHaveBeenCalledWith( + expect.objectContaining({ + filepath: "src/components/OldComponent.jsx", + }), + ); + expect(gitCommit).toHaveBeenCalled(); + expect(result).toEqual({ updatedFiles: true }); + }); + + it("should handle non-existent files during rename gracefully", async () => { + // Set up the mock to return false for existsSync + vi.mocked(fs.existsSync).mockReturnValue(false); + + const response = ``; + + const result = await processFullResponseActions(response, 1, { + chatSummary: undefined, + messageId: 1, + }); + + expect(fs.mkdirSync).toHaveBeenCalled(); + expect(fs.renameSync).not.toHaveBeenCalled(); + expect(gitCommit).not.toHaveBeenCalled(); + expect(result).toEqual({ + updatedFiles: false, + extraFiles: undefined, + extraFilesError: undefined, + }); + }); + + it("should process dyad-delete tags and delete files", async () => { + // Set up fs mocks to succeed + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.unlinkSync).mockImplementation(() => undefined); + + const response = ``; + + const result = await processFullResponseActions(response, 1, { + chatSummary: undefined, + messageId: 1, + }); + + expect(fs.unlinkSync).toHaveBeenCalledWith( + "/mock/user/data/path/mock-app-path/src/components/Unused.jsx", + ); + expect(gitRemove).toHaveBeenCalledWith( + expect.objectContaining({ + filepath: "src/components/Unused.jsx", + }), + ); + expect(gitCommit).toHaveBeenCalled(); + expect(result).toEqual({ updatedFiles: true }); + }); + + it("should handle non-existent files during delete gracefully", async () => { + // Set up the mock to return false for existsSync + vi.mocked(fs.existsSync).mockReturnValue(false); + + const response = ``; + + const result = await processFullResponseActions(response, 1, { + chatSummary: undefined, + messageId: 1, + }); + + expect(fs.unlinkSync).not.toHaveBeenCalled(); + expect(gitRemove).not.toHaveBeenCalled(); + expect(gitCommit).not.toHaveBeenCalled(); + expect(result).toEqual({ + updatedFiles: false, + extraFiles: undefined, + extraFilesError: undefined, + }); + }); + + it("should process mixed operations (write, rename, delete) in one response", async () => { + // Set up fs mocks to succeed + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + vi.mocked(fs.renameSync).mockImplementation(() => undefined); + vi.mocked(fs.unlinkSync).mockImplementation(() => undefined); + + const response = ` + import React from 'react'; export default () =>
New
;
+ + + `; + + const result = await processFullResponseActions(response, 1, { + chatSummary: undefined, + messageId: 1, + }); + + // Check write operation happened + expect(fs.writeFileSync).toHaveBeenCalledWith( + "/mock/user/data/path/mock-app-path/src/components/NewComponent.jsx", + "import React from 'react'; export default () =>
New
;", + ); + + // Check rename operation happened + expect(fs.renameSync).toHaveBeenCalledWith( + "/mock/user/data/path/mock-app-path/src/components/OldComponent.jsx", + "/mock/user/data/path/mock-app-path/src/components/RenamedComponent.jsx", + ); + + // Check delete operation happened + expect(fs.unlinkSync).toHaveBeenCalledWith( + "/mock/user/data/path/mock-app-path/src/components/Unused.jsx", + ); + + // Check git operations + expect(gitAdd).toHaveBeenCalledTimes(2); // For the write and rename + expect(gitRemove).toHaveBeenCalledTimes(2); // For the rename and delete + + // Check the commit message includes all operations + expect(gitCommit).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + "wrote 1 file(s), renamed 1 file(s), deleted 1 file(s)", + ), + }), + ); + + expect(result).toEqual({ updatedFiles: true }); + }); +}); + +describe("removeDyadTags", () => { + it("should return empty string when input is empty", () => { + const result = removeDyadTags(""); + expect(result).toBe(""); + }); + + it("should return the same text when no dyad tags are present", () => { + const text = "This is a regular text without any dyad tags."; + const result = removeDyadTags(text); + expect(result).toBe(text); + }); + + it("should remove a single dyad-write tag", () => { + const text = `Before text console.log('hello'); After text`; + const result = removeDyadTags(text); + expect(result).toBe("Before text After text"); + }); + + it("should remove a single dyad-delete tag", () => { + const text = `Before text After text`; + const result = removeDyadTags(text); + expect(result).toBe("Before text After text"); + }); + + it("should remove a single dyad-rename tag", () => { + const text = `Before text After text`; + const result = removeDyadTags(text); + expect(result).toBe("Before text After text"); + }); + + it("should remove multiple different dyad tags", () => { + const text = `Start code here middle end finish`; + const result = removeDyadTags(text); + expect(result).toBe("Start middle end finish"); + }); + + it("should remove dyad tags with multiline content", () => { + const text = `Before + +import React from 'react'; + +const Component = () => { + return
Hello World
; +}; + +export default Component; +
+After`; + const result = removeDyadTags(text); + expect(result).toBe("Before\n\nAfter"); + }); + + it("should handle dyad tags with complex attributes", () => { + const text = `Text const x = "hello world"; more text`; + const result = removeDyadTags(text); + expect(result).toBe("Text more text"); + }); + + it("should remove dyad tags and trim whitespace", () => { + const text = ` code `; + const result = removeDyadTags(text); + expect(result).toBe(""); + }); + + it("should handle nested content that looks like tags", () => { + const text = ` +const html = '
Hello
'; +const component = ; +
`; + const result = removeDyadTags(text); + expect(result).toBe(""); + }); + + it("should handle self-closing dyad tags", () => { + const text = `Before After`; + const result = removeDyadTags(text); + expect(result).toBe('Before After'); + }); + + it("should handle malformed dyad tags gracefully", () => { + const text = `Before unclosed tag After`; + const result = removeDyadTags(text); + expect(result).toBe('Before unclosed tag After'); + }); + + it("should handle dyad tags with special characters in content", () => { + const text = ` +const regex = /]*>.*?
/g; +const special = "Special chars: @#$%^&*()[]{}|\\"; +
`; + const result = removeDyadTags(text); + expect(result).toBe(""); + }); + + it("should handle multiple dyad tags of the same type", () => { + const text = `code1 between code2`; + const result = removeDyadTags(text); + expect(result).toBe("between"); + }); + + it("should handle dyad tags with custom tag names", () => { + const text = `Before content After`; + const result = removeDyadTags(text); + expect(result).toBe("Before After"); + }); +}); + +describe("hasUnclosedDyadWrite", () => { + it("should return false when there are no dyad-write tags", () => { + const text = "This is just regular text without any dyad tags."; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should return false when dyad-write tag is properly closed", () => { + const text = `console.log('hello');`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should return true when dyad-write tag is not closed", () => { + const text = `console.log('hello');`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(true); + }); + + it("should return false when dyad-write tag with attributes is properly closed", () => { + const text = `console.log('hello');`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should return true when dyad-write tag with attributes is not closed", () => { + const text = `console.log('hello');`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(true); + }); + + it("should return false when there are multiple closed dyad-write tags", () => { + const text = `code1 + Some text in between + code2`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should return true when the last dyad-write tag is unclosed", () => { + const text = `code1 + Some text in between + code2`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(true); + }); + + it("should return false when first tag is unclosed but last tag is closed", () => { + const text = `code1 + Some text in between + code2`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should handle multiline content correctly", () => { + const text = ` +import React from 'react'; + +const Component = () => { + return ( +
+

Hello World

+
+ ); +}; + +export default Component; +
`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should handle multiline unclosed content correctly", () => { + const text = ` +import React from 'react'; + +const Component = () => { + return ( +
+

Hello World

+
+ ); +}; + +export default Component;`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(true); + }); + + it("should handle complex attributes correctly", () => { + const text = ` +const message = "Hello 'world'"; +const regex = /]*>/g; +`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should handle text before and after dyad-write tags", () => { + const text = `Some text before the tag +console.log('hello'); +Some text after the tag`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should handle unclosed tag with text after", () => { + const text = `Some text before the tag +console.log('hello'); +Some text after the unclosed tag`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(true); + }); + + it("should handle empty dyad-write tags", () => { + const text = ``; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should handle unclosed empty dyad-write tags", () => { + const text = ``; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(true); + }); + + it("should focus on the last opening tag when there are mixed states", () => { + const text = `completed content + unclosed content + final content`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); + + it("should handle tags with special characters in attributes", () => { + const text = `content`; + const result = hasUnclosedDyadWrite(text); + expect(result).toBe(false); + }); +}); diff --git a/backups/backup-20251218-161645/src/__tests__/cleanFullResponse.test.ts b/backups/backup-20251218-161645/src/__tests__/cleanFullResponse.test.ts new file mode 100644 index 0000000..a784a7b --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/cleanFullResponse.test.ts @@ -0,0 +1,89 @@ +import { cleanFullResponse } from "@/ipc/utils/cleanFullResponse"; +import { describe, it, expect } from "vitest"; + +describe("cleanFullResponse", () => { + it("should replace < characters in dyad-write attributes", () => { + const input = `content`; + const expected = `content`; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should replace < characters in multiple attributes", () => { + const input = `content`; + const expected = `content`; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should handle multiple nested HTML tags in a single attribute", () => { + const input = `content`; + const expected = `content`; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should handle complex example with mixed content", () => { + const input = ` + BEFORE TAG + +import React from 'react'; + +AFTER TAG + `; + + const expected = ` + BEFORE TAG + +import React from 'react'; + +AFTER TAG + `; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should handle other dyad tag types", () => { + const input = ``; + const expected = ``; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should handle dyad-delete tags", () => { + const input = ``; + const expected = ``; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should not affect content outside dyad tags", () => { + const input = `Some text with HTML tags. content More here.`; + const expected = `Some text with HTML tags. content More here.`; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should handle empty attributes", () => { + const input = `content`; + const expected = `content`; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); + + it("should handle attributes without < characters", () => { + const input = `content`; + const expected = `content`; + + const result = cleanFullResponse(input); + expect(result).toBe(expected); + }); +}); diff --git a/backups/backup-20251218-161645/src/__tests__/formatMessagesForSummary.test.ts b/backups/backup-20251218-161645/src/__tests__/formatMessagesForSummary.test.ts new file mode 100644 index 0000000..21ce3b3 --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/formatMessagesForSummary.test.ts @@ -0,0 +1,167 @@ +import { formatMessagesForSummary } from "../ipc/handlers/chat_stream_handlers"; +import { describe, it, expect } from "vitest"; + +describe("formatMessagesForSummary", () => { + it("should return all messages when there are 8 or fewer messages", () => { + const messages = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + { role: "user", content: "How are you?" }, + { role: "assistant", content: "I'm doing well, thanks!" }, + ]; + + const result = formatMessagesForSummary(messages); + const expected = [ + 'Hello', + 'Hi there!', + 'How are you?', + 'I\'m doing well, thanks!', + ].join("\n"); + + expect(result).toBe(expected); + }); + + it("should return all messages when there are exactly 8 messages", () => { + const messages = Array.from({ length: 8 }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: `Message ${i + 1}`, + })); + + const result = formatMessagesForSummary(messages); + const expected = messages + .map((m) => `${m.content}`) + .join("\n"); + + expect(result).toBe(expected); + }); + + it("should truncate messages when there are more than 8 messages", () => { + const messages = Array.from({ length: 12 }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: `Message ${i + 1}`, + })); + + const result = formatMessagesForSummary(messages); + + // Should contain first 2 messages + expect(result).toContain('Message 1'); + expect(result).toContain('Message 2'); + + // Should contain omission indicator + expect(result).toContain( + '[... 4 messages omitted ...]', + ); + + // Should contain last 6 messages + expect(result).toContain('Message 7'); + expect(result).toContain('Message 8'); + expect(result).toContain('Message 9'); + expect(result).toContain('Message 10'); + expect(result).toContain('Message 11'); + expect(result).toContain('Message 12'); + + // Should not contain middle messages + expect(result).not.toContain('Message 3'); + expect(result).not.toContain( + 'Message 4', + ); + expect(result).not.toContain('Message 5'); + expect(result).not.toContain( + 'Message 6', + ); + }); + + it("should handle messages with undefined content", () => { + const messages = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: undefined }, + { role: "user", content: "Are you there?" }, + ]; + + const result = formatMessagesForSummary(messages); + const expected = [ + 'Hello', + 'undefined', + 'Are you there?', + ].join("\n"); + + expect(result).toBe(expected); + }); + + it("should handle empty messages array", () => { + const messages: { role: string; content: string | undefined }[] = []; + const result = formatMessagesForSummary(messages); + expect(result).toBe(""); + }); + + it("should handle single message", () => { + const messages = [{ role: "user", content: "Hello world" }]; + const result = formatMessagesForSummary(messages); + expect(result).toBe('Hello world'); + }); + + it("should correctly calculate omitted messages count", () => { + const messages = Array.from({ length: 20 }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: `Message ${i + 1}`, + })); + + const result = formatMessagesForSummary(messages); + + // Should indicate 12 messages omitted (20 total - 2 first - 6 last = 12) + expect(result).toContain( + '[... 12 messages omitted ...]', + ); + }); + + it("should handle messages with special characters in content", () => { + const messages = [ + { role: "user", content: 'Hello & "friends"' }, + { role: "assistant", content: "Hi there! content" }, + ]; + + const result = formatMessagesForSummary(messages); + + // Should preserve special characters as-is (no HTML escaping) + expect(result).toContain( + 'Hello & "friends"', + ); + expect(result).toContain( + 'Hi there! content', + ); + }); + + it("should maintain message order in truncated output", () => { + const messages = Array.from({ length: 15 }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: `Message ${i + 1}`, + })); + + const result = formatMessagesForSummary(messages); + const lines = result.split("\n"); + + // Should have exactly 9 lines (2 first + 1 omission + 6 last) + expect(lines).toHaveLength(9); + + // Check order: first 2, then omission, then last 6 + expect(lines[0]).toBe('Message 1'); + expect(lines[1]).toBe('Message 2'); + expect(lines[2]).toBe( + '[... 7 messages omitted ...]', + ); + + // Last 6 messages are messages 10-15 (indices 9-14) + // Message 10 (index 9): 9 % 2 === 1, so "assistant" + // Message 11 (index 10): 10 % 2 === 0, so "user" + // Message 12 (index 11): 11 % 2 === 1, so "assistant" + // Message 13 (index 12): 12 % 2 === 0, so "user" + // Message 14 (index 13): 13 % 2 === 1, so "assistant" + // Message 15 (index 14): 14 % 2 === 0, so "user" + expect(lines[3]).toBe('Message 10'); + expect(lines[4]).toBe('Message 11'); + expect(lines[5]).toBe('Message 12'); + expect(lines[6]).toBe('Message 13'); + expect(lines[7]).toBe('Message 14'); + expect(lines[8]).toBe('Message 15'); + }); +}); diff --git a/backups/backup-20251218-161645/src/__tests__/mention_apps.test.ts b/backups/backup-20251218-161645/src/__tests__/mention_apps.test.ts new file mode 100644 index 0000000..8088220 --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/mention_apps.test.ts @@ -0,0 +1,227 @@ +import { parseAppMentions } from "@/shared/parse_mention_apps"; +import { describe, it, expect } from "vitest"; + +describe("parseAppMentions", () => { + it("should parse basic app mentions", () => { + const prompt = "Can you help me with @app:MyApp and @app:AnotherApp?"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["MyApp", "AnotherApp"]); + }); + + it("should parse app mentions with underscores", () => { + const prompt = "I need help with @app:my_app and @app:another_app_name"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["my_app", "another_app_name"]); + }); + + it("should parse app mentions with hyphens", () => { + const prompt = "Check @app:my-app and @app:another-app-name"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["my-app", "another-app-name"]); + }); + + it("should parse app mentions with numbers", () => { + const prompt = "Update @app:app1 and @app:app2023 please"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["app1", "app2023"]); + }); + + it("should not parse mentions without app: prefix", () => { + const prompt = "Can you work on @MyApp and @AnotherApp?"; + const result = parseAppMentions(prompt); + expect(result).toEqual([]); + }); + + it("should require exact 'app:' prefix (case sensitive)", () => { + const prompt = "Check @App:MyApp and @APP:AnotherApp vs @app:ValidApp"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["ValidApp"]); + }); + + it("should parse mixed case app mentions", () => { + const prompt = "Help with @app:MyApp, @app:myapp, and @app:MYAPP"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["MyApp", "myapp", "MYAPP"]); + }); + + it("should parse app mentions with mixed characters (no spaces)", () => { + const prompt = "Check @app:My_App-2023 and @app:Another_App_Name-v2"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["My_App-2023", "Another_App_Name-v2"]); + }); + + it("should not handle spaces in app names (spaces break app names)", () => { + const prompt = "Work on @app:My_App_Name with underscores"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["My_App_Name"]); + }); + + it("should handle empty string", () => { + const result = parseAppMentions(""); + expect(result).toEqual([]); + }); + + it("should handle string with no mentions", () => { + const prompt = "This is just a regular message without any mentions"; + const result = parseAppMentions(prompt); + expect(result).toEqual([]); + }); + + it("should handle standalone @ symbol", () => { + const prompt = "This has @ symbol but no valid mention"; + const result = parseAppMentions(prompt); + expect(result).toEqual([]); + }); + + it("should ignore @ followed by special characters", () => { + const prompt = "Check @# and @! and @$ symbols"; + const result = parseAppMentions(prompt); + expect(result).toEqual([]); + }); + + it("should ignore @ at the end of string", () => { + const prompt = "This ends with @"; + const result = parseAppMentions(prompt); + expect(result).toEqual([]); + }); + + it("should parse mentions at different positions", () => { + const prompt = + "@app:StartApp in the beginning, @app:MiddleApp in middle, and @app:EndApp at end"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["StartApp", "MiddleApp", "EndApp"]); + }); + + it("should handle mentions with punctuation around them", () => { + const prompt = "Check (@app:MyApp), @app:AnotherApp! and @app:ThirdApp?"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["MyApp", "AnotherApp", "ThirdApp"]); + }); + + it("should parse mentions in different sentence structures", () => { + const prompt = ` + Can you help me with @app:WebApp? + I also need @app:MobileApp updated. + Don't forget about @app:DesktopApp. + `; + const result = parseAppMentions(prompt); + expect(result).toEqual(["WebApp", "MobileApp", "DesktopApp"]); + }); + + it("should handle duplicate mentions", () => { + const prompt = "Update @app:MyApp and also check @app:MyApp again"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["MyApp", "MyApp"]); + }); + + it("should parse mentions in multiline text", () => { + const prompt = `Line 1 has @app:App1 +Line 2 has @app:App2 +Line 3 has @app:App3`; + const result = parseAppMentions(prompt); + expect(result).toEqual(["App1", "App2", "App3"]); + }); + + it("should handle mentions with tabs and other whitespace", () => { + const prompt = "Check\t@app:TabApp\nand\r@app:NewlineApp"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["TabApp", "NewlineApp"]); + }); + + it("should parse single character app names", () => { + const prompt = "Check @app:A and @app:B and @app:1"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["A", "B", "1"]); + }); + + it("should handle very long app names", () => { + const longAppName = "VeryLongAppNameWithManyCharacters123_test-app"; + const prompt = `Check @app:${longAppName}`; + const result = parseAppMentions(prompt); + expect(result).toEqual([longAppName]); + }); + + it("should stop parsing at invalid characters", () => { + const prompt = + "Check @app:MyApp@InvalidPart and @app:AnotherApp.InvalidPart"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["MyApp", "AnotherApp"]); + }); + + it("should handle mentions with numbers and underscores mixed", () => { + const prompt = "Update @app:app_v1_2023 and @app:test_app_123"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["app_v1_2023", "test_app_123"]); + }); + + it("should handle mentions with hyphens and numbers mixed", () => { + const prompt = "Check @app:app-v1-2023 and @app:test-app-123"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["app-v1-2023", "test-app-123"]); + }); + + it("should parse mentions in URLs and complex text", () => { + const prompt = + "Visit https://example.com and check @app:WebApp for updates. Email admin@company.com about @app:MobileApp"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["WebApp", "MobileApp"]); + }); + + it("should not handle spaces in app names (spaces break app names)", () => { + const prompt = "Check @app:My_App_Name with underscores"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["My_App_Name"]); + }); + + it("should parse mentions in JSON-like strings", () => { + const prompt = '{"app": "@app:MyApp", "another": "@app:SecondApp"}'; + const result = parseAppMentions(prompt); + expect(result).toEqual(["MyApp", "SecondApp"]); + }); + + it("should handle complex real-world scenarios (no spaces in app names)", () => { + const prompt = ` + Hi there! I need help with @app:My_Web_App and @app:Mobile_App_v2. + Could you also check the status of @app:backend-service-2023? + Don't forget about @app:legacy_app and @app:NEW_PROJECT. + + Thanks! + @app:user_mention should not be confused with @app:ActualApp. + `; + const result = parseAppMentions(prompt); + expect(result).toEqual([ + "My_Web_App", + "Mobile_App_v2", + "backend-service-2023", + "legacy_app", + "NEW_PROJECT", + "user_mention", + "ActualApp", + ]); + }); + + it("should preserve order of mentions", () => { + const prompt = "@app:Third @app:First @app:Second @app:Third @app:First"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["Third", "First", "Second", "Third", "First"]); + }); + + it("should handle edge case with @ followed by space", () => { + const prompt = "This has @ space but @app:ValidApp is here"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["ValidApp"]); + }); + + it("should handle unicode characters after @", () => { + const prompt = "Check @app:AppName and @app:测试 and @app:café-app"; + const result = parseAppMentions(prompt); + // Based on the regex, unicode characters like 测试 and é should not match + expect(result).toEqual(["AppName", "caf"]); + }); + + it("should handle nested mentions pattern", () => { + const prompt = "Check @app:App1 @app:App2 @app:App3 test"; + const result = parseAppMentions(prompt); + expect(result).toEqual(["App1", "App2", "App3"]); + }); +}); diff --git a/backups/backup-20251218-161645/src/__tests__/parseOllamaHost.test.ts b/backups/backup-20251218-161645/src/__tests__/parseOllamaHost.test.ts new file mode 100644 index 0000000..bd3c185 --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/parseOllamaHost.test.ts @@ -0,0 +1,147 @@ +import { parseOllamaHost } from "@/ipc/handlers/local_model_ollama_handler"; +import { describe, it, expect } from "vitest"; + +describe("parseOllamaHost", () => { + it("should return default URL when no host is provided", () => { + const result = parseOllamaHost(); + expect(result).toBe("http://localhost:11434"); + }); + + it("should return default URL when host is undefined", () => { + const result = parseOllamaHost(undefined); + expect(result).toBe("http://localhost:11434"); + }); + + it("should return default URL when host is empty string", () => { + const result = parseOllamaHost(""); + expect(result).toBe("http://localhost:11434"); + }); + + describe("full URLs with protocol", () => { + it("should return http URLs as-is", () => { + const input = "http://localhost:11434"; + const result = parseOllamaHost(input); + expect(result).toBe("http://localhost:11434"); + }); + + it("should return https URLs as-is", () => { + const input = "https://example.com:11434"; + const result = parseOllamaHost(input); + expect(result).toBe("https://example.com:11434"); + }); + + it("should return http URLs with custom ports as-is", () => { + const input = "http://192.168.1.100:8080"; + const result = parseOllamaHost(input); + expect(result).toBe("http://192.168.1.100:8080"); + }); + + it("should return https URLs with paths as-is", () => { + const input = "https://api.example.com:443/ollama"; + const result = parseOllamaHost(input); + expect(result).toBe("https://api.example.com:443/ollama"); + }); + }); + + describe("hostname with port", () => { + it("should add http protocol to IPv4 host with port", () => { + const input = "192.168.1.100:8080"; + const result = parseOllamaHost(input); + expect(result).toBe("http://192.168.1.100:8080"); + }); + + it("should add http protocol to localhost with custom port", () => { + const input = "localhost:8080"; + const result = parseOllamaHost(input); + expect(result).toBe("http://localhost:8080"); + }); + + it("should add http protocol to domain with port", () => { + const input = "ollama.example.com:11434"; + const result = parseOllamaHost(input); + expect(result).toBe("http://ollama.example.com:11434"); + }); + + it("should add http protocol to 0.0.0.0 with port", () => { + const input = "0.0.0.0:1234"; + const result = parseOllamaHost(input); + expect(result).toBe("http://0.0.0.0:1234"); + }); + + it("should handle IPv6 with port", () => { + const input = "[::1]:8080"; + const result = parseOllamaHost(input); + expect(result).toBe("http://[::1]:8080"); + }); + }); + + describe("hostname only", () => { + it("should add http protocol and default port to IPv4 host", () => { + const input = "192.168.1.100"; + const result = parseOllamaHost(input); + expect(result).toBe("http://192.168.1.100:11434"); + }); + + it("should add http protocol and default port to localhost", () => { + const input = "localhost"; + const result = parseOllamaHost(input); + expect(result).toBe("http://localhost:11434"); + }); + + it("should add http protocol and default port to domain", () => { + const input = "ollama.example.com"; + const result = parseOllamaHost(input); + expect(result).toBe("http://ollama.example.com:11434"); + }); + + it("should add http protocol and default port to 0.0.0.0", () => { + const input = "0.0.0.0"; + const result = parseOllamaHost(input); + expect(result).toBe("http://0.0.0.0:11434"); + }); + + it("should handle IPv6 hostname", () => { + const input = "::1"; + const result = parseOllamaHost(input); + expect(result).toBe("http://[::1]:11434"); + }); + + it("should handle full IPv6 hostname", () => { + const input = "2001:db8:85a3:0:0:8a2e:370:7334"; + const result = parseOllamaHost(input); + expect(result).toBe("http://[2001:db8:85a3:0:0:8a2e:370:7334]:11434"); + }); + + it("should handle compressed IPv6 hostname", () => { + const input = "2001:db8::1"; + const result = parseOllamaHost(input); + expect(result).toBe("http://[2001:db8::1]:11434"); + }); + }); + + describe("edge cases", () => { + it("should handle hostname with unusual characters", () => { + const input = "my-ollama-server"; + const result = parseOllamaHost(input); + expect(result).toBe("http://my-ollama-server:11434"); + }); + + it("should handle hostname with dots", () => { + const input = "my.ollama.server"; + const result = parseOllamaHost(input); + expect(result).toBe("http://my.ollama.server:11434"); + }); + + it("should handle port 80", () => { + const input = "example.com:80"; + const result = parseOllamaHost(input); + expect(result).toBe("http://example.com:80"); + }); + + it("should handle port 443", () => { + const input = "example.com:443"; + const result = parseOllamaHost(input); + expect(result).toBe("http://example.com:443"); + }); + }); +}); diff --git a/backups/backup-20251218-161645/src/__tests__/path_utils.test.ts b/backups/backup-20251218-161645/src/__tests__/path_utils.test.ts new file mode 100644 index 0000000..85dca5e --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/path_utils.test.ts @@ -0,0 +1,227 @@ +import { safeJoin } from "@/ipc/utils/path_utils"; +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import os from "node:os"; + +describe("safeJoin", () => { + const testBaseDir = "/app/workspace"; + const testBaseDirWindows = "C:\\app\\workspace"; + + describe("safe paths", () => { + it("should join simple relative paths", () => { + const result = safeJoin(testBaseDir, "src", "components", "Button.tsx"); + expect(result).toBe( + path.join(testBaseDir, "src", "components", "Button.tsx"), + ); + }); + + it("should handle single file names", () => { + const result = safeJoin(testBaseDir, "package.json"); + expect(result).toBe(path.join(testBaseDir, "package.json")); + }); + + it("should handle nested directories", () => { + const result = safeJoin(testBaseDir, "src/pages/home/index.tsx"); + expect(result).toBe(path.join(testBaseDir, "src/pages/home/index.tsx")); + }); + + it("should handle paths with dots in filename", () => { + const result = safeJoin(testBaseDir, "config.test.js"); + expect(result).toBe(path.join(testBaseDir, "config.test.js")); + }); + + it("should handle empty path segments", () => { + const result = safeJoin(testBaseDir, "", "src", "", "file.ts"); + expect(result).toBe(path.join(testBaseDir, "", "src", "", "file.ts")); + }); + + it("should handle multiple path segments", () => { + const result = safeJoin(testBaseDir, "a", "b", "c", "d", "file.txt"); + expect(result).toBe( + path.join(testBaseDir, "a", "b", "c", "d", "file.txt"), + ); + }); + + it("should work with actual temp directory", () => { + const tempDir = os.tmpdir(); + const result = safeJoin(tempDir, "test", "file.txt"); + expect(result).toBe(path.join(tempDir, "test", "file.txt")); + }); + + it("should handle Windows-style relative paths with backslashes", () => { + const result = safeJoin(testBaseDir, "src\\components\\Button.tsx"); + expect(result).toBe( + path.join(testBaseDir, "src\\components\\Button.tsx"), + ); + }); + + it("should handle mixed forward/backslashes in relative paths", () => { + const result = safeJoin(testBaseDir, "src/components\\ui/button.tsx"); + expect(result).toBe( + path.join(testBaseDir, "src/components\\ui/button.tsx"), + ); + }); + + it("should handle Windows-style nested directories", () => { + const result = safeJoin( + testBaseDir, + "pages\\home\\components\\index.tsx", + ); + expect(result).toBe( + path.join(testBaseDir, "pages\\home\\components\\index.tsx"), + ); + }); + + it("should handle relative paths starting with dot and backslash", () => { + const result = safeJoin(testBaseDir, ".\\src\\file.txt"); + expect(result).toBe(path.join(testBaseDir, ".\\src\\file.txt")); + }); + }); + + describe("unsafe paths - directory traversal", () => { + it("should throw on simple parent directory traversal", () => { + expect(() => safeJoin(testBaseDir, "../outside.txt")).toThrow( + /would escape the base directory/, + ); + }); + + it("should throw on multiple parent directory traversals", () => { + expect(() => safeJoin(testBaseDir, "../../etc/passwd")).toThrow( + /would escape the base directory/, + ); + }); + + it("should throw on complex traversal paths", () => { + expect(() => safeJoin(testBaseDir, "src/../../../etc/passwd")).toThrow( + /would escape the base directory/, + ); + }); + + it("should throw on mixed traversal with valid components", () => { + expect(() => + safeJoin( + testBaseDir, + "src", + "components", + "..", + "..", + "..", + "outside.txt", + ), + ).toThrow(/would escape the base directory/); + }); + + it("should throw on absolute Unix paths", () => { + expect(() => safeJoin(testBaseDir, "/etc/passwd")).toThrow( + /would escape the base directory/, + ); + }); + + it("should throw on absolute Windows paths", () => { + expect(() => + safeJoin(testBaseDir, "C:\\Windows\\System32\\config"), + ).toThrow(/would escape the base directory/); + }); + + it("should throw on Windows UNC paths", () => { + expect(() => + safeJoin(testBaseDir, "\\\\server\\share\\file.txt"), + ).toThrow(/would escape the base directory/); + }); + + it("should throw on home directory shortcuts", () => { + expect(() => safeJoin(testBaseDir, "~/secrets.txt")).toThrow( + /would escape the base directory/, + ); + }); + }); + + describe("edge cases", () => { + it("should handle Windows-style base paths", () => { + const result = safeJoin(testBaseDirWindows, "src", "file.txt"); + expect(result).toBe(path.join(testBaseDirWindows, "src", "file.txt")); + }); + + it("should throw on Windows traversal from Unix base", () => { + expect(() => safeJoin(testBaseDir, "..\\..\\file.txt")).toThrow( + /would escape the base directory/, + ); + }); + + it("should handle current directory references safely", () => { + const result = safeJoin(testBaseDir, "./src/file.txt"); + expect(result).toBe(path.join(testBaseDir, "./src/file.txt")); + }); + + it("should handle nested current directory references", () => { + const result = safeJoin(testBaseDir, "src/./components/./Button.tsx"); + expect(result).toBe( + path.join(testBaseDir, "src/./components/./Button.tsx"), + ); + }); + + it("should throw when current dir plus traversal escapes", () => { + expect(() => safeJoin(testBaseDir, "./../../outside.txt")).toThrow( + /would escape the base directory/, + ); + }); + + it("should handle very long paths safely", () => { + const longPath = Array(50).fill("subdir").join("/") + "/file.txt"; + const result = safeJoin(testBaseDir, longPath); + expect(result).toBe(path.join(testBaseDir, longPath)); + }); + + it("should allow Windows-style paths that look like drive letters but aren't", () => { + // These look like they could be problematic but are actually safe relative paths + const result1 = safeJoin(testBaseDir, "C_drive\\file.txt"); + expect(result1).toBe(path.join(testBaseDir, "C_drive\\file.txt")); + + const result2 = safeJoin(testBaseDir, "src\\C-file.txt"); + expect(result2).toBe(path.join(testBaseDir, "src\\C-file.txt")); + }); + + it("should handle Windows paths with multiple backslashes (not UNC)", () => { + // Single backslashes in the middle are fine - it's only \\ at the start that's UNC + const result = safeJoin(testBaseDir, "src\\\\components\\\\Button.tsx"); + expect(result).toBe( + path.join(testBaseDir, "src\\\\components\\\\Button.tsx"), + ); + }); + + it("should provide descriptive error messages", () => { + expect(() => safeJoin("/base", "../outside.txt")).toThrow( + 'Unsafe path: joining "../outside.txt" with base "/base" would escape the base directory', + ); + }); + + it("should provide descriptive error for multiple segments", () => { + expect(() => safeJoin("/base", "src", "..", "..", "outside.txt")).toThrow( + 'Unsafe path: joining "src, .., .., outside.txt" with base "/base" would escape the base directory', + ); + }); + }); + + describe("boundary conditions", () => { + it("should allow paths at the exact boundary", () => { + const result = safeJoin(testBaseDir, "."); + expect(result).toBe(path.join(testBaseDir, ".")); + }); + + it("should handle paths that approach but don't cross boundary", () => { + const result = safeJoin(testBaseDir, "deep/nested/../file.txt"); + expect(result).toBe(path.join(testBaseDir, "deep/nested/../file.txt")); + }); + + it("should handle root directory as base", () => { + const result = safeJoin("/", "tmp/file.txt"); + expect(result).toBe(path.join("/", "tmp/file.txt")); + }); + + it("should throw when trying to escape root", () => { + expect(() => safeJoin("/tmp", "../etc/passwd")).toThrow( + /would escape the base directory/, + ); + }); + }); +}); diff --git a/backups/backup-20251218-161645/src/__tests__/problem_prompt.test.ts b/backups/backup-20251218-161645/src/__tests__/problem_prompt.test.ts new file mode 100644 index 0000000..70e744c --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/problem_prompt.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect } from "vitest"; +import { createProblemFixPrompt } from "../shared/problem_prompt"; +import type { ProblemReport } from "../ipc/ipc_types"; + +const snippet = `SNIPPET`; + +describe("problem_prompt", () => { + describe("createProblemFixPrompt", () => { + it("should return a message when no problems exist", () => { + const problemReport: ProblemReport = { + problems: [], + }; + + const result = createProblemFixPrompt(problemReport); + expect(result).toMatchSnapshot(); + }); + + it("should format a single error correctly", () => { + const problemReport: ProblemReport = { + problems: [ + { + file: "src/components/Button.tsx", + line: 15, + column: 23, + message: "Property 'onClick' does not exist on type 'ButtonProps'.", + code: 2339, + snippet, + }, + ], + }; + + const result = createProblemFixPrompt(problemReport); + expect(result).toMatchSnapshot(); + }); + + it("should format multiple errors across multiple files", () => { + const problemReport: ProblemReport = { + problems: [ + { + file: "src/components/Button.tsx", + line: 15, + column: 23, + message: "Property 'onClick' does not exist on type 'ButtonProps'.", + code: 2339, + snippet, + }, + { + file: "src/components/Button.tsx", + line: 8, + column: 12, + message: + "Type 'string | undefined' is not assignable to type 'string'.", + code: 2322, + snippet, + }, + { + file: "src/hooks/useApi.ts", + line: 42, + column: 5, + message: + "Argument of type 'unknown' is not assignable to parameter of type 'string'.", + code: 2345, + snippet, + }, + { + file: "src/utils/helpers.ts", + line: 45, + column: 8, + message: + "Function lacks ending return statement and return type does not include 'undefined'.", + code: 2366, + snippet, + }, + ], + }; + + const result = createProblemFixPrompt(problemReport); + expect(result).toMatchSnapshot(); + }); + + it("should handle realistic React TypeScript errors", () => { + const problemReport: ProblemReport = { + problems: [ + { + file: "src/components/UserProfile.tsx", + line: 12, + column: 35, + message: + "Type '{ children: string; }' is missing the following properties from type 'UserProfileProps': user, onEdit", + code: 2739, + snippet, + }, + { + file: "src/components/UserProfile.tsx", + line: 25, + column: 15, + message: "Object is possibly 'null'.", + code: 2531, + snippet, + }, + { + file: "src/hooks/useLocalStorage.ts", + line: 18, + column: 12, + message: "Type 'string | null' is not assignable to type 'T'.", + code: 2322, + snippet, + }, + { + file: "src/types/api.ts", + line: 45, + column: 3, + message: "Duplicate identifier 'UserRole'.", + code: 2300, + snippet, + }, + ], + }; + + const result = createProblemFixPrompt(problemReport); + expect(result).toMatchSnapshot(); + }); + }); + + describe("createConciseProblemFixPrompt", () => { + it("should return a short message when no problems exist", () => { + const problemReport: ProblemReport = { + problems: [], + }; + + const result = createProblemFixPrompt(problemReport); + expect(result).toMatchSnapshot(); + }); + + it("should format a concise prompt for single error", () => { + const problemReport: ProblemReport = { + problems: [ + { + file: "src/App.tsx", + line: 10, + column: 5, + message: "Cannot find name 'consol'. Did you mean 'console'?", + code: 2552, + snippet, + }, + ], + }; + + const result = createProblemFixPrompt(problemReport); + expect(result).toMatchSnapshot(); + }); + + it("should format a concise prompt for multiple errors", () => { + const problemReport: ProblemReport = { + problems: [ + { + file: "src/main.ts", + line: 5, + column: 12, + message: + "Cannot find module 'react-dom/client' or its corresponding type declarations.", + code: 2307, + snippet, + }, + { + file: "src/components/Modal.tsx", + line: 35, + column: 20, + message: + "Property 'isOpen' does not exist on type 'IntrinsicAttributes & ModalProps'.", + code: 2339, + snippet, + }, + ], + }; + + const result = createProblemFixPrompt(problemReport); + expect(result).toMatchSnapshot(); + }); + }); + + describe("realistic TypeScript error scenarios", () => { + it("should handle common React + TypeScript errors", () => { + const problemReport: ProblemReport = { + problems: [ + // Missing interface property + { + file: "src/components/ProductCard.tsx", + line: 22, + column: 18, + message: + "Property 'price' is missing in type '{ name: string; description: string; }' but required in type 'Product'.", + code: 2741, + snippet, + }, + // Incorrect event handler type + { + file: "src/components/SearchInput.tsx", + line: 15, + column: 45, + message: + "Type '(value: string) => void' is not assignable to type 'ChangeEventHandler'.", + code: 2322, + snippet, + }, + // Async/await without Promise return type + { + file: "src/api/userService.ts", + line: 8, + column: 1, + message: + "Function lacks ending return statement and return type does not include 'undefined'.", + code: 2366, + snippet, + }, + // Strict null check + { + file: "src/utils/dataProcessor.ts", + line: 34, + column: 25, + message: "Object is possibly 'undefined'.", + code: 2532, + snippet, + }, + ], + }; + + const result = createProblemFixPrompt(problemReport); + expect(result).toMatchSnapshot(); + }); + }); +}); diff --git a/backups/backup-20251218-161645/src/__tests__/readSettings.test.ts b/backups/backup-20251218-161645/src/__tests__/readSettings.test.ts new file mode 100644 index 0000000..31f16b0 --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/readSettings.test.ts @@ -0,0 +1,409 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { safeStorage } from "electron"; +import { readSettings, getSettingsFilePath } from "@/main/settings"; +import { getUserDataPath } from "@/paths/paths"; +import { UserSettings } from "@/lib/schemas"; + +// Mock dependencies +vi.mock("node:fs"); +vi.mock("node:path"); +vi.mock("electron", () => ({ + safeStorage: { + isEncryptionAvailable: vi.fn(), + decryptString: vi.fn(), + }, +})); +vi.mock("@/paths/paths", () => ({ + getUserDataPath: vi.fn(), +})); + +const mockFs = vi.mocked(fs); +const mockPath = vi.mocked(path); +const mockSafeStorage = vi.mocked(safeStorage); +const mockGetUserDataPath = vi.mocked(getUserDataPath); + +describe("readSettings", () => { + const mockUserDataPath = "/mock/user/data"; + const mockSettingsPath = "/mock/user/data/user-settings.json"; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetUserDataPath.mockReturnValue(mockUserDataPath); + mockPath.join.mockReturnValue(mockSettingsPath); + mockSafeStorage.isEncryptionAvailable.mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("when settings file does not exist", () => { + it("should create default settings file and return default settings", () => { + mockFs.existsSync.mockReturnValue(false); + mockFs.writeFileSync.mockImplementation(() => {}); + + const result = readSettings(); + + expect(mockFs.existsSync).toHaveBeenCalledWith(mockSettingsPath); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + mockSettingsPath, + expect.stringContaining('"selectedModel"'), + ); + expect(scrubSettings(result)).toMatchInlineSnapshot(` + { + "enableAutoFixProblems": false, + "enableAutoUpdate": true, + "enableProLazyEditsMode": true, + "enableProSmartFilesContextMode": true, + "experiments": {}, + "hasRunBefore": false, + "isRunning": false, + "lastKnownPerformance": undefined, + "providerSettings": {}, + "releaseChannel": "stable", + "selectedChatMode": "build", + "selectedModel": { + "name": "auto", + "provider": "auto", + }, + "selectedTemplateId": "react", + "telemetryConsent": "unset", + "telemetryUserId": "[scrubbed]", + } + `); + }); + }); + + describe("when settings file exists", () => { + it("should read and merge settings with defaults", () => { + const mockFileContent = { + selectedModel: { + name: "gpt-4", + provider: "openai", + }, + telemetryConsent: "opted_in", + hasRunBefore: true, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + + const result = readSettings(); + + expect(mockFs.readFileSync).toHaveBeenCalledWith( + mockSettingsPath, + "utf-8", + ); + expect(result.selectedModel).toEqual({ + name: "gpt-4", + provider: "openai", + }); + expect(result.telemetryConsent).toBe("opted_in"); + expect(result.hasRunBefore).toBe(true); + // Should still have defaults for missing properties + expect(result.enableAutoUpdate).toBe(true); + expect(result.releaseChannel).toBe("stable"); + }); + + it("should decrypt encrypted provider API keys", () => { + const mockFileContent = { + providerSettings: { + openai: { + apiKey: { + value: "encrypted-api-key", + encryptionType: "electron-safe-storage", + }, + }, + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + mockSafeStorage.decryptString.mockReturnValue("decrypted-api-key"); + + const result = readSettings(); + + expect(mockSafeStorage.decryptString).toHaveBeenCalledWith( + Buffer.from("encrypted-api-key", "base64"), + ); + expect(result.providerSettings.openai.apiKey).toEqual({ + value: "decrypted-api-key", + encryptionType: "electron-safe-storage", + }); + }); + + it("should decrypt encrypted GitHub access token", () => { + const mockFileContent = { + githubAccessToken: { + value: "encrypted-github-token", + encryptionType: "electron-safe-storage", + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + mockSafeStorage.decryptString.mockReturnValue("decrypted-github-token"); + + const result = readSettings(); + + expect(mockSafeStorage.decryptString).toHaveBeenCalledWith( + Buffer.from("encrypted-github-token", "base64"), + ); + expect(result.githubAccessToken).toEqual({ + value: "decrypted-github-token", + encryptionType: "electron-safe-storage", + }); + }); + + it("should decrypt encrypted Supabase tokens", () => { + const mockFileContent = { + supabase: { + accessToken: { + value: "encrypted-access-token", + encryptionType: "electron-safe-storage", + }, + refreshToken: { + value: "encrypted-refresh-token", + encryptionType: "electron-safe-storage", + }, + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + mockSafeStorage.decryptString + .mockReturnValueOnce("decrypted-refresh-token") + .mockReturnValueOnce("decrypted-access-token"); + + const result = readSettings(); + + expect(mockSafeStorage.decryptString).toHaveBeenCalledTimes(2); + expect(result.supabase?.refreshToken).toEqual({ + value: "decrypted-refresh-token", + encryptionType: "electron-safe-storage", + }); + expect(result.supabase?.accessToken).toEqual({ + value: "decrypted-access-token", + encryptionType: "electron-safe-storage", + }); + }); + + it("should handle plaintext secrets without decryption", () => { + const mockFileContent = { + githubAccessToken: { + value: "plaintext-token", + encryptionType: "plaintext", + }, + providerSettings: { + openai: { + apiKey: { + value: "plaintext-api-key", + encryptionType: "plaintext", + }, + }, + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + + const result = readSettings(); + + expect(mockSafeStorage.decryptString).not.toHaveBeenCalled(); + expect(result.githubAccessToken?.value).toBe("plaintext-token"); + expect(result.providerSettings.openai.apiKey?.value).toBe( + "plaintext-api-key", + ); + }); + + it("should handle secrets without encryptionType", () => { + const mockFileContent = { + githubAccessToken: { + value: "token-without-encryption-type", + }, + providerSettings: { + openai: { + apiKey: { + value: "api-key-without-encryption-type", + }, + }, + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + + const result = readSettings(); + + expect(mockSafeStorage.decryptString).not.toHaveBeenCalled(); + expect(result.githubAccessToken?.value).toBe( + "token-without-encryption-type", + ); + expect(result.providerSettings.openai.apiKey?.value).toBe( + "api-key-without-encryption-type", + ); + }); + + it("should strip extra fields not recognized by the schema", () => { + const mockFileContent = { + selectedModel: { + name: "gpt-4", + provider: "openai", + }, + telemetryConsent: "opted_in", + hasRunBefore: true, + // Extra fields that are not in the schema + unknownField: "should be removed", + deprecatedSetting: true, + extraConfig: { + someValue: 123, + anotherValue: "test", + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + + const result = readSettings(); + + expect(mockFs.readFileSync).toHaveBeenCalledWith( + mockSettingsPath, + "utf-8", + ); + expect(result.selectedModel).toEqual({ + name: "gpt-4", + provider: "openai", + }); + expect(result.telemetryConsent).toBe("opted_in"); + expect(result.hasRunBefore).toBe(true); + + // Extra fields should be stripped by schema validation + expect(result).not.toHaveProperty("unknownField"); + expect(result).not.toHaveProperty("deprecatedSetting"); + expect(result).not.toHaveProperty("extraConfig"); + + // Should still have defaults for missing properties + expect(result.enableAutoUpdate).toBe(true); + expect(result.releaseChannel).toBe("stable"); + }); + }); + + describe("error handling", () => { + it("should return default settings when file read fails", () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockImplementation(() => { + throw new Error("File read error"); + }); + + const result = readSettings(); + + expect(scrubSettings(result)).toMatchInlineSnapshot(` + { + "enableAutoFixProblems": false, + "enableAutoUpdate": true, + "enableProLazyEditsMode": true, + "enableProSmartFilesContextMode": true, + "experiments": {}, + "hasRunBefore": false, + "isRunning": false, + "lastKnownPerformance": undefined, + "providerSettings": {}, + "releaseChannel": "stable", + "selectedChatMode": "build", + "selectedModel": { + "name": "auto", + "provider": "auto", + }, + "selectedTemplateId": "react", + "telemetryConsent": "unset", + "telemetryUserId": "[scrubbed]", + } + `); + }); + + it("should return default settings when JSON parsing fails", () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue("invalid json"); + + const result = readSettings(); + + expect(result).toMatchObject({ + selectedModel: { + name: "auto", + provider: "auto", + }, + releaseChannel: "stable", + }); + }); + + it("should return default settings when schema validation fails", () => { + const mockFileContent = { + selectedModel: { + name: "gpt-4", + // Missing required 'provider' field + }, + releaseChannel: "invalid-channel", // Invalid enum value + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + + const result = readSettings(); + + expect(result).toMatchObject({ + selectedModel: { + name: "auto", + provider: "auto", + }, + releaseChannel: "stable", + }); + }); + + it("should handle decryption errors gracefully", () => { + const mockFileContent = { + githubAccessToken: { + value: "corrupted-encrypted-data", + encryptionType: "electron-safe-storage", + }, + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(mockFileContent)); + mockSafeStorage.decryptString.mockImplementation(() => { + throw new Error("Decryption failed"); + }); + + const result = readSettings(); + + expect(result).toMatchObject({ + selectedModel: { + name: "auto", + provider: "auto", + }, + releaseChannel: "stable", + }); + }); + }); + + describe("getSettingsFilePath", () => { + it("should return correct settings file path", () => { + const result = getSettingsFilePath(); + + expect(mockGetUserDataPath).toHaveBeenCalled(); + expect(mockPath.join).toHaveBeenCalledWith( + mockUserDataPath, + "user-settings.json", + ); + expect(result).toBe(mockSettingsPath); + }); + }); +}); + +function scrubSettings(result: UserSettings) { + return { + ...result, + telemetryUserId: "[scrubbed]", + }; +} diff --git a/backups/backup-20251218-161645/src/__tests__/replacePromptReference.test.ts b/backups/backup-20251218-161645/src/__tests__/replacePromptReference.test.ts new file mode 100644 index 0000000..87fd149 --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/replacePromptReference.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { replacePromptReference } from "@/ipc/utils/replacePromptReference"; + +describe("replacePromptReference", () => { + it("returns original when no references present", () => { + const input = "Hello world"; + const output = replacePromptReference(input, {}); + expect(output).toBe(input); + }); + + it("replaces a single @prompt:id with content", () => { + const input = "Use this: @prompt:42"; + const prompts = { 42: "Meaning of life" }; + const output = replacePromptReference(input, prompts); + expect(output).toBe("Use this: Meaning of life"); + }); + + it("replaces multiple occurrences and keeps surrounding text", () => { + const input = "A @prompt:1 and B @prompt:2 end"; + const prompts = { 1: "One", 2: "Two" }; + const output = replacePromptReference(input, prompts); + expect(output).toBe("A One and B Two end"); + }); + + it("leaves unknown references intact", () => { + const input = "Unknown @prompt:99 here"; + const prompts = { 1: "One" }; + const output = replacePromptReference(input, prompts); + expect(output).toBe("Unknown @prompt:99 here"); + }); + + it("supports string keys in map as well as numeric", () => { + const input = "Mix @prompt:7 and @prompt:8"; + const prompts = { "7": "Seven", 8: "Eight" } as Record< + string | number, + string + >; + const output = replacePromptReference(input, prompts); + expect(output).toBe("Mix Seven and Eight"); + }); +}); diff --git a/backups/backup-20251218-161645/src/__tests__/style-utils.test.ts b/backups/backup-20251218-161645/src/__tests__/style-utils.test.ts new file mode 100644 index 0000000..4b417b5 --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/style-utils.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { stylesToTailwind } from "../utils/style-utils"; + +describe("convertSpacingToTailwind", () => { + describe("margin conversion", () => { + it("should convert equal margins on all sides", () => { + const result = stylesToTailwind({ + margin: { left: "16px", right: "16px", top: "16px", bottom: "16px" }, + }); + expect(result).toEqual(["m-[16px]"]); + }); + + it("should convert equal horizontal margins", () => { + const result = stylesToTailwind({ + margin: { left: "16px", right: "16px" }, + }); + expect(result).toEqual(["mx-[16px]"]); + }); + + it("should convert equal vertical margins", () => { + const result = stylesToTailwind({ + margin: { top: "16px", bottom: "16px" }, + }); + expect(result).toEqual(["my-[16px]"]); + }); + }); + + describe("padding conversion", () => { + it("should convert equal padding on all sides", () => { + const result = stylesToTailwind({ + padding: { left: "20px", right: "20px", top: "20px", bottom: "20px" }, + }); + expect(result).toEqual(["p-[20px]"]); + }); + + it("should convert equal horizontal padding", () => { + const result = stylesToTailwind({ + padding: { left: "12px", right: "12px" }, + }); + expect(result).toEqual(["px-[12px]"]); + }); + + it("should convert equal vertical padding", () => { + const result = stylesToTailwind({ + padding: { top: "8px", bottom: "8px" }, + }); + expect(result).toEqual(["py-[8px]"]); + }); + }); + + describe("combined margin and padding", () => { + it("should handle both margin and padding", () => { + const result = stylesToTailwind({ + margin: { left: "16px", right: "16px" }, + padding: { top: "8px", bottom: "8px" }, + }); + expect(result).toContain("mx-[16px]"); + expect(result).toContain("py-[8px]"); + expect(result).toHaveLength(2); + }); + }); + + describe("edge cases: equal horizontal and vertical spacing", () => { + it("should consolidate px = py to p when values match", () => { + const result = stylesToTailwind({ + padding: { left: "16px", right: "16px", top: "16px", bottom: "16px" }, + }); + // When all four sides are equal, should use p-[] + expect(result).toEqual(["p-[16px]"]); + }); + + it("should consolidate mx = my to m when values match (but not all four sides)", () => { + const result = stylesToTailwind({ + margin: { left: "20px", right: "20px", top: "20px", bottom: "20px" }, + }); + // When all four sides are equal, should use m-[] + expect(result).toEqual(["m-[20px]"]); + }); + + it("should not consolidate when px != py", () => { + const result = stylesToTailwind({ + padding: { left: "16px", right: "16px", top: "8px", bottom: "8px" }, + }); + expect(result).toContain("px-[16px]"); + expect(result).toContain("py-[8px]"); + expect(result).toHaveLength(2); + }); + + it("should not consolidate when mx != my", () => { + const result = stylesToTailwind({ + margin: { left: "20px", right: "20px", top: "10px", bottom: "10px" }, + }); + expect(result).toContain("mx-[20px]"); + expect(result).toContain("my-[10px]"); + expect(result).toHaveLength(2); + }); + + it("should handle case where left != right", () => { + const result = stylesToTailwind({ + padding: { left: "16px", right: "12px", top: "8px", bottom: "8px" }, + }); + expect(result).toContain("pl-[16px]"); + expect(result).toContain("pr-[12px]"); + expect(result).toContain("py-[8px]"); + expect(result).toHaveLength(3); + }); + + it("should handle case where top != bottom", () => { + const result = stylesToTailwind({ + margin: { left: "20px", right: "20px", top: "10px", bottom: "15px" }, + }); + expect(result).toContain("mx-[20px]"); + expect(result).toContain("mt-[10px]"); + expect(result).toContain("mb-[15px]"); + expect(result).toHaveLength(3); + }); + }); +}); diff --git a/backups/backup-20251218-161645/src/__tests__/supabase_utils.test.ts b/backups/backup-20251218-161645/src/__tests__/supabase_utils.test.ts new file mode 100644 index 0000000..743344b --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/supabase_utils.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect } from "vitest"; +import { + isServerFunction, + isSharedServerModule, + extractFunctionNameFromPath, +} from "@/supabase_admin/supabase_utils"; +import { + toPosixPath, + stripSupabaseFunctionsPrefix, + buildSignature, + type FileStatEntry, +} from "@/supabase_admin/supabase_management_client"; + +describe("isServerFunction", () => { + describe("returns true for valid function paths", () => { + it("should return true for function index.ts", () => { + expect(isServerFunction("supabase/functions/hello/index.ts")).toBe(true); + }); + + it("should return true for nested function files", () => { + expect(isServerFunction("supabase/functions/hello/lib/utils.ts")).toBe( + true, + ); + }); + + it("should return true for function with complex name", () => { + expect(isServerFunction("supabase/functions/send-email/index.ts")).toBe( + true, + ); + }); + }); + + describe("returns false for non-function paths", () => { + it("should return false for shared modules", () => { + expect(isServerFunction("supabase/functions/_shared/utils.ts")).toBe( + false, + ); + }); + + it("should return false for regular source files", () => { + expect(isServerFunction("src/components/Button.tsx")).toBe(false); + }); + + it("should return false for root supabase files", () => { + expect(isServerFunction("supabase/config.toml")).toBe(false); + }); + + it("should return false for non-supabase paths", () => { + expect(isServerFunction("package.json")).toBe(false); + }); + }); +}); + +describe("isSharedServerModule", () => { + describe("returns true for _shared paths", () => { + it("should return true for files in _shared", () => { + expect(isSharedServerModule("supabase/functions/_shared/utils.ts")).toBe( + true, + ); + }); + + it("should return true for nested _shared files", () => { + expect( + isSharedServerModule("supabase/functions/_shared/lib/helpers.ts"), + ).toBe(true); + }); + + it("should return true for _shared directory itself", () => { + expect(isSharedServerModule("supabase/functions/_shared/")).toBe(true); + }); + }); + + describe("returns false for non-_shared paths", () => { + it("should return false for regular functions", () => { + expect(isSharedServerModule("supabase/functions/hello/index.ts")).toBe( + false, + ); + }); + + it("should return false for similar but different paths", () => { + expect(isSharedServerModule("supabase/functions/shared/utils.ts")).toBe( + false, + ); + }); + + it("should return false for _shared in wrong location", () => { + expect(isSharedServerModule("src/_shared/utils.ts")).toBe(false); + }); + }); +}); + +describe("extractFunctionNameFromPath", () => { + describe("extracts function name correctly from nested paths", () => { + it("should extract function name from index.ts path", () => { + expect( + extractFunctionNameFromPath("supabase/functions/hello/index.ts"), + ).toBe("hello"); + }); + + it("should extract function name from deeply nested path", () => { + expect( + extractFunctionNameFromPath("supabase/functions/hello/lib/utils.ts"), + ).toBe("hello"); + }); + + it("should extract function name from very deeply nested path", () => { + expect( + extractFunctionNameFromPath( + "supabase/functions/hello/src/helpers/format.ts", + ), + ).toBe("hello"); + }); + + it("should extract function name with dashes", () => { + expect( + extractFunctionNameFromPath("supabase/functions/send-email/index.ts"), + ).toBe("send-email"); + }); + + it("should extract function name with underscores", () => { + expect( + extractFunctionNameFromPath("supabase/functions/my_function/index.ts"), + ).toBe("my_function"); + }); + }); + + describe("throws for invalid paths", () => { + it("should throw for _shared paths", () => { + expect(() => + extractFunctionNameFromPath("supabase/functions/_shared/utils.ts"), + ).toThrow(/Function names starting with "_" are reserved/); + }); + + it("should throw for other _ prefixed directories", () => { + expect(() => + extractFunctionNameFromPath("supabase/functions/_internal/utils.ts"), + ).toThrow(/Function names starting with "_" are reserved/); + }); + + it("should throw for non-supabase paths", () => { + expect(() => + extractFunctionNameFromPath("src/components/Button.tsx"), + ).toThrow(/Invalid Supabase function path/); + }); + + it("should throw for supabase root files", () => { + expect(() => extractFunctionNameFromPath("supabase/config.toml")).toThrow( + /Invalid Supabase function path/, + ); + }); + + it("should throw for partial matches", () => { + expect(() => extractFunctionNameFromPath("supabase/functions")).toThrow( + /Invalid Supabase function path/, + ); + }); + }); + + describe("handles edge cases", () => { + it("should handle backslashes (Windows paths)", () => { + expect( + extractFunctionNameFromPath( + "supabase\\functions\\hello\\lib\\utils.ts", + ), + ).toBe("hello"); + }); + + it("should handle mixed slashes", () => { + expect( + extractFunctionNameFromPath("supabase/functions\\hello/lib\\utils.ts"), + ).toBe("hello"); + }); + }); +}); + +describe("toPosixPath", () => { + it("should keep forward slashes unchanged", () => { + expect(toPosixPath("supabase/functions/hello/index.ts")).toBe( + "supabase/functions/hello/index.ts", + ); + }); + + it("should handle empty string", () => { + expect(toPosixPath("")).toBe(""); + }); + + it("should handle single filename", () => { + expect(toPosixPath("index.ts")).toBe("index.ts"); + }); + + // Note: On Unix, path.sep is "/", so backslashes won't be converted + // This test is for documentation - actual behavior depends on platform + it("should handle path with no separators", () => { + expect(toPosixPath("filename")).toBe("filename"); + }); +}); + +describe("stripSupabaseFunctionsPrefix", () => { + describe("strips prefix correctly", () => { + it("should strip full prefix from index.ts", () => { + expect( + stripSupabaseFunctionsPrefix( + "supabase/functions/hello/index.ts", + "hello", + ), + ).toBe("index.ts"); + }); + + it("should strip prefix from nested file", () => { + expect( + stripSupabaseFunctionsPrefix( + "supabase/functions/hello/lib/utils.ts", + "hello", + ), + ).toBe("lib/utils.ts"); + }); + + it("should handle leading slash", () => { + expect( + stripSupabaseFunctionsPrefix( + "/supabase/functions/hello/index.ts", + "hello", + ), + ).toBe("index.ts"); + }); + }); + + describe("handles edge cases", () => { + it("should return filename when no prefix match", () => { + const result = stripSupabaseFunctionsPrefix("just-a-file.ts", "hello"); + expect(result).toBe("just-a-file.ts"); + }); + + it("should handle paths without function name", () => { + const result = stripSupabaseFunctionsPrefix( + "supabase/functions/other/index.ts", + "hello", + ); + // Should strip base prefix and return the rest + expect(result).toBe("other/index.ts"); + }); + + it("should handle empty relative path after prefix", () => { + // When the path is exactly the function directory + const result = stripSupabaseFunctionsPrefix( + "supabase/functions/hello", + "hello", + ); + expect(result).toBe("hello"); + }); + }); +}); + +describe("buildSignature", () => { + it("should build signature from single entry", () => { + const entries: FileStatEntry[] = [ + { + absolutePath: "/app/file.ts", + relativePath: "file.ts", + mtimeMs: 1000, + size: 100, + }, + ]; + const result = buildSignature(entries); + expect(result).toBe("file.ts:3e8:64"); + }); + + it("should build signature from multiple entries sorted by relativePath", () => { + const entries: FileStatEntry[] = [ + { + absolutePath: "/app/b.ts", + relativePath: "b.ts", + mtimeMs: 2000, + size: 200, + }, + { + absolutePath: "/app/a.ts", + relativePath: "a.ts", + mtimeMs: 1000, + size: 100, + }, + ]; + const result = buildSignature(entries); + // Should be sorted by relativePath + expect(result).toBe("a.ts:3e8:64|b.ts:7d0:c8"); + }); + + it("should return empty string for empty array", () => { + const result = buildSignature([]); + expect(result).toBe(""); + }); + + it("should produce different signatures for different mtimes", () => { + const entries1: FileStatEntry[] = [ + { + absolutePath: "/app/file.ts", + relativePath: "file.ts", + mtimeMs: 1000, + size: 100, + }, + ]; + const entries2: FileStatEntry[] = [ + { + absolutePath: "/app/file.ts", + relativePath: "file.ts", + mtimeMs: 2000, + size: 100, + }, + ]; + expect(buildSignature(entries1)).not.toBe(buildSignature(entries2)); + }); + + it("should produce different signatures for different sizes", () => { + const entries1: FileStatEntry[] = [ + { + absolutePath: "/app/file.ts", + relativePath: "file.ts", + mtimeMs: 1000, + size: 100, + }, + ]; + const entries2: FileStatEntry[] = [ + { + absolutePath: "/app/file.ts", + relativePath: "file.ts", + mtimeMs: 1000, + size: 200, + }, + ]; + expect(buildSignature(entries1)).not.toBe(buildSignature(entries2)); + }); + + it("should include path in signature for cache invalidation", () => { + const entries1: FileStatEntry[] = [ + { + absolutePath: "/app/a.ts", + relativePath: "a.ts", + mtimeMs: 1000, + size: 100, + }, + ]; + const entries2: FileStatEntry[] = [ + { + absolutePath: "/app/b.ts", + relativePath: "b.ts", + mtimeMs: 1000, + size: 100, + }, + ]; + expect(buildSignature(entries1)).not.toBe(buildSignature(entries2)); + }); +}); diff --git a/backups/backup-20251218-161645/src/__tests__/versioned_codebase_context.test.ts b/backups/backup-20251218-161645/src/__tests__/versioned_codebase_context.test.ts new file mode 100644 index 0000000..d668f1c --- /dev/null +++ b/backups/backup-20251218-161645/src/__tests__/versioned_codebase_context.test.ts @@ -0,0 +1,1121 @@ +import { + parseFilesFromMessage, + processChatMessagesWithVersionedFiles, +} from "@/ipc/utils/versioned_codebase_context"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ModelMessage } from "@ai-sdk/provider-utils"; +import type { CodebaseFile } from "@/utils/codebase"; +import crypto from "node:crypto"; + +// Mock git_utils +vi.mock("@/ipc/utils/git_utils", () => ({ + getFileAtCommit: vi.fn(), + getCurrentCommitHash: vi.fn().mockResolvedValue("mock-current-commit-hash"), + isGitStatusClean: vi.fn().mockResolvedValue(true), +})); + +// Mock electron-log +vi.mock("electron-log", () => ({ + default: { + scope: () => ({ + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +describe("parseFilesFromMessage", () => { + describe("dyad-read tags", () => { + it("should parse a single dyad-read tag", () => { + const input = ''; + const result = parseFilesFromMessage(input); + expect(result).toEqual(["src/components/Button.tsx"]); + }); + + it("should parse multiple dyad-read tags", () => { + const input = ` + + + + `; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "src/components/Button.tsx", + "src/utils/helpers.ts", + "src/styles/main.css", + ]); + }); + + it("should trim whitespace from file paths in dyad-read tags", () => { + const input = + ''; + const result = parseFilesFromMessage(input); + expect(result).toEqual(["src/components/Button.tsx"]); + }); + + it("should skip empty path attributes", () => { + const input = ` + + + + `; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "src/components/Button.tsx", + "src/utils/helpers.ts", + ]); + }); + + it("should handle file paths with special characters", () => { + const input = + ''; + const result = parseFilesFromMessage(input); + expect(result).toEqual(["src/components/@special/Button-v2.tsx"]); + }); + }); + + describe("dyad-code-search-result tags", () => { + it("should parse a single file from dyad-code-search-result", () => { + const input = ` +src/components/Button.tsx +`; + const result = parseFilesFromMessage(input); + expect(result).toEqual(["src/components/Button.tsx"]); + }); + + it("should parse multiple files from dyad-code-search-result", () => { + const input = ` +src/components/Button.tsx +src/components/Input.tsx +src/utils/helpers.ts +`; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "src/components/Button.tsx", + "src/components/Input.tsx", + "src/utils/helpers.ts", + ]); + }); + + it("should trim whitespace from each line", () => { + const input = ` + src/components/Button.tsx + src/components/Input.tsx +src/utils/helpers.ts +`; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "src/components/Button.tsx", + "src/components/Input.tsx", + "src/utils/helpers.ts", + ]); + }); + + it("should skip empty lines in dyad-code-search-result", () => { + const input = ` +src/components/Button.tsx + +src/components/Input.tsx + + +src/utils/helpers.ts +`; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "src/components/Button.tsx", + "src/components/Input.tsx", + "src/utils/helpers.ts", + ]); + }); + + it("should skip lines that look like tags (starting with < or >)", () => { + const input = ` +src/components/Button.tsx + +src/components/Input.tsx +>some-line +src/utils/helpers.ts +`; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "src/components/Button.tsx", + "src/components/Input.tsx", + "src/utils/helpers.ts", + ]); + }); + + it("should handle multiple dyad-code-search-result tags", () => { + const input = ` +src/components/Button.tsx +src/components/Input.tsx + + +Some text in between + + +src/utils/helpers.ts +src/styles/main.css +`; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "src/components/Button.tsx", + "src/components/Input.tsx", + "src/utils/helpers.ts", + "src/styles/main.css", + ]); + }); + }); + + describe("mixed tags", () => { + it("should parse both dyad-read and dyad-code-search-result tags", () => { + const input = ` + + + +src/components/Button.tsx +src/components/Input.tsx + + + +`; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "src/config/app.ts", + "src/components/Button.tsx", + "src/components/Input.tsx", + "src/utils/helpers.ts", + ]); + }); + + it("should deduplicate file paths", () => { + const input = ` + + + + +src/components/Button.tsx +src/utils/helpers.ts + +`; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "src/components/Button.tsx", + "src/utils/helpers.ts", + ]); + }); + + it("should handle complex real-world example", () => { + const input = ` +Here's what I found: + + + +I also searched for related files: + + +src/components/Header.tsx +src/components/Footer.tsx +src/styles/layout.css + + +Let me also check the config: + + + +And finally: + + +src/utils/navigation.ts +src/utils/theme.ts + +`; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "src/components/Header.tsx", + "src/components/Footer.tsx", + "src/styles/layout.css", + "src/config/site.ts", + "src/utils/navigation.ts", + "src/utils/theme.ts", + ]); + }); + }); + + describe("edge cases", () => { + it("should return empty array for empty string", () => { + const input = ""; + const result = parseFilesFromMessage(input); + expect(result).toEqual([]); + }); + + it("should return empty array when no tags present", () => { + const input = "This is just some regular text without any tags."; + const result = parseFilesFromMessage(input); + expect(result).toEqual([]); + }); + + it("should handle malformed tags gracefully", () => { + const input = ` + +src/file2.ts +`; + const result = parseFilesFromMessage(input); + // Should not match unclosed tags + expect(result).toEqual([]); + }); + + it("should handle nested angle brackets in file paths", () => { + const input = + ''; + const result = parseFilesFromMessage(input); + expect(result).toEqual(["src/components/Generic.tsx"]); + }); + + it("should preserve file path case sensitivity", () => { + const input = ` +src/Components/Button.tsx +src/components/button.tsx +SRC/COMPONENTS/BUTTON.TSX +`; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "src/Components/Button.tsx", + "src/components/button.tsx", + "SRC/COMPONENTS/BUTTON.TSX", + ]); + }); + + it("should handle very long file paths", () => { + const longPath = + "src/very/deeply/nested/directory/structure/with/many/levels/components/Button.tsx"; + const input = ``; + const result = parseFilesFromMessage(input); + expect(result).toEqual([longPath]); + }); + + it("should handle file paths with dots", () => { + const input = ` +./src/components/Button.tsx +../utils/helpers.ts +../../config/app.config.ts +`; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "./src/components/Button.tsx", + "../utils/helpers.ts", + "../../config/app.config.ts", + ]); + }); + + it("should handle absolute paths", () => { + const input = ` +/absolute/path/to/file.tsx +/another/absolute/path.ts +`; + const result = parseFilesFromMessage(input); + expect(result).toEqual([ + "/absolute/path/to/file.tsx", + "/another/absolute/path.ts", + ]); + }); + }); +}); + +describe("processChatMessagesWithVersionedFiles", () => { + beforeEach(() => { + // Clear all mocks before each test + vi.clearAllMocks(); + }); + + // Helper to compute SHA-256 hash + const hashContent = (content: string): string => { + return crypto.createHash("sha256").update(content).digest("hex"); + }; + + describe("basic functionality", () => { + it("should process files parameter and create fileIdToContent and fileReferences", async () => { + const files: CodebaseFile[] = [ + { + path: "src/components/Button.tsx", + content: "export const Button = () => ;", + }, + { + path: "src/utils/helpers.ts", + content: "export const add = (a: number, b: number) => a + b;", + }, + ]; + + const chatMessages: ModelMessage[] = []; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + // Check fileIdToContent contains hashed content + const buttonHash = hashContent(files[0].content); + const helperHash = hashContent(files[1].content); + + expect(result.fileIdToContent[buttonHash]).toBe(files[0].content); + expect(result.fileIdToContent[helperHash]).toBe(files[1].content); + + // Check fileReferences + expect(result.fileReferences).toHaveLength(2); + expect(result.fileReferences[0]).toEqual({ + path: "src/components/Button.tsx", + fileId: buttonHash, + }); + expect(result.fileReferences[1]).toEqual({ + path: "src/utils/helpers.ts", + fileId: helperHash, + }); + + // messageIndexToFilePathToFileId should be empty + expect(result.messageIndexToFilePathToFileId).toEqual({}); + }); + + it("should handle empty files array", async () => { + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = []; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(result.fileIdToContent).toEqual({}); + expect(result.fileReferences).toEqual([]); + expect(result.messageIndexToFilePathToFileId).toEqual({}); + }); + }); + + describe("processing assistant messages", () => { + it("should process assistant messages with sourceCommitHash", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + const fileContent = "const oldVersion = 'content';"; + mockGetFileAtCommit.mockResolvedValue(fileContent); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: + 'I found this file: ', + providerOptions: { + "dyad-engine": { + sourceCommitHash: "abc123", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + // Verify getFileAtCommit was called correctly + expect(mockGetFileAtCommit).toHaveBeenCalledWith({ + path: appPath, + filePath: "src/old.ts", + commitHash: "abc123", + }); + + // Check fileIdToContent + const fileHash = hashContent(fileContent); + expect(result.fileIdToContent[fileHash]).toBe(fileContent); + + // Check messageIndexToFilePathToFileId + expect(result.messageIndexToFilePathToFileId[0]).toEqual({ + "src/old.ts": fileHash, + }); + }); + + it("should process messages with array content type", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + const fileContent = "const arrayContent = 'test';"; + mockGetFileAtCommit.mockResolvedValue(fileContent); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { + type: "text", + text: 'Here is the file: ', + }, + { + type: "text", + text: "Additional text", + }, + ], + providerOptions: { + "dyad-engine": { + sourceCommitHash: "def456", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(mockGetFileAtCommit).toHaveBeenCalledWith({ + path: appPath, + filePath: "src/array.ts", + commitHash: "def456", + }); + + const fileHash = hashContent(fileContent); + expect(result.fileIdToContent[fileHash]).toBe(fileContent); + expect(result.messageIndexToFilePathToFileId[0]["src/array.ts"]).toBe( + fileHash, + ); + }); + + it("should skip user messages", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "user", + content: + 'Check this: ', + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + // getFileAtCommit should not be called for user messages + expect(mockGetFileAtCommit).not.toHaveBeenCalled(); + expect(result.messageIndexToFilePathToFileId).toEqual({}); + }); + + it("should skip assistant messages without sourceCommitHash", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: 'File here: ', + // No providerOptions + }, + { + role: "assistant", + content: + 'Another file: ', + providerOptions: { + // dyad-engine not set + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(mockGetFileAtCommit).not.toHaveBeenCalled(); + expect(result.messageIndexToFilePathToFileId).toEqual({}); + }); + + it("should skip messages with non-text content", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: [], + providerOptions: { + "dyad-engine": { + sourceCommitHash: "abc123", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(mockGetFileAtCommit).not.toHaveBeenCalled(); + expect(result.messageIndexToFilePathToFileId).toEqual({}); + }); + }); + + describe("parsing multiple file paths", () => { + it("should process multiple files from dyad-code-search-result", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + const file1Content = "file1 content"; + const file2Content = "file2 content"; + + mockGetFileAtCommit + .mockResolvedValueOnce(file1Content) + .mockResolvedValueOnce(file2Content); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: ` +src/file1.ts +src/file2.ts +`, + providerOptions: { + "dyad-engine": { + sourceCommitHash: "commit1", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(mockGetFileAtCommit).toHaveBeenCalledTimes(2); + expect(mockGetFileAtCommit).toHaveBeenCalledWith({ + path: appPath, + filePath: "src/file1.ts", + commitHash: "commit1", + }); + expect(mockGetFileAtCommit).toHaveBeenCalledWith({ + path: appPath, + filePath: "src/file2.ts", + commitHash: "commit1", + }); + + const file1Hash = hashContent(file1Content); + const file2Hash = hashContent(file2Content); + + expect(result.fileIdToContent[file1Hash]).toBe(file1Content); + expect(result.fileIdToContent[file2Hash]).toBe(file2Content); + + expect(result.messageIndexToFilePathToFileId[0]).toEqual({ + "src/file1.ts": file1Hash, + "src/file2.ts": file2Hash, + }); + }); + + it("should process mixed dyad-read and dyad-code-search-result tags", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + mockGetFileAtCommit + .mockResolvedValueOnce("file1") + .mockResolvedValueOnce("file2") + .mockResolvedValueOnce("file3"); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: ` + + + +src/file2.ts +src/file3.ts + +`, + providerOptions: { + "dyad-engine": { + sourceCommitHash: "hash1", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(mockGetFileAtCommit).toHaveBeenCalledTimes(3); + expect(Object.keys(result.messageIndexToFilePathToFileId[0])).toEqual([ + "src/file1.ts", + "src/file2.ts", + "src/file3.ts", + ]); + }); + }); + + describe("error handling", () => { + it("should handle file not found (returns null)", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + // Simulate file not found + mockGetFileAtCommit.mockResolvedValue(null); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: + 'Missing file: ', + providerOptions: { + "dyad-engine": { + sourceCommitHash: "commit1", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(mockGetFileAtCommit).toHaveBeenCalled(); + + // File should not be in results + expect(result.fileIdToContent).toEqual({}); + expect(result.messageIndexToFilePathToFileId[0]).toEqual({}); + }); + + it("should handle getFileAtCommit throwing an error", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + // Simulate error + mockGetFileAtCommit.mockRejectedValue(new Error("Git error")); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: 'Error file: ', + providerOptions: { + "dyad-engine": { + sourceCommitHash: "commit1", + }, + }, + }, + ]; + const appPath = "/test/app"; + + // Should not throw - errors are caught and logged + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(mockGetFileAtCommit).toHaveBeenCalled(); + expect(result.fileIdToContent).toEqual({}); + expect(result.messageIndexToFilePathToFileId[0]).toEqual({}); + }); + + it("should process some files successfully and skip others that error", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + const successContent = "success file"; + + mockGetFileAtCommit + .mockResolvedValueOnce(successContent) + .mockRejectedValueOnce(new Error("Error")) + .mockResolvedValueOnce(null); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: ` +src/success.ts +src/error.ts +src/missing.ts +`, + providerOptions: { + "dyad-engine": { + sourceCommitHash: "commit1", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(mockGetFileAtCommit).toHaveBeenCalledTimes(3); + + // Only the successful file should be in results + const successHash = hashContent(successContent); + expect(result.fileIdToContent[successHash]).toBe(successContent); + expect(result.messageIndexToFilePathToFileId[0]).toEqual({ + "src/success.ts": successHash, + }); + }); + }); + + describe("multiple messages", () => { + it("should process multiple messages with different commits", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + const file1AtCommit1 = "file1 at commit1"; + const file1AtCommit2 = "file1 at commit2 - different content"; + + mockGetFileAtCommit + .mockResolvedValueOnce(file1AtCommit1) + .mockResolvedValueOnce(file1AtCommit2); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "user", + content: "Show me file1", + }, + { + role: "assistant", + content: 'Here it is: ', + providerOptions: { + "dyad-engine": { + sourceCommitHash: "commit1", + }, + }, + }, + { + role: "user", + content: "Show me it again", + }, + { + role: "assistant", + content: + 'Here it is again: ', + providerOptions: { + "dyad-engine": { + sourceCommitHash: "commit2", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(mockGetFileAtCommit).toHaveBeenCalledTimes(2); + expect(mockGetFileAtCommit).toHaveBeenNthCalledWith(1, { + path: appPath, + filePath: "src/file1.ts", + commitHash: "commit1", + }); + expect(mockGetFileAtCommit).toHaveBeenNthCalledWith(2, { + path: appPath, + filePath: "src/file1.ts", + commitHash: "commit2", + }); + + const hash1 = hashContent(file1AtCommit1); + const hash2 = hashContent(file1AtCommit2); + + // Both versions should be in fileIdToContent + expect(result.fileIdToContent[hash1]).toBe(file1AtCommit1); + expect(result.fileIdToContent[hash2]).toBe(file1AtCommit2); + + // Message index 1 (first assistant message) + expect(result.messageIndexToFilePathToFileId[1]).toEqual({ + "src/file1.ts": hash1, + }); + + // Message index 3 (second assistant message) + expect(result.messageIndexToFilePathToFileId[3]).toEqual({ + "src/file1.ts": hash2, + }); + }); + }); + + describe("integration with files parameter", () => { + it("should combine files parameter with versioned files from messages", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + const versionedContent = "old version from git"; + mockGetFileAtCommit.mockResolvedValue(versionedContent); + + const files: CodebaseFile[] = [ + { + path: "src/current.ts", + content: "current version", + }, + ]; + + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: 'Old version: ', + providerOptions: { + "dyad-engine": { + sourceCommitHash: "abc123", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + const currentHash = hashContent("current version"); + const oldHash = hashContent(versionedContent); + + // Both should be present + expect(result.fileIdToContent[currentHash]).toBe("current version"); + expect(result.fileIdToContent[oldHash]).toBe(versionedContent); + + // fileReferences should only include files from the files parameter + expect(result.fileReferences).toHaveLength(1); + expect(result.fileReferences[0].path).toBe("src/current.ts"); + + // messageIndexToFilePathToFileId should have the versioned file + expect(result.messageIndexToFilePathToFileId[0]).toEqual({ + "src/old.ts": oldHash, + }); + }); + }); + + describe("content hashing", () => { + it("should deduplicate identical content with same hash", async () => { + const { getFileAtCommit } = await import("@/ipc/utils/git_utils"); + const mockGetFileAtCommit = vi.mocked(getFileAtCommit); + + const sameContent = "identical content"; + + // Both files have the same content + mockGetFileAtCommit + .mockResolvedValueOnce(sameContent) + .mockResolvedValueOnce(sameContent); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: ` +src/file1.ts +src/file2.ts +`, + providerOptions: { + "dyad-engine": { + sourceCommitHash: "commit1", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + const hash = hashContent(sameContent); + + // fileIdToContent should only have one entry for the hash + expect(Object.keys(result.fileIdToContent)).toHaveLength(1); + expect(result.fileIdToContent[hash]).toBe(sameContent); + + // Both files should point to the same hash + expect(result.messageIndexToFilePathToFileId[0]).toEqual({ + "src/file1.ts": hash, + "src/file2.ts": hash, + }); + }); + }); + + describe("hasExternalChanges", () => { + it("should default to true when no assistant message has commitHash", async () => { + const { getCurrentCommitHash, isGitStatusClean } = await import( + "@/ipc/utils/git_utils" + ); + const mockGetCurrentCommitHash = vi.mocked(getCurrentCommitHash); + const mockIsGitStatusClean = vi.mocked(isGitStatusClean); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: "No commit hash here", + providerOptions: { + "dyad-engine": { + sourceCommitHash: "abc123", + commitHash: null, + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(result.hasExternalChanges).toBe(true); + expect(mockGetCurrentCommitHash).not.toHaveBeenCalled(); + expect(mockIsGitStatusClean).not.toHaveBeenCalled(); + }); + + it("should be false when latest assistant commit matches current and git status is clean", async () => { + const { getCurrentCommitHash, isGitStatusClean } = await import( + "@/ipc/utils/git_utils" + ); + const mockGetCurrentCommitHash = vi.mocked(getCurrentCommitHash); + const mockIsGitStatusClean = vi.mocked(isGitStatusClean); + + mockGetCurrentCommitHash.mockResolvedValue("commit-123"); + mockIsGitStatusClean.mockResolvedValue(true); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: "Assistant message with commit hash", + providerOptions: { + "dyad-engine": { + sourceCommitHash: "ignored-for-this-test", + commitHash: "commit-123", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(result.hasExternalChanges).toBe(false); + expect(mockGetCurrentCommitHash).toHaveBeenCalledWith({ path: appPath }); + expect(mockIsGitStatusClean).toHaveBeenCalledWith({ path: appPath }); + }); + + it("should be true when latest assistant commit differs from current", async () => { + const { getCurrentCommitHash, isGitStatusClean } = await import( + "@/ipc/utils/git_utils" + ); + const mockGetCurrentCommitHash = vi.mocked(getCurrentCommitHash); + const mockIsGitStatusClean = vi.mocked(isGitStatusClean); + + mockGetCurrentCommitHash.mockResolvedValue("current-commit"); + mockIsGitStatusClean.mockResolvedValue(true); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: "Assistant message with different commit hash", + providerOptions: { + "dyad-engine": { + sourceCommitHash: "ignored-for-this-test", + commitHash: "older-commit", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(result.hasExternalChanges).toBe(true); + expect(mockGetCurrentCommitHash).toHaveBeenCalledWith({ path: appPath }); + expect(mockIsGitStatusClean).toHaveBeenCalledWith({ path: appPath }); + }); + + it("should be true when git status is dirty even if commits match", async () => { + const { getCurrentCommitHash, isGitStatusClean } = await import( + "@/ipc/utils/git_utils" + ); + const mockGetCurrentCommitHash = vi.mocked(getCurrentCommitHash); + const mockIsGitStatusClean = vi.mocked(isGitStatusClean); + + mockGetCurrentCommitHash.mockResolvedValue("same-commit"); + mockIsGitStatusClean.mockResolvedValue(false); + + const files: CodebaseFile[] = []; + const chatMessages: ModelMessage[] = [ + { + role: "assistant", + content: "Assistant message with matching commit but dirty status", + providerOptions: { + "dyad-engine": { + sourceCommitHash: "ignored-for-this-test", + commitHash: "same-commit", + }, + }, + }, + ]; + const appPath = "/test/app"; + + const result = await processChatMessagesWithVersionedFiles({ + files, + chatMessages, + appPath, + }); + + expect(result.hasExternalChanges).toBe(true); + expect(mockGetCurrentCommitHash).toHaveBeenCalledWith({ path: appPath }); + expect(mockIsGitStatusClean).toHaveBeenCalledWith({ path: appPath }); + }); + }); +}); diff --git a/backups/backup-20251218-161645/src/app/TitleBar.tsx b/backups/backup-20251218-161645/src/app/TitleBar.tsx new file mode 100644 index 0000000..889afb2 --- /dev/null +++ b/backups/backup-20251218-161645/src/app/TitleBar.tsx @@ -0,0 +1,244 @@ +import { useAtom } from "jotai"; +import { selectedAppIdAtom } from "@/atoms/appAtoms"; +import { useLoadApps } from "@/hooks/useLoadApps"; +import { useRouter, useLocation } from "@tanstack/react-router"; +import { useSettings } from "@/hooks/useSettings"; +import { Button } from "@/components/ui/button"; +// @ts-ignore +import logo from "../../assets/logo.svg"; +import { providerSettingsRoute } from "@/routes/settings/providers/$provider"; +import { cn } from "@/lib/utils"; +import { useDeepLink } from "@/contexts/DeepLinkContext"; +import { useEffect, useState } from "react"; +import { DyadProSuccessDialog } from "@/components/DyadProSuccessDialog"; +import { useTheme } from "@/contexts/ThemeContext"; +import { IpcClient } from "@/ipc/ipc_client"; +import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo"; +import { UserBudgetInfo } from "@/ipc/ipc_types"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { ActionHeader } from "@/components/preview_panel/ActionHeader"; + +export const TitleBar = () => { + const [selectedAppId] = useAtom(selectedAppIdAtom); + const { apps } = useLoadApps(); + const { navigate } = useRouter(); + const location = useLocation(); + const { settings, refreshSettings } = useSettings(); + const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false); + const [showWindowControls, setShowWindowControls] = useState(false); + + useEffect(() => { + // Check if we're running on Windows + const checkPlatform = async () => { + try { + const platform = await IpcClient.getInstance().getSystemPlatform(); + setShowWindowControls(platform !== "darwin"); + } catch (error) { + console.error("Failed to get platform info:", error); + } + }; + + checkPlatform(); + }, []); + + const showDyadProSuccessDialog = () => { + setIsSuccessDialogOpen(true); + }; + + const { lastDeepLink, clearLastDeepLink } = useDeepLink(); + useEffect(() => { + const handleDeepLink = async () => { + if (lastDeepLink?.type === "dyad-pro-return") { + await refreshSettings(); + showDyadProSuccessDialog(); + clearLastDeepLink(); + } + }; + handleDeepLink(); + }, [lastDeepLink?.timestamp]); + + // Get selected app name + const selectedApp = apps.find((app) => app.id === selectedAppId); + const displayText = selectedApp + ? `App: ${selectedApp.name}` + : "(no app selected)"; + + const handleAppClick = () => { + if (selectedApp) { + navigate({ to: "/app-details", search: { appId: selectedApp.id } }); + } + }; + + const isDyadPro = !!settings?.providerSettings?.auto?.apiKey?.value; + const isDyadProEnabled = Boolean(settings?.enableDyadPro); + + return ( + <> +
+
+ + Dyad Logo + + {isDyadPro && } + + {/* Preview Header */} + {location.pathname === "/chat" && ( +
+ +
+ )} + + {showWindowControls && } +
+ + setIsSuccessDialogOpen(false)} + /> + + ); +}; + +function WindowsControls() { + const { isDarkMode } = useTheme(); + const ipcClient = IpcClient.getInstance(); + + const minimizeWindow = () => { + ipcClient.minimizeWindow(); + }; + + const maximizeWindow = () => { + ipcClient.maximizeWindow(); + }; + + const closeWindow = () => { + ipcClient.closeWindow(); + }; + + return ( +
+ + + +
+ ); +} + +export function DyadProButton({ + isDyadProEnabled, +}: { + isDyadProEnabled: boolean; +}) { + const { navigate } = useRouter(); + const { userBudget } = useUserBudgetInfo(); + return ( + + ); +} + +export function AICreditStatus({ userBudget }: { userBudget: UserBudgetInfo }) { + const remaining = Math.round( + userBudget.totalCredits - userBudget.usedCredits, + ); + return ( + + +
{remaining} credits
+
+ +
+

Note: there is a slight delay in updating the credit status.

+
+
+
+ ); +} diff --git a/backups/backup-20251218-161645/src/app/layout.tsx b/backups/backup-20251218-161645/src/app/layout.tsx new file mode 100644 index 0000000..a400219 --- /dev/null +++ b/backups/backup-20251218-161645/src/app/layout.tsx @@ -0,0 +1,97 @@ +import { SidebarProvider } from "@/components/ui/sidebar"; +import { AppSidebar } from "@/components/app-sidebar"; +import { ThemeProvider } from "../contexts/ThemeContext"; +import { DeepLinkProvider } from "../contexts/DeepLinkContext"; +import { Toaster } from "sonner"; +import { TitleBar } from "./TitleBar"; +import { useEffect, type ReactNode } from "react"; +import { useRunApp } from "@/hooks/useRunApp"; +import { useAtomValue, useSetAtom } from "jotai"; +import { previewModeAtom, selectedAppIdAtom } from "@/atoms/appAtoms"; +import { useSettings } from "@/hooks/useSettings"; +import type { ZoomLevel } from "@/lib/schemas"; +import { selectedComponentsPreviewAtom } from "@/atoms/previewAtoms"; +import { chatInputValueAtom } from "@/atoms/chatAtoms"; + +const DEFAULT_ZOOM_LEVEL: ZoomLevel = "100"; + +export default function RootLayout({ children }: { children: ReactNode }) { + const { refreshAppIframe } = useRunApp(); + const previewMode = useAtomValue(previewModeAtom); + const { settings } = useSettings(); + const setSelectedComponentsPreview = useSetAtom( + selectedComponentsPreviewAtom, + ); + const setChatInput = useSetAtom(chatInputValueAtom); + const selectedAppId = useAtomValue(selectedAppIdAtom); + + useEffect(() => { + const zoomLevel = settings?.zoomLevel ?? DEFAULT_ZOOM_LEVEL; + const zoomFactor = Number(zoomLevel) / 100; + + const electronApi = ( + window as Window & { + electron?: { + webFrame?: { + setZoomFactor: (factor: number) => void; + }; + }; + } + ).electron; + + if (electronApi?.webFrame?.setZoomFactor) { + electronApi.webFrame.setZoomFactor(zoomFactor); + + return () => { + electronApi.webFrame?.setZoomFactor(Number(DEFAULT_ZOOM_LEVEL) / 100); + }; + } + + return () => {}; + }, [settings?.zoomLevel]); + // Global keyboard listener for refresh events + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Check for Ctrl+R (Windows/Linux) or Cmd+R (macOS) + if (event.key === "r" && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); // Prevent default browser refresh + if (previewMode === "preview") { + refreshAppIframe(); // Use our custom refresh function instead + } + } + }; + + // Add event listener to document + document.addEventListener("keydown", handleKeyDown); + + // Cleanup function to remove event listener + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [refreshAppIframe, previewMode]); + + useEffect(() => { + setChatInput(""); + setSelectedComponentsPreview([]); + }, [selectedAppId]); + + return ( + <> + + + + + +
+ {children} +
+ +
+
+
+ + ); +} diff --git a/backups/backup-20251218-161645/src/atoms/appAtoms.ts b/backups/backup-20251218-161645/src/atoms/appAtoms.ts new file mode 100644 index 0000000..ed1e9f0 --- /dev/null +++ b/backups/backup-20251218-161645/src/atoms/appAtoms.ts @@ -0,0 +1,28 @@ +import { atom } from "jotai"; +import type { App, AppOutput, Version } from "@/ipc/ipc_types"; +import type { UserSettings } from "@/lib/schemas"; + +export const currentAppAtom = atom(null); +export const selectedAppIdAtom = atom(null); +export const appsListAtom = atom([]); +export const appBasePathAtom = atom(""); +export const versionsListAtom = atom([]); +export const previewModeAtom = atom< + "preview" | "code" | "problems" | "configure" | "publish" | "security" +>("preview"); +export const selectedVersionIdAtom = atom(null); +export const appOutputAtom = atom([]); +export const appUrlAtom = atom< + | { appUrl: string; appId: number; originalUrl: string } + | { appUrl: null; appId: null; originalUrl: null } +>({ appUrl: null, appId: null, originalUrl: null }); +export const userSettingsAtom = atom(null); + +// Atom for storing allow-listed environment variables +export const envVarsAtom = atom>({}); + +export const previewPanelKeyAtom = atom(0); + +export const previewErrorMessageAtom = atom< + { message: string; source: "preview-app" | "dyad-app" } | undefined +>(undefined); diff --git a/backups/backup-20251218-161645/src/atoms/chatAtoms.ts b/backups/backup-20251218-161645/src/atoms/chatAtoms.ts new file mode 100644 index 0000000..684a5a5 --- /dev/null +++ b/backups/backup-20251218-161645/src/atoms/chatAtoms.ts @@ -0,0 +1,24 @@ +import type { FileAttachment, Message } from "@/ipc/ipc_types"; +import { atom } from "jotai"; +import type { ChatSummary } from "@/lib/schemas"; + +// Per-chat atoms implemented with maps keyed by chatId +export const chatMessagesByIdAtom = atom>(new Map()); +export const chatErrorByIdAtom = atom>(new Map()); + +// Atom to hold the currently selected chat ID +export const selectedChatIdAtom = atom(null); + +export const isStreamingByIdAtom = atom>(new Map()); +export const chatInputValueAtom = atom(""); +export const homeChatInputValueAtom = atom(""); + +// Atoms for chat list management +export const chatsAtom = atom([]); +export const chatsLoadingAtom = atom(false); + +// Used for scrolling to the bottom of the chat messages (per chat) +export const chatStreamCountByIdAtom = atom>(new Map()); +export const recentStreamChatIdsAtom = atom>(new Set()); + +export const attachmentsAtom = atom([]); diff --git a/backups/backup-20251218-161645/src/atoms/localModelsAtoms.ts b/backups/backup-20251218-161645/src/atoms/localModelsAtoms.ts new file mode 100644 index 0000000..f783f0c --- /dev/null +++ b/backups/backup-20251218-161645/src/atoms/localModelsAtoms.ts @@ -0,0 +1,10 @@ +import { atom } from "jotai"; +import { type LocalModel } from "@/ipc/ipc_types"; + +export const localModelsAtom = atom([]); +export const localModelsLoadingAtom = atom(false); +export const localModelsErrorAtom = atom(null); + +export const lmStudioModelsAtom = atom([]); +export const lmStudioModelsLoadingAtom = atom(false); +export const lmStudioModelsErrorAtom = atom(null); diff --git a/backups/backup-20251218-161645/src/atoms/previewAtoms.ts b/backups/backup-20251218-161645/src/atoms/previewAtoms.ts new file mode 100644 index 0000000..934abe9 --- /dev/null +++ b/backups/backup-20251218-161645/src/atoms/previewAtoms.ts @@ -0,0 +1,23 @@ +import { ComponentSelection, VisualEditingChange } from "@/ipc/ipc_types"; +import { atom } from "jotai"; + +export const selectedComponentsPreviewAtom = atom([]); + +export const visualEditingSelectedComponentAtom = + atom(null); + +export const currentComponentCoordinatesAtom = atom<{ + top: number; + left: number; + width: number; + height: number; +} | null>(null); + +export const previewIframeRefAtom = atom(null); + +export const annotatorModeAtom = atom(false); + +export const screenshotDataUrlAtom = atom(null); +export const pendingVisualChangesAtom = atom>( + new Map(), +); diff --git a/backups/backup-20251218-161645/src/atoms/proposalAtoms.ts b/backups/backup-20251218-161645/src/atoms/proposalAtoms.ts new file mode 100644 index 0000000..12083be --- /dev/null +++ b/backups/backup-20251218-161645/src/atoms/proposalAtoms.ts @@ -0,0 +1,4 @@ +import { atom } from "jotai"; +import type { ProposalResult } from "@/lib/schemas"; + +export const proposalResultAtom = atom(null); diff --git a/backups/backup-20251218-161645/src/atoms/supabaseAtoms.ts b/backups/backup-20251218-161645/src/atoms/supabaseAtoms.ts new file mode 100644 index 0000000..7f38406 --- /dev/null +++ b/backups/backup-20251218-161645/src/atoms/supabaseAtoms.ts @@ -0,0 +1,15 @@ +import { atom } from "jotai"; +import { SupabaseBranch } from "@/ipc/ipc_types"; + +// Define atom for storing the list of Supabase projects +export const supabaseProjectsAtom = atom([]); +export const supabaseBranchesAtom = atom([]); + +// Define atom for tracking loading state +export const supabaseLoadingAtom = atom(false); + +// Define atom for storing any error that occurs during loading +export const supabaseErrorAtom = atom(null); + +// Define atom for storing the currently selected Supabase project +export const selectedSupabaseProjectAtom = atom(null); diff --git a/backups/backup-20251218-161645/src/atoms/uiAtoms.ts b/backups/backup-20251218-161645/src/atoms/uiAtoms.ts new file mode 100644 index 0000000..d995ce4 --- /dev/null +++ b/backups/backup-20251218-161645/src/atoms/uiAtoms.ts @@ -0,0 +1,4 @@ +import { atom } from "jotai"; + +// Atom to track if any dropdown is currently open in the UI +export const dropdownOpenAtom = atom(false); diff --git a/backups/backup-20251218-161645/src/atoms/viewAtoms.ts b/backups/backup-20251218-161645/src/atoms/viewAtoms.ts new file mode 100644 index 0000000..be09fdf --- /dev/null +++ b/backups/backup-20251218-161645/src/atoms/viewAtoms.ts @@ -0,0 +1,9 @@ +import { atom } from "jotai"; + +export const isPreviewOpenAtom = atom(true); +export const selectedFileAtom = atom<{ + path: string; +} | null>(null); +export const activeSettingsSectionAtom = atom( + "general-settings", +); diff --git a/backups/backup-20251218-161645/src/backup_manager.ts b/backups/backup-20251218-161645/src/backup_manager.ts new file mode 100644 index 0000000..8da50f3 --- /dev/null +++ b/backups/backup-20251218-161645/src/backup_manager.ts @@ -0,0 +1,390 @@ +import * as path from "path"; +import * as fs from "fs/promises"; +import { app } from "electron"; +import * as crypto from "crypto"; +import log from "electron-log"; +import Database from "better-sqlite3"; + +const logger = log.scope("backup_manager"); + +const MAX_BACKUPS = 3; + +interface BackupManagerOptions { + settingsFile: string; + dbFile: string; +} + +interface BackupMetadata { + version: string; + timestamp: string; + reason: string; + files: { + settings: boolean; + database: boolean; + }; + checksums: { + settings: string | null; + database: string | null; + }; +} + +interface BackupInfo extends BackupMetadata { + name: string; +} + +export class BackupManager { + private readonly maxBackups: number; + private readonly settingsFilePath: string; + private readonly dbFilePath: string; + private userDataPath!: string; + private backupBasePath!: string; + + constructor(options: BackupManagerOptions) { + this.maxBackups = MAX_BACKUPS; + this.settingsFilePath = options.settingsFile; + this.dbFilePath = options.dbFile; + } + + /** + * Initialize backup system - call this on app ready + */ + async initialize(): Promise { + logger.info("Initializing backup system..."); + + // Set paths after app is ready + this.userDataPath = app.getPath("userData"); + this.backupBasePath = path.join(this.userDataPath, "backups"); + + logger.info( + `Backup system paths - UserData: ${this.userDataPath}, Backups: ${this.backupBasePath}`, + ); + + // Check if this is a version upgrade + const currentVersion = app.getVersion(); + const lastVersion = await this.getLastRunVersion(); + + if (lastVersion === null) { + logger.info("No previous version found, skipping backup"); + return; + } + + if (lastVersion === currentVersion) { + logger.info( + `No version upgrade detected. Current version: ${currentVersion}`, + ); + return; + } + + // Ensure backup directory exists + await fs.mkdir(this.backupBasePath, { recursive: true }); + logger.debug("Backup directory created/verified"); + + logger.info(`Version upgrade detected: ${lastVersion} → ${currentVersion}`); + await this.createBackup(`upgrade_from_${lastVersion}`); + + // Save current version + await this.saveCurrentVersion(currentVersion); + + // Clean up old backups + await this.cleanupOldBackups(); + logger.info("Backup system initialized successfully"); + } + + /** + * Create a backup of settings and database + */ + async createBackup(reason: string = "manual"): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const version = app.getVersion(); + const backupName = `v${version}_${timestamp}_${reason}`; + const backupPath = path.join(this.backupBasePath, backupName); + + logger.info(`Creating backup: ${backupName} (reason: ${reason})`); + + try { + // Create backup directory + await fs.mkdir(backupPath, { recursive: true }); + logger.debug(`Backup directory created: ${backupPath}`); + + // Backup settings file + const settingsBackupPath = path.join( + backupPath, + path.basename(this.settingsFilePath), + ); + const settingsExists = await this.fileExists(this.settingsFilePath); + + if (settingsExists) { + await fs.copyFile(this.settingsFilePath, settingsBackupPath); + logger.info("Settings backed up successfully"); + } else { + logger.debug("Settings file not found, skipping settings backup"); + } + + // Backup SQLite database + const dbBackupPath = path.join( + backupPath, + path.basename(this.dbFilePath), + ); + const dbExists = await this.fileExists(this.dbFilePath); + + if (dbExists) { + await this.backupSQLiteDatabase(this.dbFilePath, dbBackupPath); + logger.info("Database backed up successfully"); + } else { + logger.debug("Database file not found, skipping database backup"); + } + + // Create backup metadata + const metadata: BackupMetadata = { + version, + timestamp: new Date().toISOString(), + reason, + files: { + settings: settingsExists, + database: dbExists, + }, + checksums: { + settings: settingsExists + ? await this.getFileChecksum(settingsBackupPath) + : null, + database: dbExists ? await this.getFileChecksum(dbBackupPath) : null, + }, + }; + + await fs.writeFile( + path.join(backupPath, "backup.json"), + JSON.stringify(metadata, null, 2), + ); + + logger.info(`Backup created successfully: ${backupName}`); + return backupPath; + } catch (error) { + logger.error("Backup failed:", error); + // Clean up failed backup + try { + await fs.rm(backupPath, { recursive: true, force: true }); + logger.debug("Failed backup directory cleaned up"); + } catch (cleanupError) { + logger.error("Failed to clean up backup directory:", cleanupError); + } + throw new Error(`Backup creation failed: ${error}`); + } + } + + /** + * List all available backups + */ + async listBackups(): Promise { + try { + const entries = await fs.readdir(this.backupBasePath, { + withFileTypes: true, + }); + const backups: BackupInfo[] = []; + + logger.debug(`Found ${entries.length} entries in backup directory`); + + for (const entry of entries) { + if (entry.isDirectory()) { + const metadataPath = path.join( + this.backupBasePath, + entry.name, + "backup.json", + ); + + try { + const metadataContent = await fs.readFile(metadataPath, "utf8"); + const metadata: BackupMetadata = JSON.parse(metadataContent); + backups.push({ + name: entry.name, + ...metadata, + }); + } catch (error) { + logger.warn(`Invalid backup found: ${entry.name}`, error); + } + } + } + + logger.info(`Found ${backups.length} valid backups`); + + // Sort by timestamp, newest first + return backups.sort( + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ); + } catch (error) { + logger.error("Failed to list backups:", error); + return []; + } + } + + /** + * Clean up old backups, keeping only the most recent ones + */ + async cleanupOldBackups(): Promise { + try { + const backups = await this.listBackups(); + + if (backups.length <= this.maxBackups) { + logger.debug( + `No cleanup needed - ${backups.length} backups (max: ${this.maxBackups})`, + ); + return; + } + + // Keep the newest backups + const backupsToDelete = backups.slice(this.maxBackups); + + logger.info( + `Cleaning up ${backupsToDelete.length} old backups (keeping ${this.maxBackups} most recent)`, + ); + + for (const backup of backupsToDelete) { + const backupPath = path.join(this.backupBasePath, backup.name); + await fs.rm(backupPath, { recursive: true, force: true }); + logger.debug(`Deleted old backup: ${backup.name}`); + } + + logger.info("Old backup cleanup completed"); + } catch (error) { + logger.error("Failed to clean up old backups:", error); + } + } + + /** + * Delete a specific backup + */ + async deleteBackup(backupName: string): Promise { + const backupPath = path.join(this.backupBasePath, backupName); + + logger.info(`Deleting backup: ${backupName}`); + + try { + await fs.rm(backupPath, { recursive: true, force: true }); + logger.info(`Deleted backup: ${backupName}`); + } catch (error) { + logger.error(`Failed to delete backup ${backupName}:`, error); + throw new Error(`Failed to delete backup: ${error}`); + } + } + + /** + * Get backup size in bytes + */ + async getBackupSize(backupName: string): Promise { + const backupPath = path.join(this.backupBasePath, backupName); + logger.debug(`Calculating size for backup: ${backupName}`); + + const size = await this.getDirectorySize(backupPath); + logger.debug(`Backup ${backupName} size: ${size} bytes`); + + return size; + } + + /** + * Backup SQLite database safely + */ + private async backupSQLiteDatabase( + sourcePath: string, + destPath: string, + ): Promise { + logger.debug(`Backing up SQLite database: ${sourcePath} → ${destPath}`); + const sourceDb = new Database(sourcePath, { + readonly: true, + timeout: 10000, + }); + + try { + // This is safe even if other connections are active + await sourceDb.backup(destPath); + logger.info("Database backup completed successfully"); + } catch (error) { + logger.error("Database backup failed:", error); + throw error; + } finally { + // Always close the temporary connection + sourceDb.close(); + } + } + + /** + * Helper: Check if file exists + */ + private async fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + } + + /** + * Helper: Calculate file checksum + */ + private async getFileChecksum(filePath: string): Promise { + try { + const fileBuffer = await fs.readFile(filePath); + const hash = crypto.createHash("sha256"); + hash.update(fileBuffer); + const checksum = hash.digest("hex"); + logger.debug( + `Checksum calculated for ${filePath}: ${checksum.substring(0, 8)}...`, + ); + return checksum; + } catch (error) { + logger.error(`Failed to calculate checksum for ${filePath}:`, error); + return null; + } + } + + /** + * Helper: Get directory size recursively + */ + private async getDirectorySize(dirPath: string): Promise { + let size = 0; + + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + if (entry.isDirectory()) { + size += await this.getDirectorySize(fullPath); + } else { + const stats = await fs.stat(fullPath); + size += stats.size; + } + } + } catch (error) { + logger.error(`Failed to calculate directory size for ${dirPath}:`, error); + } + + return size; + } + + /** + * Helper: Get last run version + */ + private async getLastRunVersion(): Promise { + try { + const versionFile = path.join(this.userDataPath, ".last_version"); + const version = await fs.readFile(versionFile, "utf8"); + const trimmedVersion = version.trim(); + logger.debug(`Last run version retrieved: ${trimmedVersion}`); + return trimmedVersion; + } catch { + logger.debug("No previous version file found"); + return null; + } + } + + /** + * Helper: Save current version + */ + private async saveCurrentVersion(version: string): Promise { + const versionFile = path.join(this.userDataPath, ".last_version"); + await fs.writeFile(versionFile, version, "utf8"); + logger.debug(`Current version saved: ${version}`); + } +} diff --git a/backups/backup-20251218-161645/src/client_logic/template_hook.ts b/backups/backup-20251218-161645/src/client_logic/template_hook.ts new file mode 100644 index 0000000..c65a2fc --- /dev/null +++ b/backups/backup-20251218-161645/src/client_logic/template_hook.ts @@ -0,0 +1,46 @@ +import { IpcClient } from "@/ipc/ipc_client"; +import { getAppPort } from "../../shared/ports"; + +import { v4 as uuidv4 } from "uuid"; + +export async function neonTemplateHook({ + appId, + appName, +}: { + appId: number; + appName: string; +}) { + console.log("Creating Neon project"); + const neonProject = await IpcClient.getInstance().createNeonProject({ + name: appName, + appId: appId, + }); + + console.log("Neon project created", neonProject); + await IpcClient.getInstance().setAppEnvVars({ + appId: appId, + envVars: [ + { + key: "POSTGRES_URL", + value: neonProject.connectionString, + }, + { + key: "PAYLOAD_SECRET", + value: uuidv4(), + }, + { + key: "NEXT_PUBLIC_SERVER_URL", + value: `http://localhost:${getAppPort(appId)}`, + }, + { + key: "GMAIL_USER", + value: "example@gmail.com", + }, + { + key: "GOOGLE_APP_PASSWORD", + value: "GENERATE AT https://myaccount.google.com/apppasswords", + }, + ], + }); + console.log("App env vars set"); +} diff --git a/backups/backup-20251218-161645/src/components/AppList.tsx b/backups/backup-20251218-161645/src/components/AppList.tsx new file mode 100644 index 0000000..a1c3f9c --- /dev/null +++ b/backups/backup-20251218-161645/src/components/AppList.tsx @@ -0,0 +1,150 @@ +import { useNavigate } from "@tanstack/react-router"; +import { PlusCircle, Search } from "lucide-react"; +import { useAtom, useSetAtom } from "jotai"; +import { selectedAppIdAtom } from "@/atoms/appAtoms"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, +} from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; +import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { useLoadApps } from "@/hooks/useLoadApps"; +import { useMemo, useState } from "react"; +import { AppSearchDialog } from "./AppSearchDialog"; +import { useAddAppToFavorite } from "@/hooks/useAddAppToFavorite"; +import { AppItem } from "./appItem"; +export function AppList({ show }: { show?: boolean }) { + const navigate = useNavigate(); + const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom); + const setSelectedChatId = useSetAtom(selectedChatIdAtom); + const { apps, loading, error } = useLoadApps(); + const { toggleFavorite, isLoading: isFavoriteLoading } = + useAddAppToFavorite(); + // search dialog state + const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); + + const allApps = useMemo( + () => + apps.map((a) => ({ + id: a.id, + name: a.name, + createdAt: a.createdAt, + matchedChatTitle: null, + matchedChatMessage: null, + })), + [apps], + ); + + const favoriteApps = useMemo( + () => apps.filter((app) => app.isFavorite), + [apps], + ); + + const nonFavoriteApps = useMemo( + () => apps.filter((app) => !app.isFavorite), + [apps], + ); + + if (!show) { + return null; + } + + const handleAppClick = (id: number) => { + setSelectedAppId(id); + setSelectedChatId(null); + setIsSearchDialogOpen(false); + navigate({ + to: "/", + search: { appId: id }, + }); + }; + + const handleNewApp = () => { + navigate({ to: "/" }); + // We'll eventually need a create app workflow + }; + + const handleToggleFavorite = (appId: number, e: React.MouseEvent) => { + e.stopPropagation(); + toggleFavorite(appId); + }; + + return ( + <> + + Your Apps + +
+ + + + {loading ? ( +
+ Loading apps... +
+ ) : error ? ( +
+ Error loading apps +
+ ) : apps.length === 0 ? ( +
+ No apps found +
+ ) : ( + + Favorite apps + {favoriteApps.map((app) => ( + + ))} + Other apps + {nonFavoriteApps.map((app) => ( + + ))} + + )} +
+
+
+ + + ); +} diff --git a/backups/backup-20251218-161645/src/components/AppSearchDialog.tsx b/backups/backup-20251218-161645/src/components/AppSearchDialog.tsx new file mode 100644 index 0000000..f04ea0e --- /dev/null +++ b/backups/backup-20251218-161645/src/components/AppSearchDialog.tsx @@ -0,0 +1,153 @@ +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "./ui/command"; +import { useState, useEffect } from "react"; +import { useSearchApps } from "@/hooks/useSearchApps"; +import type { AppSearchResult } from "@/lib/schemas"; + +type AppSearchDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelectApp: (appId: number) => void; + allApps: AppSearchResult[]; +}; + +export function AppSearchDialog({ + open, + onOpenChange, + onSelectApp, + allApps, +}: AppSearchDialogProps) { + const [searchQuery, setSearchQuery] = useState(""); + function useDebouncedValue(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(handle); + }, [value, delay]); + return debounced; + } + + const debouncedQuery = useDebouncedValue(searchQuery, 150); + const { apps: searchResults } = useSearchApps(debouncedQuery); + + // Show all apps if search is empty, otherwise show search results + const appsToShow: AppSearchResult[] = + debouncedQuery.trim() === "" ? allApps : searchResults; + + const commandFilter = ( + value: string, + search: string, + keywords?: string[], + ): number => { + const q = search.trim().toLowerCase(); + if (!q) return 1; + const v = (value || "").toLowerCase(); + if (v.includes(q)) { + // Higher score for earlier match in title/value + return 100 - Math.max(0, v.indexOf(q)); + } + const foundInKeywords = (keywords || []).some((k) => + (k || "").toLowerCase().includes(q), + ); + return foundInKeywords ? 50 : 0; + }; + + function getSnippet( + text: string, + query: string, + radius = 50, + ): { + before: string; + match: string; + after: string; + raw: string; + } { + const q = query.trim(); + const lowerText = text.toLowerCase(); + const lowerQuery = q.toLowerCase(); + const idx = lowerText.indexOf(lowerQuery); + if (idx === -1) { + const raw = + text.length > radius * 2 ? text.slice(0, radius * 2) + "…" : text; + return { before: "", match: "", after: "", raw }; + } + const start = Math.max(0, idx - radius); + const end = Math.min(text.length, idx + q.length + radius); + const before = (start > 0 ? "…" : "") + text.slice(start, idx); + const match = text.slice(idx, idx + q.length); + const after = + text.slice(idx + q.length, end) + (end < text.length ? "…" : ""); + return { before, match, after, raw: before + match + after }; + } + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + onOpenChange(!open); + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, [open, onOpenChange]); + + return ( + + + + + No results found. + + + {appsToShow.map((app) => { + const isSearch = searchQuery.trim() !== ""; + let snippet = null; + if (isSearch && app.matchedChatMessage) { + snippet = getSnippet(app.matchedChatMessage, searchQuery); + } else if (isSearch && app.matchedChatTitle) { + snippet = getSnippet(app.matchedChatTitle, searchQuery); + } + return ( + onSelectApp(app.id)} + value={app.name + (snippet ? ` ${snippet.raw}` : "")} + keywords={snippet ? [snippet.raw] : []} + data-testid={`app-search-item-${app.id}`} + > +
+ {app.name} + {snippet && ( + + {snippet.before} + + {snippet.match} + + {snippet.after} + + )} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/AppUpgrades.tsx b/backups/backup-20251218-161645/src/components/AppUpgrades.tsx new file mode 100644 index 0000000..8811e83 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/AppUpgrades.tsx @@ -0,0 +1,157 @@ +import { Button } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Terminal } from "lucide-react"; +import { IpcClient } from "@/ipc/ipc_client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AppUpgrade } from "@/ipc/ipc_types"; + +export function AppUpgrades({ appId }: { appId: number | null }) { + const queryClient = useQueryClient(); + + const { + data: upgrades, + isLoading, + error: queryError, + } = useQuery({ + queryKey: ["app-upgrades", appId], + queryFn: () => { + if (!appId) { + return Promise.resolve([]); + } + return IpcClient.getInstance().getAppUpgrades({ appId }); + }, + enabled: !!appId, + }); + + const { + mutate: executeUpgrade, + isPending: isUpgrading, + error: mutationError, + variables: upgradingVariables, + } = useMutation({ + mutationFn: (upgradeId: string) => { + if (!appId) { + throw new Error("appId is not set"); + } + return IpcClient.getInstance().executeAppUpgrade({ + appId, + upgradeId, + }); + }, + onSuccess: (_, upgradeId) => { + queryClient.invalidateQueries({ queryKey: ["app-upgrades", appId] }); + if (upgradeId === "capacitor") { + // Capacitor upgrade is done, so we need to invalidate the Capacitor + // query to show the new status. + queryClient.invalidateQueries({ queryKey: ["is-capacitor", appId] }); + } + }, + }); + + const handleUpgrade = (upgradeId: string) => { + executeUpgrade(upgradeId); + }; + + if (!appId) { + return null; + } + + if (isLoading) { + return ( +
+

+ App Upgrades +

+ +
+ ); + } + + if (queryError) { + return ( +
+

+ App Upgrades +

+ + Error loading upgrades + {queryError.message} + +
+ ); + } + + const currentUpgrades = upgrades?.filter((u) => u.isNeeded) ?? []; + + return ( +
+

+ App Upgrades +

+ {currentUpgrades.length === 0 ? ( +
+ App is up-to-date and has all Dyad capabilities enabled +
+ ) : ( +
+ {currentUpgrades.map((upgrade: AppUpgrade) => ( +
+
+

+ {upgrade.title} +

+

+ {upgrade.description} +

+ {mutationError && upgradingVariables === upgrade.id && ( + + + + Upgrade Failed + + + {(mutationError as Error).message}{" "} + { + e.stopPropagation(); + IpcClient.getInstance().openExternalUrl( + upgrade.manualUpgradeUrl ?? "https://dyad.sh/docs", + ); + }} + className="underline font-medium hover:dark:text-red-200" + > + Manual Upgrade Instructions + + + + )} +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/AutoApproveSwitch.tsx b/backups/backup-20251218-161645/src/components/AutoApproveSwitch.tsx new file mode 100644 index 0000000..9452516 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/AutoApproveSwitch.tsx @@ -0,0 +1,27 @@ +import { useSettings } from "@/hooks/useSettings"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { showInfo } from "@/lib/toast"; + +export function AutoApproveSwitch({ + showToast = true, +}: { + showToast?: boolean; +}) { + const { settings, updateSettings } = useSettings(); + return ( +
+ { + updateSettings({ autoApproveChanges: !settings?.autoApproveChanges }); + if (!settings?.autoApproveChanges && showToast) { + showInfo("You can disable auto-approve in the Settings."); + } + }} + /> + +
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/AutoFixProblemsSwitch.tsx b/backups/backup-20251218-161645/src/components/AutoFixProblemsSwitch.tsx new file mode 100644 index 0000000..1bfbc22 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/AutoFixProblemsSwitch.tsx @@ -0,0 +1,30 @@ +import { useSettings } from "@/hooks/useSettings"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +import { showInfo } from "@/lib/toast"; + +export function AutoFixProblemsSwitch({ + showToast = false, +}: { + showToast?: boolean; +}) { + const { settings, updateSettings } = useSettings(); + return ( +
+ { + updateSettings({ + enableAutoFixProblems: !settings?.enableAutoFixProblems, + }); + if (!settings?.enableAutoFixProblems && showToast) { + showInfo("You can disable Auto-fix problems in the Settings page."); + } + }} + /> + +
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/AutoUpdateSwitch.tsx b/backups/backup-20251218-161645/src/components/AutoUpdateSwitch.tsx new file mode 100644 index 0000000..1e60206 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/AutoUpdateSwitch.tsx @@ -0,0 +1,36 @@ +import { useSettings } from "@/hooks/useSettings"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { toast } from "sonner"; +import { IpcClient } from "@/ipc/ipc_client"; + +export function AutoUpdateSwitch() { + const { settings, updateSettings } = useSettings(); + + if (!settings) { + return null; + } + + return ( +
+ { + updateSettings({ enableAutoUpdate: checked }); + toast("Auto-update settings changed", { + description: + "You will need to restart Dyad for your settings to take effect.", + action: { + label: "Restart Dyad", + onClick: () => { + IpcClient.getInstance().restartDyad(); + }, + }, + }); + }} + /> + +
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/BugScreenshotDialog.tsx b/backups/backup-20251218-161645/src/components/BugScreenshotDialog.tsx new file mode 100644 index 0000000..123a080 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/BugScreenshotDialog.tsx @@ -0,0 +1,91 @@ +import { IpcClient } from "@/ipc/ipc_client"; +import { Dialog, DialogTitle } from "@radix-ui/react-dialog"; +import { DialogContent, DialogHeader } from "./ui/dialog"; +import { Button } from "./ui/button"; +import { BugIcon, Camera } from "lucide-react"; +import { useState } from "react"; +import { ScreenshotSuccessDialog } from "./ScreenshotSuccessDialog"; + +interface BugScreenshotDialogProps { + isOpen: boolean; + onClose: () => void; + handleReportBug: () => Promise; + isLoading: boolean; +} +export function BugScreenshotDialog({ + isOpen, + onClose, + handleReportBug, + isLoading, +}: BugScreenshotDialogProps) { + const [isScreenshotSuccessOpen, setIsScreenshotSuccessOpen] = useState(false); + const [screenshotError, setScreenshotError] = useState(null); + + const handleReportBugWithScreenshot = async () => { + setScreenshotError(null); + onClose(); + setTimeout(async () => { + try { + await IpcClient.getInstance().takeScreenshot(); + setIsScreenshotSuccessOpen(true); + } catch (error) { + setScreenshotError( + error instanceof Error ? error.message : "Failed to take screenshot", + ); + } + }, 200); // Small delay for dialog to close + }; + + return ( + + + + Take a screenshot? + +
+
+ +

+ You'll get better and faster responses if you do this! +

+
+
+ +

+ We'll still try to respond but might not be able to help as much. +

+
+ {screenshotError && ( +

+ Failed to take screenshot: {screenshotError} +

+ )} +
+
+ setIsScreenshotSuccessOpen(false)} + handleReportBug={handleReportBug} + isLoading={isLoading} + /> +
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/CapacitorControls.tsx b/backups/backup-20251218-161645/src/components/CapacitorControls.tsx new file mode 100644 index 0000000..2f65675 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/CapacitorControls.tsx @@ -0,0 +1,258 @@ +import { useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { IpcClient } from "@/ipc/ipc_client"; +import { showSuccess } from "@/lib/toast"; +import { + Smartphone, + TabletSmartphone, + Loader2, + ExternalLink, + Copy, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface CapacitorControlsProps { + appId: number; +} + +type CapacitorStatus = "idle" | "syncing" | "opening"; + +export function CapacitorControls({ appId }: CapacitorControlsProps) { + const [errorDialogOpen, setErrorDialogOpen] = useState(false); + const [errorDetails, setErrorDetails] = useState<{ + title: string; + message: string; + } | null>(null); + const [iosStatus, setIosStatus] = useState("idle"); + const [androidStatus, setAndroidStatus] = useState("idle"); + + // Check if Capacitor is installed + const { data: isCapacitor, isLoading } = useQuery({ + queryKey: ["is-capacitor", appId], + queryFn: () => IpcClient.getInstance().isCapacitor({ appId }), + enabled: appId !== undefined && appId !== null, + }); + + const showErrorDialog = (title: string, error: unknown) => { + const errorMessage = error instanceof Error ? error.message : String(error); + setErrorDetails({ title, message: errorMessage }); + setErrorDialogOpen(true); + }; + + // Sync and open iOS mutation + const syncAndOpenIosMutation = useMutation({ + mutationFn: async () => { + setIosStatus("syncing"); + // First sync + await IpcClient.getInstance().syncCapacitor({ appId }); + setIosStatus("opening"); + // Then open iOS + await IpcClient.getInstance().openIos({ appId }); + }, + onSuccess: () => { + setIosStatus("idle"); + showSuccess("Synced and opened iOS project in Xcode"); + }, + onError: (error) => { + setIosStatus("idle"); + showErrorDialog("Failed to sync and open iOS project", error); + }, + }); + + // Sync and open Android mutation + const syncAndOpenAndroidMutation = useMutation({ + mutationFn: async () => { + setAndroidStatus("syncing"); + // First sync + await IpcClient.getInstance().syncCapacitor({ appId }); + setAndroidStatus("opening"); + // Then open Android + await IpcClient.getInstance().openAndroid({ appId }); + }, + onSuccess: () => { + setAndroidStatus("idle"); + showSuccess("Synced and opened Android project in Android Studio"); + }, + onError: (error) => { + setAndroidStatus("idle"); + showErrorDialog("Failed to sync and open Android project", error); + }, + }); + + // Helper function to get button text based on status + const getIosButtonText = () => { + switch (iosStatus) { + case "syncing": + return { main: "Syncing...", sub: "Building app" }; + case "opening": + return { main: "Opening...", sub: "Launching Xcode" }; + default: + return { main: "Sync & Open iOS", sub: "Xcode" }; + } + }; + + const getAndroidButtonText = () => { + switch (androidStatus) { + case "syncing": + return { main: "Syncing...", sub: "Building app" }; + case "opening": + return { main: "Opening...", sub: "Launching Android Studio" }; + default: + return { main: "Sync & Open Android", sub: "Android Studio" }; + } + }; + + // Don't render anything if loading or if Capacitor is not installed + if (isLoading || !isCapacitor) { + return null; + } + + const iosButtonText = getIosButtonText(); + const androidButtonText = getAndroidButtonText(); + + return ( + <> + + + + Mobile Development + + + + Sync and open your Capacitor mobile projects + + + +
+ + + +
+
+
+ + {/* Error Dialog */} + + + + + {errorDetails?.title} + + + An error occurred while running the Capacitor command. See details + below: + + + + {errorDetails && ( +
+
+
+                  {errorDetails.message}
+                
+
+ +
+ )} + +
+ + +
+
+
+ + ); +} diff --git a/backups/backup-20251218-161645/src/components/ChatInputControls.tsx b/backups/backup-20251218-161645/src/components/ChatInputControls.tsx new file mode 100644 index 0000000..2c28731 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/ChatInputControls.tsx @@ -0,0 +1,37 @@ +import { ContextFilesPicker } from "./ContextFilesPicker"; +import { ModelPicker } from "./ModelPicker"; +import { ProModeSelector } from "./ProModeSelector"; +import { ChatModeSelector } from "./ChatModeSelector"; +import { McpToolsPicker } from "@/components/McpToolsPicker"; +import { useSettings } from "@/hooks/useSettings"; + +export function ChatInputControls({ + showContextFilesPicker = false, +}: { + showContextFilesPicker?: boolean; +}) { + const { settings } = useSettings(); + + return ( +
+ + {settings?.selectedChatMode === "agent" && ( + <> +
+ + + )} +
+ +
+ +
+ {showContextFilesPicker && ( + <> + +
+ + )} +
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/ChatList.tsx b/backups/backup-20251218-161645/src/components/ChatList.tsx new file mode 100644 index 0000000..f66f44b --- /dev/null +++ b/backups/backup-20251218-161645/src/components/ChatList.tsx @@ -0,0 +1,303 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; + +import { formatDistanceToNow } from "date-fns"; +import { PlusCircle, MoreVertical, Trash2, Edit3, Search } from "lucide-react"; +import { useAtom } from "jotai"; +import { selectedChatIdAtom } from "@/atoms/chatAtoms"; +import { selectedAppIdAtom } from "@/atoms/appAtoms"; +import { dropdownOpenAtom } from "@/atoms/uiAtoms"; +import { IpcClient } from "@/ipc/ipc_client"; +import { showError, showSuccess } from "@/lib/toast"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useChats } from "@/hooks/useChats"; +import { RenameChatDialog } from "@/components/chat/RenameChatDialog"; +import { DeleteChatDialog } from "@/components/chat/DeleteChatDialog"; + +import { ChatSearchDialog } from "./ChatSearchDialog"; +import { useSelectChat } from "@/hooks/useSelectChat"; + +export function ChatList({ show }: { show?: boolean }) { + const navigate = useNavigate(); + const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom); + const [selectedAppId] = useAtom(selectedAppIdAtom); + const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom); + + const { chats, loading, refreshChats } = useChats(selectedAppId); + const routerState = useRouterState(); + const isChatRoute = routerState.location.pathname === "/chat"; + + // Rename dialog state + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [renameChatId, setRenameChatId] = useState(null); + const [renameChatTitle, setRenameChatTitle] = useState(""); + + // Delete dialog state + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [deleteChatId, setDeleteChatId] = useState(null); + const [deleteChatTitle, setDeleteChatTitle] = useState(""); + + // search dialog state + const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); + const { selectChat } = useSelectChat(); + + // Update selectedChatId when route changes + useEffect(() => { + if (isChatRoute) { + const id = routerState.location.search.id; + if (id) { + console.log("Setting selected chat id to", id); + setSelectedChatId(id); + } + } + }, [isChatRoute, routerState.location.search, setSelectedChatId]); + + if (!show) { + return; + } + + const handleChatClick = ({ + chatId, + appId, + }: { + chatId: number; + appId: number; + }) => { + selectChat({ chatId, appId }); + setIsSearchDialogOpen(false); + }; + + const handleNewChat = async () => { + // Only create a new chat if an app is selected + if (selectedAppId) { + try { + // Create a new chat with an empty title for now + const chatId = await IpcClient.getInstance().createChat(selectedAppId); + + // Navigate to the new chat + setSelectedChatId(chatId); + navigate({ + to: "/chat", + search: { id: chatId }, + }); + + // Refresh the chat list + await refreshChats(); + } catch (error) { + // DO A TOAST + showError(`Failed to create new chat: ${(error as any).toString()}`); + } + } else { + // If no app is selected, navigate to home page + navigate({ to: "/" }); + } + }; + + const handleDeleteChat = async (chatId: number) => { + try { + await IpcClient.getInstance().deleteChat(chatId); + showSuccess("Chat deleted successfully"); + + // If the deleted chat was selected, navigate to home + if (selectedChatId === chatId) { + setSelectedChatId(null); + navigate({ to: "/chat" }); + } + + // Refresh the chat list + await refreshChats(); + } catch (error) { + showError(`Failed to delete chat: ${(error as any).toString()}`); + } + }; + + const handleDeleteChatClick = (chatId: number, chatTitle: string) => { + setDeleteChatId(chatId); + setDeleteChatTitle(chatTitle); + setIsDeleteDialogOpen(true); + }; + + const handleConfirmDelete = async () => { + if (deleteChatId !== null) { + await handleDeleteChat(deleteChatId); + setIsDeleteDialogOpen(false); + setDeleteChatId(null); + setDeleteChatTitle(""); + } + }; + + const handleRenameChat = (chatId: number, currentTitle: string) => { + setRenameChatId(chatId); + setRenameChatTitle(currentTitle); + setIsRenameDialogOpen(true); + }; + + const handleRenameDialogClose = (open: boolean) => { + setIsRenameDialogOpen(open); + if (!open) { + setRenameChatId(null); + setRenameChatTitle(""); + } + }; + + return ( + <> + + Recent Chats + +
+ + + + {loading ? ( +
+ Loading chats... +
+ ) : chats.length === 0 ? ( +
+ No chats found +
+ ) : ( + + {chats.map((chat) => ( + +
+ + + {selectedChatId === chat.id && ( + setIsDropdownOpen(open)} + > + + + + + + handleRenameChat(chat.id, chat.title || "") + } + className="px-3 py-2" + > + + Rename Chat + + + handleDeleteChatClick( + chat.id, + chat.title || "New Chat", + ) + } + className="px-3 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/50 focus:bg-red-50 dark:focus:bg-red-950/50" + > + + Delete Chat + + + + )} +
+
+ ))} +
+ )} +
+
+
+ + {/* Rename Chat Dialog */} + {renameChatId !== null && ( + + )} + + {/* Delete Chat Dialog */} + + + {/* Chat Search Dialog */} + + + ); +} diff --git a/backups/backup-20251218-161645/src/components/ChatModeSelector.tsx b/backups/backup-20251218-161645/src/components/ChatModeSelector.tsx new file mode 100644 index 0000000..c3c5abe --- /dev/null +++ b/backups/backup-20251218-161645/src/components/ChatModeSelector.tsx @@ -0,0 +1,95 @@ +import { + MiniSelectTrigger, + Select, + SelectContent, + SelectItem, + SelectValue, +} from "@/components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useSettings } from "@/hooks/useSettings"; +import type { ChatMode } from "@/lib/schemas"; +import { cn } from "@/lib/utils"; +import { detectIsMac } from "@/hooks/useChatModeToggle"; + +export function ChatModeSelector() { + const { settings, updateSettings } = useSettings(); + + const selectedMode = settings?.selectedChatMode || "build"; + + const handleModeChange = (value: string) => { + updateSettings({ selectedChatMode: value as ChatMode }); + }; + + const getModeDisplayName = (mode: ChatMode) => { + switch (mode) { + case "build": + return "Build"; + case "ask": + return "Ask"; + case "agent": + return "Build (MCP)"; + default: + return "Build"; + } + }; + const isMac = detectIsMac(); + + return ( + + ); +} diff --git a/backups/backup-20251218-161645/src/components/ChatPanel.tsx b/backups/backup-20251218-161645/src/components/ChatPanel.tsx new file mode 100644 index 0000000..ee6e858 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/ChatPanel.tsx @@ -0,0 +1,204 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { + chatMessagesByIdAtom, + chatStreamCountByIdAtom, + isStreamingByIdAtom, +} from "../atoms/chatAtoms"; +import { IpcClient } from "@/ipc/ipc_client"; + +import { ChatHeader } from "./chat/ChatHeader"; +import { MessagesList } from "./chat/MessagesList"; +import { ChatInput } from "./chat/ChatInput"; +import { VersionPane } from "./chat/VersionPane"; +import { ChatError } from "./chat/ChatError"; +import { Button } from "@/components/ui/button"; +import { ArrowDown } from "lucide-react"; + +interface ChatPanelProps { + chatId?: number; + isPreviewOpen: boolean; + onTogglePreview: () => void; +} + +export function ChatPanel({ + chatId, + isPreviewOpen, + onTogglePreview, +}: ChatPanelProps) { + const messagesById = useAtomValue(chatMessagesByIdAtom); + const setMessagesById = useSetAtom(chatMessagesByIdAtom); + const [isVersionPaneOpen, setIsVersionPaneOpen] = useState(false); + const [error, setError] = useState(null); + const streamCountById = useAtomValue(chatStreamCountByIdAtom); + const isStreamingById = useAtomValue(isStreamingByIdAtom); + // Reference to store the processed prompt so we don't submit it twice + + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + + // Scroll-related properties + const [isUserScrolling, setIsUserScrolling] = useState(false); + const [showScrollButton, setShowScrollButton] = useState(false); + const userScrollTimeoutRef = useRef(null); + const lastScrollTopRef = useRef(0); + const scrollToBottom = (behavior: ScrollBehavior = "smooth") => { + messagesEndRef.current?.scrollIntoView({ behavior }); + }; + + const handleScrollButtonClick = () => { + if (!messagesContainerRef.current) return; + + scrollToBottom("smooth"); + }; + + const getDistanceFromBottom = () => { + if (!messagesContainerRef.current) return 0; + const container = messagesContainerRef.current; + return ( + container.scrollHeight - (container.scrollTop + container.clientHeight) + ); + }; + + const isNearBottom = (threshold: number = 100) => { + return getDistanceFromBottom() <= threshold; + }; + + const scrollAwayThreshold = 150; // pixels from bottom to consider "scrolled away" + + const handleScroll = useCallback(() => { + if (!messagesContainerRef.current) return; + + const container = messagesContainerRef.current; + const distanceFromBottom = + container.scrollHeight - (container.scrollTop + container.clientHeight); + + // User has scrolled away from bottom + if (distanceFromBottom > scrollAwayThreshold) { + setIsUserScrolling(true); + setShowScrollButton(true); + + if (userScrollTimeoutRef.current) { + window.clearTimeout(userScrollTimeoutRef.current); + } + + userScrollTimeoutRef.current = window.setTimeout(() => { + setIsUserScrolling(false); + }, 2000); // Increased timeout to 2 seconds + } else { + // User is near bottom + setIsUserScrolling(false); + setShowScrollButton(false); + } + lastScrollTopRef.current = container.scrollTop; + }, []); + + useEffect(() => { + const streamCount = chatId ? (streamCountById.get(chatId) ?? 0) : 0; + console.log("streamCount - scrolling to bottom", streamCount); + scrollToBottom(); + }, [ + chatId, + chatId ? (streamCountById.get(chatId) ?? 0) : 0, + chatId ? (isStreamingById.get(chatId) ?? false) : false, + ]); + + useEffect(() => { + const container = messagesContainerRef.current; + if (container) { + container.addEventListener("scroll", handleScroll, { passive: true }); + } + + return () => { + if (container) { + container.removeEventListener("scroll", handleScroll); + } + if (userScrollTimeoutRef.current) { + window.clearTimeout(userScrollTimeoutRef.current); + } + }; + }, [handleScroll]); + + const fetchChatMessages = useCallback(async () => { + if (!chatId) { + // no-op when no chat + return; + } + const chat = await IpcClient.getInstance().getChat(chatId); + setMessagesById((prev) => { + const next = new Map(prev); + next.set(chatId, chat.messages); + return next; + }); + }, [chatId, setMessagesById]); + + useEffect(() => { + fetchChatMessages(); + }, [fetchChatMessages]); + + const messages = chatId ? (messagesById.get(chatId) ?? []) : []; + const isStreaming = chatId ? (isStreamingById.get(chatId) ?? false) : false; + + // Auto-scroll effect when messages change during streaming + useEffect(() => { + if ( + !isUserScrolling && + isStreaming && + messagesContainerRef.current && + messages.length > 0 + ) { + // Only auto-scroll if user is close to bottom + if (isNearBottom(280)) { + requestAnimationFrame(() => { + scrollToBottom("instant"); + }); + } + } + }, [messages, isUserScrolling, isStreaming]); + + return ( +
+ setIsVersionPaneOpen(!isVersionPaneOpen)} + /> +
+ {!isVersionPaneOpen && ( +
+
+ + + {/* Scroll to bottom button */} + {showScrollButton && ( +
+ +
+ )} +
+ + setError(null)} /> + +
+ )} + setIsVersionPaneOpen(false)} + /> +
+
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/ChatSearchDialog.tsx b/backups/backup-20251218-161645/src/components/ChatSearchDialog.tsx new file mode 100644 index 0000000..4717454 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/ChatSearchDialog.tsx @@ -0,0 +1,159 @@ +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "./ui/command"; +import { useState, useEffect } from "react"; +import { useSearchChats } from "@/hooks/useSearchChats"; +import type { ChatSummary, ChatSearchResult } from "@/lib/schemas"; + +type ChatSearchDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelectChat: ({ chatId, appId }: { chatId: number; appId: number }) => void; + appId: number | null; + allChats: ChatSummary[]; +}; + +export function ChatSearchDialog({ + open, + onOpenChange, + appId, + onSelectChat, + allChats, +}: ChatSearchDialogProps) { + const [searchQuery, setSearchQuery] = useState(""); + function useDebouncedValue(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(handle); + }, [value, delay]); + return debounced; + } + + const debouncedQuery = useDebouncedValue(searchQuery, 150); + const { chats: searchResults } = useSearchChats(appId, debouncedQuery); + + // Show all chats if search is empty, otherwise show search results + const chatsToShow = debouncedQuery.trim() === "" ? allChats : searchResults; + + const commandFilter = ( + value: string, + search: string, + keywords?: string[], + ): number => { + const q = search.trim().toLowerCase(); + if (!q) return 1; + const v = (value || "").toLowerCase(); + if (v.includes(q)) { + // Higher score for earlier match in title/value + return 100 - Math.max(0, v.indexOf(q)); + } + const foundInKeywords = (keywords || []).some((k) => + (k || "").toLowerCase().includes(q), + ); + return foundInKeywords ? 50 : 0; + }; + + function getSnippet( + text: string, + query: string, + radius = 50, + ): { + before: string; + match: string; + after: string; + raw: string; + } { + const q = query.trim(); + const lowerText = text; + const lowerQuery = q.toLowerCase(); + const idx = lowerText.toLowerCase().indexOf(lowerQuery); + if (idx === -1) { + const raw = + text.length > radius * 2 ? text.slice(0, radius * 2) + "…" : text; + return { before: "", match: "", after: "", raw }; + } + const start = Math.max(0, idx - radius); + const end = Math.min(text.length, idx + q.length + radius); + const before = (start > 0 ? "…" : "") + text.slice(start, idx); + const match = text.slice(idx, idx + q.length); + const after = + text.slice(idx + q.length, end) + (end < text.length ? "…" : ""); + return { before, match, after, raw: before + match + after }; + } + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + onOpenChange(!open); + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, [open, onOpenChange]); + + return ( + + + + No results found. + + {chatsToShow.map((chat) => { + const isSearch = searchQuery.trim() !== ""; + const hasSnippet = + isSearch && + "matchedMessageContent" in chat && + (chat as ChatSearchResult).matchedMessageContent; + const snippet = hasSnippet + ? getSnippet( + (chat as ChatSearchResult).matchedMessageContent as string, + searchQuery, + ) + : null; + return ( + + onSelectChat({ chatId: chat.id, appId: chat.appId }) + } + value={ + (chat.title || "Untitled Chat") + + (snippet ? ` ${snippet.raw}` : "") + } + keywords={snippet ? [snippet.raw] : []} + > +
+ {chat.title || "Untitled Chat"} + {snippet && ( + + {snippet.before} + + {snippet.match} + + {snippet.after} + + )} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/CommunityCodeConsentDialog.tsx b/backups/backup-20251218-161645/src/components/CommunityCodeConsentDialog.tsx new file mode 100644 index 0000000..a073a58 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/CommunityCodeConsentDialog.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +interface CommunityCodeConsentDialogProps { + isOpen: boolean; + onAccept: () => void; + onCancel: () => void; +} + +export const CommunityCodeConsentDialog: React.FC< + CommunityCodeConsentDialogProps +> = ({ isOpen, onAccept, onCancel }) => { + return ( + !open && onCancel()}> + + + Community Code Notice + +

+ This code was created by a Dyad community member, not our core + team. +

+

+ Community code can be very helpful, but since it's built + independently, it may have bugs, security risks, or could cause + issues with your system. We can't provide official support if + problems occur. +

+

+ We recommend reviewing the code on GitHub first. Only proceed if + you're comfortable with these risks. +

+
+
+ + Cancel + Accept + +
+
+ ); +}; diff --git a/backups/backup-20251218-161645/src/components/ConfirmationDialog.tsx b/backups/backup-20251218-161645/src/components/ConfirmationDialog.tsx new file mode 100644 index 0000000..e01fec7 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/ConfirmationDialog.tsx @@ -0,0 +1,84 @@ +import React from "react"; + +interface ConfirmationDialogProps { + isOpen: boolean; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + confirmButtonClass?: string; + onConfirm: () => void; + onCancel: () => void; +} + +export default function ConfirmationDialog({ + isOpen, + title, + message, + confirmText = "Confirm", + cancelText = "Cancel", + confirmButtonClass = "bg-red-600 hover:bg-red-700 focus:ring-red-500", + onConfirm, + onCancel, +}: ConfirmationDialogProps) { + if (!isOpen) return null; + + return ( +
+
+
+ +
+
+
+
+ + + +
+
+

+ {title} +

+
+

+ {message} +

+
+
+
+
+
+ + +
+
+
+
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/ContextFilesPicker.tsx b/backups/backup-20251218-161645/src/components/ContextFilesPicker.tsx new file mode 100644 index 0000000..1dafdd5 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/ContextFilesPicker.tsx @@ -0,0 +1,412 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +import { InfoIcon, Settings2, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./ui/tooltip"; +import { useSettings } from "@/hooks/useSettings"; +import { useContextPaths } from "@/hooks/useContextPaths"; +import type { ContextPathResult } from "@/lib/schemas"; + +export function ContextFilesPicker() { + const { settings } = useSettings(); + const { + contextPaths, + smartContextAutoIncludes, + excludePaths, + updateContextPaths, + updateSmartContextAutoIncludes, + updateExcludePaths, + } = useContextPaths(); + const [isOpen, setIsOpen] = useState(false); + const [newPath, setNewPath] = useState(""); + const [newAutoIncludePath, setNewAutoIncludePath] = useState(""); + const [newExcludePath, setNewExcludePath] = useState(""); + + const addPath = () => { + if ( + newPath.trim() === "" || + contextPaths.find((p: ContextPathResult) => p.globPath === newPath) + ) { + setNewPath(""); + return; + } + const newPaths = [ + ...contextPaths.map(({ globPath }: ContextPathResult) => ({ globPath })), + { + globPath: newPath, + }, + ]; + updateContextPaths(newPaths); + setNewPath(""); + }; + + const removePath = (pathToRemove: string) => { + const newPaths = contextPaths + .filter((p: ContextPathResult) => p.globPath !== pathToRemove) + .map(({ globPath }: ContextPathResult) => ({ globPath })); + updateContextPaths(newPaths); + }; + + const addAutoIncludePath = () => { + if ( + newAutoIncludePath.trim() === "" || + smartContextAutoIncludes.find( + (p: ContextPathResult) => p.globPath === newAutoIncludePath, + ) + ) { + setNewAutoIncludePath(""); + return; + } + const newPaths = [ + ...smartContextAutoIncludes.map(({ globPath }: ContextPathResult) => ({ + globPath, + })), + { + globPath: newAutoIncludePath, + }, + ]; + updateSmartContextAutoIncludes(newPaths); + setNewAutoIncludePath(""); + }; + + const removeAutoIncludePath = (pathToRemove: string) => { + const newPaths = smartContextAutoIncludes + .filter((p: ContextPathResult) => p.globPath !== pathToRemove) + .map(({ globPath }: ContextPathResult) => ({ globPath })); + updateSmartContextAutoIncludes(newPaths); + }; + + const addExcludePath = () => { + if ( + newExcludePath.trim() === "" || + excludePaths.find((p: ContextPathResult) => p.globPath === newExcludePath) + ) { + setNewExcludePath(""); + return; + } + const newPaths = [ + ...excludePaths.map(({ globPath }: ContextPathResult) => ({ globPath })), + { + globPath: newExcludePath, + }, + ]; + updateExcludePaths(newPaths); + setNewExcludePath(""); + }; + + const removeExcludePath = (pathToRemove: string) => { + const newPaths = excludePaths + .filter((p: ContextPathResult) => p.globPath !== pathToRemove) + .map(({ globPath }: ContextPathResult) => ({ globPath })); + updateExcludePaths(newPaths); + }; + + const isSmartContextEnabled = + settings?.enableDyadPro && settings?.enableProSmartFilesContextMode; + + return ( + + + + + + + + Codebase Context + + + +
+
+

Codebase Context

+

+ + + + + Select the files to use as context.{" "} + + + + + {isSmartContextEnabled ? ( +

+ With Smart Context, Dyad uses the most relevant files as + context. +

+ ) : ( +

By default, Dyad uses your whole codebase.

+ )} + + + +

+
+ +
+ setNewPath(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + addPath(); + } + }} + /> + +
+ + + {contextPaths.length > 0 ? ( +
+ {contextPaths.map((p: ContextPathResult) => ( +
+
+ + + + {p.globPath} + + + +

{p.globPath}

+
+
+ + {p.files} files, ~{p.tokens} tokens + +
+
+ +
+
+ ))} +
+ ) : ( +
+

+ {isSmartContextEnabled + ? "Dyad will use Smart Context to automatically find the most relevant files to use as context." + : "Dyad will use the entire codebase as context."} +

+
+ )} +
+ +
+
+

Exclude Paths

+

+ + + + + These files will be excluded from the context.{" "} + + + + +

+ Exclude paths take precedence - files that match both + include and exclude patterns will be excluded. +

+ + + +

+
+ +
+ setNewExcludePath(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + addExcludePath(); + } + }} + /> + +
+ + + {excludePaths.length > 0 && ( +
+ {excludePaths.map((p: ContextPathResult) => ( +
+
+ + + + {p.globPath} + + + +

{p.globPath}

+
+
+ + {p.files} files, ~{p.tokens} tokens + +
+
+ +
+
+ ))} +
+ )} +
+
+ + {isSmartContextEnabled && ( +
+
+

Smart Context Auto-includes

+

+ + + + + These files will always be included in the context.{" "} + + + + +

+ Auto-include files are always included in the context + in addition to the files selected as relevant by Smart + Context. +

+ + + +

+
+ +
+ setNewAutoIncludePath(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + addAutoIncludePath(); + } + }} + /> + +
+ + + {smartContextAutoIncludes.length > 0 && ( +
+ {smartContextAutoIncludes.map((p: ContextPathResult) => ( +
+
+ + + + {p.globPath} + + + +

{p.globPath}

+
+
+ + {p.files} files, ~{p.tokens} tokens + +
+
+ +
+
+ ))} +
+ )} +
+
+ )} +
+
+
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/CopyErrorMessage.tsx b/backups/backup-20251218-161645/src/components/CopyErrorMessage.tsx new file mode 100644 index 0000000..82981fc --- /dev/null +++ b/backups/backup-20251218-161645/src/components/CopyErrorMessage.tsx @@ -0,0 +1,49 @@ +import { Copy, Check } from "lucide-react"; +import { useState } from "react"; + +interface CopyErrorMessageProps { + errorMessage: string; + className?: string; +} + +export const CopyErrorMessage = ({ + errorMessage, + className = "", +}: CopyErrorMessageProps) => { + const [isCopied, setIsCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(errorMessage); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } catch (err) { + console.error("Failed to copy error message:", err); + } + }; + + return ( + + ); +}; diff --git a/backups/backup-20251218-161645/src/components/CreateAppDialog.tsx b/backups/backup-20251218-161645/src/components/CreateAppDialog.tsx new file mode 100644 index 0000000..165a51d --- /dev/null +++ b/backups/backup-20251218-161645/src/components/CreateAppDialog.tsx @@ -0,0 +1,137 @@ +import React, { useState } 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 { useCreateApp } from "@/hooks/useCreateApp"; +import { useCheckName } from "@/hooks/useCheckName"; +import { useSetAtom } from "jotai"; +import { selectedAppIdAtom } from "@/atoms/appAtoms"; +import { NEON_TEMPLATE_IDS, Template } from "@/shared/templates"; + +import { useRouter } from "@tanstack/react-router"; + +import { Loader2 } from "lucide-react"; +import { neonTemplateHook } from "@/client_logic/template_hook"; +import { showError } from "@/lib/toast"; + +interface CreateAppDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + template: Template | undefined; +} + +export function CreateAppDialog({ + open, + onOpenChange, + template, +}: CreateAppDialogProps) { + const setSelectedAppId = useSetAtom(selectedAppIdAtom); + const [appName, setAppName] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const { createApp } = useCreateApp(); + const { data: nameCheckResult } = useCheckName(appName); + const router = useRouter(); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!appName.trim()) { + return; + } + + if (nameCheckResult?.exists) { + return; + } + + setIsSubmitting(true); + try { + const result = await createApp({ name: appName.trim() }); + if (template && NEON_TEMPLATE_IDS.has(template.id)) { + await neonTemplateHook({ + appId: result.app.id, + appName: result.app.name, + }); + } + setSelectedAppId(result.app.id); + // Navigate to the new app's first chat + router.navigate({ + to: "/chat", + search: { id: result.chatId }, + }); + setAppName(""); + onOpenChange(false); + } catch (error) { + showError(error as any); + // Error is already handled by createApp hook or shown above + console.error("Error creating app:", error); + } finally { + setIsSubmitting(false); + } + }; + + const isNameValid = appName.trim().length > 0; + const nameExists = nameCheckResult?.exists; + const canSubmit = isNameValid && !nameExists && !isSubmitting; + + return ( + + + + Create New App + + {`Create a new app using the ${template?.title} template.`} + + + +
+
+
+ + setAppName(e.target.value)} + placeholder="Enter app name..." + className={nameExists ? "border-red-500" : ""} + disabled={isSubmitting} + /> + {nameExists && ( +

+ An app with this name already exists +

+ )} +
+
+ + + + + +
+
+
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/CreateCustomModelDialog.tsx b/backups/backup-20251218-161645/src/components/CreateCustomModelDialog.tsx new file mode 100644 index 0000000..e181f78 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/CreateCustomModelDialog.tsx @@ -0,0 +1,200 @@ +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { IpcClient } from "@/ipc/ipc_client"; +import { useMutation } from "@tanstack/react-query"; +import { showError, showSuccess } from "@/lib/toast"; + +interface CreateCustomModelDialogProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + providerId: string; +} + +export function CreateCustomModelDialog({ + isOpen, + onClose, + onSuccess, + providerId, +}: CreateCustomModelDialogProps) { + const [apiName, setApiName] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [description, setDescription] = useState(""); + const [maxOutputTokens, setMaxOutputTokens] = useState(""); + const [contextWindow, setContextWindow] = useState(""); + + const ipcClient = IpcClient.getInstance(); + + const mutation = useMutation({ + mutationFn: async () => { + const params = { + apiName, + displayName, + providerId, + description: description || undefined, + maxOutputTokens: maxOutputTokens + ? parseInt(maxOutputTokens, 10) + : undefined, + contextWindow: contextWindow ? parseInt(contextWindow, 10) : undefined, + }; + + if (!params.apiName) throw new Error("Model API name is required"); + if (!params.displayName) + throw new Error("Model display name is required"); + if (maxOutputTokens && isNaN(params.maxOutputTokens ?? NaN)) + throw new Error("Max Output Tokens must be a valid number"); + if (contextWindow && isNaN(params.contextWindow ?? NaN)) + throw new Error("Context Window must be a valid number"); + + await ipcClient.createCustomLanguageModel(params); + }, + onSuccess: () => { + showSuccess("Custom model created successfully!"); + resetForm(); + onSuccess(); // Refetch or update UI + onClose(); + }, + onError: (error) => { + showError(error); + }, + }); + + const resetForm = () => { + setApiName(""); + setDisplayName(""); + setDescription(""); + setMaxOutputTokens(""); + setContextWindow(""); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + mutation.mutate(); + }; + + const handleClose = () => { + if (!mutation.isPending) { + resetForm(); + onClose(); + } + }; + + return ( + + + + Add Custom Model + + Configure a new language model for the selected provider. + + +
+
+
+ + ) => + setApiName(e.target.value) + } + className="col-span-3" + placeholder="This must match the model expected by the API" + required + disabled={mutation.isPending} + /> +
+
+ + ) => + setDisplayName(e.target.value) + } + className="col-span-3" + placeholder="Human-friendly name for the model" + required + disabled={mutation.isPending} + /> +
+
+ + ) => + setDescription(e.target.value) + } + className="col-span-3" + placeholder="Optional: Describe the model's capabilities" + disabled={mutation.isPending} + /> +
+
+ + ) => + setMaxOutputTokens(e.target.value) + } + className="col-span-3" + placeholder="Optional: e.g., 4096" + disabled={mutation.isPending} + /> +
+
+ + ) => + setContextWindow(e.target.value) + } + className="col-span-3" + placeholder="Optional: e.g., 8192" + disabled={mutation.isPending} + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/CreateCustomProviderDialog.tsx b/backups/backup-20251218-161645/src/components/CreateCustomProviderDialog.tsx new file mode 100644 index 0000000..d33a2a4 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/CreateCustomProviderDialog.tsx @@ -0,0 +1,213 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; +import { useCustomLanguageModelProvider } from "@/hooks/useCustomLanguageModelProvider"; +import type { LanguageModelProvider } from "@/ipc/ipc_types"; + +interface CreateCustomProviderDialogProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + editingProvider?: LanguageModelProvider | null; +} + +export function CreateCustomProviderDialog({ + isOpen, + onClose, + onSuccess, + editingProvider = null, +}: CreateCustomProviderDialogProps) { + const [id, setId] = useState(""); + const [name, setName] = useState(""); + const [apiBaseUrl, setApiBaseUrl] = useState(""); + const [envVarName, setEnvVarName] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const isEditMode = Boolean(editingProvider); + + const { createProvider, editProvider, isCreating, isEditing, error } = + useCustomLanguageModelProvider(); + // Load provider data when editing + useEffect(() => { + if (editingProvider && isOpen) { + const cleanId = editingProvider.id?.startsWith("custom::") + ? editingProvider.id.replace("custom::", "") + : editingProvider.id || ""; + setId(cleanId); + setName(editingProvider.name || ""); + setApiBaseUrl(editingProvider.apiBaseUrl || ""); + setEnvVarName(editingProvider.envVarName || ""); + } else if (!isOpen) { + // Reset form when dialog closes + setId(""); + setName(""); + setApiBaseUrl(""); + setEnvVarName(""); + setErrorMessage(""); + } + }, [editingProvider, isOpen]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrorMessage(""); + + try { + if (isEditMode && editingProvider) { + const cleanId = editingProvider.id?.startsWith("custom::") + ? editingProvider.id.replace("custom::", "") + : editingProvider.id || ""; + await editProvider({ + id: cleanId, + name: name.trim(), + apiBaseUrl: apiBaseUrl.trim(), + envVarName: envVarName.trim() || undefined, + }); + } else { + await createProvider({ + id: id.trim(), + name: name.trim(), + apiBaseUrl: apiBaseUrl.trim(), + envVarName: envVarName.trim() || undefined, + }); + } + + // Reset form + setId(""); + setName(""); + setApiBaseUrl(""); + setEnvVarName(""); + + onSuccess(); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : `Failed to ${isEditMode ? "edit" : "create"} custom provider`, + ); + } + }; + + const handleClose = () => { + if (!isCreating && !isEditing) { + setErrorMessage(""); + onClose(); + } + }; + const isLoading = isCreating || isEditing; + + return ( + + + + + {isEditMode ? "Edit Custom Provider" : "Add Custom Provider"} + + + {isEditMode + ? "Update your custom language model provider configuration." + : "Connect to a custom language model provider API."} + + + +
+
+ + setId(e.target.value)} + placeholder="E.g., my-provider" + required + disabled={isLoading || isEditMode} + /> +

+ A unique identifier for this provider (no spaces). +

+
+ +
+ + setName(e.target.value)} + placeholder="E.g., My Provider" + required + disabled={isLoading} + /> +

+ The name that will be displayed in the UI. +

+
+ +
+ + setApiBaseUrl(e.target.value)} + placeholder="E.g., https://api.example.com/v1" + required + disabled={isLoading} + /> +

+ The base URL for the API endpoint. +

+
+ +
+ + setEnvVarName(e.target.value)} + placeholder="E.g., MY_PROVIDER_API_KEY" + disabled={isLoading} + /> +

+ Environment variable name for the API key. +

+
+ + {(errorMessage || error) && ( +
+ {errorMessage || + (error instanceof Error + ? error.message + : "Failed to create custom provider")} +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/backups/backup-20251218-161645/src/components/CreatePromptDialog.tsx b/backups/backup-20251218-161645/src/components/CreatePromptDialog.tsx new file mode 100644 index 0000000..96f8a39 --- /dev/null +++ b/backups/backup-20251218-161645/src/components/CreatePromptDialog.tsx @@ -0,0 +1,276 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Plus, Save, Edit2 } from "lucide-react"; + +interface CreateOrEditPromptDialogProps { + mode: "create" | "edit"; + prompt?: { + id: number; + title: string; + description: string | null; + content: string; + }; + onCreatePrompt?: (prompt: { + title: string; + description?: string; + content: string; + }) => Promise; + onUpdatePrompt?: (prompt: { + id: number; + title: string; + description?: string; + content: string; + }) => Promise; + trigger?: React.ReactNode; + prefillData?: { + title: string; + description: string; + content: string; + }; + isOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function CreateOrEditPromptDialog({ + mode, + prompt, + onCreatePrompt, + onUpdatePrompt, + trigger, + prefillData, + isOpen, + onOpenChange, +}: CreateOrEditPromptDialogProps) { + const [internalOpen, setInternalOpen] = useState(false); + const open = isOpen !== undefined ? isOpen : internalOpen; + const setOpen = onOpenChange || setInternalOpen; + + const [draft, setDraft] = useState({ + title: "", + description: "", + content: "", + }); + const textareaRef = useRef(null); + + // Auto-resize textarea function + const adjustTextareaHeight = () => { + const textarea = textareaRef.current; + if (textarea) { + // Store current height to avoid flicker + const currentHeight = textarea.style.height; + textarea.style.height = "auto"; + const scrollHeight = textarea.scrollHeight; + const maxHeight = window.innerHeight * 0.6 - 100; // 60vh in pixels + const minHeight = 150; // 150px minimum + const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight); + + // Only update if height actually changed to reduce reflows + if (`${newHeight}px` !== currentHeight) { + textarea.style.height = `${newHeight}px`; + } + } + }; + + // Initialize draft with prompt data when editing or prefill data + useEffect(() => { + if (mode === "edit" && prompt) { + setDraft({ + title: prompt.title, + description: prompt.description || "", + content: prompt.content, + }); + } else if (prefillData) { + setDraft({ + title: prefillData.title, + description: prefillData.description, + content: prefillData.content, + }); + } else { + setDraft({ title: "", description: "", content: "" }); + } + }, [mode, prompt, prefillData, open]); + + // Auto-resize textarea when content changes + useEffect(() => { + adjustTextareaHeight(); + }, [draft.content]); + + // Trigger resize when dialog opens + useEffect(() => { + if (open) { + // Small delay to ensure the dialog is fully rendered + setTimeout(adjustTextareaHeight, 0); + } + }, [open]); + + const resetDraft = () => { + if (mode === "edit" && prompt) { + setDraft({ + title: prompt.title, + description: prompt.description || "", + content: prompt.content, + }); + } else if (prefillData) { + setDraft({ + title: prefillData.title, + description: prefillData.description, + content: prefillData.content, + }); + } else { + setDraft({ title: "", description: "", content: "" }); + } + }; + + const onSave = async () => { + if (!draft.title.trim() || !draft.content.trim()) return; + + if (mode === "create" && onCreatePrompt) { + await onCreatePrompt({ + title: draft.title.trim(), + description: draft.description.trim() || undefined, + content: draft.content, + }); + } else if (mode === "edit" && onUpdatePrompt && prompt) { + await onUpdatePrompt({ + id: prompt.id, + title: draft.title.trim(), + description: draft.description.trim() || undefined, + content: draft.content, + }); + } + + setOpen(false); + }; + + const handleCancel = () => { + resetDraft(); + setOpen(false); + }; + + return ( + + {trigger ? ( + {trigger} + ) : mode === "create" ? ( + + + + ) : ( + + + + + + + +

Edit prompt

+
+
+ )} + + + + {mode === "create" ? "Create New Prompt" : "Edit Prompt"} + + + {mode === "create" + ? "Create a new prompt template for your library." + : "Edit your prompt template."} + + +
+ setDraft((d) => ({ ...d, title: e.target.value }))} + /> + + setDraft((d) => ({ ...d, description: e.target.value })) + } + /> +