Compare commits
25 Commits
705608ae46
...
7cf8317f55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cf8317f55 | ||
|
|
2e31c508da | ||
|
|
3fd45ec253 | ||
|
|
47992f48dd | ||
|
|
91cf1e97c3 | ||
|
|
a6d6a4cdaf | ||
|
|
213def4a67 | ||
|
|
9d33f3757d | ||
|
|
a4ab1a7f84 | ||
|
|
86e4005795 | ||
|
|
70d4f5980e | ||
|
|
1ce399584e | ||
|
|
8d88460fe1 | ||
|
|
f2960a94b9 | ||
|
|
976e065fe5 | ||
|
|
5b789cb971 | ||
|
|
d3f3ac3ae1 | ||
|
|
a7bcec220a | ||
|
|
20866d5d8c | ||
|
|
352d4330ed | ||
|
|
c174778d5f | ||
|
|
4b17870049 | ||
|
|
1b678041ab | ||
|
|
6d66e13ea2 | ||
|
|
560cd1791d |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -107,6 +107,10 @@ jobs:
|
||||
# Merge reports after playwright-tests, even if some shards have failed
|
||||
if: ${{ !cancelled() }}
|
||||
needs: [test]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
59
.github/workflows/playwright-comment.yml
vendored
Normal file
59
.github/workflows/playwright-comment.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Playwright Report Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.pull_requests[0].number }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.base_ref }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Download Playwright HTML report
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: html-report--attempt-${{ github.event.workflow_run.run_attempt }}
|
||||
path: playwright-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.event.workflow_run.repository.full_name }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
if-no-artifact-found: warn
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-blob-reports
|
||||
pattern: blob-report-*
|
||||
merge-multiple: true
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.event.workflow_run.repository.full_name }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
if-no-artifact-found: warn
|
||||
|
||||
- name: Generate Playwright summary comment
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
|
||||
PLAYWRIGHT_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
with:
|
||||
script: |
|
||||
const { run } = require('./scripts/generate-playwright-summary.js');
|
||||
await run({ github, context, core });
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
{ name: "windows", image: "windows-latest" },
|
||||
# See https://github.com/dyad-sh/dyad/issues/96
|
||||
{ name: "linux", image: "ubuntu-22.04" },
|
||||
{ name: "macos-intel", image: "macos-13" },
|
||||
{ name: "macos-intel", image: "macos-15-intel" },
|
||||
{ name: "macos", image: "macos-latest" },
|
||||
]
|
||||
runs-on: ${{ matrix.os.image }}
|
||||
|
||||
198
UPDATE_GUIDE.md
198
UPDATE_GUIDE.md
@@ -1,198 +0,0 @@
|
||||
# Dyad Update Management Guide
|
||||
|
||||
This guide explains how to update your forked Dyad application while preserving your custom modifications.
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
Your setup uses a **selective update strategy** that:
|
||||
- Keeps your custom code separate from the main codebase
|
||||
- Automatically preserves custom modifications during updates
|
||||
- Provides backup and rollback capabilities
|
||||
- Minimizes merge conflicts
|
||||
|
||||
## 📁 Custom Code Structure
|
||||
|
||||
Your custom modifications are organized in `src/custom/`:
|
||||
|
||||
```
|
||||
src/custom/
|
||||
├── index.ts # Main entry point for custom features
|
||||
├── hooks/
|
||||
│ └── useSmartContext.ts # Custom smart context hook
|
||||
├── ipc/
|
||||
│ └── smart_context_handlers.ts # Custom IPC handlers
|
||||
└── utils/
|
||||
└── smart_context_store.ts # Custom utilities
|
||||
```
|
||||
|
||||
## 🚀 Update Process
|
||||
|
||||
### Method 1: Automated Update (Recommended)
|
||||
|
||||
Use the provided update script:
|
||||
|
||||
```bash
|
||||
./update-dyad-v2.sh
|
||||
```
|
||||
|
||||
**What the script does:**
|
||||
1. Creates a timestamped backup
|
||||
2. Backs up your custom code
|
||||
3. Fetches latest changes from upstream
|
||||
4. Resets to the latest upstream version
|
||||
5. Restores your custom code
|
||||
6. Pushes updates to your fork
|
||||
|
||||
### Method 2: Manual Update
|
||||
|
||||
If you prefer manual control:
|
||||
|
||||
```bash
|
||||
# 1. Create backup
|
||||
cp -r src/custom/ dyad-backup-$(date +%Y%m%d-%H%M%S)/
|
||||
|
||||
# 2. Fetch latest changes
|
||||
git fetch upstream
|
||||
|
||||
# 3. Reset to latest upstream
|
||||
git reset --hard upstream/main
|
||||
|
||||
# 4. Restore custom code
|
||||
cp -r dyad-backup-*/src/custom/ src/
|
||||
|
||||
# 5. Commit and push
|
||||
git add src/custom/
|
||||
git commit -m "Restore custom code after update"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## 🔄 Update Workflow
|
||||
|
||||
### Before Updating
|
||||
1. **Test current state** - Ensure your app works properly
|
||||
2. **Commit any changes** - Don't have uncommitted work
|
||||
3. **Check custom code** - Note any modifications that might need updates
|
||||
|
||||
### After Updating
|
||||
1. **Run npm install** - Update dependencies if needed
|
||||
2. **Test the application** - Ensure everything works
|
||||
3. **Check custom integrations** - Verify custom features still work
|
||||
4. **Update custom code** if needed - Adapt to any API changes
|
||||
|
||||
## 🛠️ Adding New Custom Features
|
||||
|
||||
When adding new custom features:
|
||||
|
||||
1. **Place in src/custom/** - Keep custom code organized
|
||||
2. **Update imports** - Use relative imports within custom directory
|
||||
3. **Document changes** - Note what each custom feature does
|
||||
4. **Test integration** - Ensure custom features work with main app
|
||||
|
||||
Example:
|
||||
```typescript
|
||||
// src/custom/components/MyCustomComponent.tsx
|
||||
import { useSmartContext } from '../hooks/useSmartContext';
|
||||
|
||||
export const MyCustomComponent = () => {
|
||||
// Your custom logic
|
||||
};
|
||||
```
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Merge Conflicts
|
||||
If you encounter merge conflicts during manual updates:
|
||||
|
||||
```bash
|
||||
# Abort the merge
|
||||
git merge --abort
|
||||
|
||||
# Use the automated script instead
|
||||
./update-dyad-v2.sh
|
||||
```
|
||||
|
||||
### Custom Code Not Working
|
||||
After an update, if custom features don't work:
|
||||
|
||||
1. **Check API changes** - The upstream may have changed interfaces
|
||||
2. **Update imports** - File paths might have changed
|
||||
3. **Review breaking changes** - Check upstream release notes
|
||||
4. **Test incrementally** - Isolate the problematic code
|
||||
|
||||
### Backup Restoration
|
||||
If you need to restore from backup:
|
||||
|
||||
```bash
|
||||
# Find your backup directory
|
||||
ls dyad-backup-*
|
||||
|
||||
# Restore specific files
|
||||
cp -r dyad-backup-YYYYMMDD-HHMMSS/src/custom/ src/
|
||||
|
||||
# Or restore entire project (last resort)
|
||||
rm -rf * .gitignore
|
||||
cp -r dyad-backup-YYYYMMDD-HHMMSS/* .
|
||||
cp dyad-backup-YYYYMMDD-HHMMSS/.gitignore .
|
||||
```
|
||||
|
||||
## 📋 Best Practices
|
||||
|
||||
### Regular Maintenance
|
||||
- **Update frequently** - Smaller updates are easier to manage
|
||||
- **Test after each update** - Catch issues early
|
||||
- **Keep custom code minimal** - Only customize what's necessary
|
||||
- **Document customizations** - Future you will thank you
|
||||
|
||||
### Code Organization
|
||||
- **Separate concerns** - Keep UI, logic, and utilities separate
|
||||
- **Use TypeScript** - Catch integration issues early
|
||||
- **Follow existing patterns** - Match the upstream code style
|
||||
- **Avoid modifying core files** - Use extension patterns when possible
|
||||
|
||||
### Backup Strategy
|
||||
- **Multiple backups** - Keep several backup versions
|
||||
- **Offsite backup** - Consider cloud storage for critical backups
|
||||
- **Test backups** - Ensure you can restore from backup
|
||||
- **Label clearly** - Use descriptive backup names
|
||||
|
||||
## 🔧 Advanced Configuration
|
||||
|
||||
### Custom Update Script
|
||||
You can modify `update-dyad-v2.sh` to:
|
||||
- Skip certain files from backup
|
||||
- Add custom post-update steps
|
||||
- Include additional validation
|
||||
- Send notifications on completion
|
||||
|
||||
### Selective File Restoration
|
||||
To restore only specific custom files:
|
||||
|
||||
```bash
|
||||
# Restore specific directory
|
||||
cp -r dyad-backup-*/src/custom/hooks/ src/custom/
|
||||
|
||||
# Restore specific file
|
||||
cp dyad-backup-*/src/custom/index.ts src/custom/
|
||||
```
|
||||
|
||||
## 📞 Getting Help
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. **Check this guide first** - Most common issues are covered
|
||||
2. **Review the script output** - Error messages are informative
|
||||
3. **Test with a clean state** - Start fresh if needed
|
||||
4. **Document the issue** - Note what you were trying to do
|
||||
|
||||
## 🎉 Success Indicators
|
||||
|
||||
You'll know the update was successful when:
|
||||
- ✅ Script completes without errors
|
||||
- ✅ Custom code is present in `src/custom/`
|
||||
- ✅ Application starts and runs normally
|
||||
- ✅ Custom features work as expected
|
||||
- ✅ No merge conflicts in git status
|
||||
|
||||
---
|
||||
|
||||
**Remember**: The goal is to make updates painless and predictable. When in doubt, use the automated script and keep good backups!
|
||||
@@ -765,3 +765,4 @@
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
52a977b backup: auto-commit before update - Fri Dec 5 15:16:35 +07 2025
|
||||
8a1cecb backup: auto-commit before update - Fri Dec 5 15:12:17 +07 2025
|
||||
e6de49c fix: update .gitignore to exclude 'out/' directory
|
||||
6d74721 Add user settings configuration for GPT-5 Codex model with Azure provider
|
||||
d22227b feat: implement fuzzy search and replace functionality with Levenshtein distance
|
||||
11986a0 Add project files
|
||||
3b43cb5 Add blank
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import type {
|
||||
SmartContextMeta,
|
||||
SmartContextSnippet,
|
||||
SmartContextRetrieveResult,
|
||||
} from "@/ipc/ipc_types";
|
||||
|
||||
export function useSmartContextMeta(chatId: number) {
|
||||
return useQuery<SmartContextMeta, Error>({
|
||||
queryKey: ["smart-context", chatId, "meta"],
|
||||
queryFn: async () => {
|
||||
const ipc = IpcClient.getInstance();
|
||||
return ipc.getSmartContextMeta(chatId);
|
||||
},
|
||||
enabled: !!chatId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRetrieveSmartContext(
|
||||
chatId: number,
|
||||
query: string,
|
||||
budgetTokens: number,
|
||||
) {
|
||||
return useQuery<SmartContextRetrieveResult, Error>({
|
||||
queryKey: ["smart-context", chatId, "retrieve", query, budgetTokens],
|
||||
queryFn: async () => {
|
||||
const ipc = IpcClient.getInstance();
|
||||
return ipc.retrieveSmartContext({ chatId, query, budgetTokens });
|
||||
},
|
||||
enabled: !!chatId && !!query && budgetTokens > 0,
|
||||
meta: { showErrorToast: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertSmartContextSnippets(chatId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<number, Error, Array<Pick<SmartContextSnippet, "text" | "source">>>({
|
||||
mutationFn: async (snippets) => {
|
||||
const ipc = IpcClient.getInstance();
|
||||
return ipc.upsertSmartContextSnippets(chatId, snippets);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["smart-context", chatId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRollingSummary(chatId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<SmartContextMeta, Error, { summary: string }>({
|
||||
mutationFn: async ({ summary }) => {
|
||||
const ipc = IpcClient.getInstance();
|
||||
return ipc.updateSmartContextRollingSummary(chatId, summary);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["smart-context", chatId, "meta"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// Custom modules for moreminimore-vibe
|
||||
// This file exports all custom functionality to make imports easier
|
||||
|
||||
// Custom hooks
|
||||
export { useSmartContextMeta, useRetrieveSmartContext, useUpsertSmartContextSnippets, useUpdateRollingSummary } from './hooks/useSmartContext';
|
||||
|
||||
// Custom IPC handlers (these will need to be imported and registered in the main process)
|
||||
export { registerSmartContextHandlers } from './ipc/smart_context_handlers';
|
||||
|
||||
// Custom utilities
|
||||
export * from './utils/smart_context_store';
|
||||
|
||||
// Re-export types that might be needed
|
||||
export type {
|
||||
SmartContextMeta,
|
||||
SmartContextSnippet,
|
||||
SmartContextRetrieveResult,
|
||||
} from '../ipc/ipc_types';
|
||||
@@ -1,65 +0,0 @@
|
||||
import log from "electron-log";
|
||||
import { createLoggedHandler } from "./safe_handle";
|
||||
import {
|
||||
appendSnippets,
|
||||
readMeta,
|
||||
retrieveContext,
|
||||
updateRollingSummary,
|
||||
rebuildIndex,
|
||||
type SmartContextSnippet,
|
||||
type SmartContextMeta,
|
||||
} from "../utils/smart_context_store";
|
||||
|
||||
const logger = log.scope("smart_context_handlers");
|
||||
const handle = createLoggedHandler(logger);
|
||||
|
||||
export interface UpsertSnippetsParams {
|
||||
chatId: number;
|
||||
snippets: Array<{
|
||||
text: string;
|
||||
source:
|
||||
| { type: "message"; messageIndex?: number }
|
||||
| { type: "code"; filePath: string }
|
||||
| { type: "attachment"; name: string; mime?: string }
|
||||
| { type: "other"; label?: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RetrieveContextParams {
|
||||
chatId: number;
|
||||
query: string;
|
||||
budgetTokens: number;
|
||||
}
|
||||
|
||||
export function registerSmartContextHandlers() {
|
||||
handle("sc:get-meta", async (_event, chatId: number): Promise<SmartContextMeta> => {
|
||||
return readMeta(chatId);
|
||||
});
|
||||
|
||||
handle(
|
||||
"sc:upsert-snippets",
|
||||
async (_event, params: UpsertSnippetsParams): Promise<number> => {
|
||||
const count = await appendSnippets(params.chatId, params.snippets);
|
||||
return count;
|
||||
},
|
||||
);
|
||||
|
||||
handle(
|
||||
"sc:update-rolling-summary",
|
||||
async (_event, params: { chatId: number; summary: string }): Promise<SmartContextMeta> => {
|
||||
return updateRollingSummary(params.chatId, params.summary);
|
||||
},
|
||||
);
|
||||
|
||||
handle(
|
||||
"sc:retrieve-context",
|
||||
async (_event, params: RetrieveContextParams) => {
|
||||
return retrieveContext(params.chatId, params.query, params.budgetTokens);
|
||||
},
|
||||
);
|
||||
|
||||
handle("sc:rebuild-index", async (_event, chatId: number) => {
|
||||
await rebuildIndex(chatId);
|
||||
return { ok: true } as const;
|
||||
});
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getUserDataPath } from "../../paths/paths";
|
||||
import { estimateTokens } from "./token_utils";
|
||||
|
||||
export type SmartContextSource =
|
||||
| { type: "message"; messageIndex?: number }
|
||||
| { type: "code"; filePath: string }
|
||||
| { type: "attachment"; name: string; mime?: string }
|
||||
| { type: "other"; label?: string };
|
||||
|
||||
export interface SmartContextSnippet {
|
||||
id: string;
|
||||
text: string;
|
||||
score?: number;
|
||||
source: SmartContextSource;
|
||||
ts: number; // epoch ms
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
export interface SmartContextMetaConfig {
|
||||
maxSnippets?: number;
|
||||
}
|
||||
|
||||
export interface SmartContextMeta {
|
||||
entityId: string; // e.g., chatId as string
|
||||
updatedAt: number;
|
||||
rollingSummary?: string;
|
||||
summaryTokens?: number;
|
||||
config?: SmartContextMetaConfig;
|
||||
}
|
||||
|
||||
function getThreadDir(chatId: number): string {
|
||||
const base = path.join(getUserDataPath(), "smart-context", "threads");
|
||||
return path.join(base, String(chatId));
|
||||
}
|
||||
|
||||
function getMetaPath(chatId: number): string {
|
||||
return path.join(getThreadDir(chatId), "meta.json");
|
||||
}
|
||||
|
||||
function getSnippetsPath(chatId: number): string {
|
||||
return path.join(getThreadDir(chatId), "snippets.jsonl");
|
||||
}
|
||||
|
||||
async function ensureDir(dir: string): Promise<void> {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export async function readMeta(chatId: number): Promise<SmartContextMeta> {
|
||||
const dir = getThreadDir(chatId);
|
||||
await ensureDir(dir);
|
||||
const metaPath = getMetaPath(chatId);
|
||||
try {
|
||||
const raw = await fs.readFile(metaPath, "utf8");
|
||||
const meta = JSON.parse(raw) as SmartContextMeta;
|
||||
return meta;
|
||||
} catch {
|
||||
const fresh: SmartContextMeta = {
|
||||
entityId: String(chatId),
|
||||
updatedAt: Date.now(),
|
||||
rollingSummary: "",
|
||||
summaryTokens: 0,
|
||||
config: { maxSnippets: 400 },
|
||||
};
|
||||
await fs.writeFile(metaPath, JSON.stringify(fresh, null, 2), "utf8");
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeMeta(
|
||||
chatId: number,
|
||||
meta: SmartContextMeta,
|
||||
): Promise<void> {
|
||||
const dir = getThreadDir(chatId);
|
||||
await ensureDir(dir);
|
||||
const metaPath = getMetaPath(chatId);
|
||||
const updated: SmartContextMeta = {
|
||||
...meta,
|
||||
entityId: String(chatId),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await fs.writeFile(metaPath, JSON.stringify(updated, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export async function updateRollingSummary(
|
||||
chatId: number,
|
||||
summary: string,
|
||||
): Promise<SmartContextMeta> {
|
||||
const meta = await readMeta(chatId);
|
||||
const summaryTokens = estimateTokens(summary || "");
|
||||
const next: SmartContextMeta = {
|
||||
...meta,
|
||||
rollingSummary: summary,
|
||||
summaryTokens,
|
||||
};
|
||||
await writeMeta(chatId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function appendSnippets(
|
||||
chatId: number,
|
||||
snippets: Omit<SmartContextSnippet, "id" | "ts" | "tokens">[],
|
||||
): Promise<number> {
|
||||
const dir = getThreadDir(chatId);
|
||||
await ensureDir(dir);
|
||||
const snippetsPath = getSnippetsPath(chatId);
|
||||
const withDefaults: SmartContextSnippet[] = snippets.map((s) => ({
|
||||
id: randomUUID(),
|
||||
ts: Date.now(),
|
||||
tokens: estimateTokens(s.text),
|
||||
...s,
|
||||
}));
|
||||
const lines = withDefaults.map((obj) => JSON.stringify(obj)).join("\n");
|
||||
await fs.appendFile(snippetsPath, (lines ? lines + "\n" : ""), "utf8");
|
||||
|
||||
// prune if exceeded max
|
||||
const meta = await readMeta(chatId);
|
||||
const maxSnippets = meta.config?.maxSnippets ?? 400;
|
||||
try {
|
||||
const file = await fs.readFile(snippetsPath, "utf8");
|
||||
const allLines = file.split("\n").filter(Boolean);
|
||||
if (allLines.length > maxSnippets) {
|
||||
const toKeep = allLines.slice(allLines.length - maxSnippets);
|
||||
await fs.writeFile(snippetsPath, toKeep.join("\n") + "\n", "utf8");
|
||||
return toKeep.length;
|
||||
}
|
||||
return allLines.length;
|
||||
} catch {
|
||||
return withDefaults.length;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAllSnippets(chatId: number): Promise<SmartContextSnippet[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(getSnippetsPath(chatId), "utf8");
|
||||
return raw
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as SmartContextSnippet);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(value: number, min: number, max: number): number {
|
||||
if (max === min) return 0;
|
||||
return (value - min) / (max - min);
|
||||
}
|
||||
|
||||
function keywordScore(text: string, query: string): number {
|
||||
const toTokens = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_\- ]+/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
const qTokens = new Set(toTokens(query));
|
||||
const tTokens = toTokens(text);
|
||||
if (qTokens.size === 0 || tTokens.length === 0) return 0;
|
||||
let hits = 0;
|
||||
for (const tok of tTokens) if (qTokens.has(tok)) hits++;
|
||||
return hits / tTokens.length; // simple overlap ratio
|
||||
}
|
||||
|
||||
export interface RetrieveContextResult {
|
||||
rollingSummary?: string;
|
||||
usedTokens: number;
|
||||
snippets: SmartContextSnippet[];
|
||||
}
|
||||
|
||||
export async function retrieveContext(
|
||||
chatId: number,
|
||||
query: string,
|
||||
budgetTokens: number,
|
||||
): Promise<RetrieveContextResult> {
|
||||
const meta = await readMeta(chatId);
|
||||
const snippets = await readAllSnippets(chatId);
|
||||
const now = Date.now();
|
||||
let minTs = now;
|
||||
let maxTs = 0;
|
||||
for (const s of snippets) {
|
||||
if (s.ts < minTs) minTs = s.ts;
|
||||
if (s.ts > maxTs) maxTs = s.ts;
|
||||
}
|
||||
const scored = snippets.map((s) => {
|
||||
const recency = normalize(s.ts, minTs, maxTs);
|
||||
const kw = keywordScore(s.text, query);
|
||||
const base = 0.6 * kw + 0.4 * recency;
|
||||
const score = base;
|
||||
return { ...s, score } as SmartContextSnippet;
|
||||
});
|
||||
scored.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
|
||||
const picked: SmartContextSnippet[] = [];
|
||||
let usedTokens = 0;
|
||||
for (const s of scored) {
|
||||
const t = s.tokens ?? estimateTokens(s.text);
|
||||
if (usedTokens + t > budgetTokens) break;
|
||||
picked.push(s);
|
||||
usedTokens += t;
|
||||
}
|
||||
|
||||
const rollingSummary = meta.rollingSummary || "";
|
||||
return { rollingSummary, usedTokens, snippets: picked };
|
||||
}
|
||||
|
||||
export async function rebuildIndex(_chatId: number): Promise<void> {
|
||||
// Placeholder for future embedding/vector index rebuild.
|
||||
return;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/update-dyad.sh b/update-dyad.sh
|
||||
index 2763b94..4d7da4c 100755
|
||||
--- a/update-dyad.sh
|
||||
+++ b/update-dyad.sh
|
||||
@@ -78,7 +78,7 @@ git log --oneline HEAD..upstream/main --reverse
|
||||
|
||||
# Attempt to merge
|
||||
print_status "Merging upstream changes into your branch..."
|
||||
-if git merge upstream/main -m "merge: update from upstream - $(date)"; then
|
||||
+if git merge upstream/main -m "merge: update from upstream - $(date)" --allow-unrelated-histories; then
|
||||
print_success "✅ Update completed successfully!"
|
||||
|
||||
# Check for any conflicts that need manual resolution
|
||||
75
e2e-tests/annotator.spec.ts
Normal file
75
e2e-tests/annotator.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { testSkipIfWindows } from "./helpers/test_helper";
|
||||
import { expect } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
|
||||
testSkipIfWindows(
|
||||
"annotator - capture and submit screenshot",
|
||||
async ({ po }) => {
|
||||
await po.setUpDyadPro({ autoApprove: true });
|
||||
|
||||
// Create a basic app
|
||||
await po.sendPrompt("basic");
|
||||
|
||||
// Click the annotator button to activate annotator mode
|
||||
await po.clickPreviewAnnotatorButton();
|
||||
|
||||
// Wait for annotator mode to be active
|
||||
await po.waitForAnnotatorMode();
|
||||
|
||||
// Submit the screenshot to chat
|
||||
await po.clickAnnotatorSubmit();
|
||||
|
||||
await expect(po.getChatInput()).toContainText(
|
||||
"Please update the UI based on these screenshots",
|
||||
);
|
||||
|
||||
// Verify the screenshot was attached to chat context
|
||||
await po.sendPrompt("[dump]");
|
||||
|
||||
// Wait for the LLM response containing the dump path to appear in the UI
|
||||
// before attempting to extract it from the messages list
|
||||
await po.page.waitForSelector("text=/\\[\\[dyad-dump-path=.*\\]\\]/");
|
||||
|
||||
// Get the dump file path from the messages list
|
||||
const messagesListText = await po.page
|
||||
.getByTestId("messages-list")
|
||||
.textContent();
|
||||
const dumpPathMatch = messagesListText?.match(
|
||||
/\[\[dyad-dump-path=([^\]]+)\]\]/,
|
||||
);
|
||||
|
||||
if (!dumpPathMatch) {
|
||||
throw new Error("No dump path found in messages list");
|
||||
}
|
||||
|
||||
const dumpFilePath = dumpPathMatch[1];
|
||||
const dumpContent = fs.readFileSync(dumpFilePath, "utf-8");
|
||||
const parsedDump = JSON.parse(dumpContent);
|
||||
|
||||
// Get the last message from the dump
|
||||
const messages = parsedDump.body.messages;
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
expect(lastMessage).toBeTruthy();
|
||||
expect(lastMessage.content).toBeTruthy();
|
||||
|
||||
// The content is an array with text and image parts
|
||||
expect(Array.isArray(lastMessage.content)).toBe(true);
|
||||
|
||||
// Find the text part and verify it mentions the PNG attachment
|
||||
const textPart = lastMessage.content.find(
|
||||
(part: any) => part.type === "text",
|
||||
);
|
||||
expect(textPart).toBeTruthy();
|
||||
expect(textPart.text).toMatch(/annotated-screenshot-.*\.png/);
|
||||
expect(textPart.text).toMatch(/image\/png/);
|
||||
|
||||
// Find the image part and verify it has the correct structure
|
||||
const imagePart = lastMessage.content.find(
|
||||
(part: any) => part.type === "image_url",
|
||||
);
|
||||
expect(imagePart).toBeTruthy();
|
||||
expect(imagePart.image_url).toBeTruthy();
|
||||
expect(imagePart.image_url.url).toMatch(/^data:image\/png;base64,/);
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { testSkipIfWindows, test } from "./helpers/test_helper";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
testSkipIfWindows("fix error with AI", async ({ po }) => {
|
||||
await po.setUp({ autoApprove: true });
|
||||
@@ -20,6 +21,26 @@ testSkipIfWindows("fix error with AI", async ({ po }) => {
|
||||
await po.snapshotPreview();
|
||||
});
|
||||
|
||||
testSkipIfWindows("copy error message from banner", async ({ po }) => {
|
||||
await po.setUp({ autoApprove: true });
|
||||
await po.sendPrompt("tc=create-error");
|
||||
|
||||
await po.page.getByText("Error Line 6 error", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
|
||||
await po.clickCopyErrorMessage();
|
||||
|
||||
const clipboardText = await po.getClipboardText();
|
||||
expect(clipboardText).toContain("Error Line 6 error");
|
||||
expect(clipboardText.length).toBeGreaterThan(0);
|
||||
|
||||
await expect(po.page.getByRole("button", { name: "Copied" })).toBeVisible();
|
||||
|
||||
await expect(po.page.getByRole("button", { name: "Copied" })).toBeHidden({
|
||||
timeout: 3000,
|
||||
});
|
||||
});
|
||||
test("fix all errors button", async ({ po }) => {
|
||||
await po.setUp({ autoApprove: true });
|
||||
await po.sendPrompt("tc=create-multiple-errors");
|
||||
|
||||
@@ -2,3 +2,4 @@ Here is a simple response to test the context limit banner functionality.
|
||||
|
||||
This message simulates being close to the model's context window limit.
|
||||
|
||||
|
||||
|
||||
1
e2e-tests/fixtures/engine/basic.md
Normal file
1
e2e-tests/fixtures/engine/basic.md
Normal file
@@ -0,0 +1 @@
|
||||
This is a simple basic response
|
||||
@@ -553,6 +553,22 @@ export class PageObject {
|
||||
await this.page.getByTestId("preview-open-browser-button").click();
|
||||
}
|
||||
|
||||
async clickPreviewAnnotatorButton() {
|
||||
await this.page
|
||||
.getByTestId("preview-annotator-button")
|
||||
.click({ timeout: Timeout.EXTRA_LONG });
|
||||
}
|
||||
|
||||
async waitForAnnotatorMode() {
|
||||
// Wait for the annotator toolbar to be visible
|
||||
await expect(this.page.getByRole("button", { name: "Select" })).toBeVisible(
|
||||
{ timeout: Timeout.MEDIUM },
|
||||
);
|
||||
}
|
||||
|
||||
async clickAnnotatorSubmit() {
|
||||
await this.page.getByRole("button", { name: "Add to Chat" }).click();
|
||||
}
|
||||
locateLoadingAppPreview() {
|
||||
return this.page.getByText("Preparing app preview...");
|
||||
}
|
||||
@@ -575,6 +591,13 @@ export class PageObject {
|
||||
await this.page.getByRole("button", { name: "Fix error with AI" }).click();
|
||||
}
|
||||
|
||||
async clickCopyErrorMessage() {
|
||||
await this.page.getByRole("button", { name: /Copy/ }).click();
|
||||
}
|
||||
|
||||
async getClipboardText(): Promise<string> {
|
||||
return await this.page.evaluate(() => navigator.clipboard.readText());
|
||||
}
|
||||
async clickFixAllErrors() {
|
||||
await this.page.getByRole("button", { name: /Fix All Errors/ }).click();
|
||||
}
|
||||
|
||||
150
e2e-tests/performance_monitor.spec.ts
Normal file
150
e2e-tests/performance_monitor.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { Timeout, testWithConfig } from "./helpers/test_helper";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
testWithConfig({
|
||||
preLaunchHook: async ({ userDataDir }) => {
|
||||
// Set up a force-close scenario by creating settings with isRunning: true
|
||||
// and lastKnownPerformance data
|
||||
const settingsPath = path.join(userDataDir, "user-settings.json");
|
||||
const settings = {
|
||||
hasRunBefore: true,
|
||||
isRunning: true, // Simulate force-close
|
||||
enableAutoUpdate: false,
|
||||
releaseChannel: "stable",
|
||||
lastKnownPerformance: {
|
||||
timestamp: Date.now() - 5000, // 5 seconds ago
|
||||
memoryUsageMB: 256,
|
||||
cpuUsagePercent: 45.5,
|
||||
systemMemoryUsageMB: 8192,
|
||||
systemMemoryTotalMB: 16384,
|
||||
systemMemoryPercent: 50.0,
|
||||
systemCpuPercent: 35.2,
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
},
|
||||
})(
|
||||
"force-close detection shows dialog with performance data",
|
||||
async ({ po }) => {
|
||||
// Wait for the home page to be visible first
|
||||
await expect(po.getHomeChatInputContainer()).toBeVisible({
|
||||
timeout: Timeout.LONG,
|
||||
});
|
||||
|
||||
// Check if the force-close dialog is visible by looking for the heading
|
||||
await expect(
|
||||
po.page.getByRole("heading", { name: "Force Close Detected" }),
|
||||
).toBeVisible({ timeout: Timeout.MEDIUM });
|
||||
|
||||
// Verify the warning message
|
||||
await expect(
|
||||
po.page.getByText(
|
||||
"The app was not closed properly the last time it was running",
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
// Verify performance data is displayed
|
||||
await expect(po.page.getByText("Last Known State:")).toBeVisible();
|
||||
|
||||
// Check Process Metrics section
|
||||
await expect(po.page.getByText("Process Metrics")).toBeVisible();
|
||||
await expect(po.page.getByText("256 MB")).toBeVisible();
|
||||
await expect(po.page.getByText("45.5%")).toBeVisible();
|
||||
|
||||
// Check System Metrics section
|
||||
await expect(po.page.getByText("System Metrics")).toBeVisible();
|
||||
await expect(po.page.getByText("8192 / 16384 MB")).toBeVisible();
|
||||
await expect(po.page.getByText("35.2%")).toBeVisible();
|
||||
|
||||
// Close the dialog
|
||||
await po.page.getByRole("button", { name: "OK" }).click();
|
||||
|
||||
// Verify dialog is closed by checking the heading is no longer visible
|
||||
await expect(
|
||||
po.page.getByRole("heading", { name: "Force Close Detected" }),
|
||||
).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
testWithConfig({
|
||||
preLaunchHook: async ({ userDataDir }) => {
|
||||
// Set up scenario without force-close (proper shutdown)
|
||||
const settingsPath = path.join(userDataDir, "user-settings.json");
|
||||
const settings = {
|
||||
hasRunBefore: true,
|
||||
isRunning: false, // Proper shutdown - no force-close
|
||||
enableAutoUpdate: false,
|
||||
releaseChannel: "stable",
|
||||
lastKnownPerformance: {
|
||||
timestamp: Date.now() - 5000,
|
||||
memoryUsageMB: 256,
|
||||
cpuUsagePercent: 45.5,
|
||||
systemMemoryUsageMB: 8192,
|
||||
systemMemoryTotalMB: 16384,
|
||||
systemMemoryPercent: 50.0,
|
||||
systemCpuPercent: 35.2,
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
},
|
||||
})("no force-close dialog when app was properly shut down", async ({ po }) => {
|
||||
// Verify the home page loaded normally
|
||||
await expect(po.getHomeChatInputContainer()).toBeVisible({
|
||||
timeout: Timeout.LONG,
|
||||
});
|
||||
|
||||
// Verify that the force-close dialog is NOT shown
|
||||
await expect(
|
||||
po.page.getByRole("heading", { name: "Force Close Detected" }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
testWithConfig({})(
|
||||
"performance information is being captured during normal operation",
|
||||
async ({ po, electronApp }) => {
|
||||
// Wait for the app to load
|
||||
await expect(po.getHomeChatInputContainer()).toBeVisible({
|
||||
timeout: Timeout.LONG,
|
||||
});
|
||||
|
||||
// Get the user data directory
|
||||
const userDataDir = (electronApp as any).$dyadUserDataDir;
|
||||
const settingsPath = path.join(userDataDir, "user-settings.json");
|
||||
|
||||
// Wait a bit to allow performance monitoring to capture at least one data point
|
||||
// Performance monitoring runs every 30 seconds, but we'll wait 35 seconds to be safe
|
||||
await po.page.waitForTimeout(35000);
|
||||
|
||||
// Read the settings file
|
||||
const settingsContent = fs.readFileSync(settingsPath, "utf-8");
|
||||
const settings = JSON.parse(settingsContent);
|
||||
|
||||
// Verify that lastKnownPerformance exists and has all required fields
|
||||
expect(settings.lastKnownPerformance).toBeDefined();
|
||||
expect(settings.lastKnownPerformance.timestamp).toBeGreaterThan(0);
|
||||
expect(settings.lastKnownPerformance.memoryUsageMB).toBeGreaterThan(0);
|
||||
expect(
|
||||
settings.lastKnownPerformance.cpuUsagePercent,
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
expect(settings.lastKnownPerformance.systemMemoryUsageMB).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(settings.lastKnownPerformance.systemMemoryTotalMB).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
settings.lastKnownPerformance.systemCpuPercent,
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Verify the timestamp is recent (within the last minute)
|
||||
const now = Date.now();
|
||||
const timeDiff = now - settings.lastKnownPerformance.timestamp;
|
||||
expect(timeDiff).toBeLessThan(60000); // Less than 1 minute old
|
||||
},
|
||||
);
|
||||
15
e2e-tests/smart_context_balanced.spec.ts
Normal file
15
e2e-tests/smart_context_balanced.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { testSkipIfWindows } from "./helpers/test_helper";
|
||||
|
||||
testSkipIfWindows("smart context balanced - simple", async ({ po }) => {
|
||||
await po.setUpDyadPro({ autoApprove: true });
|
||||
const proModesDialog = await po.openProModesDialog({
|
||||
location: "home-chat-input-container",
|
||||
});
|
||||
await proModesDialog.setSmartContextMode("balanced");
|
||||
await proModesDialog.close();
|
||||
|
||||
await po.sendPrompt("[dump]");
|
||||
|
||||
await po.snapshotServerDump("request");
|
||||
await po.snapshotMessages({ replaceDumpPath: true });
|
||||
});
|
||||
@@ -16,5 +16,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": false,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -16,5 +16,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -18,5 +18,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -7,11 +7,12 @@
|
||||
- img
|
||||
- text: "src/pages/Index.tsx Summary: intentionally add first error"
|
||||
- img
|
||||
- text: Error
|
||||
- text: Error First error in Index...
|
||||
- img
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Fix with AI":
|
||||
- img
|
||||
- text: First error in Index...
|
||||
- img
|
||||
- img
|
||||
- text: ErrorComponent.tsx
|
||||
- button "Edit":
|
||||
@@ -19,11 +20,12 @@
|
||||
- img
|
||||
- text: "src/components/ErrorComponent.tsx Summary: intentionally add second error"
|
||||
- img
|
||||
- text: Error
|
||||
- text: Error Second error in ErrorComponent...
|
||||
- img
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Fix with AI":
|
||||
- img
|
||||
- text: Second error in ErrorComponent...
|
||||
- img
|
||||
- img
|
||||
- text: helper.ts
|
||||
- button "Edit":
|
||||
@@ -31,11 +33,12 @@
|
||||
- img
|
||||
- text: "src/utils/helper.ts Summary: intentionally add third error"
|
||||
- img
|
||||
- text: Error
|
||||
- text: Error Third error in helper...
|
||||
- img
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Fix with AI":
|
||||
- img
|
||||
- text: Third error in helper...
|
||||
- img
|
||||
- button "Fix All Errors (3)":
|
||||
- img
|
||||
- button:
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "beta",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -16,5 +16,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
- paragraph: "[dump]"
|
||||
- paragraph: "[[dyad-dump-path=*]]"
|
||||
- button:
|
||||
- img
|
||||
- img
|
||||
- text: Approved
|
||||
- img
|
||||
- text: less than a minute ago
|
||||
- button "Request ID":
|
||||
- img
|
||||
- button "Retry":
|
||||
- img
|
||||
File diff suppressed because one or more lines are too long
@@ -24,5 +24,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -25,5 +25,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -24,5 +24,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -25,5 +25,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -15,5 +15,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -16,5 +16,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -15,5 +15,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -16,5 +16,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -15,5 +15,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -16,5 +16,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -16,5 +16,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -17,5 +17,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -17,5 +17,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -25,5 +25,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -8,14 +8,6 @@
|
||||
"role": "system",
|
||||
"content": "[[SYSTEM_MESSAGE]]"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "tc=1"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Error: Test case file not found: 1.md"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[dump] hi"
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -8,22 +8,6 @@
|
||||
"role": "system",
|
||||
"content": "[[SYSTEM_MESSAGE]]"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "tc=1"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Error: Test case file not found: 1.md"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[dump] hi"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "[[dyad-dump-path=*]]"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[dump] hi"
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -8,30 +8,6 @@
|
||||
"role": "system",
|
||||
"content": "[[SYSTEM_MESSAGE]]"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "tc=1"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Error: Test case file not found: 1.md"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[dump] hi"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "[[dyad-dump-path=*]]"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[dump] hi"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "[[dyad-dump-path=*]]"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[dump] hi"
|
||||
|
||||
@@ -24,5 +24,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -25,5 +25,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -25,5 +25,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -25,5 +25,6 @@
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"releaseChannel": "stable",
|
||||
"isRunning": true,
|
||||
"isTestMode": true
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "There was an issue with the following `dyad-search-replace` tags. Make sure you use `dyad-read` to read the latest version of the file and then trying to do search & replace again.\n \nFile path: src/pages/Index.tsx\nError: Unable to apply search-replace to file"
|
||||
"content": "There was an issue with the following `dyad-search-replace` tags. Make sure you use `dyad-read` to read the latest version of the file and then trying to do search & replace again.\n \nFile path: src/pages/Index.tsx\nError: Unable to apply search-replace to file because: Search block did not match any content in the target file. Best fuzzy match had similarity of 0.0% (threshold: 90.0%)"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "There was an issue with the following `dyad-search-replace` tags. Please fix the errors by generating the code changes using `dyad-write` tags instead.\n \nFile path: src/pages/Index.tsx\nError: Unable to apply search-replace to file"
|
||||
"content": "There was an issue with the following `dyad-search-replace` tags. Please fix the errors by generating the code changes using `dyad-write` tags instead.\n \nFile path: src/pages/Index.tsx\nError: Unable to apply search-replace to file because: Search block did not match any content in the target file. Best fuzzy match had similarity of 0.0% (threshold: 90.0%)"
|
||||
}
|
||||
],
|
||||
"stream": true,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
=== src/pages/Index.tsx ===
|
||||
// Update this page (the content is just a fallback if you fail to update the page)
|
||||
import { MadeWithDyad } from "@/components/made-with-dyad";
|
||||
|
||||
const Index = () => {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mx-[20px] my-[10px]">Welcome to Your Blank App</h1>
|
||||
<p className="text-xl text-gray-600">Start building your amazing project here!
|
||||
</p>
|
||||
</div>
|
||||
<MadeWithDyad />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
@@ -0,0 +1,18 @@
|
||||
=== src/pages/Index.tsx ===
|
||||
// Update this page (the content is just a fallback if you fail to update the page)
|
||||
import { MadeWithDyad } from "@/components/made-with-dyad";
|
||||
|
||||
const Index = () => {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Hello from E2E Test</h1>
|
||||
<p className="text-xl text-gray-600">Start building your amazing project here!
|
||||
</p>
|
||||
</div>
|
||||
<MadeWithDyad />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { testSkipIfWindows, Timeout } from "./helpers/test_helper";
|
||||
import { PageObject, testSkipIfWindows, Timeout } from "./helpers/test_helper";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
testSkipIfWindows("undo", async ({ po }) => {
|
||||
await po.setUp({ autoApprove: true });
|
||||
const runUndoTest = async (po: PageObject, nativeGit: boolean) => {
|
||||
await po.setUp({ autoApprove: true, nativeGit });
|
||||
await po.sendPrompt("tc=write-index");
|
||||
await po.sendPrompt("tc=write-index-2");
|
||||
|
||||
@@ -31,4 +31,12 @@ testSkipIfWindows("undo", async ({ po }) => {
|
||||
// Also, could be slow.
|
||||
timeout: Timeout.LONG,
|
||||
});
|
||||
};
|
||||
|
||||
testSkipIfWindows("undo", async ({ po }) => {
|
||||
await runUndoTest(po, false);
|
||||
});
|
||||
|
||||
testSkipIfWindows("undo with native git", async ({ po }) => {
|
||||
await runUndoTest(po, true);
|
||||
});
|
||||
|
||||
219
e2e-tests/visual_editing.spec.ts
Normal file
219
e2e-tests/visual_editing.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { testSkipIfWindows, Timeout } from "./helpers/test_helper";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
testSkipIfWindows("edit style of one selected component", async ({ po }) => {
|
||||
await po.setUpDyadPro();
|
||||
await po.sendPrompt("tc=basic");
|
||||
await po.clickTogglePreviewPanel();
|
||||
await po.clickPreviewPickElement();
|
||||
|
||||
// Select a component
|
||||
await po
|
||||
.getPreviewIframeElement()
|
||||
.contentFrame()
|
||||
.getByRole("heading", { name: "Welcome to Your Blank App" })
|
||||
.click();
|
||||
|
||||
// Wait for the toolbar to appear (check for the Margin button which is always visible)
|
||||
const marginButton = po.page.getByRole("button", { name: "Margin" });
|
||||
await expect(marginButton).toBeVisible({
|
||||
timeout: Timeout.MEDIUM,
|
||||
});
|
||||
|
||||
// Ensure the toolbar has proper coordinates before clicking
|
||||
await expect(async () => {
|
||||
const box = await marginButton.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.y).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: Timeout.MEDIUM });
|
||||
|
||||
// Click on margin button to open the margin popover
|
||||
await marginButton.click();
|
||||
|
||||
// Wait for the popover to fully open by checking for the popover content container
|
||||
const marginDialog = po.page
|
||||
.locator('[role="dialog"]')
|
||||
.filter({ hasText: "Margin" });
|
||||
await expect(marginDialog).toBeVisible({
|
||||
timeout: Timeout.LONG,
|
||||
});
|
||||
|
||||
// Edit margin - set horizontal margin
|
||||
const marginXInput = po.page.getByLabel("Horizontal");
|
||||
await marginXInput.fill("20");
|
||||
|
||||
// Edit margin - set vertical margin
|
||||
const marginYInput = po.page.getByLabel("Vertical");
|
||||
await marginYInput.fill("10");
|
||||
|
||||
// Close the popover by clicking outside or pressing escape
|
||||
await po.page.keyboard.press("Escape");
|
||||
|
||||
// Check if the changes are applied to UI by verifying the visual changes dialog appears
|
||||
await expect(po.page.getByText(/\d+ component[s]? modified/)).toBeVisible({
|
||||
timeout: Timeout.MEDIUM,
|
||||
});
|
||||
|
||||
// Save the changes
|
||||
await po.page.getByRole("button", { name: "Save Changes" }).click();
|
||||
|
||||
// Wait for the success toast
|
||||
await po.waitForToastWithText("Visual changes saved to source files");
|
||||
|
||||
// Verify that the changes are applied to codebase
|
||||
await po.snapshotAppFiles({
|
||||
name: "visual-editing-single-component-margin",
|
||||
files: ["src/pages/Index.tsx"],
|
||||
});
|
||||
});
|
||||
|
||||
testSkipIfWindows("edit text of the selected component", async ({ po }) => {
|
||||
await po.setUpDyadPro();
|
||||
await po.sendPrompt("tc=basic");
|
||||
await po.clickTogglePreviewPanel();
|
||||
await po.clickPreviewPickElement();
|
||||
|
||||
// Click on component that contains static text
|
||||
await po
|
||||
.getPreviewIframeElement()
|
||||
.contentFrame()
|
||||
.getByRole("heading", { name: "Welcome to Your Blank App" })
|
||||
.click();
|
||||
|
||||
// Wait for the toolbar to appear (check for the Margin button which is always visible)
|
||||
await expect(po.page.getByRole("button", { name: "Margin" })).toBeVisible({
|
||||
timeout: Timeout.MEDIUM,
|
||||
});
|
||||
|
||||
// Get the iframe and access the content
|
||||
const iframe = po.getPreviewIframeElement();
|
||||
const frame = iframe.contentFrame();
|
||||
|
||||
// Find the heading element in the iframe
|
||||
const heading = frame.getByRole("heading", {
|
||||
name: "Welcome to Your Blank App",
|
||||
});
|
||||
|
||||
await heading.dblclick();
|
||||
|
||||
// Wait for contentEditable to be enabled
|
||||
await expect(async () => {
|
||||
const isEditable = await heading.evaluate(
|
||||
(el) => (el as HTMLElement).isContentEditable,
|
||||
);
|
||||
expect(isEditable).toBe(true);
|
||||
}).toPass({ timeout: Timeout.MEDIUM });
|
||||
|
||||
// Clear the existing text and type new text
|
||||
await heading.press("Meta+A");
|
||||
await heading.type("Hello from E2E Test");
|
||||
|
||||
// Click outside to finish editing
|
||||
await frame.locator("body").click({ position: { x: 10, y: 10 } });
|
||||
|
||||
// Verify the changes are applied in the UI
|
||||
await expect(frame.getByText("Hello from E2E Test")).toBeVisible({
|
||||
timeout: Timeout.MEDIUM,
|
||||
});
|
||||
|
||||
// Verify the visual changes dialog appears
|
||||
await expect(po.page.getByText(/\d+ component[s]? modified/)).toBeVisible({
|
||||
timeout: Timeout.MEDIUM,
|
||||
});
|
||||
|
||||
// Save the changes
|
||||
await po.page.getByRole("button", { name: "Save Changes" }).click();
|
||||
|
||||
// Wait for the success toast
|
||||
await po.waitForToastWithText("Visual changes saved to source files");
|
||||
|
||||
// Verify that the changes are applied to the codebase
|
||||
await po.snapshotAppFiles({
|
||||
name: "visual-editing-text-content",
|
||||
files: ["src/pages/Index.tsx"],
|
||||
});
|
||||
});
|
||||
|
||||
testSkipIfWindows("discard changes", async ({ po }) => {
|
||||
await po.setUpDyadPro();
|
||||
await po.sendPrompt("tc=basic");
|
||||
await po.clickTogglePreviewPanel();
|
||||
await po.clickPreviewPickElement();
|
||||
|
||||
// Select a component
|
||||
await po
|
||||
.getPreviewIframeElement()
|
||||
.contentFrame()
|
||||
.getByRole("heading", { name: "Welcome to Your Blank App" })
|
||||
.click();
|
||||
|
||||
// Wait for the toolbar to appear (check for the Margin button which is always visible)
|
||||
const marginButton = po.page.getByRole("button", { name: "Margin" });
|
||||
await expect(marginButton).toBeVisible({
|
||||
timeout: Timeout.MEDIUM,
|
||||
});
|
||||
|
||||
// Ensure the toolbar has proper coordinates before clicking
|
||||
await expect(async () => {
|
||||
const box = await marginButton.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.y).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: Timeout.MEDIUM });
|
||||
|
||||
// Click on margin button to open the margin popover
|
||||
await marginButton.click();
|
||||
|
||||
// Wait for the popover to fully open by checking for the popover content container
|
||||
const marginDialog = po.page
|
||||
.locator('[role="dialog"]')
|
||||
.filter({ hasText: "Margin" });
|
||||
await expect(marginDialog).toBeVisible({
|
||||
timeout: Timeout.LONG,
|
||||
});
|
||||
|
||||
// Edit margin
|
||||
const marginXInput = po.page.getByLabel("Horizontal");
|
||||
await marginXInput.fill("30");
|
||||
|
||||
const marginYInput = po.page.getByLabel("Vertical");
|
||||
await marginYInput.fill("30");
|
||||
|
||||
// Close the popover
|
||||
await po.page.keyboard.press("Escape");
|
||||
|
||||
// Wait for the popover to close
|
||||
await expect(marginDialog).not.toBeVisible({
|
||||
timeout: Timeout.MEDIUM,
|
||||
});
|
||||
|
||||
// Check if the changes are applied to UI
|
||||
await expect(po.page.getByText(/\d+ component[s]? modified/)).toBeVisible({
|
||||
timeout: Timeout.MEDIUM,
|
||||
});
|
||||
|
||||
// Take a snapshot of the app files before discarding
|
||||
const appPathBefore = await po.getCurrentAppPath();
|
||||
const appFileBefore = fs.readFileSync(
|
||||
path.join(appPathBefore, "src", "pages", "Index.tsx"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Discard the changes
|
||||
await po.page.getByRole("button", { name: "Discard" }).click();
|
||||
|
||||
// Verify the visual changes dialog is gone
|
||||
await expect(po.page.getByText(/\d+ component[s]? modified/)).not.toBeVisible(
|
||||
{ timeout: Timeout.MEDIUM },
|
||||
);
|
||||
|
||||
// Verify that the changes are NOT applied to codebase
|
||||
const appFileAfter = fs.readFileSync(
|
||||
path.join(appPathBefore, "src", "pages", "Index.tsx"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// The file content should be the same as before
|
||||
expect(appFileAfter).toBe(appFileBefore);
|
||||
});
|
||||
@@ -32,6 +32,9 @@ const ignore = (file: string) => {
|
||||
if (file.startsWith("/node_modules/stacktrace-js/dist")) {
|
||||
return false;
|
||||
}
|
||||
if (file.startsWith("/node_modules/html-to-image")) {
|
||||
return false;
|
||||
}
|
||||
if (file.startsWith("/node_modules/better-sqlite3")) {
|
||||
return false;
|
||||
}
|
||||
@@ -74,6 +77,7 @@ const config: ForgeConfig = {
|
||||
},
|
||||
asar: true,
|
||||
ignore,
|
||||
extraResource: ["node_modules/dugite/git"],
|
||||
// ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/],
|
||||
},
|
||||
rebuildConfig: {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export default {
|
||||
testDir: "e2e-tests",
|
||||
reporter: [["html", { open: "never" }]],
|
||||
reporter: [
|
||||
["html", { open: "never" }],
|
||||
["json", { outputFile: "playwright-report/results.json" }],
|
||||
],
|
||||
};
|
||||
|
||||
820
package-lock.json
generated
820
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dyad",
|
||||
"productName": "dyad",
|
||||
"version": "0.29.0-beta.1",
|
||||
"version": "0.30.0-beta.1",
|
||||
"description": "Free, local, open-source AI app builder",
|
||||
"main": ".vite/build/main.js",
|
||||
"repository": {
|
||||
@@ -94,6 +94,7 @@
|
||||
"@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",
|
||||
@@ -134,6 +135,7 @@
|
||||
"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",
|
||||
@@ -143,20 +145,25 @@
|
||||
"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.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"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.5.2",
|
||||
"react-shiki": "^0.9.0",
|
||||
"recast": "^0.23.11",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shell-env": "^4.0.1",
|
||||
"shiki": "^3.2.1",
|
||||
|
||||
350
scripts/generate-playwright-summary.js
Normal file
350
scripts/generate-playwright-summary.js
Normal file
@@ -0,0 +1,350 @@
|
||||
// This script parses Playwright JSON results and generates a PR comment summary
|
||||
// Used by the CI workflow's merge-reports job
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
// Strip ANSI escape codes from terminal output
|
||||
function stripAnsi(str) {
|
||||
if (!str) return str;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, "").replace(/\u001b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
function ensureOsBucket(resultsByOs, os) {
|
||||
if (!os) return;
|
||||
if (!resultsByOs[os]) {
|
||||
resultsByOs[os] = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
flaky: 0,
|
||||
failures: [],
|
||||
flakyTests: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function detectOperatingSystemsFromReport(report) {
|
||||
const detected = new Set();
|
||||
|
||||
function traverseSuites(suites = []) {
|
||||
for (const suite of suites) {
|
||||
for (const spec of suite.specs || []) {
|
||||
for (const test of spec.tests || []) {
|
||||
for (const result of test.results || []) {
|
||||
for (const attachment of result.attachments || []) {
|
||||
const p = attachment.path || "";
|
||||
if (p.includes("darwin") || p.includes("macos")) {
|
||||
detected.add("macOS");
|
||||
} else if (p.includes("win32") || p.includes("windows")) {
|
||||
detected.add("Windows");
|
||||
}
|
||||
}
|
||||
|
||||
const stack = result.error?.stack || "";
|
||||
if (stack.includes("/Users/")) {
|
||||
detected.add("macOS");
|
||||
} else if (stack.includes("C:\\") || stack.includes("D:\\")) {
|
||||
detected.add("Windows");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (suite.suites?.length) {
|
||||
traverseSuites(suite.suites);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverseSuites(report?.suites);
|
||||
|
||||
return detected;
|
||||
}
|
||||
|
||||
function determineIssueNumber({ context }) {
|
||||
const envNumber = process.env.PR_NUMBER;
|
||||
if (envNumber) return Number(envNumber);
|
||||
|
||||
if (context.eventName === "workflow_run") {
|
||||
const prFromPayload =
|
||||
context.payload?.workflow_run?.pull_requests?.[0]?.number;
|
||||
if (prFromPayload) return prFromPayload;
|
||||
} else {
|
||||
throw new Error("This script should only be run in a workflow_run")
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function run({ github, context, core }) {
|
||||
// Read the JSON report
|
||||
const reportPath = "playwright-report/results.json";
|
||||
if (!fs.existsSync(reportPath)) {
|
||||
console.log("No results.json found, skipping comment");
|
||||
return;
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
||||
|
||||
// Identify which OS each blob report came from
|
||||
const blobDir = "all-blob-reports";
|
||||
const blobFiles = fs.existsSync(blobDir) ? fs.readdirSync(blobDir) : [];
|
||||
const hasMacOS = blobFiles.some((f) => f.includes("darwin"));
|
||||
const hasWindows = blobFiles.some((f) => f.includes("win32"));
|
||||
|
||||
// Initialize per-OS results
|
||||
const resultsByOs = {};
|
||||
if (hasMacOS) ensureOsBucket(resultsByOs, "macOS");
|
||||
if (hasWindows) ensureOsBucket(resultsByOs, "Windows");
|
||||
|
||||
if (Object.keys(resultsByOs).length === 0) {
|
||||
const detected = detectOperatingSystemsFromReport(report);
|
||||
if (detected.size === 0) {
|
||||
ensureOsBucket(resultsByOs, "macOS");
|
||||
ensureOsBucket(resultsByOs, "Windows");
|
||||
} else {
|
||||
for (const os of detected) ensureOsBucket(resultsByOs, os);
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse suites and collect test results
|
||||
function traverseSuites(suites, parentTitle = "") {
|
||||
for (const suite of suites || []) {
|
||||
const suiteTitle = parentTitle
|
||||
? `${parentTitle} > ${suite.title}`
|
||||
: suite.title;
|
||||
|
||||
for (const spec of suite.specs || []) {
|
||||
for (const test of spec.tests || []) {
|
||||
const results = test.results || [];
|
||||
if (results.length === 0) continue;
|
||||
|
||||
// Use the final result (last retry attempt) to determine the test outcome
|
||||
const finalResult = results[results.length - 1];
|
||||
|
||||
// Determine OS from attachments in any result (they contain platform paths)
|
||||
let os = null;
|
||||
for (const result of results) {
|
||||
for (const att of result.attachments || []) {
|
||||
const p = att.path || "";
|
||||
if (p.includes("darwin") || p.includes("macos")) {
|
||||
os = "macOS";
|
||||
break;
|
||||
}
|
||||
if (p.includes("win32") || p.includes("windows")) {
|
||||
os = "Windows";
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (os) break;
|
||||
|
||||
// Fallback: check error stack for OS paths
|
||||
if (result.error?.stack) {
|
||||
if (result.error.stack.includes("/Users/")) {
|
||||
os = "macOS";
|
||||
break;
|
||||
} else if (
|
||||
result.error.stack.includes("C:\\") ||
|
||||
result.error.stack.includes("D:\\")
|
||||
) {
|
||||
os = "Windows";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't know, assign to both (will be roughly split)
|
||||
const osTargets = os
|
||||
? [os]
|
||||
: Object.keys(resultsByOs).length > 0
|
||||
? Object.keys(resultsByOs)
|
||||
: ["macOS", "Windows"];
|
||||
|
||||
// Check if this is a flaky test (passed eventually but had prior failures)
|
||||
const hadPriorFailure = results
|
||||
.slice(0, -1)
|
||||
.some(
|
||||
(r) =>
|
||||
r.status === "failed" ||
|
||||
r.status === "timedOut" ||
|
||||
r.status === "interrupted",
|
||||
);
|
||||
const isFlaky = finalResult.status === "passed" && hadPriorFailure;
|
||||
|
||||
for (const targetOs of osTargets) {
|
||||
ensureOsBucket(resultsByOs, targetOs);
|
||||
const status = finalResult.status;
|
||||
|
||||
if (isFlaky) {
|
||||
resultsByOs[targetOs].flaky++;
|
||||
resultsByOs[targetOs].passed++;
|
||||
resultsByOs[targetOs].flakyTests.push({
|
||||
title: `${suiteTitle} > ${spec.title}`,
|
||||
retries: results.length - 1,
|
||||
});
|
||||
} else if (status === "passed") {
|
||||
resultsByOs[targetOs].passed++;
|
||||
} else if (
|
||||
status === "failed" ||
|
||||
status === "timedOut" ||
|
||||
status === "interrupted"
|
||||
) {
|
||||
resultsByOs[targetOs].failed++;
|
||||
const errorMsg =
|
||||
finalResult.error?.message?.split("\n")[0] || "Test failed";
|
||||
resultsByOs[targetOs].failures.push({
|
||||
title: `${suiteTitle} > ${spec.title}`,
|
||||
error: stripAnsi(errorMsg),
|
||||
});
|
||||
} else if (status === "skipped") {
|
||||
resultsByOs[targetOs].skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested suites
|
||||
if (suite.suites) {
|
||||
traverseSuites(suite.suites, suiteTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverseSuites(report.suites);
|
||||
|
||||
// Calculate totals
|
||||
let totalPassed = 0,
|
||||
totalFailed = 0,
|
||||
totalSkipped = 0,
|
||||
totalFlaky = 0;
|
||||
for (const os of Object.keys(resultsByOs)) {
|
||||
totalPassed += resultsByOs[os].passed;
|
||||
totalFailed += resultsByOs[os].failed;
|
||||
totalSkipped += resultsByOs[os].skipped;
|
||||
totalFlaky += resultsByOs[os].flaky;
|
||||
}
|
||||
|
||||
// Build the comment
|
||||
let comment = "## 🎭 Playwright Test Results\n\n";
|
||||
const allPassed = totalFailed === 0;
|
||||
|
||||
if (allPassed) {
|
||||
comment += "### ✅ All tests passed!\n\n";
|
||||
comment += "| OS | Passed | Flaky | Skipped |\n";
|
||||
comment += "|:---|:---:|:---:|:---:|\n";
|
||||
for (const [os, data] of Object.entries(resultsByOs)) {
|
||||
const emoji = os === "macOS" ? "🍎" : "🪟";
|
||||
comment += `| ${emoji} ${os} | ${data.passed} | ${data.flaky} | ${data.skipped} |\n`;
|
||||
}
|
||||
comment += `\n**Total: ${totalPassed} tests passed**`;
|
||||
if (totalFlaky > 0) comment += ` (${totalFlaky} flaky)`;
|
||||
if (totalSkipped > 0) comment += ` (${totalSkipped} skipped)`;
|
||||
|
||||
// List flaky tests even when all passed
|
||||
if (totalFlaky > 0) {
|
||||
comment += "\n\n### ⚠️ Flaky Tests\n\n";
|
||||
for (const [os, data] of Object.entries(resultsByOs)) {
|
||||
if (data.flakyTests.length === 0) continue;
|
||||
const emoji = os === "macOS" ? "🍎" : "🪟";
|
||||
comment += `#### ${emoji} ${os}\n\n`;
|
||||
for (const f of data.flakyTests.slice(0, 10)) {
|
||||
comment += `- \`${f.title}\` (passed after ${f.retries} ${f.retries === 1 ? "retry" : "retries"})\n`;
|
||||
}
|
||||
if (data.flakyTests.length > 10) {
|
||||
comment += `- ... and ${data.flakyTests.length - 10} more\n`;
|
||||
}
|
||||
comment += "\n";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
comment += "### ❌ Some tests failed\n\n";
|
||||
comment += "| OS | Passed | Failed | Flaky | Skipped |\n";
|
||||
comment += "|:---|:---:|:---:|:---:|:---:|\n";
|
||||
for (const [os, data] of Object.entries(resultsByOs)) {
|
||||
const emoji = os === "macOS" ? "🍎" : "🪟";
|
||||
comment += `| ${emoji} ${os} | ${data.passed} | ${data.failed} | ${data.flaky} | ${data.skipped} |\n`;
|
||||
}
|
||||
comment += `\n**Summary: ${totalPassed} passed, ${totalFailed} failed**`;
|
||||
if (totalFlaky > 0) comment += `, ${totalFlaky} flaky`;
|
||||
if (totalSkipped > 0) comment += `, ${totalSkipped} skipped`;
|
||||
|
||||
comment += "\n\n### Failed Tests\n\n";
|
||||
|
||||
for (const [os, data] of Object.entries(resultsByOs)) {
|
||||
if (data.failures.length === 0) continue;
|
||||
const emoji = os === "macOS" ? "🍎" : "🪟";
|
||||
comment += `#### ${emoji} ${os}\n\n`;
|
||||
for (const f of data.failures.slice(0, 10)) {
|
||||
const errorPreview =
|
||||
f.error.length > 150 ? f.error.substring(0, 150) + "..." : f.error;
|
||||
comment += `- \`${f.title}\`\n - ${errorPreview}\n`;
|
||||
}
|
||||
if (data.failures.length > 10) {
|
||||
comment += `- ... and ${data.failures.length - 10} more\n`;
|
||||
}
|
||||
comment += "\n";
|
||||
}
|
||||
|
||||
// List flaky tests
|
||||
if (totalFlaky > 0) {
|
||||
comment += "### ⚠️ Flaky Tests\n\n";
|
||||
for (const [os, data] of Object.entries(resultsByOs)) {
|
||||
if (data.flakyTests.length === 0) continue;
|
||||
const emoji = os === "macOS" ? "🍎" : "🪟";
|
||||
comment += `#### ${emoji} ${os}\n\n`;
|
||||
for (const f of data.flakyTests.slice(0, 10)) {
|
||||
comment += `- \`${f.title}\` (passed after ${f.retries} ${f.retries === 1 ? "retry" : "retries"})\n`;
|
||||
}
|
||||
if (data.flakyTests.length > 10) {
|
||||
comment += `- ... and ${data.flakyTests.length - 10} more\n`;
|
||||
}
|
||||
comment += "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const repoUrl = `https://github.com/${process.env.GITHUB_REPOSITORY}`;
|
||||
const runId = process.env.PLAYWRIGHT_RUN_ID || process.env.GITHUB_RUN_ID;
|
||||
comment += `\n---\n📊 [View full report](${repoUrl}/actions/runs/${runId})`;
|
||||
|
||||
// Post or update comment on PR
|
||||
const prNumber = determineIssueNumber({ context });
|
||||
|
||||
if (prNumber) {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
|
||||
const botComment = comments.find(
|
||||
(c) =>
|
||||
c.user?.type === "Bot" &&
|
||||
c.body?.includes("🎭 Playwright Test Results"),
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: comment,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: comment,
|
||||
});
|
||||
}
|
||||
} else if (!prNumber) {
|
||||
console.log("No pull request detected; skipping PR comment");
|
||||
}
|
||||
|
||||
// Always output to job summary
|
||||
await core.summary.addRaw(comment).write();
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
7
shared/ports.ts
Normal file
7
shared/ports.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Calculate the port for a given app based on its ID.
|
||||
* Uses a base port of 32100 and offsets by appId % 10_000.
|
||||
*/
|
||||
export function getAppPort(appId: number): number {
|
||||
return 32100 + (appId % 10_000);
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
hasUnclosedDyadWrite,
|
||||
} from "../ipc/handlers/chat_stream_handlers";
|
||||
import fs from "node:fs";
|
||||
import git from "isomorphic-git";
|
||||
import { db } from "../db";
|
||||
import { cleanFullResponse } from "@/ipc/utils/cleanFullResponse";
|
||||
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 () => {
|
||||
@@ -43,14 +43,19 @@ vi.mock("node:fs", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock isomorphic-git
|
||||
vi.mock("isomorphic-git", () => ({
|
||||
default: {
|
||||
add: vi.fn().mockResolvedValue(undefined),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
commit: vi.fn().mockResolvedValue(undefined),
|
||||
statusMatrix: 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
|
||||
@@ -703,12 +708,12 @@ describe("processFullResponse", () => {
|
||||
"/mock/user/data/path/mock-app-path/src/file1.js",
|
||||
"console.log('Hello');",
|
||||
);
|
||||
expect(git.add).toHaveBeenCalledWith(
|
||||
expect(gitAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/file1.js",
|
||||
}),
|
||||
);
|
||||
expect(git.commit).toHaveBeenCalled();
|
||||
expect(gitCommit).toHaveBeenCalled();
|
||||
expect(result).toEqual({ updatedFiles: true });
|
||||
});
|
||||
|
||||
@@ -783,24 +788,24 @@ describe("processFullResponse", () => {
|
||||
);
|
||||
|
||||
// Verify git operations were called for each file
|
||||
expect(git.add).toHaveBeenCalledWith(
|
||||
expect(gitAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/file1.js",
|
||||
}),
|
||||
);
|
||||
expect(git.add).toHaveBeenCalledWith(
|
||||
expect(gitAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/utils/file2.js",
|
||||
}),
|
||||
);
|
||||
expect(git.add).toHaveBeenCalledWith(
|
||||
expect(gitAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/components/Button.tsx",
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify commit was called once after all files were added
|
||||
expect(git.commit).toHaveBeenCalledTimes(1);
|
||||
expect(gitCommit).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ updatedFiles: true });
|
||||
});
|
||||
|
||||
@@ -825,17 +830,17 @@ describe("processFullResponse", () => {
|
||||
"/mock/user/data/path/mock-app-path/src/components/OldComponent.jsx",
|
||||
"/mock/user/data/path/mock-app-path/src/components/NewComponent.jsx",
|
||||
);
|
||||
expect(git.add).toHaveBeenCalledWith(
|
||||
expect(gitAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/components/NewComponent.jsx",
|
||||
}),
|
||||
);
|
||||
expect(git.remove).toHaveBeenCalledWith(
|
||||
expect(gitRemove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/components/OldComponent.jsx",
|
||||
}),
|
||||
);
|
||||
expect(git.commit).toHaveBeenCalled();
|
||||
expect(gitCommit).toHaveBeenCalled();
|
||||
expect(result).toEqual({ updatedFiles: true });
|
||||
});
|
||||
|
||||
@@ -852,7 +857,7 @@ describe("processFullResponse", () => {
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalled();
|
||||
expect(fs.renameSync).not.toHaveBeenCalled();
|
||||
expect(git.commit).not.toHaveBeenCalled();
|
||||
expect(gitCommit).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
updatedFiles: false,
|
||||
extraFiles: undefined,
|
||||
@@ -875,12 +880,12 @@ describe("processFullResponse", () => {
|
||||
expect(fs.unlinkSync).toHaveBeenCalledWith(
|
||||
"/mock/user/data/path/mock-app-path/src/components/Unused.jsx",
|
||||
);
|
||||
expect(git.remove).toHaveBeenCalledWith(
|
||||
expect(gitRemove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filepath: "src/components/Unused.jsx",
|
||||
}),
|
||||
);
|
||||
expect(git.commit).toHaveBeenCalled();
|
||||
expect(gitCommit).toHaveBeenCalled();
|
||||
expect(result).toEqual({ updatedFiles: true });
|
||||
});
|
||||
|
||||
@@ -896,8 +901,8 @@ describe("processFullResponse", () => {
|
||||
});
|
||||
|
||||
expect(fs.unlinkSync).not.toHaveBeenCalled();
|
||||
expect(git.remove).not.toHaveBeenCalled();
|
||||
expect(git.commit).not.toHaveBeenCalled();
|
||||
expect(gitRemove).not.toHaveBeenCalled();
|
||||
expect(gitCommit).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
updatedFiles: false,
|
||||
extraFiles: undefined,
|
||||
@@ -942,11 +947,11 @@ describe("processFullResponse", () => {
|
||||
);
|
||||
|
||||
// Check git operations
|
||||
expect(git.add).toHaveBeenCalledTimes(2); // For the write and rename
|
||||
expect(git.remove).toHaveBeenCalledTimes(2); // For the rename and delete
|
||||
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(git.commit).toHaveBeenCalledWith(
|
||||
expect(gitCommit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(
|
||||
"wrote 1 file(s), renamed 1 file(s), deleted 1 file(s)",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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", () => {
|
||||
|
||||
@@ -59,6 +59,8 @@ describe("readSettings", () => {
|
||||
"enableProSmartFilesContextMode": true,
|
||||
"experiments": {},
|
||||
"hasRunBefore": false,
|
||||
"isRunning": false,
|
||||
"lastKnownPerformance": undefined,
|
||||
"providerSettings": {},
|
||||
"releaseChannel": "stable",
|
||||
"selectedChatMode": "build",
|
||||
@@ -305,6 +307,8 @@ describe("readSettings", () => {
|
||||
"enableProSmartFilesContextMode": true,
|
||||
"experiments": {},
|
||||
"hasRunBefore": false,
|
||||
"isRunning": false,
|
||||
"lastKnownPerformance": undefined,
|
||||
"providerSettings": {},
|
||||
"releaseChannel": "stable",
|
||||
"selectedChatMode": "build",
|
||||
|
||||
118
src/__tests__/style-utils.test.ts
Normal file
118
src/__tests__/style-utils.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
352
src/__tests__/supabase_utils.test.ts
Normal file
352
src/__tests__/supabase_utils.test.ts
Normal file
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message } from "@/ipc/ipc_types";
|
||||
import type { FileAttachment, Message } from "@/ipc/ipc_types";
|
||||
import { atom } from "jotai";
|
||||
import type { ChatSummary } from "@/lib/schemas";
|
||||
|
||||
@@ -20,3 +20,5 @@ export const chatsLoadingAtom = atom<boolean>(false);
|
||||
// Used for scrolling to the bottom of the chat messages (per chat)
|
||||
export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
|
||||
export const recentStreamChatIdsAtom = atom<Set<number>>(new Set<number>());
|
||||
|
||||
export const attachmentsAtom = atom<FileAttachment[]>([]);
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { ComponentSelection } from "@/ipc/ipc_types";
|
||||
import { ComponentSelection, VisualEditingChange } from "@/ipc/ipc_types";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const selectedComponentsPreviewAtom = atom<ComponentSelection[]>([]);
|
||||
|
||||
export const visualEditingSelectedComponentAtom =
|
||||
atom<ComponentSelection | null>(null);
|
||||
|
||||
export const currentComponentCoordinatesAtom = atom<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
export const previewIframeRefAtom = atom<HTMLIFrameElement | null>(null);
|
||||
|
||||
export const annotatorModeAtom = atom<boolean>(false);
|
||||
|
||||
export const screenshotDataUrlAtom = atom<string | null>(null);
|
||||
export const pendingVisualChangesAtom = atom<Map<string, VisualEditingChange>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { getAppPort } from "../../shared/ports";
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
@@ -29,7 +30,7 @@ export async function neonTemplateHook({
|
||||
},
|
||||
{
|
||||
key: "NEXT_PUBLIC_SERVER_URL",
|
||||
value: "http://localhost:32100",
|
||||
value: `http://localhost:${getAppPort(appId)}`,
|
||||
},
|
||||
{
|
||||
key: "GMAIL_USER",
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ChatModeSelector() {
|
||||
case "ask":
|
||||
return "Ask";
|
||||
case "agent":
|
||||
return "Agent";
|
||||
return "Build (MCP)";
|
||||
default:
|
||||
return "Build";
|
||||
}
|
||||
@@ -83,9 +83,9 @@ export function ChatModeSelector() {
|
||||
</SelectItem>
|
||||
<SelectItem value="agent">
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">Agent (experimental)</span>
|
||||
<span className="font-medium">Build with MCP (experimental)</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Agent can use tools (MCP) and generate code
|
||||
Like Build, but can use tools (MCP) to generate code
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
49
src/components/CopyErrorMessage.tsx
Normal file
49
src/components/CopyErrorMessage.tsx
Normal file
@@ -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 (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
|
||||
isCopied
|
||||
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300"
|
||||
: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
} ${className}`}
|
||||
title={isCopied ? "Copied!" : "Copy error message"}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check size={14} />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={14} />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ 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 { useSettings } from "@/hooks/useSettings";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { showError, showSuccess } from "@/lib/toast";
|
||||
|
||||
@@ -44,6 +45,7 @@ export function EditCustomModelDialog({
|
||||
const [description, setDescription] = useState("");
|
||||
const [maxOutputTokens, setMaxOutputTokens] = useState<string>("");
|
||||
const [contextWindow, setContextWindow] = useState<string>("");
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
|
||||
@@ -89,7 +91,22 @@ export function EditCustomModelDialog({
|
||||
// Then create the new model
|
||||
await ipcClient.createCustomLanguageModel(newParams);
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
if (
|
||||
settings?.selectedModel?.name === model?.apiName &&
|
||||
settings?.selectedModel?.provider === providerId
|
||||
) {
|
||||
const newModel = {
|
||||
...settings.selectedModel,
|
||||
name: apiName,
|
||||
};
|
||||
try {
|
||||
await updateSettings({ selectedModel: newModel });
|
||||
} catch {
|
||||
showError("Failed to update settings");
|
||||
return; // stop closing dialog
|
||||
}
|
||||
}
|
||||
showSuccess("Custom model updated successfully!");
|
||||
onSuccess();
|
||||
onClose();
|
||||
|
||||
128
src/components/ForceCloseDialog.tsx
Normal file
128
src/components/ForceCloseDialog.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
interface ForceCloseDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
performanceData?: {
|
||||
timestamp: number;
|
||||
memoryUsageMB: number;
|
||||
cpuUsagePercent?: number;
|
||||
systemMemoryUsageMB?: number;
|
||||
systemMemoryTotalMB?: number;
|
||||
systemCpuPercent?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function ForceCloseDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
performanceData,
|
||||
}: ForceCloseDialogProps) {
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<AlertDialogContent className="max-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||
<AlertDialogTitle>Force Close Detected</AlertDialogTitle>
|
||||
</div>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="text-base">
|
||||
The app was not closed properly the last time it was running.
|
||||
This could indicate a crash or unexpected termination.
|
||||
</div>
|
||||
|
||||
{performanceData && (
|
||||
<div className="rounded-lg border bg-muted/50 p-4 space-y-3">
|
||||
<div className="font-semibold text-sm text-foreground">
|
||||
Last Known State:{" "}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{formatTimestamp(performanceData.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{/* Process Metrics */}
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-foreground">
|
||||
Process Metrics
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Memory:</span>
|
||||
<span className="font-mono">
|
||||
{performanceData.memoryUsageMB} MB
|
||||
</span>
|
||||
</div>
|
||||
{performanceData.cpuUsagePercent !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">CPU:</span>
|
||||
<span className="font-mono">
|
||||
{performanceData.cpuUsagePercent}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Metrics */}
|
||||
{(performanceData.systemMemoryUsageMB !== undefined ||
|
||||
performanceData.systemCpuPercent !== undefined) && (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-foreground">
|
||||
System Metrics
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{performanceData.systemMemoryUsageMB !== undefined &&
|
||||
performanceData.systemMemoryTotalMB !==
|
||||
undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Memory:
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{performanceData.systemMemoryUsageMB} /{" "}
|
||||
{performanceData.systemMemoryTotalMB} MB
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{performanceData.systemCpuPercent !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
CPU:
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{performanceData.systemCpuPercent}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={onClose}>OK</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { showError } from "@/lib/toast";
|
||||
import { HelpBotDialog } from "./HelpBotDialog";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { BugScreenshotDialog } from "./BugScreenshotDialog";
|
||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
||||
|
||||
interface HelpDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -43,7 +44,7 @@ export function HelpDialog({ isOpen, onClose }: HelpDialogProps) {
|
||||
const [isBugScreenshotOpen, setIsBugScreenshotOpen] = useState(false);
|
||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
const { userBudget } = useUserBudgetInfo();
|
||||
const isDyadProUser = settings?.providerSettings?.["auto"]?.apiKey?.value;
|
||||
|
||||
// Function to reset all dialog state
|
||||
@@ -103,6 +104,7 @@ Issues that do not meet these requirements will be closed and may need to be res
|
||||
- Node Version: ${debugInfo.nodeVersion || "n/a"}
|
||||
- PNPM Version: ${debugInfo.pnpmVersion || "n/a"}
|
||||
- Node Path: ${debugInfo.nodePath || "n/a"}
|
||||
- Pro User ID: ${userBudget?.redactedUserId || "n/a"}
|
||||
- Telemetry ID: ${debugInfo.telemetryId || "n/a"}
|
||||
- Model: ${debugInfo.selectedLanguageModel || "n/a"}
|
||||
|
||||
@@ -226,6 +228,7 @@ Issues that do not meet these requirements will be closed and may need to be res
|
||||
-->
|
||||
|
||||
Session ID: ${sessionId}
|
||||
Pro User ID: ${userBudget?.redactedUserId || "n/a"}
|
||||
|
||||
## Issue Description (required)
|
||||
<!-- Please describe the issue you're experiencing -->
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ChevronsDownUp,
|
||||
ChartColumnIncreasing,
|
||||
SendHorizontalIcon,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -65,11 +66,16 @@ import { ChatErrorBox } from "./ChatErrorBox";
|
||||
import {
|
||||
selectedComponentsPreviewAtom,
|
||||
previewIframeRefAtom,
|
||||
visualEditingSelectedComponentAtom,
|
||||
currentComponentCoordinatesAtom,
|
||||
pendingVisualChangesAtom,
|
||||
} from "@/atoms/previewAtoms";
|
||||
import { SelectedComponentsDisplay } from "./SelectedComponentDisplay";
|
||||
import { useCheckProblems } from "@/hooks/useCheckProblems";
|
||||
import { LexicalChatInput } from "./LexicalChatInput";
|
||||
import { useChatModeToggle } from "@/hooks/useChatModeToggle";
|
||||
import { VisualEditingChangesDialog } from "@/components/preview_panel/VisualEditingChangesDialog";
|
||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
||||
|
||||
const showTokenBarAtom = atom(false);
|
||||
|
||||
@@ -92,7 +98,15 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
selectedComponentsPreviewAtom,
|
||||
);
|
||||
const previewIframeRef = useAtomValue(previewIframeRefAtom);
|
||||
const setVisualEditingSelectedComponent = useSetAtom(
|
||||
visualEditingSelectedComponentAtom,
|
||||
);
|
||||
const setCurrentComponentCoordinates = useSetAtom(
|
||||
currentComponentCoordinatesAtom,
|
||||
);
|
||||
const setPendingVisualChanges = useSetAtom(pendingVisualChangesAtom);
|
||||
const { checkProblems } = useCheckProblems(appId);
|
||||
const { refreshAppIframe } = useRunApp();
|
||||
// Use the attachments hook
|
||||
const {
|
||||
attachments,
|
||||
@@ -124,6 +138,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
proposal.type === "code-proposal" &&
|
||||
messageId === lastMessage.id;
|
||||
|
||||
const { userBudget } = useUserBudgetInfo();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setShowError(true);
|
||||
@@ -160,7 +176,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
? selectedComponents
|
||||
: [];
|
||||
setSelectedComponents([]);
|
||||
|
||||
setVisualEditingSelectedComponent(null);
|
||||
// Clear overlays in the preview iframe
|
||||
if (previewIframeRef?.contentWindow) {
|
||||
previewIframeRef.contentWindow.postMessage(
|
||||
@@ -307,6 +323,58 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{userBudget ? (
|
||||
<VisualEditingChangesDialog
|
||||
iframeRef={
|
||||
previewIframeRef
|
||||
? { current: previewIframeRef }
|
||||
: { current: null }
|
||||
}
|
||||
onReset={() => {
|
||||
// Exit component selection mode and visual editing
|
||||
setSelectedComponents([]);
|
||||
setVisualEditingSelectedComponent(null);
|
||||
setCurrentComponentCoordinates(null);
|
||||
setPendingVisualChanges(new Map());
|
||||
refreshAppIframe();
|
||||
|
||||
// Deactivate component selector in iframe
|
||||
if (previewIframeRef?.contentWindow) {
|
||||
previewIframeRef.contentWindow.postMessage(
|
||||
{ type: "deactivate-dyad-component-selector" },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
selectedComponents.length > 0 && (
|
||||
<div className="border-b border-border p-3 bg-muted/30">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
"https://dyad.sh/pro",
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
<Lock size={16} />
|
||||
<span className="font-medium">Visual editor (Pro)</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Visual editing lets you make UI changes without AI and is
|
||||
a Pro-only feature
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<SelectedComponentsDisplay />
|
||||
|
||||
{/* Use the AttachmentsList component */}
|
||||
|
||||
@@ -1,16 +1,78 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
memo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { isInlineCode, useShikiHighlighter } from "react-shiki";
|
||||
import github from "@shikijs/themes/github-light-default";
|
||||
import githubDark from "@shikijs/themes/github-dark-default";
|
||||
import React, { useState, useEffect, memo, type ReactNode } from "react";
|
||||
import ShikiHighlighter, {
|
||||
isInlineCode,
|
||||
createHighlighterCore,
|
||||
createJavaScriptRegexEngine,
|
||||
} from "react-shiki/core";
|
||||
import type { Element as HastElement } from "hast";
|
||||
import { useTheme } from "../../contexts/ThemeContext";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import github from "@shikijs/themes/github-light-default";
|
||||
import githubDark from "@shikijs/themes/github-dark-default";
|
||||
// common languages
|
||||
import astro from "@shikijs/langs/astro";
|
||||
import css from "@shikijs/langs/css";
|
||||
import graphql from "@shikijs/langs/graphql";
|
||||
import html from "@shikijs/langs/html";
|
||||
import java from "@shikijs/langs/java";
|
||||
import javascript from "@shikijs/langs/javascript";
|
||||
import json from "@shikijs/langs/json";
|
||||
import jsx from "@shikijs/langs/jsx";
|
||||
import less from "@shikijs/langs/less";
|
||||
import markdown from "@shikijs/langs/markdown";
|
||||
import python from "@shikijs/langs/python";
|
||||
import sass from "@shikijs/langs/sass";
|
||||
import scss from "@shikijs/langs/scss";
|
||||
import shell from "@shikijs/langs/shell";
|
||||
import sql from "@shikijs/langs/sql";
|
||||
import tsx from "@shikijs/langs/tsx";
|
||||
import typescript from "@shikijs/langs/typescript";
|
||||
import vue from "@shikijs/langs/vue";
|
||||
|
||||
type HighlighterCore = Awaited<ReturnType<typeof createHighlighterCore>>;
|
||||
|
||||
// Create a singleton highlighter instance
|
||||
let highlighterPromise: Promise<HighlighterCore> | null = null;
|
||||
|
||||
function getHighlighter(): Promise<HighlighterCore> {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighterCore({
|
||||
themes: [github, githubDark],
|
||||
langs: [
|
||||
astro,
|
||||
css,
|
||||
graphql,
|
||||
html,
|
||||
java,
|
||||
javascript,
|
||||
json,
|
||||
jsx,
|
||||
less,
|
||||
markdown,
|
||||
python,
|
||||
sass,
|
||||
scss,
|
||||
shell,
|
||||
sql,
|
||||
tsx,
|
||||
typescript,
|
||||
vue,
|
||||
],
|
||||
engine: createJavaScriptRegexEngine(),
|
||||
});
|
||||
}
|
||||
return highlighterPromise as Promise<HighlighterCore>;
|
||||
}
|
||||
|
||||
function useHighlighter() {
|
||||
const [highlighter, setHighlighter] = useState<HighlighterCore>();
|
||||
|
||||
useEffect(() => {
|
||||
getHighlighter().then(setHighlighter);
|
||||
}, []);
|
||||
|
||||
return highlighter;
|
||||
}
|
||||
|
||||
interface CodeHighlightProps {
|
||||
className?: string | undefined;
|
||||
@@ -32,29 +94,8 @@ export const CodeHighlight = memo(
|
||||
};
|
||||
|
||||
const { isDarkMode } = useTheme();
|
||||
const highlighter = useHighlighter();
|
||||
|
||||
// Cache for the highlighted code
|
||||
const highlightedCodeCache = useRef<ReactNode | null>(null);
|
||||
|
||||
// Only update the highlighted code if the inputs change
|
||||
const highlightedCode = useShikiHighlighter(
|
||||
code,
|
||||
language,
|
||||
isDarkMode ? githubDark : github,
|
||||
{
|
||||
delay: 150,
|
||||
},
|
||||
);
|
||||
|
||||
// Update the cache whenever we get a new highlighted code
|
||||
useEffect(() => {
|
||||
if (highlightedCode) {
|
||||
highlightedCodeCache.current = highlightedCode;
|
||||
}
|
||||
}, [highlightedCode]);
|
||||
|
||||
// Use the cached version during transitions to prevent flickering
|
||||
const displayedCode = highlightedCode || highlightedCodeCache.current;
|
||||
return !isInline ? (
|
||||
<div
|
||||
className="shiki not-prose relative [&_pre]:overflow-auto
|
||||
@@ -77,7 +118,20 @@ export const CodeHighlight = memo(
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{displayedCode}
|
||||
{highlighter ? (
|
||||
<ShikiHighlighter
|
||||
highlighter={highlighter}
|
||||
language={language}
|
||||
theme={isDarkMode ? "github-dark-default" : "github-light-default"}
|
||||
delay={150}
|
||||
>
|
||||
{code}
|
||||
</ShikiHighlighter>
|
||||
) : (
|
||||
<pre>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { CopyErrorMessage } from "@/components/CopyErrorMessage";
|
||||
interface DyadOutputProps {
|
||||
type: "error" | "warning";
|
||||
message?: string;
|
||||
@@ -59,19 +60,6 @@ export const DyadOutput: React.FC<DyadOutputProps> = ({
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
|
||||
{/* Fix with AI button - always visible for errors */}
|
||||
{isError && message && (
|
||||
<div className="absolute top-9 left-2">
|
||||
<button
|
||||
onClick={handleAIFix}
|
||||
className="cursor-pointer flex items-center justify-center bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white rounded text-xs p-1 w-24 h-6"
|
||||
>
|
||||
<Sparkles size={16} className="mr-1" />
|
||||
<span>Fix with AI</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content, padded to avoid label */}
|
||||
<div className="flex items-center justify-between pl-24 pr-6">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -103,6 +91,22 @@ export const DyadOutput: React.FC<DyadOutputProps> = ({
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons at the bottom - always visible for errors */}
|
||||
{isError && message && (
|
||||
<div className="mt-3 px-6 flex justify-end gap-2">
|
||||
<CopyErrorMessage
|
||||
errorMessage={children ? `${message}\n${children}` : message}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAIFix}
|
||||
className="cursor-pointer flex items-center justify-center bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white rounded text-xs px-2 py-1 h-6"
|
||||
>
|
||||
<Sparkles size={14} className="mr-1" />
|
||||
<span>Fix with AI</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
selectedComponentsPreviewAtom,
|
||||
previewIframeRefAtom,
|
||||
visualEditingSelectedComponentAtom,
|
||||
} from "@/atoms/previewAtoms";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { Code2, X } from "lucide-react";
|
||||
|
||||
export function SelectedComponentsDisplay() {
|
||||
@@ -10,11 +11,15 @@ export function SelectedComponentsDisplay() {
|
||||
selectedComponentsPreviewAtom,
|
||||
);
|
||||
const previewIframeRef = useAtomValue(previewIframeRefAtom);
|
||||
const setVisualEditingSelectedComponent = useSetAtom(
|
||||
visualEditingSelectedComponentAtom,
|
||||
);
|
||||
|
||||
const handleRemoveComponent = (index: number) => {
|
||||
const componentToRemove = selectedComponents[index];
|
||||
const newComponents = selectedComponents.filter((_, i) => i !== index);
|
||||
setSelectedComponents(newComponents);
|
||||
setVisualEditingSelectedComponent(null);
|
||||
|
||||
// Remove the specific overlay from the iframe
|
||||
if (previewIframeRef?.contentWindow) {
|
||||
@@ -30,7 +35,7 @@ export function SelectedComponentsDisplay() {
|
||||
|
||||
const handleClearAll = () => {
|
||||
setSelectedComponents([]);
|
||||
|
||||
setVisualEditingSelectedComponent(null);
|
||||
if (previewIframeRef?.contentWindow) {
|
||||
previewIframeRef.contentWindow.postMessage(
|
||||
{ type: "clear-dyad-component-overlays" },
|
||||
|
||||
@@ -1,40 +1,6 @@
|
||||
import { editor } from "monaco-editor";
|
||||
|
||||
import type { editor } from "monaco-editor";
|
||||
import { loader } from "@monaco-editor/react";
|
||||
|
||||
import * as monaco from "monaco-editor";
|
||||
// @ts-ignore
|
||||
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||
// @ts-ignore
|
||||
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
|
||||
// @ts-ignore
|
||||
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
|
||||
// @ts-ignore
|
||||
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
|
||||
// @ts-ignore
|
||||
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_, label) {
|
||||
if (label === "json") {
|
||||
return new jsonWorker();
|
||||
}
|
||||
if (label === "css" || label === "scss" || label === "less") {
|
||||
return new cssWorker();
|
||||
}
|
||||
if (label === "html" || label === "handlebars" || label === "razor") {
|
||||
return new htmlWorker();
|
||||
}
|
||||
if (label === "typescript" || label === "javascript") {
|
||||
return new tsWorker();
|
||||
}
|
||||
return new editorWorker();
|
||||
},
|
||||
};
|
||||
|
||||
loader.config({ monaco });
|
||||
|
||||
// loader.init().then(/* ... */);
|
||||
export const customLight: editor.IStandaloneThemeData = {
|
||||
base: "vs",
|
||||
inherit: false,
|
||||
@@ -106,8 +72,6 @@ export const customLight: editor.IStandaloneThemeData = {
|
||||
},
|
||||
};
|
||||
|
||||
editor.defineTheme("dyad-light", customLight);
|
||||
|
||||
export const customDark: editor.IStandaloneThemeData = {
|
||||
base: "vs-dark",
|
||||
inherit: false,
|
||||
@@ -178,12 +142,15 @@ export const customDark: editor.IStandaloneThemeData = {
|
||||
},
|
||||
};
|
||||
|
||||
editor.defineTheme("dyad-dark", customDark);
|
||||
loader.init().then((monaco) => {
|
||||
monaco.editor.defineTheme("dyad-light", customLight);
|
||||
monaco.editor.defineTheme("dyad-dark", customDark);
|
||||
|
||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
jsx: monaco.languages.typescript.JsxEmit.React, // Enable JSX
|
||||
});
|
||||
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
|
||||
// Too noisy because we don't have the full TS environment.
|
||||
noSemanticValidation: true,
|
||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
jsx: monaco.languages.typescript.JsxEmit.React, // Enable JSX
|
||||
});
|
||||
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
|
||||
// Too noisy because we don't have the full TS environment.
|
||||
noSemanticValidation: true,
|
||||
});
|
||||
});
|
||||
|
||||
53
src/components/preview_panel/AnnotatorOnlyForPro.tsx
Normal file
53
src/components/preview_panel/AnnotatorOnlyForPro.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Lock, ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
interface AnnotatorOnlyForProProps {
|
||||
onGoBack: () => void;
|
||||
}
|
||||
|
||||
export const AnnotatorOnlyForPro = ({ onGoBack }: AnnotatorOnlyForProProps) => {
|
||||
const handleGetPro = () => {
|
||||
IpcClient.getInstance().openExternalUrl("https://dyad.sh/pro");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-background relative">
|
||||
{/* Go Back Button */}
|
||||
<button
|
||||
onClick={onGoBack}
|
||||
className="absolute top-4 left-4 p-2 hover:bg-accent rounded-md transition-all z-10 group"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft
|
||||
size={20}
|
||||
className="text-foreground/70 group-hover:text-foreground transition-colors"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Centered Content */}
|
||||
<div className="flex flex-col items-center justify-center h-full px-8">
|
||||
{/* Lock Icon */}
|
||||
<Lock size={72} className="text-primary/60 dark:text-primary/70 mb-8" />
|
||||
|
||||
{/* Message */}
|
||||
<h2 className="text-3xl font-semibold text-foreground mb-4 text-center">
|
||||
Annotator is a Pro Feature
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-10 text-center max-w-md text-base leading-relaxed">
|
||||
Unlock the ability to annotate screenshots and enhance your workflow
|
||||
with Dyad Pro.
|
||||
</p>
|
||||
|
||||
{/* Get Pro Button */}
|
||||
<Button
|
||||
onClick={handleGetPro}
|
||||
size="lg"
|
||||
className="px-8 shadow-md hover:shadow-lg transition-all"
|
||||
>
|
||||
Get Dyad Pro
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
214
src/components/preview_panel/AnnotatorToolbar.tsx
Normal file
214
src/components/preview_panel/AnnotatorToolbar.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
MousePointer2,
|
||||
Pencil,
|
||||
Type,
|
||||
Trash2,
|
||||
Undo,
|
||||
Redo,
|
||||
Check,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ToolbarColorPicker } from "./ToolbarColorPicker";
|
||||
|
||||
interface AnnotatorToolbarProps {
|
||||
tool: "select" | "draw" | "text";
|
||||
color: string;
|
||||
selectedId: string | null;
|
||||
historyStep: number;
|
||||
historyLength: number;
|
||||
onToolChange: (tool: "select" | "draw" | "text") => void;
|
||||
onColorChange: (color: string) => void;
|
||||
onDelete: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onSubmit: () => void;
|
||||
onDeactivate: () => void;
|
||||
hasSubmitHandler: boolean;
|
||||
}
|
||||
|
||||
export const AnnotatorToolbar = ({
|
||||
tool,
|
||||
color,
|
||||
selectedId,
|
||||
historyStep,
|
||||
historyLength,
|
||||
onToolChange,
|
||||
onColorChange,
|
||||
onDelete,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onSubmit,
|
||||
onDeactivate,
|
||||
hasSubmitHandler,
|
||||
}: AnnotatorToolbarProps) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-2 border-b space-x-2">
|
||||
<TooltipProvider>
|
||||
{/* Tool Selection Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onToolChange("select")}
|
||||
aria-label="Select"
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors duration-200",
|
||||
tool === "select"
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900",
|
||||
)}
|
||||
>
|
||||
<MousePointer2 size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Select</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onToolChange("draw")}
|
||||
aria-label="Draw"
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors duration-200",
|
||||
tool === "draw"
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900",
|
||||
)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Draw</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onToolChange("text")}
|
||||
aria-label="Text"
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors duration-200",
|
||||
tool === "text"
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: "text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900",
|
||||
)}
|
||||
>
|
||||
<Type size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Text</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="p-1 rounded transition-colors duration-200 hover:bg-purple-200 dark:hover:bg-purple-900">
|
||||
<ToolbarColorPicker color={color} onChange={onColorChange} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Color</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px bg-gray-200 dark:bg-gray-700 h-4" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
aria-label="Delete"
|
||||
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!selectedId}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete Selected</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px bg-gray-200 dark:bg-gray-700 h-4" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onUndo}
|
||||
aria-label="Undo"
|
||||
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={historyStep === 0}
|
||||
>
|
||||
<Undo size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Undo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onRedo}
|
||||
aria-label="Redo"
|
||||
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={historyStep === historyLength - 1}
|
||||
>
|
||||
<Redo size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Redo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px bg-gray-200 dark:bg-gray-700 h-4" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
aria-label="Add to Chat"
|
||||
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!hasSubmitHandler}
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Add to Chat</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onDeactivate}
|
||||
aria-label="Close Annotator"
|
||||
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Close Annotator</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
176
src/components/preview_panel/DraggableTextInput.tsx
Normal file
176
src/components/preview_panel/DraggableTextInput.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface DraggableTextInputProps {
|
||||
input: {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
adjustedX: number;
|
||||
adjustedY: number;
|
||||
value: string;
|
||||
};
|
||||
index: number;
|
||||
totalInputs: number;
|
||||
scale: number;
|
||||
onMove: (
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
adjustedX: number,
|
||||
adjustedY: number,
|
||||
) => void;
|
||||
onChange: (id: string, value: string) => void;
|
||||
onKeyDown: (id: string, e: React.KeyboardEvent, index: number) => void;
|
||||
onRemove: (id: string) => void;
|
||||
spanRef: React.MutableRefObject<HTMLSpanElement[]>;
|
||||
inputRef: React.MutableRefObject<HTMLInputElement[]>;
|
||||
color: string;
|
||||
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const DraggableTextInput = ({
|
||||
input,
|
||||
index,
|
||||
totalInputs,
|
||||
scale,
|
||||
onMove,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onRemove,
|
||||
spanRef,
|
||||
inputRef,
|
||||
color,
|
||||
containerRef,
|
||||
}: DraggableTextInputProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDragging && containerRef?.current && elementRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const elementRect = elementRef.current.getBoundingClientRect();
|
||||
|
||||
let newX = e.clientX - dragOffset.current.x;
|
||||
let newY = e.clientY - dragOffset.current.y;
|
||||
|
||||
// Constrain within container bounds
|
||||
newX = Math.max(
|
||||
0,
|
||||
Math.min(newX, containerRect.width - elementRect.width),
|
||||
);
|
||||
newY = Math.max(
|
||||
0,
|
||||
Math.min(newY, containerRect.height - elementRect.height),
|
||||
);
|
||||
|
||||
// Calculate adjusted coordinates for the canvas
|
||||
const adjustedX = newX / scale;
|
||||
const adjustedY = newY / scale;
|
||||
onMove(input.id, newX, newY, adjustedX, adjustedY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, input.id, onMove, scale, containerRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elementRef}
|
||||
className="absolute z-[999]"
|
||||
style={{
|
||||
left: `${input.x}px`,
|
||||
top: `${input.y}px`,
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 cursor-move p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors z-10"
|
||||
onMouseDown={(e) => {
|
||||
setIsDragging(true);
|
||||
dragOffset.current = {
|
||||
x: e.clientX - input.x,
|
||||
y: e.clientY - input.y,
|
||||
};
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
title="Drag to move"
|
||||
>
|
||||
{/* Grip dots icon - smaller and more subtle */}
|
||||
<svg
|
||||
width="8"
|
||||
height="12"
|
||||
viewBox="0 0 8 12"
|
||||
fill="currentColor"
|
||||
className="text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<circle cx="2" cy="2" r="1" />
|
||||
<circle cx="6" cy="2" r="1" />
|
||||
<circle cx="2" cy="6" r="1" />
|
||||
<circle cx="6" cy="6" r="1" />
|
||||
<circle cx="2" cy="10" r="1" />
|
||||
<circle cx="6" cy="10" r="1" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span
|
||||
ref={(e) => {
|
||||
if (e) spanRef.current[index] = e;
|
||||
}}
|
||||
className="
|
||||
absolute
|
||||
invisible
|
||||
whitespace-pre
|
||||
text-base
|
||||
font-normal
|
||||
"
|
||||
></span>
|
||||
<input
|
||||
autoFocus={index === totalInputs - 1}
|
||||
type="text"
|
||||
value={input.value}
|
||||
onChange={(e) => onChange(input.id, e.target.value)}
|
||||
onKeyDown={(e) => onKeyDown(input.id, e, index)}
|
||||
className="pl-8 pr-8 py-2 bg-[var(--background)] border-2 rounded-md shadow-lg text-gray-900 dark:text-gray-100 focus:outline-none min-w-[200px] cursor-text"
|
||||
style={{ borderColor: color }}
|
||||
placeholder="Type text..."
|
||||
ref={(e) => {
|
||||
if (e) inputRef.current[index] = e;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Close Button - Rightmost */}
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors z-10 group"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemove(input.id);
|
||||
}}
|
||||
title="Remove text input"
|
||||
type="button"
|
||||
>
|
||||
<X className="w-3 h-3 text-gray-400 dark:text-gray-500 group-hover:text-red-600 dark:group-hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,8 +23,10 @@ import {
|
||||
Monitor,
|
||||
Tablet,
|
||||
Smartphone,
|
||||
Pen,
|
||||
} from "lucide-react";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { CopyErrorMessage } from "@/components/CopyErrorMessage";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
import { useParseRouter } from "@/hooks/useParseRouter";
|
||||
@@ -37,7 +39,12 @@ import {
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import {
|
||||
selectedComponentsPreviewAtom,
|
||||
visualEditingSelectedComponentAtom,
|
||||
currentComponentCoordinatesAtom,
|
||||
previewIframeRefAtom,
|
||||
annotatorModeAtom,
|
||||
screenshotDataUrlAtom,
|
||||
pendingVisualChangesAtom,
|
||||
} from "@/atoms/previewAtoms";
|
||||
import { ComponentSelection } from "@/ipc/ipc_types";
|
||||
import {
|
||||
@@ -56,6 +63,12 @@ import { useRunApp } from "@/hooks/useRunApp";
|
||||
import { useShortcut } from "@/hooks/useShortcut";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizePath } from "../../../shared/normalizePath";
|
||||
import { showError } from "@/lib/toast";
|
||||
import { AnnotatorOnlyForPro } from "./AnnotatorOnlyForPro";
|
||||
import { useAttachments } from "@/hooks/useAttachments";
|
||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
||||
import { Annotator } from "@/pro/ui/components/Annotator/Annotator";
|
||||
import { VisualEditingToolbar } from "./VisualEditingToolbar";
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: { message: string; source: "preview-app" | "dyad-app" } | undefined;
|
||||
@@ -136,13 +149,14 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Fix button at the bottom */}
|
||||
{/* Action buttons at the bottom */}
|
||||
{!isDockerError && error.source === "preview-app" && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<div className="mt-3 px-6 flex justify-end gap-2">
|
||||
<CopyErrorMessage errorMessage={error.message} />
|
||||
<button
|
||||
disabled={isStreaming}
|
||||
onClick={onAIFix}
|
||||
className="cursor-pointer flex items-center space-x-1 px-2 py-0.5 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="cursor-pointer flex items-center space-x-1 px-2 py-1 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Fix error with AI</span>
|
||||
@@ -165,6 +179,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
const { streamMessage } = useStreamChat();
|
||||
const { routes: availableRoutes } = useParseRouter(selectedAppId);
|
||||
const { restartApp } = useRunApp();
|
||||
const { userBudget } = useUserBudgetInfo();
|
||||
const isProMode = !!userBudget;
|
||||
|
||||
// Navigation state
|
||||
const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] =
|
||||
@@ -173,12 +189,28 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
const [canGoForward, setCanGoForward] = useState(false);
|
||||
const [navigationHistory, setNavigationHistory] = useState<string[]>([]);
|
||||
const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0);
|
||||
const [selectedComponentsPreview, setSelectedComponentsPreview] = useAtom(
|
||||
const setSelectedComponentsPreview = useSetAtom(
|
||||
selectedComponentsPreviewAtom,
|
||||
);
|
||||
const [visualEditingSelectedComponent, setVisualEditingSelectedComponent] =
|
||||
useAtom(visualEditingSelectedComponentAtom);
|
||||
const setCurrentComponentCoordinates = useSetAtom(
|
||||
currentComponentCoordinatesAtom,
|
||||
);
|
||||
const setPreviewIframeRef = useSetAtom(previewIframeRefAtom);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [isPicking, setIsPicking] = useState(false);
|
||||
const [annotatorMode, setAnnotatorMode] = useAtom(annotatorModeAtom);
|
||||
const [screenshotDataUrl, setScreenshotDataUrl] = useAtom(
|
||||
screenshotDataUrlAtom,
|
||||
);
|
||||
|
||||
const { addAttachments } = useAttachments();
|
||||
const setPendingChanges = useSetAtom(pendingVisualChangesAtom);
|
||||
|
||||
// AST Analysis State
|
||||
const [isDynamicComponent, setIsDynamicComponent] = useState(false);
|
||||
const [hasStaticText, setHasStaticText] = useState(false);
|
||||
|
||||
// Device mode state
|
||||
type DeviceMode = "desktop" | "tablet" | "mobile";
|
||||
@@ -194,23 +226,117 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
//detect if the user is using Mac
|
||||
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||
|
||||
const analyzeComponent = async (componentId: string) => {
|
||||
if (!componentId || !selectedAppId) return;
|
||||
|
||||
try {
|
||||
const result = await IpcClient.getInstance().analyzeComponent({
|
||||
appId: selectedAppId,
|
||||
componentId,
|
||||
});
|
||||
setIsDynamicComponent(result.isDynamic);
|
||||
setHasStaticText(result.hasStaticText);
|
||||
|
||||
// Automatically enable text editing if component has static text
|
||||
if (result.hasStaticText && iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "enable-dyad-text-editing",
|
||||
data: {
|
||||
componentId: componentId,
|
||||
runtimeId: visualEditingSelectedComponent?.runtimeId,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to analyze component", err);
|
||||
setIsDynamicComponent(false);
|
||||
setHasStaticText(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextUpdated = async (data: any) => {
|
||||
const { componentId, text } = data;
|
||||
if (!componentId || !selectedAppId) return;
|
||||
|
||||
// Parse componentId to extract file path and line number
|
||||
const [filePath, lineStr] = componentId.split(":");
|
||||
const lineNumber = parseInt(lineStr, 10);
|
||||
|
||||
if (!filePath || isNaN(lineNumber)) {
|
||||
console.error("Invalid componentId format:", componentId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store text change in pending changes
|
||||
setPendingChanges((prev) => {
|
||||
const updated = new Map(prev);
|
||||
const existing = updated.get(componentId);
|
||||
|
||||
updated.set(componentId, {
|
||||
componentId: componentId,
|
||||
componentName:
|
||||
existing?.componentName || visualEditingSelectedComponent?.name || "",
|
||||
relativePath: filePath,
|
||||
lineNumber: lineNumber,
|
||||
styles: existing?.styles || {},
|
||||
textContent: text,
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Function to get current styles from selected element
|
||||
const getCurrentElementStyles = () => {
|
||||
if (!iframeRef.current?.contentWindow || !visualEditingSelectedComponent)
|
||||
return;
|
||||
|
||||
try {
|
||||
// Send message to iframe to get current styles
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "get-dyad-component-styles",
|
||||
data: {
|
||||
elementId: visualEditingSelectedComponent.id,
|
||||
runtimeId: visualEditingSelectedComponent.runtimeId,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to get element styles:", error);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
setAnnotatorMode(false);
|
||||
}, []);
|
||||
// Reset visual editing state when app changes or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup on unmount or when app changes
|
||||
setVisualEditingSelectedComponent(null);
|
||||
setPendingChanges(new Map());
|
||||
setCurrentComponentCoordinates(null);
|
||||
};
|
||||
}, [selectedAppId]);
|
||||
|
||||
// Update iframe ref atom
|
||||
useEffect(() => {
|
||||
setPreviewIframeRef(iframeRef.current);
|
||||
}, [iframeRef.current, setPreviewIframeRef]);
|
||||
|
||||
// Deactivate component selector when selection is cleared
|
||||
// Send pro mode status to iframe
|
||||
useEffect(() => {
|
||||
if (!selectedComponentsPreview || selectedComponentsPreview.length === 0) {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ type: "deactivate-dyad-component-selector" },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
setIsPicking(false);
|
||||
if (iframeRef.current?.contentWindow && isComponentSelectorInitialized) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ type: "dyad-pro-mode", enabled: isProMode },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
}, [selectedComponentsPreview]);
|
||||
}, [isProMode, isComponentSelectorInitialized]);
|
||||
|
||||
// Add message listener for iframe errors and navigation events
|
||||
useEffect(() => {
|
||||
@@ -222,41 +348,102 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
|
||||
if (event.data?.type === "dyad-component-selector-initialized") {
|
||||
setIsComponentSelectorInitialized(true);
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: "dyad-pro-mode", enabled: isProMode },
|
||||
"*",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-text-updated") {
|
||||
handleTextUpdated(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-text-finalized") {
|
||||
handleTextUpdated(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-component-selected") {
|
||||
console.log("Component picked:", event.data);
|
||||
|
||||
// Parse the single selected component
|
||||
const component = event.data.component
|
||||
? parseComponentSelection({
|
||||
type: "dyad-component-selected",
|
||||
id: event.data.component.id,
|
||||
name: event.data.component.name,
|
||||
})
|
||||
: null;
|
||||
const component = parseComponentSelection(event.data);
|
||||
|
||||
if (!component) return;
|
||||
|
||||
// Add to existing components, avoiding duplicates by id
|
||||
// Store the coordinates
|
||||
if (event.data.coordinates && isProMode) {
|
||||
setCurrentComponentCoordinates(event.data.coordinates);
|
||||
}
|
||||
|
||||
// Add to selected components if not already there
|
||||
setSelectedComponentsPreview((prev) => {
|
||||
// Check if this component is already selected
|
||||
if (prev.some((c) => c.id === component.id)) {
|
||||
const exists = prev.some((c) => {
|
||||
// Check by runtimeId if available otherwise by id
|
||||
// Stored components may have lost their runtimeId after re-renders or reloading the page
|
||||
if (component.runtimeId && c.runtimeId) {
|
||||
return c.runtimeId === component.runtimeId;
|
||||
}
|
||||
return c.id === component.id;
|
||||
});
|
||||
if (exists) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, component];
|
||||
});
|
||||
|
||||
if (isProMode) {
|
||||
// Set as the highlighted component for visual editing
|
||||
setVisualEditingSelectedComponent(component);
|
||||
// Trigger AST analysis
|
||||
analyzeComponent(component.id);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-component-deselected") {
|
||||
const componentId = event.data.componentId;
|
||||
if (componentId) {
|
||||
// Disable text editing for the deselected component
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "disable-dyad-text-editing",
|
||||
data: { componentId },
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedComponentsPreview((prev) =>
|
||||
prev.filter((c) => c.id !== componentId),
|
||||
);
|
||||
setVisualEditingSelectedComponent((prev) => {
|
||||
const shouldClear = prev?.id === componentId;
|
||||
if (shouldClear) {
|
||||
setCurrentComponentCoordinates(null);
|
||||
}
|
||||
return shouldClear ? null : prev;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-component-coordinates-updated") {
|
||||
if (event.data.coordinates) {
|
||||
setCurrentComponentCoordinates(event.data.coordinates);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-screenshot-response") {
|
||||
if (event.data.success && event.data.dataUrl) {
|
||||
setScreenshotDataUrl(event.data.dataUrl);
|
||||
setAnnotatorMode(true);
|
||||
} else {
|
||||
showError(event.data.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -346,6 +533,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
setErrorMessage,
|
||||
setIsComponentSelectorInitialized,
|
||||
setSelectedComponentsPreview,
|
||||
setVisualEditingSelectedComponent,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -364,11 +552,26 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
}
|
||||
}, [appUrl]);
|
||||
|
||||
// Get current styles when component is selected for visual editing
|
||||
useEffect(() => {
|
||||
if (visualEditingSelectedComponent) {
|
||||
getCurrentElementStyles();
|
||||
}
|
||||
}, [visualEditingSelectedComponent]);
|
||||
|
||||
// Function to activate component selector in the iframe
|
||||
const handleActivateComponentSelector = () => {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
const newIsPicking = !isPicking;
|
||||
if (!newIsPicking) {
|
||||
// Clean up any text editing states when deactivating
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ type: "cleanup-all-text-editing" },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
setIsPicking(newIsPicking);
|
||||
setVisualEditingSelectedComponent(null);
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: newIsPicking
|
||||
@@ -380,6 +583,22 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Function to handle annotator button click
|
||||
const handleAnnotatorClick = () => {
|
||||
if (annotatorMode) {
|
||||
setAnnotatorMode(false);
|
||||
return;
|
||||
}
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "dyad-take-screenshot",
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Activate component selector using a shortcut
|
||||
useShortcut(
|
||||
"c",
|
||||
@@ -431,6 +650,10 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
const handleReload = () => {
|
||||
setReloadKey((prevKey) => prevKey + 1);
|
||||
setErrorMessage(undefined);
|
||||
// Reset visual editing state
|
||||
setVisualEditingSelectedComponent(null);
|
||||
setPendingChanges(new Map());
|
||||
setCurrentComponentCoordinates(null);
|
||||
// Optionally, add logic here if you need to explicitly stop/start the app again
|
||||
// For now, just changing the key should remount the iframe
|
||||
console.debug("Reloading iframe preview for app", selectedAppId);
|
||||
@@ -493,203 +716,239 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Browser-style header */}
|
||||
<div className="flex items-center p-2 border-b space-x-2 ">
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleActivateComponentSelector}
|
||||
className={`p-1 rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isPicking
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
|
||||
}`}
|
||||
disabled={
|
||||
loading || !selectedAppId || !isComponentSelectorInitialized
|
||||
}
|
||||
data-testid="preview-pick-element-button"
|
||||
>
|
||||
<MousePointerClick size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isPicking
|
||||
? "Deactivate component selector"
|
||||
: "Select component"}
|
||||
</p>
|
||||
<p>{isMac ? "⌘ + ⇧ + C" : "Ctrl + ⇧ + C"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoBack || loading || !selectedAppId}
|
||||
onClick={handleNavigateBack}
|
||||
data-testid="preview-navigate-back-button"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoForward || loading || !selectedAppId}
|
||||
onClick={handleNavigateForward}
|
||||
data-testid="preview-navigate-forward-button"
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReload}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={loading || !selectedAppId}
|
||||
data-testid="preview-refresh-button"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */}
|
||||
<div className="relative flex-grow min-w-20">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm text-gray-700 dark:text-gray-200 cursor-pointer w-full min-w-0">
|
||||
<span className="truncate flex-1 mr-2 min-w-0">
|
||||
{navigationHistory[currentHistoryPosition]
|
||||
? new URL(navigationHistory[currentHistoryPosition])
|
||||
.pathname
|
||||
: "/"}
|
||||
</span>
|
||||
<ChevronDown size={14} className="flex-shrink-0" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
{availableRoutes.length > 0 ? (
|
||||
availableRoutes.map((route) => (
|
||||
<DropdownMenuItem
|
||||
key={route.path}
|
||||
onClick={() => navigateToRoute(route.path)}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<span>{route.label}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{route.path}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>Loading routes...</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="flex items-center space-x-1 px-3 py-1 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
|
||||
title="Restart App"
|
||||
>
|
||||
<Power size={16} />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
<button
|
||||
data-testid="preview-open-browser-button"
|
||||
onClick={() => {
|
||||
if (originalUrl) {
|
||||
IpcClient.getInstance().openExternalUrl(originalUrl);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
|
||||
{/* Device Mode Button */}
|
||||
<Popover open={isDevicePopoverOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
data-testid="device-mode-button"
|
||||
onClick={() => {
|
||||
// Toggle popover open/close
|
||||
if (isDevicePopoverOpen) setDeviceMode("desktop");
|
||||
setIsDevicePopoverOpen(!isDevicePopoverOpen);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 dark:text-gray-300",
|
||||
deviceMode !== "desktop" && "bg-gray-200 dark:bg-gray-700",
|
||||
)}
|
||||
title="Device Mode"
|
||||
>
|
||||
<MonitorSmartphone size={16} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-2"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={deviceMode}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setDeviceMode(value as DeviceMode);
|
||||
setIsDevicePopoverOpen(false);
|
||||
{/* Browser-style header - hide when annotator is active */}
|
||||
{!annotatorMode && (
|
||||
<div className="flex items-center p-2 border-b space-x-2">
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleActivateComponentSelector}
|
||||
className={`p-1 rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isPicking
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
|
||||
}`}
|
||||
disabled={
|
||||
loading ||
|
||||
!selectedAppId ||
|
||||
!isComponentSelectorInitialized
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{/* Tooltips placed inside items instead of wrapping
|
||||
to avoid asChild prop merging that breaks highlighting */}
|
||||
<ToggleGroupItem value="desktop" aria-label="Desktop view">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center justify-center">
|
||||
<Monitor size={16} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Desktop</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="tablet" aria-label="Tablet view">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center justify-center">
|
||||
<Tablet size={16} className="scale-x-130" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Tablet</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="mobile" aria-label="Mobile view">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center justify-center">
|
||||
<Smartphone size={16} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Mobile</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</TooltipProvider>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
data-testid="preview-pick-element-button"
|
||||
>
|
||||
<MousePointerClick size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isPicking
|
||||
? "Deactivate component selector"
|
||||
: "Select component"}
|
||||
</p>
|
||||
<p>{isMac ? "⌘ + ⇧ + C" : "Ctrl + ⇧ + C"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleAnnotatorClick}
|
||||
className={`p-1 rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
annotatorMode
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
|
||||
}`}
|
||||
disabled={
|
||||
loading ||
|
||||
!selectedAppId ||
|
||||
isPicking ||
|
||||
!isComponentSelectorInitialized
|
||||
}
|
||||
data-testid="preview-annotator-button"
|
||||
>
|
||||
<Pen size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{annotatorMode
|
||||
? "Annotator mode active"
|
||||
: "Activate annotator"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoBack || loading || !selectedAppId}
|
||||
onClick={handleNavigateBack}
|
||||
data-testid="preview-navigate-back-button"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoForward || loading || !selectedAppId}
|
||||
onClick={handleNavigateForward}
|
||||
data-testid="preview-navigate-forward-button"
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReload}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={loading || !selectedAppId}
|
||||
data-testid="preview-refresh-button"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-grow ">
|
||||
{/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */}
|
||||
<div className="relative flex-grow min-w-20">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm text-gray-700 dark:text-gray-200 cursor-pointer w-full min-w-0">
|
||||
<span className="truncate flex-1 mr-2 min-w-0">
|
||||
{navigationHistory[currentHistoryPosition]
|
||||
? new URL(navigationHistory[currentHistoryPosition])
|
||||
.pathname
|
||||
: "/"}
|
||||
</span>
|
||||
<ChevronDown size={14} className="flex-shrink-0" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
{availableRoutes.length > 0 ? (
|
||||
availableRoutes.map((route) => (
|
||||
<DropdownMenuItem
|
||||
key={route.path}
|
||||
onClick={() => navigateToRoute(route.path)}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<span>{route.label}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{route.path}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>
|
||||
Loading routes...
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="flex items-center space-x-1 px-3 py-1 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
|
||||
title="Restart App"
|
||||
>
|
||||
<Power size={16} />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
<button
|
||||
data-testid="preview-open-browser-button"
|
||||
onClick={() => {
|
||||
if (originalUrl) {
|
||||
IpcClient.getInstance().openExternalUrl(originalUrl);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
|
||||
{/* Device Mode Button */}
|
||||
<Popover open={isDevicePopoverOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
data-testid="device-mode-button"
|
||||
onClick={() => {
|
||||
// Toggle popover open/close
|
||||
if (isDevicePopoverOpen) setDeviceMode("desktop");
|
||||
setIsDevicePopoverOpen(!isDevicePopoverOpen);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 dark:text-gray-300",
|
||||
deviceMode !== "desktop" && "bg-gray-200 dark:bg-gray-700",
|
||||
)}
|
||||
title="Device Mode"
|
||||
>
|
||||
<MonitorSmartphone size={16} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-2"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={deviceMode}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setDeviceMode(value as DeviceMode);
|
||||
setIsDevicePopoverOpen(false);
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{/* Tooltips placed inside items instead of wrapping
|
||||
to avoid asChild prop merging that breaks highlighting */}
|
||||
<ToggleGroupItem value="desktop" aria-label="Desktop view">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center justify-center">
|
||||
<Monitor size={16} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Desktop</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="tablet" aria-label="Tablet view">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center justify-center">
|
||||
<Tablet size={16} className="scale-x-130" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Tablet</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="mobile" aria-label="Mobile view">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center justify-center">
|
||||
<Smartphone size={16} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Mobile</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</TooltipProvider>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex-grow overflow-hidden">
|
||||
<ErrorBanner
|
||||
error={errorMessage}
|
||||
onDismiss={() => setErrorMessage(undefined)}
|
||||
@@ -717,24 +976,60 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
deviceMode !== "desktop" && "flex justify-center",
|
||||
)}
|
||||
>
|
||||
<iframe
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-downloads"
|
||||
data-testid="preview-iframe-element"
|
||||
onLoad={() => {
|
||||
setErrorMessage(undefined);
|
||||
}}
|
||||
ref={iframeRef}
|
||||
key={reloadKey}
|
||||
title={`Preview for App ${selectedAppId}`}
|
||||
className="w-full h-full border-none bg-white dark:bg-gray-950"
|
||||
style={
|
||||
deviceMode == "desktop"
|
||||
? {}
|
||||
: { width: `${deviceWidthConfig[deviceMode]}px` }
|
||||
}
|
||||
src={appUrl}
|
||||
allow="clipboard-read; clipboard-write; fullscreen; microphone; camera; display-capture; geolocation; autoplay; picture-in-picture"
|
||||
/>
|
||||
{annotatorMode && screenshotDataUrl ? (
|
||||
<div
|
||||
className="w-full h-full bg-white dark:bg-gray-950"
|
||||
style={
|
||||
deviceMode == "desktop"
|
||||
? {}
|
||||
: { width: `${deviceWidthConfig[deviceMode]}px` }
|
||||
}
|
||||
>
|
||||
{userBudget ? (
|
||||
<Annotator
|
||||
screenshotUrl={screenshotDataUrl}
|
||||
onSubmit={addAttachments}
|
||||
handleAnnotatorClick={handleAnnotatorClick}
|
||||
/>
|
||||
) : (
|
||||
<AnnotatorOnlyForPro
|
||||
onGoBack={() => setAnnotatorMode(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<iframe
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-downloads"
|
||||
data-testid="preview-iframe-element"
|
||||
onLoad={() => {
|
||||
setErrorMessage(undefined);
|
||||
}}
|
||||
ref={iframeRef}
|
||||
key={reloadKey}
|
||||
title={`Preview for App ${selectedAppId}`}
|
||||
className="w-full h-full border-none bg-white dark:bg-gray-950"
|
||||
style={
|
||||
deviceMode == "desktop"
|
||||
? {}
|
||||
: { width: `${deviceWidthConfig[deviceMode]}px` }
|
||||
}
|
||||
src={appUrl}
|
||||
allow="clipboard-read; clipboard-write; fullscreen; microphone; camera; display-capture; geolocation; autoplay; picture-in-picture"
|
||||
/>
|
||||
{/* Visual Editing Toolbar */}
|
||||
{isProMode &&
|
||||
visualEditingSelectedComponent &&
|
||||
selectedAppId && (
|
||||
<VisualEditingToolbar
|
||||
selectedComponent={visualEditingSelectedComponent}
|
||||
iframeRef={iframeRef}
|
||||
isDynamic={isDynamicComponent}
|
||||
hasStaticText={hasStaticText}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -743,16 +1038,20 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
};
|
||||
|
||||
function parseComponentSelection(data: any): ComponentSelection | null {
|
||||
if (!data || data.type !== "dyad-component-selected") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const component = data.component;
|
||||
if (
|
||||
!data ||
|
||||
data.type !== "dyad-component-selected" ||
|
||||
typeof data.id !== "string" ||
|
||||
typeof data.name !== "string"
|
||||
!component ||
|
||||
typeof component.id !== "string" ||
|
||||
typeof component.name !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id, name } = data;
|
||||
const { id, name, runtimeId } = component;
|
||||
|
||||
// The id is expected to be in the format "filepath:line:column"
|
||||
const parts = id.split(":");
|
||||
@@ -781,6 +1080,7 @@ function parseComponentSelection(data: any): ComponentSelection | null {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
runtimeId,
|
||||
relativePath: normalizePath(relativePath),
|
||||
lineNumber,
|
||||
columnNumber,
|
||||
|
||||
56
src/components/preview_panel/StylePopover.tsx
Normal file
56
src/components/preview_panel/StylePopover.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ReactNode } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface StylePopoverProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
tooltip: string;
|
||||
children: ReactNode;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}
|
||||
|
||||
export function StylePopover({
|
||||
icon,
|
||||
title,
|
||||
tooltip,
|
||||
children,
|
||||
side = "bottom",
|
||||
}: StylePopoverProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-[#7f22fe] dark:text-gray-200"
|
||||
aria-label={tooltip}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{icon}</TooltipTrigger>
|
||||
<TooltipContent side={side}>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side={side} className="w-64">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm" style={{ color: "#7f22fe" }}>
|
||||
{title}
|
||||
</h4>
|
||||
{children}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
25
src/components/preview_panel/ToolbarColorPicker.tsx
Normal file
25
src/components/preview_panel/ToolbarColorPicker.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
interface ToolbarColorPickerProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export const ToolbarColorPicker = ({
|
||||
color,
|
||||
onChange,
|
||||
}: ToolbarColorPickerProps) => {
|
||||
return (
|
||||
<label
|
||||
className="h-[16px] w-[16px] rounded-sm cursor-pointer transition-all overflow-hidden block self-center"
|
||||
style={{ backgroundColor: color }}
|
||||
title="Choose color"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="opacity-0 w-full h-full"
|
||||
aria-label="Choose color"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
179
src/components/preview_panel/VisualEditingChangesDialog.tsx
Normal file
179
src/components/preview_panel/VisualEditingChangesDialog.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { pendingVisualChangesAtom } from "@/atoms/previewAtoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { showError, showSuccess } from "@/lib/toast";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
|
||||
interface VisualEditingChangesDialogProps {
|
||||
onReset?: () => void;
|
||||
iframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
||||
}
|
||||
|
||||
export function VisualEditingChangesDialog({
|
||||
onReset,
|
||||
iframeRef,
|
||||
}: VisualEditingChangesDialogProps) {
|
||||
const [pendingChanges, setPendingChanges] = useAtom(pendingVisualChangesAtom);
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const textContentCache = useRef<Map<string, string>>(new Map());
|
||||
const [allResponsesReceived, setAllResponsesReceived] = useState(false);
|
||||
const expectedResponsesRef = useRef<Set<string>>(new Set());
|
||||
const isWaitingForResponses = useRef(false);
|
||||
|
||||
// Listen for text content responses
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === "dyad-text-content-response") {
|
||||
const { componentId, text } = event.data;
|
||||
if (text !== null) {
|
||||
textContentCache.current.set(componentId, text);
|
||||
}
|
||||
|
||||
// Mark this response as received
|
||||
expectedResponsesRef.current.delete(componentId);
|
||||
|
||||
// Check if all responses received (only if we're actually waiting)
|
||||
if (
|
||||
isWaitingForResponses.current &&
|
||||
expectedResponsesRef.current.size === 0
|
||||
) {
|
||||
setAllResponsesReceived(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, []);
|
||||
|
||||
// Execute when all responses are received
|
||||
useEffect(() => {
|
||||
if (allResponsesReceived && isSaving) {
|
||||
const applyChanges = async () => {
|
||||
try {
|
||||
const changesToSave = Array.from(pendingChanges.values());
|
||||
|
||||
// Update changes with cached text content
|
||||
const updatedChanges = changesToSave.map((change) => {
|
||||
const cachedText = textContentCache.current.get(change.componentId);
|
||||
if (cachedText !== undefined) {
|
||||
return { ...change, textContent: cachedText };
|
||||
}
|
||||
return change;
|
||||
});
|
||||
|
||||
await IpcClient.getInstance().applyVisualEditingChanges({
|
||||
appId: selectedAppId!,
|
||||
changes: updatedChanges,
|
||||
});
|
||||
|
||||
setPendingChanges(new Map());
|
||||
textContentCache.current.clear();
|
||||
showSuccess("Visual changes saved to source files");
|
||||
onReset?.();
|
||||
} catch (error) {
|
||||
console.error("Failed to save visual editing changes:", error);
|
||||
showError(`Failed to save changes: ${error}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setAllResponsesReceived(false);
|
||||
isWaitingForResponses.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
applyChanges();
|
||||
}
|
||||
}, [
|
||||
allResponsesReceived,
|
||||
isSaving,
|
||||
pendingChanges,
|
||||
selectedAppId,
|
||||
onReset,
|
||||
setPendingChanges,
|
||||
]);
|
||||
|
||||
if (pendingChanges.size === 0) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const changesToSave = Array.from(pendingChanges.values());
|
||||
|
||||
if (iframeRef?.current?.contentWindow) {
|
||||
// Reset state for new request
|
||||
setAllResponsesReceived(false);
|
||||
expectedResponsesRef.current.clear();
|
||||
isWaitingForResponses.current = true;
|
||||
|
||||
// Track which components we're expecting responses from
|
||||
for (const change of changesToSave) {
|
||||
expectedResponsesRef.current.add(change.componentId);
|
||||
}
|
||||
|
||||
// Request text content for each component
|
||||
for (const change of changesToSave) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "get-dyad-text-content",
|
||||
data: { componentId: change.componentId },
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
// If no responses are expected, trigger immediately
|
||||
if (expectedResponsesRef.current.size === 0) {
|
||||
setAllResponsesReceived(true);
|
||||
}
|
||||
} else {
|
||||
await IpcClient.getInstance().applyVisualEditingChanges({
|
||||
appId: selectedAppId!,
|
||||
changes: changesToSave,
|
||||
});
|
||||
|
||||
setPendingChanges(new Map());
|
||||
textContentCache.current.clear();
|
||||
showSuccess("Visual changes saved to source files");
|
||||
onReset?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save visual editing changes:", error);
|
||||
showError(`Failed to save changes: ${error}`);
|
||||
setIsSaving(false);
|
||||
isWaitingForResponses.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
setPendingChanges(new Map());
|
||||
onReset?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--background)] border-b border-[var(--border)] px-2 lg:px-4 py-1.5 flex flex-col lg:flex-row items-start lg:items-center lg:justify-between gap-1.5 lg:gap-4 flex-wrap">
|
||||
<p className="text-xs lg:text-sm w-full lg:w-auto">
|
||||
<span className="font-medium">{pendingChanges.size}</span> component
|
||||
{pendingChanges.size > 1 ? "s" : ""} modified
|
||||
</p>
|
||||
<div className="flex gap-1 lg:gap-2 w-full lg:w-auto flex-wrap">
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||
<Check size={14} className="mr-1" />
|
||||
<span>{isSaving ? "Saving..." : "Save Changes"}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleDiscard}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<X size={14} className="mr-1" />
|
||||
<span>Discard</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
531
src/components/preview_panel/VisualEditingToolbar.tsx
Normal file
531
src/components/preview_panel/VisualEditingToolbar.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Move, Square, Palette, Type } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ComponentSelection } from "@/ipc/ipc_types";
|
||||
import { useSetAtom, useAtomValue } from "jotai";
|
||||
import {
|
||||
pendingVisualChangesAtom,
|
||||
selectedComponentsPreviewAtom,
|
||||
currentComponentCoordinatesAtom,
|
||||
visualEditingSelectedComponentAtom,
|
||||
} from "@/atoms/previewAtoms";
|
||||
import { StylePopover } from "./StylePopover";
|
||||
import { ColorPicker } from "@/components/ui/ColorPicker";
|
||||
import { NumberInput } from "@/components/ui/NumberInput";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { rgbToHex, processNumericValue } from "@/utils/style-utils";
|
||||
|
||||
const FONT_WEIGHT_OPTIONS = [
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "100", label: "Thin (100)" },
|
||||
{ value: "200", label: "Extra Light (200)" },
|
||||
{ value: "300", label: "Light (300)" },
|
||||
{ value: "400", label: "Normal (400)" },
|
||||
{ value: "500", label: "Medium (500)" },
|
||||
{ value: "600", label: "Semi Bold (600)" },
|
||||
{ value: "700", label: "Bold (700)" },
|
||||
{ value: "800", label: "Extra Bold (800)" },
|
||||
{ value: "900", label: "Black (900)" },
|
||||
] as const;
|
||||
|
||||
const FONT_FAMILY_OPTIONS = [
|
||||
{ value: "", label: "Default" },
|
||||
// Sans-serif (clean, modern)
|
||||
{ value: "Arial, sans-serif", label: "Arial" },
|
||||
{ value: "Inter, sans-serif", label: "Inter" },
|
||||
{ value: "Roboto, sans-serif", label: "Roboto" },
|
||||
// Serif (traditional, elegant)
|
||||
{ value: "Georgia, serif", label: "Georgia" },
|
||||
{ value: "'Times New Roman', Times, serif", label: "Times New Roman" },
|
||||
{ value: "Merriweather, serif", label: "Merriweather" },
|
||||
// Monospace (code, technical)
|
||||
{ value: "'Courier New', Courier, monospace", label: "Courier New" },
|
||||
{ value: "'Fira Code', monospace", label: "Fira Code" },
|
||||
{ value: "Consolas, monospace", label: "Consolas" },
|
||||
// Display/Decorative (bold, distinctive)
|
||||
{ value: "Impact, fantasy", label: "Impact" },
|
||||
{ value: "'Bebas Neue', cursive", label: "Bebas Neue" },
|
||||
// Cursive/Handwriting (casual, friendly)
|
||||
{ value: "'Comic Sans MS', cursive", label: "Comic Sans MS" },
|
||||
{ value: "'Brush Script MT', cursive", label: "Brush Script" },
|
||||
] as const;
|
||||
|
||||
interface VisualEditingToolbarProps {
|
||||
selectedComponent: ComponentSelection | null;
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
isDynamic: boolean;
|
||||
hasStaticText: boolean;
|
||||
}
|
||||
|
||||
export function VisualEditingToolbar({
|
||||
selectedComponent,
|
||||
iframeRef,
|
||||
isDynamic,
|
||||
hasStaticText,
|
||||
}: VisualEditingToolbarProps) {
|
||||
const coordinates = useAtomValue(currentComponentCoordinatesAtom);
|
||||
const [currentMargin, setCurrentMargin] = useState({ x: "", y: "" });
|
||||
const [currentPadding, setCurrentPadding] = useState({ x: "", y: "" });
|
||||
const [currentBorder, setCurrentBorder] = useState({
|
||||
width: "",
|
||||
radius: "",
|
||||
color: "#000000",
|
||||
});
|
||||
const [currentBackgroundColor, setCurrentBackgroundColor] =
|
||||
useState("#ffffff");
|
||||
const [currentTextStyles, setCurrentTextStyles] = useState({
|
||||
fontSize: "",
|
||||
fontWeight: "",
|
||||
fontFamily: "",
|
||||
color: "#000000",
|
||||
});
|
||||
const setPendingChanges = useSetAtom(pendingVisualChangesAtom);
|
||||
const setSelectedComponentsPreview = useSetAtom(
|
||||
selectedComponentsPreviewAtom,
|
||||
);
|
||||
const setVisualEditingSelectedComponent = useSetAtom(
|
||||
visualEditingSelectedComponentAtom,
|
||||
);
|
||||
|
||||
const handleDeselectComponent = () => {
|
||||
if (!selectedComponent) return;
|
||||
|
||||
setSelectedComponentsPreview((prev) =>
|
||||
prev.filter((c) => c.id !== selectedComponent.id),
|
||||
);
|
||||
setVisualEditingSelectedComponent(null);
|
||||
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "remove-dyad-component-overlay",
|
||||
componentId: selectedComponent.id,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const sendStyleModification = (styles: {
|
||||
margin?: { left?: string; right?: string; top?: string; bottom?: string };
|
||||
padding?: { left?: string; right?: string; top?: string; bottom?: string };
|
||||
|
||||
border?: { width?: string; radius?: string; color?: string };
|
||||
backgroundColor?: string;
|
||||
text?: { fontSize?: string; fontWeight?: string; color?: string };
|
||||
}) => {
|
||||
if (!iframeRef.current?.contentWindow || !selectedComponent) return;
|
||||
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "modify-dyad-component-styles",
|
||||
data: {
|
||||
elementId: selectedComponent.id,
|
||||
runtimeId: selectedComponent.runtimeId,
|
||||
styles,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "update-dyad-overlay-positions",
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
setPendingChanges((prev) => {
|
||||
const updated = new Map(prev);
|
||||
const existing = updated.get(selectedComponent.id);
|
||||
const newStyles: any = { ...existing?.styles };
|
||||
|
||||
if (styles.margin) {
|
||||
newStyles.margin = { ...existing?.styles?.margin, ...styles.margin };
|
||||
}
|
||||
if (styles.padding) {
|
||||
newStyles.padding = { ...existing?.styles?.padding, ...styles.padding };
|
||||
}
|
||||
|
||||
if (styles.border) {
|
||||
newStyles.border = { ...existing?.styles?.border, ...styles.border };
|
||||
}
|
||||
if (styles.backgroundColor) {
|
||||
newStyles.backgroundColor = styles.backgroundColor;
|
||||
}
|
||||
if (styles.text) {
|
||||
newStyles.text = { ...existing?.styles?.text, ...styles.text };
|
||||
}
|
||||
|
||||
updated.set(selectedComponent.id, {
|
||||
componentId: selectedComponent.id,
|
||||
componentName: selectedComponent.name,
|
||||
relativePath: selectedComponent.relativePath,
|
||||
lineNumber: selectedComponent.lineNumber,
|
||||
styles: newStyles,
|
||||
textContent: existing?.textContent || "",
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const getCurrentElementStyles = () => {
|
||||
if (!iframeRef.current?.contentWindow || !selectedComponent) return;
|
||||
|
||||
try {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "get-dyad-component-styles",
|
||||
data: {
|
||||
elementId: selectedComponent.id,
|
||||
runtimeId: selectedComponent.runtimeId,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to get element styles:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedComponent) {
|
||||
getCurrentElementStyles();
|
||||
}
|
||||
}, [selectedComponent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (coordinates && iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "update-component-coordinates",
|
||||
coordinates,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
}, [coordinates, iframeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === "dyad-component-styles") {
|
||||
const { margin, padding, border, backgroundColor, text } =
|
||||
event.data.data;
|
||||
|
||||
const marginX = margin?.left === margin?.right ? margin.left : "";
|
||||
const marginY = margin?.top === margin?.bottom ? margin.top : "";
|
||||
const paddingX = padding?.left === padding?.right ? padding.left : "";
|
||||
const paddingY = padding?.top === padding?.bottom ? padding.top : "";
|
||||
|
||||
setCurrentMargin({ x: marginX, y: marginY });
|
||||
setCurrentPadding({ x: paddingX, y: paddingY });
|
||||
setCurrentBorder({
|
||||
width: border?.width || "",
|
||||
radius: border?.radius || "",
|
||||
color: rgbToHex(border?.color),
|
||||
});
|
||||
setCurrentBackgroundColor(rgbToHex(backgroundColor) || "#ffffff");
|
||||
setCurrentTextStyles({
|
||||
fontSize: text?.fontSize || "",
|
||||
fontWeight: text?.fontWeight || "",
|
||||
fontFamily: text?.fontFamily || "",
|
||||
color: rgbToHex(text?.color) || "#000000",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, []);
|
||||
|
||||
const handleSpacingChange = (
|
||||
type: "margin" | "padding",
|
||||
axis: "x" | "y",
|
||||
value: string,
|
||||
) => {
|
||||
const setter = type === "margin" ? setCurrentMargin : setCurrentPadding;
|
||||
setter((prev) => ({ ...prev, [axis]: value }));
|
||||
|
||||
if (value) {
|
||||
const processedValue = processNumericValue(value);
|
||||
const data =
|
||||
axis === "x"
|
||||
? { left: processedValue, right: processedValue }
|
||||
: { top: processedValue, bottom: processedValue };
|
||||
|
||||
sendStyleModification({ [type]: data });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBorderChange = (
|
||||
property: "width" | "radius" | "color",
|
||||
value: string,
|
||||
) => {
|
||||
const newBorder = { ...currentBorder, [property]: value };
|
||||
setCurrentBorder(newBorder);
|
||||
|
||||
if (value) {
|
||||
let processedValue = value;
|
||||
if (property !== "color" && /^\d+$/.test(value)) {
|
||||
processedValue = `${value}px`;
|
||||
}
|
||||
|
||||
if (property === "width" || property === "color") {
|
||||
sendStyleModification({
|
||||
border: {
|
||||
width:
|
||||
property === "width"
|
||||
? processedValue
|
||||
: currentBorder.width || "0px",
|
||||
color: property === "color" ? processedValue : currentBorder.color,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendStyleModification({ border: { [property]: processedValue } });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextStyleChange = (
|
||||
property: "fontSize" | "fontWeight" | "fontFamily" | "color",
|
||||
value: string,
|
||||
) => {
|
||||
setCurrentTextStyles((prev) => ({ ...prev, [property]: value }));
|
||||
|
||||
if (value) {
|
||||
let processedValue = value;
|
||||
if (property === "fontSize" && /^\d+$/.test(value)) {
|
||||
processedValue = `${value}px`;
|
||||
}
|
||||
|
||||
sendStyleModification({ text: { [property]: processedValue } });
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedComponent || !coordinates) return null;
|
||||
|
||||
const toolbarTop = coordinates.top + coordinates.height + 4;
|
||||
const toolbarLeft = coordinates.left;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bg-[var(--background)] border border-[var(--border)] rounded-md shadow-lg z-50 flex flex-row items-center p-2 gap-1"
|
||||
style={{
|
||||
top: `${toolbarTop}px`,
|
||||
left: `${toolbarLeft}px`,
|
||||
}}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleDeselectComponent}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-[#7f22fe] dark:text-gray-200"
|
||||
aria-label="Deselect Component"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Deselect Component</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{isDynamic ? (
|
||||
<div className="flex items-center px-2 py-1 text-yellow-800 dark:text-yellow-200 rounded text-xs font-medium">
|
||||
<span>This component is styled dynamically</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StylePopover
|
||||
icon={<Move size={16} />}
|
||||
title="Margin"
|
||||
tooltip="Margin"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<NumberInput
|
||||
id="margin-x"
|
||||
label="Horizontal"
|
||||
value={currentMargin.x}
|
||||
onChange={(v) => handleSpacingChange("margin", "x", v)}
|
||||
placeholder="10"
|
||||
/>
|
||||
<NumberInput
|
||||
id="margin-y"
|
||||
label="Vertical"
|
||||
value={currentMargin.y}
|
||||
onChange={(v) => handleSpacingChange("margin", "y", v)}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
</StylePopover>
|
||||
|
||||
<StylePopover
|
||||
icon={
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<rect x="7" y="7" width="10" height="10" rx="1" />
|
||||
</svg>
|
||||
}
|
||||
title="Padding"
|
||||
tooltip="Padding"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<NumberInput
|
||||
id="padding-x"
|
||||
label="Horizontal"
|
||||
value={currentPadding.x}
|
||||
onChange={(v) => handleSpacingChange("padding", "x", v)}
|
||||
placeholder="10"
|
||||
/>
|
||||
<NumberInput
|
||||
id="padding-y"
|
||||
label="Vertical"
|
||||
value={currentPadding.y}
|
||||
onChange={(v) => handleSpacingChange("padding", "y", v)}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
</StylePopover>
|
||||
|
||||
<StylePopover
|
||||
icon={<Square size={16} />}
|
||||
title="Border"
|
||||
tooltip="Border"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<NumberInput
|
||||
id="border-width"
|
||||
label="Width"
|
||||
value={currentBorder.width}
|
||||
onChange={(v) => handleBorderChange("width", v)}
|
||||
placeholder="1"
|
||||
/>
|
||||
<NumberInput
|
||||
id="border-radius"
|
||||
label="Radius"
|
||||
value={currentBorder.radius}
|
||||
onChange={(v) => handleBorderChange("radius", v)}
|
||||
placeholder="4"
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="border-color" className="text-xs">
|
||||
Color
|
||||
</Label>
|
||||
<ColorPicker
|
||||
id="border-color"
|
||||
value={currentBorder.color}
|
||||
onChange={(v) => handleBorderChange("color", v)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StylePopover>
|
||||
|
||||
<StylePopover
|
||||
icon={<Palette size={16} />}
|
||||
title="Background Color"
|
||||
tooltip="Background"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="bg-color" className="text-xs">
|
||||
Color
|
||||
</Label>
|
||||
<ColorPicker
|
||||
id="bg-color"
|
||||
value={currentBackgroundColor}
|
||||
onChange={(v) => {
|
||||
setCurrentBackgroundColor(v);
|
||||
if (v) sendStyleModification({ backgroundColor: v });
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</StylePopover>
|
||||
|
||||
{hasStaticText && (
|
||||
<StylePopover
|
||||
icon={<Type size={16} />}
|
||||
title="Text Style"
|
||||
tooltip="Text Style"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<NumberInput
|
||||
id="font-size"
|
||||
label="Font Size"
|
||||
value={currentTextStyles.fontSize}
|
||||
onChange={(v) => handleTextStyleChange("fontSize", v)}
|
||||
placeholder="16"
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="font-weight" className="text-xs">
|
||||
Font Weight
|
||||
</Label>
|
||||
<select
|
||||
id="font-weight"
|
||||
className="mt-1 h-8 text-xs w-full rounded-md border border-input bg-background px-3 py-2"
|
||||
value={currentTextStyles.fontWeight}
|
||||
onChange={(e) =>
|
||||
handleTextStyleChange("fontWeight", e.target.value)
|
||||
}
|
||||
>
|
||||
{FONT_WEIGHT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="font-family" className="text-xs">
|
||||
Font Family
|
||||
</Label>
|
||||
<select
|
||||
id="font-family"
|
||||
className="mt-1 h-8 text-xs w-full rounded-md border border-input bg-background px-3 py-2"
|
||||
value={currentTextStyles.fontFamily}
|
||||
onChange={(e) =>
|
||||
handleTextStyleChange("fontFamily", e.target.value)
|
||||
}
|
||||
>
|
||||
{FONT_FAMILY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="text-color" className="text-xs">
|
||||
Text Color
|
||||
</Label>
|
||||
<ColorPicker
|
||||
id="text-color"
|
||||
value={currentTextStyles.color}
|
||||
onChange={(v) => handleTextStyleChange("color", v)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StylePopover>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/components/ui/ColorPicker.tsx
Normal file
35
src/components/ui/ColorPicker.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ColorPickerProps {
|
||||
id: string;
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColorPicker({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
className = "",
|
||||
}: ColorPickerProps) {
|
||||
return (
|
||||
<div className={`flex gap-2 ${className}`}>
|
||||
<Input
|
||||
id={id}
|
||||
type="color"
|
||||
className="h-8 w-12 p-1 cursor-pointer"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="#000000"
|
||||
className="h-8 text-xs flex-1"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/components/ui/NumberInput.tsx
Normal file
42
src/components/ui/NumberInput.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface NumberInputProps {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
step?: string;
|
||||
min?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NumberInput({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "0",
|
||||
step = "1",
|
||||
min = "0",
|
||||
className = "",
|
||||
}: NumberInputProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Label htmlFor={id} className="text-xs">
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
placeholder={placeholder}
|
||||
className="mt-1 h-8 text-xs"
|
||||
value={value.replace(/[^\d.-]/g, "") || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
step={step}
|
||||
min={min}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import type {
|
||||
SmartContextMeta,
|
||||
SmartContextSnippet,
|
||||
SmartContextRetrieveResult,
|
||||
} from "@/ipc/ipc_types";
|
||||
|
||||
export function useSmartContextMeta(chatId: number) {
|
||||
return useQuery<SmartContextMeta, Error>({
|
||||
queryKey: ["smart-context", chatId, "meta"],
|
||||
queryFn: async () => {
|
||||
const ipc = IpcClient.getInstance();
|
||||
return ipc.getSmartContextMeta(chatId);
|
||||
},
|
||||
enabled: !!chatId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRetrieveSmartContext(
|
||||
chatId: number,
|
||||
query: string,
|
||||
budgetTokens: number,
|
||||
) {
|
||||
return useQuery<SmartContextRetrieveResult, Error>({
|
||||
queryKey: ["smart-context", chatId, "retrieve", query, budgetTokens],
|
||||
queryFn: async () => {
|
||||
const ipc = IpcClient.getInstance();
|
||||
return ipc.retrieveSmartContext({ chatId, query, budgetTokens });
|
||||
},
|
||||
enabled: !!chatId && !!query && budgetTokens > 0,
|
||||
meta: { showErrorToast: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertSmartContextSnippets(chatId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<number, Error, Array<Pick<SmartContextSnippet, "text" | "source">>>({
|
||||
mutationFn: async (snippets) => {
|
||||
const ipc = IpcClient.getInstance();
|
||||
return ipc.upsertSmartContextSnippets(chatId, snippets);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["smart-context", chatId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRollingSummary(chatId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<SmartContextMeta, Error, { summary: string }>({
|
||||
mutationFn: async ({ summary }) => {
|
||||
const ipc = IpcClient.getInstance();
|
||||
return ipc.updateSmartContextRollingSummary(chatId, summary);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["smart-context", chatId, "meta"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// Custom modules for moreminimore-vibe
|
||||
// This file exports all custom functionality to make imports easier
|
||||
|
||||
// Custom hooks
|
||||
export { useSmartContextMeta, useRetrieveSmartContext, useUpsertSmartContextSnippets, useUpdateRollingSummary } from './hooks/useSmartContext';
|
||||
|
||||
// Custom IPC handlers (these will need to be imported and registered in the main process)
|
||||
export { registerSmartContextHandlers } from './ipc/smart_context_handlers';
|
||||
|
||||
// Custom utilities
|
||||
export * from './utils/smart_context_store';
|
||||
|
||||
// Re-export types that might be needed
|
||||
export type {
|
||||
SmartContextMeta,
|
||||
SmartContextSnippet,
|
||||
SmartContextRetrieveResult,
|
||||
} from '../ipc/ipc_types';
|
||||
@@ -1,65 +0,0 @@
|
||||
import log from "electron-log";
|
||||
import { createLoggedHandler } from "./safe_handle";
|
||||
import {
|
||||
appendSnippets,
|
||||
readMeta,
|
||||
retrieveContext,
|
||||
updateRollingSummary,
|
||||
rebuildIndex,
|
||||
type SmartContextSnippet,
|
||||
type SmartContextMeta,
|
||||
} from "../utils/smart_context_store";
|
||||
|
||||
const logger = log.scope("smart_context_handlers");
|
||||
const handle = createLoggedHandler(logger);
|
||||
|
||||
export interface UpsertSnippetsParams {
|
||||
chatId: number;
|
||||
snippets: Array<{
|
||||
text: string;
|
||||
source:
|
||||
| { type: "message"; messageIndex?: number }
|
||||
| { type: "code"; filePath: string }
|
||||
| { type: "attachment"; name: string; mime?: string }
|
||||
| { type: "other"; label?: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RetrieveContextParams {
|
||||
chatId: number;
|
||||
query: string;
|
||||
budgetTokens: number;
|
||||
}
|
||||
|
||||
export function registerSmartContextHandlers() {
|
||||
handle("sc:get-meta", async (_event, chatId: number): Promise<SmartContextMeta> => {
|
||||
return readMeta(chatId);
|
||||
});
|
||||
|
||||
handle(
|
||||
"sc:upsert-snippets",
|
||||
async (_event, params: UpsertSnippetsParams): Promise<number> => {
|
||||
const count = await appendSnippets(params.chatId, params.snippets);
|
||||
return count;
|
||||
},
|
||||
);
|
||||
|
||||
handle(
|
||||
"sc:update-rolling-summary",
|
||||
async (_event, params: { chatId: number; summary: string }): Promise<SmartContextMeta> => {
|
||||
return updateRollingSummary(params.chatId, params.summary);
|
||||
},
|
||||
);
|
||||
|
||||
handle(
|
||||
"sc:retrieve-context",
|
||||
async (_event, params: RetrieveContextParams) => {
|
||||
return retrieveContext(params.chatId, params.query, params.budgetTokens);
|
||||
},
|
||||
);
|
||||
|
||||
handle("sc:rebuild-index", async (_event, chatId: number) => {
|
||||
await rebuildIndex(chatId);
|
||||
return { ok: true } as const;
|
||||
});
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getUserDataPath } from "../../paths/paths";
|
||||
import { estimateTokens } from "./token_utils";
|
||||
|
||||
export type SmartContextSource =
|
||||
| { type: "message"; messageIndex?: number }
|
||||
| { type: "code"; filePath: string }
|
||||
| { type: "attachment"; name: string; mime?: string }
|
||||
| { type: "other"; label?: string };
|
||||
|
||||
export interface SmartContextSnippet {
|
||||
id: string;
|
||||
text: string;
|
||||
score?: number;
|
||||
source: SmartContextSource;
|
||||
ts: number; // epoch ms
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
export interface SmartContextMetaConfig {
|
||||
maxSnippets?: number;
|
||||
}
|
||||
|
||||
export interface SmartContextMeta {
|
||||
entityId: string; // e.g., chatId as string
|
||||
updatedAt: number;
|
||||
rollingSummary?: string;
|
||||
summaryTokens?: number;
|
||||
config?: SmartContextMetaConfig;
|
||||
}
|
||||
|
||||
function getThreadDir(chatId: number): string {
|
||||
const base = path.join(getUserDataPath(), "smart-context", "threads");
|
||||
return path.join(base, String(chatId));
|
||||
}
|
||||
|
||||
function getMetaPath(chatId: number): string {
|
||||
return path.join(getThreadDir(chatId), "meta.json");
|
||||
}
|
||||
|
||||
function getSnippetsPath(chatId: number): string {
|
||||
return path.join(getThreadDir(chatId), "snippets.jsonl");
|
||||
}
|
||||
|
||||
async function ensureDir(dir: string): Promise<void> {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export async function readMeta(chatId: number): Promise<SmartContextMeta> {
|
||||
const dir = getThreadDir(chatId);
|
||||
await ensureDir(dir);
|
||||
const metaPath = getMetaPath(chatId);
|
||||
try {
|
||||
const raw = await fs.readFile(metaPath, "utf8");
|
||||
const meta = JSON.parse(raw) as SmartContextMeta;
|
||||
return meta;
|
||||
} catch {
|
||||
const fresh: SmartContextMeta = {
|
||||
entityId: String(chatId),
|
||||
updatedAt: Date.now(),
|
||||
rollingSummary: "",
|
||||
summaryTokens: 0,
|
||||
config: { maxSnippets: 400 },
|
||||
};
|
||||
await fs.writeFile(metaPath, JSON.stringify(fresh, null, 2), "utf8");
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeMeta(
|
||||
chatId: number,
|
||||
meta: SmartContextMeta,
|
||||
): Promise<void> {
|
||||
const dir = getThreadDir(chatId);
|
||||
await ensureDir(dir);
|
||||
const metaPath = getMetaPath(chatId);
|
||||
const updated: SmartContextMeta = {
|
||||
...meta,
|
||||
entityId: String(chatId),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await fs.writeFile(metaPath, JSON.stringify(updated, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export async function updateRollingSummary(
|
||||
chatId: number,
|
||||
summary: string,
|
||||
): Promise<SmartContextMeta> {
|
||||
const meta = await readMeta(chatId);
|
||||
const summaryTokens = estimateTokens(summary || "");
|
||||
const next: SmartContextMeta = {
|
||||
...meta,
|
||||
rollingSummary: summary,
|
||||
summaryTokens,
|
||||
};
|
||||
await writeMeta(chatId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function appendSnippets(
|
||||
chatId: number,
|
||||
snippets: Omit<SmartContextSnippet, "id" | "ts" | "tokens">[],
|
||||
): Promise<number> {
|
||||
const dir = getThreadDir(chatId);
|
||||
await ensureDir(dir);
|
||||
const snippetsPath = getSnippetsPath(chatId);
|
||||
const withDefaults: SmartContextSnippet[] = snippets.map((s) => ({
|
||||
id: randomUUID(),
|
||||
ts: Date.now(),
|
||||
tokens: estimateTokens(s.text),
|
||||
...s,
|
||||
}));
|
||||
const lines = withDefaults.map((obj) => JSON.stringify(obj)).join("\n");
|
||||
await fs.appendFile(snippetsPath, (lines ? lines + "\n" : ""), "utf8");
|
||||
|
||||
// prune if exceeded max
|
||||
const meta = await readMeta(chatId);
|
||||
const maxSnippets = meta.config?.maxSnippets ?? 400;
|
||||
try {
|
||||
const file = await fs.readFile(snippetsPath, "utf8");
|
||||
const allLines = file.split("\n").filter(Boolean);
|
||||
if (allLines.length > maxSnippets) {
|
||||
const toKeep = allLines.slice(allLines.length - maxSnippets);
|
||||
await fs.writeFile(snippetsPath, toKeep.join("\n") + "\n", "utf8");
|
||||
return toKeep.length;
|
||||
}
|
||||
return allLines.length;
|
||||
} catch {
|
||||
return withDefaults.length;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAllSnippets(chatId: number): Promise<SmartContextSnippet[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(getSnippetsPath(chatId), "utf8");
|
||||
return raw
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as SmartContextSnippet);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(value: number, min: number, max: number): number {
|
||||
if (max === min) return 0;
|
||||
return (value - min) / (max - min);
|
||||
}
|
||||
|
||||
function keywordScore(text: string, query: string): number {
|
||||
const toTokens = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_\- ]+/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
const qTokens = new Set(toTokens(query));
|
||||
const tTokens = toTokens(text);
|
||||
if (qTokens.size === 0 || tTokens.length === 0) return 0;
|
||||
let hits = 0;
|
||||
for (const tok of tTokens) if (qTokens.has(tok)) hits++;
|
||||
return hits / tTokens.length; // simple overlap ratio
|
||||
}
|
||||
|
||||
export interface RetrieveContextResult {
|
||||
rollingSummary?: string;
|
||||
usedTokens: number;
|
||||
snippets: SmartContextSnippet[];
|
||||
}
|
||||
|
||||
export async function retrieveContext(
|
||||
chatId: number,
|
||||
query: string,
|
||||
budgetTokens: number,
|
||||
): Promise<RetrieveContextResult> {
|
||||
const meta = await readMeta(chatId);
|
||||
const snippets = await readAllSnippets(chatId);
|
||||
const now = Date.now();
|
||||
let minTs = now;
|
||||
let maxTs = 0;
|
||||
for (const s of snippets) {
|
||||
if (s.ts < minTs) minTs = s.ts;
|
||||
if (s.ts > maxTs) maxTs = s.ts;
|
||||
}
|
||||
const scored = snippets.map((s) => {
|
||||
const recency = normalize(s.ts, minTs, maxTs);
|
||||
const kw = keywordScore(s.text, query);
|
||||
const base = 0.6 * kw + 0.4 * recency;
|
||||
const score = base;
|
||||
return { ...s, score } as SmartContextSnippet;
|
||||
});
|
||||
scored.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
|
||||
const picked: SmartContextSnippet[] = [];
|
||||
let usedTokens = 0;
|
||||
for (const s of scored) {
|
||||
const t = s.tokens ?? estimateTokens(s.text);
|
||||
if (usedTokens + t > budgetTokens) break;
|
||||
picked.push(s);
|
||||
usedTokens += t;
|
||||
}
|
||||
|
||||
const rollingSummary = meta.rollingSummary || "";
|
||||
return { rollingSummary, usedTokens, snippets: picked };
|
||||
}
|
||||
|
||||
export async function rebuildIndex(_chatId: number): Promise<void> {
|
||||
// Placeholder for future embedding/vector index rebuild.
|
||||
return;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import type { FileAttachment } from "@/ipc/ipc_types";
|
||||
import { useAtom } from "jotai";
|
||||
import { attachmentsAtom } from "@/atoms/chatAtoms";
|
||||
|
||||
export function useAttachments() {
|
||||
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
||||
const [attachments, setAttachments] = useAtom(attachmentsAtom);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
@@ -133,5 +135,6 @@ export function useAttachments() {
|
||||
handleDrop,
|
||||
clearAttachments,
|
||||
handlePaste,
|
||||
addAttachments,
|
||||
};
|
||||
}
|
||||
|
||||
60
src/ipc/git_types.ts
Normal file
60
src/ipc/git_types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Type definitions for Git operations
|
||||
export type GitCommit = {
|
||||
oid: string;
|
||||
commit: {
|
||||
message: string;
|
||||
author: {
|
||||
timestamp: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
export interface GitBaseParams {
|
||||
path: string;
|
||||
}
|
||||
export interface GitCommitParams extends GitBaseParams {
|
||||
message: string;
|
||||
amend?: boolean;
|
||||
}
|
||||
export interface GitFileParams extends GitBaseParams {
|
||||
filepath: string;
|
||||
}
|
||||
export interface GitCheckoutParams extends GitBaseParams {
|
||||
ref: string;
|
||||
}
|
||||
export interface GitBranchRenameParams extends GitBaseParams {
|
||||
oldBranch: string;
|
||||
newBranch: string;
|
||||
}
|
||||
export interface GitCloneParams {
|
||||
path: string; // destination
|
||||
url: string;
|
||||
depth?: number | null;
|
||||
singleBranch?: boolean;
|
||||
accessToken?: string;
|
||||
}
|
||||
export interface GitLogParams extends GitBaseParams {
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export interface GitResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
export interface GitPushParams extends GitBaseParams {
|
||||
branch: string;
|
||||
accessToken: string;
|
||||
force?: boolean;
|
||||
}
|
||||
export interface GitFileAtCommitParams extends GitBaseParams {
|
||||
filePath: string;
|
||||
commitHash: string;
|
||||
}
|
||||
export interface GitSetRemoteUrlParams extends GitBaseParams {
|
||||
remoteUrl: string;
|
||||
}
|
||||
export interface GitInitParams extends GitBaseParams {
|
||||
ref?: string; // branch name, default = "main"
|
||||
}
|
||||
export interface GitStageToRevertParams extends GitBaseParams {
|
||||
targetOid: string;
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getDyadAppPath, getUserDataPath } from "../../paths/paths";
|
||||
import { ChildProcess, spawn } from "node:child_process";
|
||||
import git from "isomorphic-git";
|
||||
import { promises as fsPromises } from "node:fs";
|
||||
|
||||
// Import our utility modules
|
||||
@@ -36,7 +35,7 @@ import killPort from "kill-port";
|
||||
import util from "util";
|
||||
import log from "electron-log";
|
||||
import {
|
||||
deploySupabaseFunctions,
|
||||
deploySupabaseFunction,
|
||||
getSupabaseProjectName,
|
||||
} from "../../supabase_admin/supabase_management_client";
|
||||
import { createLoggedHandler } from "./safe_handle";
|
||||
@@ -44,16 +43,31 @@ import { getLanguageModelProviders } from "../shared/language_model_helpers";
|
||||
import { startProxy } from "../utils/start_proxy_server";
|
||||
import { Worker } from "worker_threads";
|
||||
import { createFromTemplate } from "./createFromTemplate";
|
||||
import { gitCommit } from "../utils/git_utils";
|
||||
import {
|
||||
gitCommit,
|
||||
gitAdd,
|
||||
gitInit,
|
||||
gitListBranches,
|
||||
gitRenameBranch,
|
||||
} from "../utils/git_utils";
|
||||
import { safeSend } from "../utils/safe_sender";
|
||||
import { normalizePath } from "../../../shared/normalizePath";
|
||||
import { isServerFunction } from "@/supabase_admin/supabase_utils";
|
||||
import {
|
||||
isServerFunction,
|
||||
isSharedServerModule,
|
||||
deployAllSupabaseFunctions,
|
||||
extractFunctionNameFromPath,
|
||||
} from "@/supabase_admin/supabase_utils";
|
||||
import { getVercelTeamSlug } from "../utils/vercel_utils";
|
||||
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
||||
import { AppSearchResult } from "@/lib/schemas";
|
||||
|
||||
const DEFAULT_COMMAND =
|
||||
"(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)";
|
||||
import { getAppPort } from "../../../shared/ports";
|
||||
|
||||
function getDefaultCommand(appId: number): string {
|
||||
const port = getAppPort(appId);
|
||||
return `(pnpm install && pnpm run dev --port ${port}) || (npm install --legacy-peer-deps && npm run dev -- --port ${port})`;
|
||||
}
|
||||
async function copyDir(
|
||||
source: string,
|
||||
destination: string,
|
||||
@@ -140,7 +154,7 @@ async function executeAppLocalNode({
|
||||
installCommand?: string | null;
|
||||
startCommand?: string | null;
|
||||
}): Promise<void> {
|
||||
const command = getCommand({ installCommand, startCommand });
|
||||
const command = getCommand({ appId, installCommand, startCommand });
|
||||
const spawnedProcess = spawn(command, [], {
|
||||
cwd: appPath,
|
||||
shell: true,
|
||||
@@ -408,6 +422,7 @@ RUN npm install -g pnpm
|
||||
});
|
||||
|
||||
// Run the Docker container
|
||||
const port = getAppPort(appId);
|
||||
const process = spawn(
|
||||
"docker",
|
||||
[
|
||||
@@ -416,7 +431,7 @@ RUN npm install -g pnpm
|
||||
"--name",
|
||||
containerName,
|
||||
"-p",
|
||||
"32100:32100",
|
||||
`${port}:${port}`,
|
||||
"-v",
|
||||
`${appPath}:/app`,
|
||||
"-v",
|
||||
@@ -428,7 +443,7 @@ RUN npm install -g pnpm
|
||||
`dyad-app-${appId}`,
|
||||
"sh",
|
||||
"-c",
|
||||
getCommand({ installCommand, startCommand }),
|
||||
getCommand({ appId, installCommand, startCommand }),
|
||||
],
|
||||
{
|
||||
stdio: "pipe",
|
||||
@@ -585,18 +600,11 @@ export function registerAppHandlers() {
|
||||
});
|
||||
|
||||
// Initialize git repo and create first commit
|
||||
await git.init({
|
||||
fs: fs,
|
||||
dir: fullAppPath,
|
||||
defaultBranch: "main",
|
||||
});
|
||||
|
||||
await gitInit({ path: fullAppPath, ref: "main" });
|
||||
|
||||
// Stage all files
|
||||
await git.add({
|
||||
fs: fs,
|
||||
dir: fullAppPath,
|
||||
filepath: ".",
|
||||
});
|
||||
await gitAdd({ path: fullAppPath, filepath: "." });
|
||||
|
||||
// Create initial commit
|
||||
const commitHash = await gitCommit({
|
||||
@@ -657,18 +665,10 @@ export function registerAppHandlers() {
|
||||
|
||||
if (!withHistory) {
|
||||
// Initialize git repo and create first commit
|
||||
await git.init({
|
||||
fs: fs,
|
||||
dir: newAppPath,
|
||||
defaultBranch: "main",
|
||||
});
|
||||
await gitInit({ path: newAppPath, ref: "main" });
|
||||
|
||||
// Stage all files
|
||||
await git.add({
|
||||
fs: fs,
|
||||
dir: newAppPath,
|
||||
filepath: ".",
|
||||
});
|
||||
await gitAdd({ path: newAppPath, filepath: "." });
|
||||
|
||||
// Create initial commit
|
||||
await gitCommit({
|
||||
@@ -822,8 +822,8 @@ export function registerAppHandlers() {
|
||||
|
||||
const appPath = getDyadAppPath(app.path);
|
||||
try {
|
||||
// There may have been a previous run that left a process on port 32100.
|
||||
await cleanUpPort(32100);
|
||||
// There may have been a previous run that left a process on this port.
|
||||
await cleanUpPort(getAppPort(appId));
|
||||
await executeApp({
|
||||
appPath,
|
||||
appId,
|
||||
@@ -923,8 +923,8 @@ export function registerAppHandlers() {
|
||||
logger.log(`App ${appId} not running. Proceeding to start.`);
|
||||
}
|
||||
|
||||
// There may have been a previous run that left a process on port 32100.
|
||||
await cleanUpPort(32100);
|
||||
// There may have been a previous run that left a process on this port.
|
||||
await cleanUpPort(getAppPort(appId));
|
||||
|
||||
// Now start the app again
|
||||
const app = await db.query.apps.findFirst({
|
||||
@@ -1007,6 +1007,8 @@ export function registerAppHandlers() {
|
||||
content,
|
||||
}: { appId: number; filePath: string; content: string },
|
||||
): Promise<EditAppFileReturnType> => {
|
||||
// It should already be normalized, but just in case.
|
||||
filePath = normalizePath(filePath);
|
||||
const app = await db.query.apps.findFirst({
|
||||
where: eq(apps.id, appId),
|
||||
});
|
||||
@@ -1049,11 +1051,7 @@ export function registerAppHandlers() {
|
||||
|
||||
// Check if git repository exists and commit the change
|
||||
if (fs.existsSync(path.join(appPath, ".git"))) {
|
||||
await git.add({
|
||||
fs,
|
||||
dir: appPath,
|
||||
filepath: filePath,
|
||||
});
|
||||
await gitAdd({ path: appPath, filepath: filePath });
|
||||
|
||||
await gitCommit({
|
||||
path: appPath,
|
||||
@@ -1065,18 +1063,49 @@ export function registerAppHandlers() {
|
||||
throw new Error(`Failed to write file: ${error.message}`);
|
||||
}
|
||||
|
||||
if (isServerFunction(filePath) && app.supabaseProjectId) {
|
||||
try {
|
||||
await deploySupabaseFunctions({
|
||||
supabaseProjectId: app.supabaseProjectId,
|
||||
functionName: path.basename(path.dirname(filePath)),
|
||||
content: content,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error deploying Supabase function ${filePath}:`, error);
|
||||
return {
|
||||
warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`,
|
||||
};
|
||||
if (app.supabaseProjectId) {
|
||||
// Check if shared module was modified - redeploy all functions
|
||||
if (isSharedServerModule(filePath)) {
|
||||
try {
|
||||
logger.info(
|
||||
`Shared module ${filePath} modified, redeploying all Supabase functions`,
|
||||
);
|
||||
const deployErrors = await deployAllSupabaseFunctions({
|
||||
appPath,
|
||||
supabaseProjectId: app.supabaseProjectId,
|
||||
});
|
||||
if (deployErrors.length > 0) {
|
||||
return {
|
||||
warning: `File saved, but some Supabase functions failed to deploy: ${deployErrors.join(", ")}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error redeploying Supabase functions after shared module change:`,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
warning: `File saved, but failed to redeploy Supabase functions: ${error}`,
|
||||
};
|
||||
}
|
||||
} else if (isServerFunction(filePath)) {
|
||||
// Regular function file - deploy just this function
|
||||
try {
|
||||
const functionName = extractFunctionNameFromPath(filePath);
|
||||
await deploySupabaseFunction({
|
||||
supabaseProjectId: app.supabaseProjectId,
|
||||
functionName,
|
||||
appPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error deploying Supabase function ${filePath}:`,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
@@ -1398,7 +1427,7 @@ export function registerAppHandlers() {
|
||||
return withLock(appId, async () => {
|
||||
try {
|
||||
// Check if the old branch exists
|
||||
const branches = await git.listBranches({ fs, dir: appPath });
|
||||
const branches = await gitListBranches({ path: appPath });
|
||||
if (!branches.includes(oldBranchName)) {
|
||||
throw new Error(`Branch '${oldBranchName}' not found.`);
|
||||
}
|
||||
@@ -1414,11 +1443,10 @@ export function registerAppHandlers() {
|
||||
);
|
||||
}
|
||||
|
||||
await git.renameBranch({
|
||||
fs: fs,
|
||||
dir: appPath,
|
||||
oldref: oldBranchName,
|
||||
ref: newBranchName,
|
||||
await gitRenameBranch({
|
||||
path: appPath,
|
||||
oldBranch: oldBranchName,
|
||||
newBranch: newBranchName,
|
||||
});
|
||||
logger.info(
|
||||
`Branch renamed from '${oldBranchName}' to '${newBranchName}' for app ${appId}`,
|
||||
@@ -1550,16 +1578,18 @@ export function registerAppHandlers() {
|
||||
}
|
||||
|
||||
function getCommand({
|
||||
appId,
|
||||
installCommand,
|
||||
startCommand,
|
||||
}: {
|
||||
appId: number;
|
||||
installCommand?: string | null;
|
||||
startCommand?: string | null;
|
||||
}) {
|
||||
const hasCustomCommands = !!installCommand?.trim() && !!startCommand?.trim();
|
||||
return hasCustomCommands
|
||||
? `${installCommand!.trim()} && ${startCommand!.trim()}`
|
||||
: DEFAULT_COMMAND;
|
||||
: getDefaultCommand(appId);
|
||||
}
|
||||
|
||||
async function cleanUpPort(port: number) {
|
||||
|
||||
@@ -205,7 +205,7 @@ async function applyCapacitor({
|
||||
// Install Capacitor dependencies
|
||||
await simpleSpawn({
|
||||
command:
|
||||
"pnpm add @capacitor/core @capacitor/cli @capacitor/ios @capacitor/android || npm install @capacitor/core @capacitor/cli @capacitor/ios @capacitor/android --legacy-peer-deps",
|
||||
"pnpm add @capacitor/core@7.4.4 @capacitor/cli@7.4.4 @capacitor/ios@7.4.4 @capacitor/android@7.4.4 || npm install @capacitor/core@7.4.4 @capacitor/cli@7.4.4 @capacitor/ios@7.4.4 @capacitor/android@7.4.4 --legacy-peer-deps",
|
||||
cwd: appPath,
|
||||
successMessage: "Capacitor dependencies installed successfully",
|
||||
errorPrefix: "Failed to install Capacitor dependencies",
|
||||
|
||||
@@ -3,13 +3,12 @@ import { db } from "../../db";
|
||||
import { apps, chats, messages } from "../../db/schema";
|
||||
import { desc, eq, and, like } from "drizzle-orm";
|
||||
import type { ChatSearchResult, ChatSummary } from "../../lib/schemas";
|
||||
import * as git from "isomorphic-git";
|
||||
import * as fs from "fs";
|
||||
import { createLoggedHandler } from "./safe_handle";
|
||||
|
||||
import log from "electron-log";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
import { UpdateChatParams } from "../ipc_types";
|
||||
import { getCurrentCommitHash } from "../utils/git_utils";
|
||||
|
||||
const logger = log.scope("chat_handlers");
|
||||
const handle = createLoggedHandler(logger);
|
||||
@@ -31,9 +30,8 @@ export function registerChatHandlers() {
|
||||
let initialCommitHash = null;
|
||||
try {
|
||||
// Get the current git revision of main branch
|
||||
initialCommitHash = await git.resolveRef({
|
||||
fs,
|
||||
dir: getDyadAppPath(app.path),
|
||||
initialCommitHash = await getCurrentCommitHash({
|
||||
path: getDyadAppPath(app.path),
|
||||
ref: "main",
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import git from "isomorphic-git";
|
||||
import http from "isomorphic-git/http/node";
|
||||
import { app } from "electron";
|
||||
import { copyDirectoryRecursive } from "../utils/file_utils";
|
||||
import { gitClone, getCurrentCommitHash } from "../utils/git_utils";
|
||||
import { readSettings } from "@/main/settings";
|
||||
import { getTemplateOrThrow } from "../utils/template_utils";
|
||||
import log from "electron-log";
|
||||
@@ -35,9 +34,6 @@ export async function createFromTemplate({
|
||||
}
|
||||
|
||||
async function cloneRepo(repoUrl: string): Promise<string> {
|
||||
let orgName: string;
|
||||
let repoName: string;
|
||||
|
||||
const url = new URL(repoUrl);
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error("Repository URL must use HTTPS.");
|
||||
@@ -55,8 +51,8 @@ async function cloneRepo(repoUrl: string): Promise<string> {
|
||||
);
|
||||
}
|
||||
|
||||
orgName = pathParts[0];
|
||||
repoName = path.basename(pathParts[1], ".git"); // Remove .git suffix if present
|
||||
const orgName = pathParts[0];
|
||||
const repoName = path.basename(pathParts[1], ".git"); // Remove .git suffix if present
|
||||
|
||||
if (!orgName || !repoName) {
|
||||
// This case should ideally be caught by pathParts.length !== 2
|
||||
@@ -83,41 +79,31 @@ async function cloneRepo(repoUrl: string): Promise<string> {
|
||||
const apiUrl = `https://api.github.com/repos/${orgName}/${repoName}/commits/HEAD`;
|
||||
logger.info(`Fetching remote SHA from ${apiUrl}`);
|
||||
|
||||
let remoteSha: string | undefined;
|
||||
|
||||
const response = await http.request({
|
||||
url: apiUrl,
|
||||
// Use native fetch instead of isomorphic-git http.request
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "Dyad", // GitHub API requires a User-Agent
|
||||
"User-Agent": "Dyad", // GitHub API requires this
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.statusCode === 200 && response.body) {
|
||||
// Convert AsyncIterableIterator<Uint8Array> to string
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of response.body) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const responseBodyStr = Buffer.concat(chunks).toString("utf8");
|
||||
const commitData = JSON.parse(responseBodyStr);
|
||||
remoteSha = commitData.sha;
|
||||
if (!remoteSha) {
|
||||
throw new Error("SHA not found in GitHub API response.");
|
||||
}
|
||||
logger.info(`Successfully fetched remote SHA: ${remoteSha}`);
|
||||
} else {
|
||||
// Handle non-200 responses
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API request failed with status ${response.statusCode}: ${response.statusMessage}`,
|
||||
`GitHub API request failed with status ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
// Parse JSON directly (fetch handles streaming internally)
|
||||
const commitData = await response.json();
|
||||
const remoteSha = commitData.sha;
|
||||
if (!remoteSha) {
|
||||
throw new Error("SHA not found in GitHub API response.");
|
||||
}
|
||||
|
||||
const localSha = await git.resolveRef({
|
||||
fs,
|
||||
dir: cachePath,
|
||||
ref: "HEAD",
|
||||
});
|
||||
logger.info(`Successfully fetched remote SHA: ${remoteSha}`);
|
||||
|
||||
// Compare with local SHA
|
||||
const localSha = await getCurrentCommitHash({ path: cachePath });
|
||||
|
||||
if (remoteSha === localSha) {
|
||||
logger.info(
|
||||
@@ -129,7 +115,7 @@ async function cloneRepo(repoUrl: string): Promise<string> {
|
||||
`Local cache for ${repoName} (SHA: ${localSha}) is outdated (Remote SHA: ${remoteSha}). Removing and re-cloning.`,
|
||||
);
|
||||
fs.rmSync(cachePath, { recursive: true, force: true });
|
||||
// Proceed to clone
|
||||
// Continue to clone…
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
@@ -144,14 +130,7 @@ async function cloneRepo(repoUrl: string): Promise<string> {
|
||||
|
||||
logger.info(`Cloning ${repoUrl} to ${cachePath}`);
|
||||
try {
|
||||
await git.clone({
|
||||
fs,
|
||||
http,
|
||||
dir: cachePath,
|
||||
url: repoUrl,
|
||||
singleBranch: true,
|
||||
depth: 1,
|
||||
});
|
||||
await gitClone({ path: cachePath, url: repoUrl, depth: 1 });
|
||||
logger.info(`Successfully cloned ${repoUrl} to ${cachePath}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to clone ${repoUrl} to ${cachePath}: `, err);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ipcMain, BrowserWindow, IpcMainInvokeEvent } from "electron";
|
||||
import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process
|
||||
import { writeSettings, readSettings } from "../../main/settings";
|
||||
import git, { clone } from "isomorphic-git";
|
||||
import http from "isomorphic-git/http/node";
|
||||
import { gitSetRemoteUrl, gitPush, gitClone } from "../utils/git_utils";
|
||||
import * as schema from "../../db/schema";
|
||||
import fs from "node:fs";
|
||||
import { getDyadAppPath } from "../../paths/paths";
|
||||
@@ -575,25 +574,17 @@ async function handlePushToGithub(
|
||||
? `${GITHUB_GIT_BASE}/${app.githubOrg}/${app.githubRepo}.git`
|
||||
: `https://${accessToken}:x-oauth-basic@github.com/${app.githubOrg}/${app.githubRepo}.git`;
|
||||
// Set or update remote URL using git config
|
||||
await git.setConfig({
|
||||
fs,
|
||||
dir: appPath,
|
||||
path: "remote.origin.url",
|
||||
value: remoteUrl,
|
||||
await gitSetRemoteUrl({
|
||||
path: appPath,
|
||||
remoteUrl,
|
||||
});
|
||||
|
||||
// Push to GitHub
|
||||
await git.push({
|
||||
fs,
|
||||
http,
|
||||
dir: appPath,
|
||||
remote: "origin",
|
||||
ref: "main",
|
||||
remoteRef: branch,
|
||||
onAuth: () => ({
|
||||
username: accessToken,
|
||||
password: "x-oauth-basic",
|
||||
}),
|
||||
force: !!force,
|
||||
await gitPush({
|
||||
path: appPath,
|
||||
branch,
|
||||
accessToken,
|
||||
force,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
@@ -673,8 +664,11 @@ async function handleCloneRepoFromUrl(
|
||||
}
|
||||
|
||||
const appPath = getDyadAppPath(finalAppName);
|
||||
if (!fs.existsSync(appPath)) {
|
||||
fs.mkdirSync(appPath, { recursive: true });
|
||||
// Ensure the app directory exists if native git is disabled
|
||||
if (!settings.enableNativeGit) {
|
||||
if (!fs.existsSync(appPath)) {
|
||||
fs.mkdirSync(appPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
// Use authenticated URL if token exists, otherwise use public HTTPS URL
|
||||
const cloneUrl = accessToken
|
||||
@@ -683,17 +677,10 @@ async function handleCloneRepoFromUrl(
|
||||
: `https://${accessToken}:x-oauth-basic@github.com/${owner}/${repoName}.git`
|
||||
: `https://github.com/${owner}/${repoName}.git`; // Changed: use public HTTPS URL instead of original url
|
||||
try {
|
||||
await clone({
|
||||
fs,
|
||||
http,
|
||||
dir: appPath,
|
||||
await gitClone({
|
||||
path: appPath,
|
||||
url: cloneUrl,
|
||||
onAuth: accessToken
|
||||
? () => ({
|
||||
username: accessToken,
|
||||
password: "x-oauth-basic",
|
||||
})
|
||||
: undefined,
|
||||
accessToken,
|
||||
singleBranch: false,
|
||||
});
|
||||
} catch (cloneErr) {
|
||||
|
||||
@@ -8,11 +8,10 @@ import { apps } from "@/db/schema";
|
||||
import { db } from "@/db";
|
||||
import { chats } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import git from "isomorphic-git";
|
||||
|
||||
import { ImportAppParams, ImportAppResult } from "../ipc_types";
|
||||
import { copyDirectoryRecursive } from "../utils/file_utils";
|
||||
import { gitCommit } from "../utils/git_utils";
|
||||
import { gitCommit, gitAdd, gitInit } from "../utils/git_utils";
|
||||
|
||||
const logger = log.scope("import-handlers");
|
||||
const handle = createLoggedHandler(logger);
|
||||
@@ -106,18 +105,11 @@ export function registerImportHandlers() {
|
||||
.catch(() => false);
|
||||
if (!isGitRepo) {
|
||||
// Initialize git repo and create first commit
|
||||
await git.init({
|
||||
fs: fs,
|
||||
dir: destPath,
|
||||
defaultBranch: "main",
|
||||
});
|
||||
await gitInit({ path: destPath, ref: "main" });
|
||||
|
||||
// Stage all files
|
||||
await git.add({
|
||||
fs: fs,
|
||||
dir: destPath,
|
||||
filepath: ".",
|
||||
});
|
||||
|
||||
await gitAdd({ path: destPath, filepath: "." });
|
||||
|
||||
// Create initial commit
|
||||
await gitCommit({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user