Skip to content

feat: Implement Dev panel & IDE functionalities #1740

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Apr 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/studio/common/ide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export class IDE {
static readonly CURSOR = new IDE('Cursor', IdeType.CURSOR, 'cursor', 'CursorLogo');
static readonly ZED = new IDE('Zed', IdeType.ZED, 'zed', 'ZedLogo');
static readonly WINDSURF = new IDE('Windsurf', IdeType.WINDSURF, 'windsurf', 'WindsurfLogo');
static readonly ONLOOK = new IDE('Onlook', IdeType.ONLOOK, 'onlook', 'Code');

private constructor(
public readonly displayName: string,
Expand All @@ -29,19 +30,26 @@ export class IDE {
return IDE.ZED;
case IdeType.WINDSURF:
return IDE.WINDSURF;
case IdeType.ONLOOK:
return IDE.ONLOOK;
default:
throw new Error(`Unknown IDE type: ${type}`);
}
}

static getAll(): IDE[] {
return [this.VS_CODE, this.CURSOR, this.ZED, this.WINDSURF];
return [this.VS_CODE, this.CURSOR, this.ZED, this.WINDSURF, this.ONLOOK];
}

getCodeCommand(templateNode: TemplateNode) {
const filePath = templateNode.path;
const startTag = templateNode.startTag;
const endTag = templateNode.endTag || startTag;

if (this.type === IdeType.ONLOOK) {
return `internal://${filePath}`;
}

let codeCommand = `${this.command}://file/${filePath}`;

if (startTag && endTag) {
Expand All @@ -59,6 +67,10 @@ export class IDE {
}

getCodeFileCommand(filePath: string, line?: number) {
if (this.type === IdeType.ONLOOK) {
return `internal://${filePath}`;
}

let command = `${this.command}://file/${filePath}`;
if (line) {
command += `:${line}`;
Expand Down
148 changes: 148 additions & 0 deletions apps/studio/electron/main/code/files-scan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { promises as fs } from 'fs';
import * as path from 'path';
import { nanoid } from 'nanoid';
import { CUSTOM_OUTPUT_DIR } from '@onlook/models';

export interface FileNode {
id: string;
name: string;
path: string;
isDirectory: boolean;
children?: FileNode[];
extension?: string;
}

// Directories to ignore during scanning
const IGNORED_DIRECTORIES = ['node_modules', '.git', '.next', 'dist', 'build', CUSTOM_OUTPUT_DIR];

// Extensions focus for code editing
const PREFERRED_EXTENSIONS = [
'.js',
'.jsx',
'.ts',
'.tsx',
'.html',
'.css',
'.scss',
'.json',
'.md',
'.mdx',
];

/**
* Scans a directory recursively to build a tree of files and folders
*/
async function scanDirectory(
dir: string,
maxDepth: number = 10,
currentDepth: number = 0,
): Promise<FileNode[]> {
// Prevents infinite recursion and going too deep
if (currentDepth >= maxDepth) {
return [];
}

try {
const entries = await fs.readdir(dir, { withFileTypes: true });
const nodes: FileNode[] = [];

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);

// Skips ignored directories
if (entry.isDirectory() && IGNORED_DIRECTORIES.includes(entry.name)) {
continue;
}

if (entry.isDirectory()) {
const children = await scanDirectory(fullPath, maxDepth, currentDepth + 1);
if (children.length > 0) {
nodes.push({
id: nanoid(),
name: entry.name,
path: fullPath,
isDirectory: true,
children,
});
}
} else {
const extension = path.extname(entry.name);
nodes.push({
id: nanoid(),
name: entry.name,
path: fullPath,
isDirectory: false,
extension,
});
}
}

// Sorts directories first, then files
return nodes.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) {
return -1;
}
if (!a.isDirectory && b.isDirectory) {
return 1;
}
return a.name.localeCompare(b.name);
});
} catch (error) {
console.error(`Error scanning directory ${dir}:`, error);
return [];
}
}

/**
* Scans project files and returns a tree structure
*/
export async function scanProjectFiles(projectRoot: string): Promise<FileNode[]> {
try {
return await scanDirectory(projectRoot);
} catch (error) {
console.error('Error scanning project files:', error);
return [];
}
}

/**
* Gets a flat list of all files with specified extensions
*/
export async function getProjectFiles(
projectRoot: string,
extensions: string[] = PREFERRED_EXTENSIONS,
): Promise<FileNode[]> {
const allFiles: FileNode[] = [];

async function collectFiles(dir: string): Promise<void> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);

if (entry.isDirectory()) {
if (!IGNORED_DIRECTORIES.includes(entry.name)) {
await collectFiles(fullPath);
}
} else {
const extension = path.extname(entry.name);
if (extensions.length === 0 || extensions.includes(extension)) {
allFiles.push({
id: nanoid(),
name: entry.name,
path: fullPath,
isDirectory: false,
extension,
});
}
}
}
} catch (error) {
console.error(`Error collecting files from ${dir}:`, error);
}
}

await collectFiles(projectRoot);
return allFiles;
}
37 changes: 36 additions & 1 deletion apps/studio/electron/main/code/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { CodeDiff } from '@onlook/models/code';
import { MainChannels } from '@onlook/models/constants';
import type { TemplateNode } from '@onlook/models/element';
import { DEFAULT_IDE } from '@onlook/models/ide';
import { DEFAULT_IDE, IdeType } from '@onlook/models/ide';
import { dialog, shell } from 'electron';
import { mainWindow } from '..';
import { GENERATE_CODE_OPTIONS } from '../run/helpers';
import { PersistentStorage } from '../storage';
import { generateCode } from './diff/helpers';
Expand Down Expand Up @@ -85,12 +87,45 @@ function getIdeFromUserSettings(): IDE {
export function openInIde(templateNode: TemplateNode) {
const ide = getIdeFromUserSettings();
const command = ide.getCodeCommand(templateNode);

if (ide.type === IdeType.ONLOOK) {
// Send an event to the renderer process to view the file in Onlook's internal IDE
const startTag = templateNode.startTag;
const endTag = templateNode.endTag || startTag;

if (startTag && endTag) {
mainWindow?.webContents.send(MainChannels.VIEW_CODE_IN_ONLOOK, {
filePath: templateNode.path,
startLine: startTag.start.line,
startColumn: startTag.start.column,
endLine: endTag.end.line,
endColumn: endTag.end.column - 1,
});
} else {
mainWindow?.webContents.send(MainChannels.VIEW_CODE_IN_ONLOOK, {
filePath: templateNode.path,
});
}
return;
}

shell.openExternal(command);
}

export function openFileInIde(filePath: string, line?: number) {
const ide = getIdeFromUserSettings();
const command = ide.getCodeFileCommand(filePath, line);

if (ide.type === IdeType.ONLOOK) {
// Send an event to the renderer process to view the file in Onlook's internal IDE
mainWindow?.webContents.send(MainChannels.VIEW_CODE_IN_ONLOOK, {
filePath,
line,
startLine: line,
});
return;
}

shell.openExternal(command);
}

Expand Down
22 changes: 22 additions & 0 deletions apps/studio/electron/main/events/files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { MainChannels } from '@onlook/models/constants';
import { ipcMain } from 'electron';
import { scanProjectFiles, getProjectFiles } from '../code/files-scan';

export function listenForFileMessages() {
// Scan all project files and return a tree structure
ipcMain.handle(MainChannels.SCAN_FILES, async (_event, projectRoot: string) => {
const files = await scanProjectFiles(projectRoot);
return files;
});

// Get a flat list of all files with the given extensions
ipcMain.handle(
MainChannels.GET_PROJECT_FILES,
async (
_event,
{ projectRoot, extensions }: { projectRoot: string; extensions?: string[] },
) => {
return await getProjectFiles(projectRoot, extensions);
},
);
}
2 changes: 2 additions & 0 deletions apps/studio/electron/main/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { listenForAuthMessages } from './auth';
import { listenForChatMessages } from './chat';
import { listenForCodeMessages } from './code';
import { listenForCreateMessages } from './create';
import { listenForFileMessages } from './files';
import { listenForHostingMessages } from './hosting';
import { listenForPageMessages } from './page';
import { listenForPaymentMessages } from './payments';
Expand All @@ -31,6 +32,7 @@ export function listenForIpcMessages() {
listenForPageMessages();
listenForAssetMessages();
listenForVersionsMessages();
listenForFileMessages();
}

export function removeIpcListeners() {
Expand Down
6 changes: 6 additions & 0 deletions apps/studio/electron/preload/webview/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { getElementIndex } from './elements/move';
import { drag, endAllDrag, endDrag, startDrag } from './elements/move/drag';
import { getComputedStyleByDomId } from './elements/style';
import { editText, startEditingText, stopEditingText } from './elements/text';
import { onOnlookViewCode, removeOnlookViewCode, viewCodeInOnlook } from './events/code';
import { setWebviewId } from './state';
import { getTheme, setTheme } from './theme';

Expand Down Expand Up @@ -63,5 +64,10 @@ export function setApi() {
startEditingText,
editText,
stopEditingText,

// Onlook IDE
onOnlookViewCode,
removeOnlookViewCode,
viewCodeInOnlook,
});
}
20 changes: 20 additions & 0 deletions apps/studio/electron/preload/webview/events/code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { MainChannels } from '@onlook/models/constants';
import type { IpcRendererEvent } from 'electron';
import { ipcRenderer } from 'electron';

export function onOnlookViewCode(callback: (data: any) => void) {
const subscription = (_event: IpcRendererEvent, data: any) => callback(data);
ipcRenderer.on(MainChannels.VIEW_CODE_IN_ONLOOK, subscription);
return () => ipcRenderer.removeListener(MainChannels.VIEW_CODE_IN_ONLOOK, subscription);
}

export function removeOnlookViewCode(callback: (data: any) => void) {
ipcRenderer.removeListener(
MainChannels.VIEW_CODE_IN_ONLOOK,
callback as (event: IpcRendererEvent, ...args: any[]) => void,
);
}

export function viewCodeInOnlook(args: any) {
return ipcRenderer.invoke(MainChannels.VIEW_CODE_IN_ONLOOK, args);
}
14 changes: 14 additions & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@
},
"dependencies": {
"@ai-sdk/anthropic": "^1.1.17",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/closebrackets": "^0.19.2",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lint": "^6.8.5",
"@codemirror/matchbrackets": "^0.19.4",
"@codemirror/rectangular-selection": "^0.19.2",
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource-variable/inter": "^5.1.0",
Expand All @@ -46,6 +59,7 @@
"@parcel/watcher": "^2.5.1",
"@shikijs/monaco": "^1.22.0",
"@supabase/supabase-js": "^2.45.6",
"@uiw/react-codemirror": "^4.21.21",
"@trainloop/sdk": "^0.1.8",
"@types/webfontloader": "^1.6.38",
"@xterm/xterm": "^5.6.0-beta.98",
Expand Down
Loading