diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 10effb86..ab1b5981 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -github: Gerome-Elaassaad +github: codinit-dev diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index 1cf68746..63ef8902 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -69,7 +69,7 @@ jobs: - name: Build Electron app env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.TOKEN }} NODE_OPTIONS: "--max_old_space_size=4096" run: | if [ "$RUNNER_OS" == "Windows" ]; then @@ -92,6 +92,7 @@ jobs: dist/*.AppImage dist/*.zip dist/*.blockmap + dist/*.yml retention-days: 1 if-no-files-found: warn @@ -122,5 +123,6 @@ jobs: artifacts/**/*.AppImage artifacts/**/*.zip artifacts/**/*.blockmap + artifacts/**/*.yml env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index b2b3a058..e2609f4e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,5 @@ CLAUDE.md AGENTS.md* .mcp.json -.claude \ No newline at end of file +.claude +backend \ No newline at end of file diff --git a/LICENSE b/LICENSE index 9a48e763..77703be1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 CodinIT.dev +Copyright (c) 2026 CodinIT.dev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 1c0649a8..c3eb2cc0 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,70 @@ - -hero-image - +

+ CodinIT.dev Hero +

- - Featured on HuntScreens + + Fazier badge -

- - Download CodinIT.dev +     +     + + CodinIT.dev badge

+

CodinIT.dev — Open‑Source AI App Builder

+

- ⚡ CodinIT.dev — OpenSource AI App Builder ⚡
- Build, manage, and deploy intelligent applications directly from your browser or desktop. + Build, manage, and deploy intelligent applications faster — directly from your browser or desktop.

--- -## 🚀 Quick Start +## Overview + +CodinIT.dev is an open‑source, AI full‑stack development platform designed to help developers build modern Node.js applications with speed and precision. It combines code generation, project management, and deployment tools into a single workflow, powered by your choice of AI providers. + +Whether you are prototyping, scaling a SaaS product, or experimenting with local LLMs, CodinIT.dev adapts to your stack and workflow. + +--- + +## Quick Start -Get up and running with **CodinIT.dev** in just a few steps. +### Run as a Desktop App -### 1️⃣ Clone the Repository +Download the latest prebuilt release for macOS, Windows, and Linux. + +[Download Latest Release](https://github.com/codinit-dev/codinit-dev/releases/latest) + +Get up and running in minutes. + +### 1. Clone the Repository ```bash git clone https://github.com/codinit-dev/codinit-dev.git -cd codinit-app -```` +cd codinit-dev + +``` -### Install Dependencies +### 2. Install Dependencies ```bash # npm @@ -43,86 +73,57 @@ npm install # or pnpm pnpm install -# or yarn -yarn install -``` - -### 2️⃣ Set Up the Database - -Ensure you have a PostgreSQL database running. (Supabase recommended.) - -### 3️⃣ Configure Environment - -```bash -cp .env.example .env.local ``` -Add your keys: +### 3. Configure Environment -```bash -OPENAI_API_KEY=your_openai_key -ANTHROPIC_API_KEY=your_anthropic_key -SUPABASE_URL=your_supabase_url -SUPABASE_ANON_KEY=your_supabase_anon_key -``` +Create a `.env` file and add your preferred AI provider keys. You can mix and match multiple providers depending on your requirements. -### 4️⃣ Run the Dev Server +### 4. Run the Development Server ```bash pnpm run dev + ``` -The app will be available at: -👉 [http://localhost:5173](http://localhost:5173) +The application will be available at: http://localhost:5173 --- -## 🧩 Key Features +## Core Capabilities -* 🧠 AI-powered full-stack development for Node.js apps -* 🌐 Integrations with 19+ AI providers -* 🖥️ Web + Desktop (Electron) support -* 🐳 Docker-ready, deployable to Vercel/Netlify/GitHub Pages -* 🔍 Built-in search, diff view, and file locking system -* 🧰 Supabase integration, data visualization, and voice prompting +- **Automated Full-Stack Engineering:** Streamline the creation and management of complex Node.js architectures using intelligent generation. +- **Universal Model Integration:** Seamlessly connect with over 19 cloud and local AI providers. +- **Hybrid Environment Support:** native compatibility for both Web browsers and Desktop (Electron) environments. +- **Production-Ready Containerization:** Fully Dockerized workflow with preset configurations for Vercel, Netlify, and GitHub Pages. +- **Integrated Development Suite:** Includes robust utilities such as semantic search, diff visualization, and concurrency file-locking. +- **Expanded Ecosystem Connectivity:** Native integration with Supabase, real-time data visualization tools, and voice-command interfaces. +- **Vendor-Neutral Infrastructure:** A flexible architecture designed to prevent vendor lock-in, allowing dynamic switching between backend providers. --- -## 🔑 API Providers +## Supported AI Providers + +CodinIT.dev allows you to use one provider or switch dynamically per task. + +### Cloud Providers -**Cloud Providers:** OpenAI, Anthropic, Google, Groq, xAI, DeepSeek, Cohere, Mistral, Together, Perplexity, HuggingFace, OpenRouter, and more. -**Local:** -Ollama, LM Studio, OpenAI-compatible local endpoints. +### Local Providers + +Ollama, LM Studio, and OpenAI‑compatible local endpoints. --- -## 🖥️ Desktop & Docker Options +## Deployment & Desktop Usage -### Run via Docker +### Run with Docker ```bash npm run dockerbuild docker compose --profile development up -``` - -### Run as Desktop App -Download the latest release: -👉 [Latest Release](https://github.com/codinit-dev/codinit-dev/releases/latest) - ---- - -## 🤝 Contributing - -We welcome contributions! -Open an issue, submit a PR, or join discussions to help shape the future of CodinIT.dev. - ---- +``` -

- CodinIT.dev — Build Faster. Code Smarter.
- Download the latest version → -

diff --git a/__tests__/unit/providers/manager.test.ts b/__tests__/unit/providers/manager.test.ts index b54ca7ee..6aeb0f15 100644 --- a/__tests__/unit/providers/manager.test.ts +++ b/__tests__/unit/providers/manager.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { LLMManager } from '~/lib/modules/llm/manager'; import { mockOpenAIModels, mockApiKeys } from '../../fixtures/api-responses'; @@ -9,6 +9,15 @@ describe('LLMManager', () => { // Reset the singleton instance for each test (LLMManager as any)._instance = null; manager = LLMManager.getInstance(); + + // Silence console for cleaner test output + vi.spyOn(console, 'log').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); + vi.spyOn(console, 'warn').mockImplementation(() => { }); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); describe('getInstance', () => { diff --git a/__tests__/unit/runtime/code-validator.test.ts b/__tests__/unit/runtime/code-validator.test.ts index 243b4bf2..01749903 100644 --- a/__tests__/unit/runtime/code-validator.test.ts +++ b/__tests__/unit/runtime/code-validator.test.ts @@ -1,7 +1,17 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { validateCode } from '~/lib/runtime/code-validator'; describe('code-validator', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'debug').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); describe('validateCode - General', () => { it('should detect placeholder comments', () => { const code = ` diff --git a/__tests__/unit/runtime/message-parser.test.ts b/__tests__/unit/runtime/message-parser.test.ts index d6caa858..50aca263 100644 --- a/__tests__/unit/runtime/message-parser.test.ts +++ b/__tests__/unit/runtime/message-parser.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; function cleanoutMarkdownSyntax(content: string) { const codeBlockRegex = /^\s*```[\w-]*\s*\n?([\s\S]*?)\n?\s*```\s*$/; @@ -18,6 +18,15 @@ function cleanoutMarkdownSyntax(content: string) { } describe('message-parser - cleanoutMarkdownSyntax', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('Basic markdown removal', () => { it('should remove markdown code block with language', () => { const input = '```javascript\nconst x = 1;\n```'; diff --git a/__tests__/unit/server/llm-utils.test.ts b/__tests__/unit/server/llm-utils.test.ts index ab22de59..14f887d6 100644 --- a/__tests__/unit/server/llm-utils.test.ts +++ b/__tests__/unit/server/llm-utils.test.ts @@ -1,8 +1,26 @@ -import { describe, it, expect } from 'vitest'; -import { extractFileReferences, createReferencedFilesContext, processFileReferences } from '~/lib/.server/llm/utils'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + extractFileReferences, + createReferencedFilesContext, + processFileReferences, + extractPropertiesFromMessage, + simplifyCodinitActions, + createFilesContext, + extractCurrentContext, +} from '~/lib/.server/llm/utils'; import type { FileMap } from '~/lib/.server/llm/constants'; +import type { Message } from 'ai'; describe('LLM Utils - File References', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('extractFileReferences', () => { it('should extract single file reference', () => { const text = 'Can you check @src/index.ts?'; @@ -268,3 +286,396 @@ Can you fix it to properly import from @app/utils.ts?`; }); }); }); + +describe('LLM Utils - Message Processing', () => { + describe('extractPropertiesFromMessage', () => { + it('should extract model and provider from message content', () => { + const message = { + role: 'user' as const, + content: '[Model: gpt-4]\n\n[Provider: OpenAI]\n\nHello world', + }; + const result = extractPropertiesFromMessage(message); + expect(result.model).toBe('gpt-4'); + expect(result.provider).toBe('OpenAI'); + expect(result.content).toBe('Hello world'); + }); + + it('should use default model when not specified', () => { + const message = { + role: 'user' as const, + content: '[Provider: Anthropic]\n\nHello world', + }; + const result = extractPropertiesFromMessage(message); + expect(result.model).toBe('claude-4-5-sonnet-latest'); + expect(result.provider).toBe('Anthropic'); + }); + + it('should use default provider when not specified', () => { + const message = { + role: 'user' as const, + content: '[Model: gpt-4]\n\nHello world', + }; + const result = extractPropertiesFromMessage(message); + expect(result.model).toBe('gpt-4'); + expect(result.provider).toBe('Anthropic'); + }); + + it('should handle plain text without model or provider', () => { + const message = { + role: 'user' as const, + content: 'Hello world', + }; + const result = extractPropertiesFromMessage(message); + expect(result.model).toBe('claude-4-5-sonnet-latest'); + expect(result.provider).toBe('Anthropic'); + expect(result.content).toBe('Hello world'); + }); + + it('should handle array content with text type', () => { + const message = { + role: 'user' as const, + content: [{ type: 'text' as const, text: '[Model: gpt-4]\n\n[Provider: OpenAI]\n\nHello' }], + } as any; + const result = extractPropertiesFromMessage(message); + expect(result.model).toBe('gpt-4'); + expect(result.provider).toBe('OpenAI'); + }); + + it('should handle array content with image and text', () => { + const message = { + role: 'user' as const, + content: [ + { type: 'text' as const, text: '[Model: gpt-4]\n\nDescribe this image' }, + { type: 'image_url' as const, image_url: { url: 'https://example.com/image.png' } }, + ], + } as any; + const result = extractPropertiesFromMessage(message); + expect(result.model).toBe('gpt-4'); + expect(Array.isArray(result.content)).toBe(true); + + if (Array.isArray(result.content)) { + expect(result.content[0].type).toBe('text'); + expect(result.content[1].type).toBe('image_url'); + } + }); + + it('should remove model and provider tags from cleaned content', () => { + const message = { + role: 'user' as const, + content: '[Model: gpt-4]\n\n[Provider: OpenAI]\n\nActual message', + }; + const result = extractPropertiesFromMessage(message); + expect(result.content).toBe('Actual message'); + expect(result.content).not.toContain('[Model:'); + expect(result.content).not.toContain('[Provider:'); + }); + + it('should handle empty text in array content', () => { + const message = { + role: 'user' as const, + content: [{ type: 'text' as const, text: '' }], + } as any; + const result = extractPropertiesFromMessage(message); + expect(result.model).toBe('claude-4-5-sonnet-latest'); + expect(result.provider).toBe('Anthropic'); + }); + }); + + describe('simplifyCodinitActions', () => { + it('should simplify codinitAction tags with type="file"', () => { + const input = + 'console.log("hello");\nconsole.log("world");'; + const result = simplifyCodinitActions(input); + expect(result).toContain('...'); + expect(result).not.toContain('console.log("hello")'); + }); + + it('should preserve non-file action types', () => { + const input = 'npm install'; + const result = simplifyCodinitActions(input); + expect(result).toBe(input); + }); + + it('should handle multiple file actions', () => { + const input = `content a +content b`; + const result = simplifyCodinitActions(input); + expect(result).not.toContain('content a'); + expect(result).not.toContain('content b'); + expect(result.match(/\.\.\./g)?.length).toBe(2); + }); + + it('should handle mixed action types', () => { + const input = `file content +npm test`; + const result = simplifyCodinitActions(input); + expect(result).not.toContain('file content'); + expect(result).toContain('npm test'); + }); + + it('should handle multiline file content', () => { + const input = ` +function hello() { + console.log("world"); +} +`; + const result = simplifyCodinitActions(input); + expect(result).toContain('...'); + expect(result).not.toContain('function hello'); + }); + + it('should return original string if no file actions found', () => { + const input = 'No codinit actions here'; + const result = simplifyCodinitActions(input); + expect(result).toBe(input); + }); + + it('should preserve attributes in opening tag', () => { + const input = 'content'; + const result = simplifyCodinitActions(input); + expect(result).toContain('type="file"'); + expect(result).toContain('filePath="/test.ts"'); + }); + }); + + describe('createFilesContext', () => { + const mockFiles: FileMap = { + '/home/project/src/index.ts': { + type: 'file', + content: 'export const hello = "world";', + isBinary: false, + }, + '/home/project/src/utils.ts': { + type: 'file', + content: 'export function add(a: number, b: number) {\n return a + b;\n}', + isBinary: false, + }, + '/home/project/node_modules/package/index.js': { + type: 'file', + content: 'module.exports = {};', + isBinary: false, + }, + '/home/project/.git/config': { + type: 'file', + content: '[core]\n repositoryformatversion = 0', + isBinary: false, + }, + '/home/project/src': { + type: 'folder', + }, + }; + + it('should create context for all non-ignored files', () => { + const result = createFilesContext(mockFiles); + expect(result).toContain(' { + const result = createFilesContext(mockFiles); + expect(result).not.toContain('node_modules'); + }); + + it('should ignore .git files', () => { + const result = createFilesContext(mockFiles); + expect(result).not.toContain('.git'); + }); + + it('should skip folders', () => { + const result = createFilesContext(mockFiles); + const folderReferences = result.match(/filePath="src"/g); + expect(folderReferences).toBeNull(); + }); + + it('should use full path by default', () => { + const result = createFilesContext(mockFiles, false); + expect(result).toContain('/home/project/src/index.ts'); + }); + + it('should use relative path when specified', () => { + const result = createFilesContext(mockFiles, true); + expect(result).toContain('src/index.ts'); + expect(result).not.toContain('/home/project/src/index.ts'); + }); + + it('should preserve file content formatting', () => { + const result = createFilesContext(mockFiles); + expect(result).toContain('export function add'); + expect(result).toContain('return a + b;'); + }); + + it('should wrap content in codinitArtifact', () => { + const result = createFilesContext(mockFiles); + expect(result).toMatch(/^$/); + }); + + it('should use codinitAction for each file', () => { + const result = createFilesContext(mockFiles); + const actionCount = (result.match(/ { + it('should extract codeContext from last assistant message', () => { + const messages: Message[] = [ + { + id: '1', + role: 'user', + content: 'Hello', + }, + { + id: '2', + role: 'assistant', + content: 'Hi there', + annotations: [ + { + type: 'codeContext', + files: ['src/index.ts'], + }, + ], + }, + ]; + const result = extractCurrentContext(messages); + expect(result.codeContext).toBeDefined(); + expect(result.codeContext?.type).toBe('codeContext'); + }); + + it('should extract chatSummary from last assistant message', () => { + const messages: Message[] = [ + { + id: '1', + role: 'user', + content: 'Hello', + }, + { + id: '2', + role: 'assistant', + content: 'Hi', + annotations: [ + { + type: 'chatSummary', + summary: 'User greeted the assistant', + }, + ], + }, + ]; + const result = extractCurrentContext(messages); + expect(result.summary).toBeDefined(); + expect(result.summary?.type).toBe('chatSummary'); + }); + + it('should return undefined when no assistant messages', () => { + const messages: Message[] = [ + { + id: '1', + role: 'user', + content: 'Hello', + }, + ]; + const result = extractCurrentContext(messages); + expect(result.summary).toBeUndefined(); + expect(result.codeContext).toBeUndefined(); + }); + + it('should return undefined when assistant message has no annotations', () => { + const messages: Message[] = [ + { + id: '1', + role: 'user', + content: 'Hello', + }, + { + id: '2', + role: 'assistant', + content: 'Hi', + }, + ]; + const result = extractCurrentContext(messages); + expect(result.summary).toBeUndefined(); + expect(result.codeContext).toBeUndefined(); + }); + + it('should use last assistant message when multiple exist', () => { + const messages: Message[] = [ + { + id: '1', + role: 'assistant', + content: 'First', + annotations: [ + { + type: 'codeContext', + files: ['old.ts'], + }, + ], + }, + { + id: '2', + role: 'assistant', + content: 'Second', + annotations: [ + { + type: 'codeContext', + files: ['new.ts'], + }, + ], + }, + ]; + const result = extractCurrentContext(messages); + expect(result.codeContext).toBeDefined(); + }); + + it('should handle annotations with invalid objects', () => { + const messages: Message[] = [ + { + id: '1', + role: 'assistant', + content: 'Hi', + annotations: [null, 'string', 123] as any, + }, + ]; + const result = extractCurrentContext(messages); + expect(result.summary).toBeUndefined(); + expect(result.codeContext).toBeUndefined(); + }); + + it('should skip annotations without type property', () => { + const messages: Message[] = [ + { + id: '1', + role: 'assistant', + content: 'Hi', + annotations: [{ data: 'some data' }, { type: 'codeContext', files: [] }], + }, + ]; + const result = extractCurrentContext(messages); + expect(result.codeContext).toBeDefined(); + }); + + it('should break on first matching annotation', () => { + const messages: Message[] = [ + { + id: '1', + role: 'assistant', + content: 'Hi', + annotations: [ + { type: 'codeContext', files: ['first.ts'] }, + { type: 'chatSummary', summary: 'should not reach' }, + ], + }, + ]; + const result = extractCurrentContext(messages); + expect(result.codeContext).toBeDefined(); + expect(result.summary).toBeUndefined(); + }); + + it('should handle empty messages array', () => { + const messages: Message[] = []; + const result = extractCurrentContext(messages); + expect(result.summary).toBeUndefined(); + expect(result.codeContext).toBeUndefined(); + }); + }); +}); diff --git a/__tests__/unit/storage.test.ts b/__tests__/unit/storage.test.ts new file mode 100644 index 00000000..1d08e7c3 --- /dev/null +++ b/__tests__/unit/storage.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { initializeProject, saveFileLocal, isElectron } from '~/utils/electron'; + +describe('Storage System', () => { + beforeEach(() => { + // Reset global window object mocks + (global as any).window = { + electronAPI: { + initializeProject: vi.fn(), + saveFileLocal: vi.fn(), + }, + }; + + // Silence console.error for expected failures + vi.spyOn(console, 'error').mockImplementation(() => { }); + }); + + describe('isElectron', () => { + it('should return true when electronAPI is available', () => { + expect(isElectron()).toBe(true); + }); + + it('should return false when window is undefined', () => { + const originalWindow = global.window; + delete (global as any).window; + expect(isElectron()).toBe(false); + global.window = originalWindow; + }); + }); + + describe('initializeProject', () => { + it('should call electronAPI.initializeProject with projectName', async () => { + const projectName = 'test-project'; + const mockInit = vi.spyOn((window as any).electronAPI, 'initializeProject').mockResolvedValue(true); + + const result = await initializeProject(projectName); + + expect(result).toBe(true); + expect(mockInit).toHaveBeenCalledWith(projectName); + }); + + it('should return false if initializeProject fails', async () => { + const projectName = 'test-project'; + vi.spyOn((window as any).electronAPI, 'initializeProject').mockRejectedValue(new Error('Failed')); + + const result = await initializeProject(projectName); + + expect(result).toBe(false); + }); + }); + + describe('saveFileLocal', () => { + it('should call electronAPI.saveFileLocal with correct arguments', async () => { + const projectName = 'test-project'; + const filePath = 'src/test.ts'; + const content = 'test content'; + const mockSave = vi.spyOn((window as any).electronAPI, 'saveFileLocal').mockResolvedValue(true); + + const result = await saveFileLocal(projectName, filePath, content); + + expect(result).toBe(true); + expect(mockSave).toHaveBeenCalledWith(projectName, filePath, content); + }); + + it('should handle Uint8Array content', async () => { + const content = new Uint8Array([1, 2, 3]); + const mockSave = vi.spyOn((window as any).electronAPI, 'saveFileLocal').mockResolvedValue(true); + + const result = await saveFileLocal('project', 'file', content); + + expect(result).toBe(true); + expect(mockSave).toHaveBeenCalledWith('project', 'file', content); + }); + }); +}); diff --git a/__tests__/unit/utils/diff.test.ts b/__tests__/unit/utils/diff.test.ts index ab2145f5..71efc7b3 100644 --- a/__tests__/unit/utils/diff.test.ts +++ b/__tests__/unit/utils/diff.test.ts @@ -1,8 +1,18 @@ -import { describe, it, expect } from 'vitest'; -import { extractRelativePath } from '~/utils/diff'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { extractRelativePath, computeFileModifications, diffFiles, fileModificationsToHTML } from '~/utils/diff'; import { WORK_DIR } from '~/utils/constants'; +import type { FileMap } from '~/lib/stores/files'; describe('extractRelativePath', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should strip out WORK_DIR from file paths', () => { const filePath = `${WORK_DIR}/src/components/Button.tsx`; const result = extractRelativePath(filePath); @@ -41,3 +51,293 @@ describe('extractRelativePath', () => { (global as any).WORK_DIR = originalWorkDir; }); }); + +describe('diffFiles', () => { + it('should generate unified diff for changed content', () => { + const oldContent = 'Hello World'; + const newContent = 'Hello TypeScript'; + const result = diffFiles('test.ts', oldContent, newContent); + expect(result).toBeDefined(); + expect(result).toContain('-Hello World'); + expect(result).toContain('+Hello TypeScript'); + }); + + it('should return undefined for identical files', () => { + const content = 'Hello World'; + const result = diffFiles('test.ts', content, content); + expect(result).toBeUndefined(); + }); + + it('should handle multiline changes', () => { + const oldContent = 'line1\nline2\nline3'; + const newContent = 'line1\nmodified line2\nline3'; + const result = diffFiles('test.ts', oldContent, newContent); + expect(result).toBeDefined(); + expect(result).toContain('-line2'); + expect(result).toContain('+modified line2'); + }); + + it('should handle added lines', () => { + const oldContent = 'line1\nline2'; + const newContent = 'line1\nline2\nline3'; + const result = diffFiles('test.ts', oldContent, newContent); + expect(result).toBeDefined(); + expect(result).toContain('+line3'); + }); + + it('should handle removed lines', () => { + const oldContent = 'line1\nline2\nline3'; + const newContent = 'line1\nline3'; + const result = diffFiles('test.ts', oldContent, newContent); + expect(result).toBeDefined(); + expect(result).toContain('-line2'); + }); + + it('should not include file header in diff', () => { + const oldContent = 'old content'; + const newContent = 'new content'; + const result = diffFiles('test.ts', oldContent, newContent); + expect(result).toBeDefined(); + expect(result).not.toContain('--- test.ts'); + expect(result).not.toContain('+++ test.ts'); + }); + + it('should handle empty old content', () => { + const oldContent = ''; + const newContent = 'new content'; + const result = diffFiles('test.ts', oldContent, newContent); + expect(result).toBeDefined(); + expect(result).toContain('+new content'); + }); + + it('should handle empty new content', () => { + const oldContent = 'old content'; + const newContent = ''; + const result = diffFiles('test.ts', oldContent, newContent); + expect(result).toBeDefined(); + expect(result).toContain('-old content'); + }); + + it('should handle complex code changes', () => { + const oldContent = `function hello() { + console.log("old"); +}`; + const newContent = `function hello() { + console.log("new"); + return true; +}`; + const result = diffFiles('test.ts', oldContent, newContent); + expect(result).toBeDefined(); + expect(result).toContain('- console.log("old");'); + expect(result).toContain('+ console.log("new");'); + expect(result).toContain('+ return true;'); + }); +}); + +describe('computeFileModifications', () => { + it('should compute modifications for changed files', () => { + const files: FileMap = { + '/home/project/test.ts': { + type: 'file', + content: 'new content', + isBinary: false, + }, + }; + const modifiedFiles = new Map([['/home/project/test.ts', 'old content']]); + const result = computeFileModifications(files, modifiedFiles); + expect(result).toBeDefined(); + expect(result).toHaveProperty('/home/project/test.ts'); + }); + + it('should return undefined when no files modified', () => { + const files: FileMap = { + '/home/project/test.ts': { + type: 'file', + content: 'same content', + isBinary: false, + }, + }; + const modifiedFiles = new Map([['/home/project/test.ts', 'same content']]); + const result = computeFileModifications(files, modifiedFiles); + expect(result).toBeUndefined(); + }); + + it('should choose appropriate type based on size optimization', () => { + const files: FileMap = { + '/home/project/test.ts': { + type: 'file', + content: 'new content', + isBinary: false, + }, + }; + const modifiedFiles = new Map([['/home/project/test.ts', 'old content']]); + const result = computeFileModifications(files, modifiedFiles); + expect(result).toBeDefined(); + expect(result?.['/home/project/test.ts']?.type).toMatch(/^(file|diff)$/); + expect(result?.['/home/project/test.ts']?.content).toBeDefined(); + }); + + it('should optimize by using smaller representation', () => { + const files: FileMap = { + '/home/project/small.ts': { + type: 'file', + content: 'x', + isBinary: false, + }, + }; + const modifiedFiles = new Map([['/home/project/small.ts', 'y']]); + const result = computeFileModifications(files, modifiedFiles); + expect(result).toBeDefined(); + + const mod = result?.['/home/project/small.ts']; + expect(mod).toBeDefined(); + expect(['file', 'diff']).toContain(mod?.type); + }); + + it('should skip folders', () => { + const files: FileMap = { + '/home/project/src': { + type: 'folder', + }, + }; + const modifiedFiles = new Map([['/home/project/src', 'some content']]); + const result = computeFileModifications(files, modifiedFiles); + expect(result).toBeUndefined(); + }); + + it('should handle multiple modified files', () => { + const files: FileMap = { + '/home/project/file1.ts': { + type: 'file', + content: 'new content 1', + isBinary: false, + }, + '/home/project/file2.ts': { + type: 'file', + content: 'new content 2', + isBinary: false, + }, + }; + const modifiedFiles = new Map([ + ['/home/project/file1.ts', 'old content 1'], + ['/home/project/file2.ts', 'old content 2'], + ]); + const result = computeFileModifications(files, modifiedFiles); + expect(result).toBeDefined(); + expect(Object.keys(result!)).toHaveLength(2); + expect(result).toHaveProperty('/home/project/file1.ts'); + expect(result).toHaveProperty('/home/project/file2.ts'); + }); + + it('should ignore non-existent files in modifications map', () => { + const files: FileMap = { + '/home/project/exists.ts': { + type: 'file', + content: 'new content', + isBinary: false, + }, + }; + const modifiedFiles = new Map([ + ['/home/project/exists.ts', 'old content'], + ['/home/project/nonexistent.ts', 'old content'], + ]); + const result = computeFileModifications(files, modifiedFiles); + expect(result).toBeDefined(); + expect(Object.keys(result!)).toHaveLength(1); + expect(result).toHaveProperty('/home/project/exists.ts'); + }); +}); + +describe('fileModificationsToHTML', () => { + it('should convert modifications to HTML format', () => { + const modifications = { + '/home/project/test.ts': { + type: 'diff' as const, + content: '@@ -1,1 +1,1 @@\n-old\n+new', + }, + }; + const result = fileModificationsToHTML(modifications); + expect(result).toBeDefined(); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should return undefined for empty modifications', () => { + const modifications = {}; + const result = fileModificationsToHTML(modifications); + expect(result).toBeUndefined(); + }); + + it('should handle file type modifications', () => { + const modifications = { + '/home/project/test.ts': { + type: 'file' as const, + content: 'complete file content', + }, + }; + const result = fileModificationsToHTML(modifications); + expect(result).toBeDefined(); + expect(result).toContain(''); + expect(result).toContain('complete file content'); + expect(result).toContain(''); + }); + + it('should handle multiple modifications', () => { + const modifications = { + '/home/project/file1.ts': { + type: 'diff' as const, + content: 'diff content 1', + }, + '/home/project/file2.ts': { + type: 'file' as const, + content: 'file content 2', + }, + }; + const result = fileModificationsToHTML(modifications); + expect(result).toBeDefined(); + expect(result).toContain('file1.ts'); + expect(result).toContain('file2.ts'); + expect(result).toContain('diff content 1'); + expect(result).toContain('file content 2'); + }); + + it('should properly escape file paths with special characters', () => { + const modifications = { + '/home/project/file with spaces.ts': { + type: 'diff' as const, + content: 'diff content', + }, + }; + const result = fileModificationsToHTML(modifications); + expect(result).toBeDefined(); + expect(result).toContain('path="/home/project/file with spaces.ts"'); + }); + + it('should preserve diff content formatting', () => { + const modifications = { + '/home/project/test.ts': { + type: 'diff' as const, + content: '@@ -1,3 +1,3 @@\n line1\n-line2\n+modified line2\n line3', + }, + }; + const result = fileModificationsToHTML(modifications); + expect(result).toBeDefined(); + expect(result).toContain('@@ -1,3 +1,3 @@'); + expect(result).toContain('-line2'); + expect(result).toContain('+modified line2'); + }); + + it('should handle paths with quotes', () => { + const modifications = { + '/home/project/file"with"quotes.ts': { + type: 'diff' as const, + content: 'content', + }, + }; + const result = fileModificationsToHTML(modifications); + expect(result).toBeDefined(); + expect(result).toContain('path='); + }); +}); diff --git a/__tests__/unit/utils/toolMentionParser.test.ts b/__tests__/unit/utils/toolMentionParser.test.ts index f3b709b4..4022a0f3 100644 --- a/__tests__/unit/utils/toolMentionParser.test.ts +++ b/__tests__/unit/utils/toolMentionParser.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { shouldShowAutocomplete, detectReferenceType, @@ -7,6 +7,15 @@ import { } from '~/utils/toolMentionParser'; describe('toolMentionParser', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => { }); + vi.spyOn(console, 'error').mockImplementation(() => { }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('shouldShowAutocomplete', () => { it('should return isOpen=true when @ is at start of text', () => { const result = shouldShowAutocomplete('@', 1); diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 139ed8f7..ce8fa65e 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -9,7 +9,6 @@ import { TabTile } from '~/components/@settings/shared/components/TabTile'; import { SearchInterface } from '~/components/@settings/shared/components/SearchInterface'; import { initializeSearchIndex } from '~/components/@settings/shared/utils/settingsSearch'; import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck'; -import { useFeatures } from '~/lib/hooks/useFeatures'; import { useNotifications } from '~/lib/hooks/useNotifications'; import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus'; import { useDebugStatus } from '~/lib/hooks/useDebugStatus'; @@ -33,13 +32,11 @@ import NotificationsTab from '~/components/@settings/tabs/notifications/Notifica import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab'; import { DataTab } from '~/components/@settings/tabs/data/DataTab'; import DebugTab from '~/components/@settings/tabs/debug/DebugTab'; -import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab'; import UpdateTab from '~/components/@settings/tabs/update/UpdateTab'; import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab'; import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab'; import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab'; import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab'; -import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab'; import ApiKeysTab from '~/components/@settings/tabs/api-keys/APIKeysTab'; interface ControlPanelProps { @@ -106,7 +103,7 @@ const AnimatedSwitch = ({ checked, onCheckedChange, id, label }: AnimatedSwitchP className={classNames( 'relative inline-flex h-6 w-11 items-center rounded-full', 'transition-all duration-300 ease-[cubic-bezier(0.87,_0,_0.13,_1)]', - 'bg-gray-200 dark:bg-gray-700', + 'bg-codinit-elements-background-depth-3', 'data-[state=checked]:bg-blue-500', 'focus:outline-none focus:ring-2 focus:ring-blue-500/20', 'cursor-pointer', @@ -170,7 +167,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { // Status hooks const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck(); - const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures(); const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications(); const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus(); const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus(); @@ -347,12 +343,8 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { return ; case 'debug': return ; - case 'event-logs': - return ; case 'update': return ; - case 'task-manager': - return ; case 'service-status': return ; default: @@ -364,8 +356,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { switch (tabId) { case 'update': return hasUpdate; - case 'features': - return hasNewFeatures; case 'notifications': return hasUnreadNotifications; case 'connection': @@ -381,8 +371,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { switch (tabId) { case 'update': return `New update available (v${currentVersion})`; - case 'features': - return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`; case 'notifications': return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`; case 'connection': @@ -412,9 +400,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { case 'update': acknowledgeUpdate(); break; - case 'features': - acknowledgeAllFeatures(); - break; case 'notifications': markAllAsRead(); break; @@ -456,9 +441,9 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { - - {isEnvVarsExpanded && ( -
-

- You can configure connections using environment variables in your{' '} - - .env.local - {' '} - file: -

-
-
- # GitHub Authentication -
-
- VITE_GITHUB_ACCESS_TOKEN=your_token_here -
-
- # Optional: Specify token type (defaults to 'classic' if not specified) -
-
- VITE_GITHUB_TOKEN_TYPE=classic|fine-grained -
-
- # Netlify Authentication -
-
- VITE_NETLIFY_ACCESS_TOKEN=your_token_here -
-
-
-

- Token types: -

-
    -
  • - classic - Personal Access Token with{' '} - - repo, read:org, read:user - {' '} - scopes -
  • -
  • - fine-grained - Fine-grained token with Repository and - Organization access -
  • -
-

- When set, these variables will be used automatically without requiring manual connection. -

-
-
- )} - -
-
}> @@ -166,7 +78,7 @@ export default function ConnectionsTab() {
{/* Additional help text */} -
+

Troubleshooting Tip: diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx index 272ad5d0..99cee4ef 100644 --- a/app/components/@settings/tabs/connections/GithubConnection.tsx +++ b/app/components/@settings/tabs/connections/GithubConnection.tsx @@ -552,18 +552,18 @@ export default function GitHubConnection() {

{!connection.user && ( -
+

Tip: You can also set the{' '} - + VITE_GITHUB_ACCESS_TOKEN {' '} environment variable to connect automatically.

For fine-grained tokens, also set{' '} - + VITE_GITHUB_TOKEN_TYPE=fine-grained

@@ -584,7 +584,7 @@ export default function GitHubConnection() { disabled={isConnecting || !!connection.user} className={classNames( 'w-full px-3 py-2 rounded-lg text-sm', - 'bg-codinit-elements-background-depth-1 dark:bg-codinit-elements-background-depth-1', + 'bg-codinit-elements-background-depth-1 dark:bg-gray-800/50', 'border border-codinit-elements-borderColor dark:border-codinit-elements-borderColor', 'text-codinit-elements-textPrimary dark:text-codinit-elements-textPrimary', 'focus:outline-none focus:ring-1 focus:ring-codinit-elements-item-contentAccent dark:focus:ring-codinit-elements-item-contentAccent', @@ -610,7 +610,7 @@ export default function GitHubConnection() { }`} className={classNames( 'w-full px-3 py-2 rounded-lg text-sm', - 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', + 'bg-[#F8F8F8] dark:bg-[#999999]', 'border border-[#E5E5E5] dark:border-[#333333]', 'text-codinit-elements-textPrimary placeholder-codinit-elements-textTertiary', 'focus:outline-none focus:ring-1 focus:ring-codinit-elements-borderColorActive', @@ -721,7 +721,7 @@ export default function GitHubConnection() { {connection.user && connection.stats && (
-
+
{connection.user.login} -
+
GitHub Stats @@ -794,7 +794,7 @@ export default function GitHubConnection() { ].map((stat, index) => (
{stat.label} {stat.value} @@ -820,7 +820,7 @@ export default function GitHubConnection() { ].map((stat, index) => (
{stat.label} @@ -858,7 +858,7 @@ export default function GitHubConnection() { ].map((stat, index) => (
{stat.label} @@ -885,7 +885,7 @@ export default function GitHubConnection() { ].map((stat, index) => (
{stat.label} @@ -914,7 +914,7 @@ export default function GitHubConnection() { href={repo.html_url} target="_blank" rel="noopener noreferrer" - className="group block p-4 rounded-lg bg-codinit-elements-background-depth-1 dark:bg-codinit-elements-background-depth-1 border border-codinit-elements-borderColor dark:border-codinit-elements-borderColor hover:border-codinit-elements-borderColorActive dark:hover:border-codinit-elements-borderColorActive transition-all duration-200" + className="group block p-4 rounded-lg bg-codinit-elements-background-depth-1 dark:bg-gray-800/50 border border-codinit-elements-borderColor dark:border-codinit-elements-borderColor hover:border-codinit-elements-borderColorActive dark:hover:border-codinit-elements-borderColorActive transition-all duration-200" >
diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/NetlifyConnection.tsx index ff4205fe..7c3d6010 100644 --- a/app/components/@settings/tabs/connections/NetlifyConnection.tsx +++ b/app/components/@settings/tabs/connections/NetlifyConnection.tsx @@ -299,7 +299,7 @@ export default function NetlifyConnection() {
-
+
@@ -343,7 +343,7 @@ export default function NetlifyConnection() {
{sites.length > 0 && (
-
+

@@ -370,7 +370,7 @@ export default function NetlifyConnection() {
{activeSiteIndex !== -1 && deploys.length > 0 && ( -
+

@@ -485,7 +485,7 @@ export default function NetlifyConnection() { {deploys.map((deploy) => (
@@ -578,7 +578,7 @@ export default function NetlifyConnection() {
)} {activeSiteIndex !== -1 && builds.length > 0 && ( -
+

@@ -589,7 +589,7 @@ export default function NetlifyConnection() { {builds.map((build) => (
@@ -659,7 +659,7 @@ export default function NetlifyConnection() { placeholder="Enter your Netlify API token" className={classNames( 'w-full px-3 py-2 rounded-lg text-sm', - 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', + 'bg-[#F8F8F8] dark:bg-[#999999]', 'border border-[#E5E5E5] dark:border-[#333333]', 'text-codinit-elements-textPrimary placeholder-codinit-elements-textTertiary', 'focus:outline-none focus:ring-1 focus:ring-codinit-elements-borderColorActive', diff --git a/app/components/@settings/tabs/connections/components/ConnectionForm.tsx b/app/components/@settings/tabs/connections/components/ConnectionForm.tsx deleted file mode 100644 index b71dd436..00000000 --- a/app/components/@settings/tabs/connections/components/ConnectionForm.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import React, { useEffect } from 'react'; -import { classNames } from '~/utils/classNames'; -import type { GitHubAuthState } from '~/components/@settings/tabs/connections/types/GitHub'; -import Cookies from 'js-cookie'; -import { getLocalStorage } from '~/lib/persistence'; - -const GITHUB_TOKEN_KEY = 'github_token'; - -interface ConnectionFormProps { - authState: GitHubAuthState; - setAuthState: React.Dispatch>; - onSave: (e: React.FormEvent) => void; - onDisconnect: () => void; -} - -export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) { - // Check for saved token on mount - useEffect(() => { - const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || Cookies.get('githubToken') || getLocalStorage(GITHUB_TOKEN_KEY); - - if (savedToken && !authState.tokenInfo?.token) { - setAuthState((prev: GitHubAuthState) => ({ - ...prev, - tokenInfo: { - token: savedToken, - scope: [], - avatar_url: '', - name: null, - created_at: new Date().toISOString(), - followers: 0, - }, - })); - - // Ensure the token is also saved with the correct key for API requests - Cookies.set('githubToken', savedToken); - } - }, []); - - return ( -
-
-
-
-
-
-
-
-

Connection Settings

-

Configure your GitHub connection

-
-
-
- -
-
- - setAuthState((prev: GitHubAuthState) => ({ ...prev, username: e.target.value }))} - className={classNames( - 'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg', - 'text-codinit-elements-textPrimary placeholder-codinit-elements-textTertiary text-base', - 'border-[#E5E5E5] dark:border-[#1A1A1A]', - 'focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500', - 'transition-all duration-200', - )} - placeholder="e.g., octocat" - /> -
- -
-
- - - Generate new token -
- -
- - setAuthState((prev: GitHubAuthState) => ({ - ...prev, - tokenInfo: { - token: e.target.value, - scope: [], - avatar_url: '', - name: null, - created_at: new Date().toISOString(), - followers: 0, - }, - username: '', - isConnected: false, - isVerifying: false, - isLoadingRepos: false, - })) - } - className={classNames( - 'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg', - 'text-codinit-elements-textPrimary placeholder-codinit-elements-textTertiary text-base', - 'border-[#E5E5E5] dark:border-[#1A1A1A]', - 'focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500', - 'transition-all duration-200', - )} - placeholder="ghp_xxxxxxxxxxxx" - /> -
- -
-
- {!authState.isConnected ? ( - - ) : ( - <> - - -
- Connected - - - )} -
- {authState.rateLimits && ( -
-
- Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()} -
- )} -
- -
-
- ); -} diff --git a/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx b/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx index 1bec738c..147b35db 100644 --- a/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx +++ b/app/components/@settings/tabs/connections/components/GitHubAuthDialog.tsx @@ -75,7 +75,7 @@ export function GitHubAuthDialog({ isOpen, onClose }: GitHubAuthDialogProps) { return ( !open && onClose()}> - +
- +
- + Access Private Repositories - + To access private repositories, you need to connect your GitHub account by providing a personal access token. -
-

Connect with GitHub Token

+
+

Connect with GitHub Token

-