Skip to content

Commit 0897527

Browse files
authored
feat: Implement Dev panel & IDE functionalities (#1740)
* implemented code mirror code * implement file scans & other ide functionalities * fix bun lockfile * clean up * more clean up * Move tab change logic * Clean up * fix undo in one file loads code from another file * fix "View in code" in brand panel does not work in new IDE * persist devtab state and improve "view in onlook" functionalities * fix Long tab names break into multiple lines instead of truncating * Ignore next-prod dir
1 parent 47321cd commit 0897527

File tree

23 files changed

+1693
-147
lines changed

23 files changed

+1693
-147
lines changed

apps/studio/common/ide.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export class IDE {
77
static readonly CURSOR = new IDE('Cursor', IdeType.CURSOR, 'cursor', 'CursorLogo');
88
static readonly ZED = new IDE('Zed', IdeType.ZED, 'zed', 'ZedLogo');
99
static readonly WINDSURF = new IDE('Windsurf', IdeType.WINDSURF, 'windsurf', 'WindsurfLogo');
10+
static readonly ONLOOK = new IDE('Onlook', IdeType.ONLOOK, 'onlook', 'Code');
1011

1112
private constructor(
1213
public readonly displayName: string,
@@ -29,19 +30,26 @@ export class IDE {
2930
return IDE.ZED;
3031
case IdeType.WINDSURF:
3132
return IDE.WINDSURF;
33+
case IdeType.ONLOOK:
34+
return IDE.ONLOOK;
3235
default:
3336
throw new Error(`Unknown IDE type: ${type}`);
3437
}
3538
}
3639

3740
static getAll(): IDE[] {
38-
return [this.VS_CODE, this.CURSOR, this.ZED, this.WINDSURF];
41+
return [this.VS_CODE, this.CURSOR, this.ZED, this.WINDSURF, this.ONLOOK];
3942
}
4043

4144
getCodeCommand(templateNode: TemplateNode) {
4245
const filePath = templateNode.path;
4346
const startTag = templateNode.startTag;
4447
const endTag = templateNode.endTag || startTag;
48+
49+
if (this.type === IdeType.ONLOOK) {
50+
return `internal://${filePath}`;
51+
}
52+
4553
let codeCommand = `${this.command}://file/${filePath}`;
4654

4755
if (startTag && endTag) {
@@ -59,6 +67,10 @@ export class IDE {
5967
}
6068

6169
getCodeFileCommand(filePath: string, line?: number) {
70+
if (this.type === IdeType.ONLOOK) {
71+
return `internal://${filePath}`;
72+
}
73+
6274
let command = `${this.command}://file/${filePath}`;
6375
if (line) {
6476
command += `:${line}`;
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { promises as fs } from 'fs';
2+
import * as path from 'path';
3+
import { nanoid } from 'nanoid';
4+
import { CUSTOM_OUTPUT_DIR } from '@onlook/models';
5+
6+
export interface FileNode {
7+
id: string;
8+
name: string;
9+
path: string;
10+
isDirectory: boolean;
11+
children?: FileNode[];
12+
extension?: string;
13+
}
14+
15+
// Directories to ignore during scanning
16+
const IGNORED_DIRECTORIES = ['node_modules', '.git', '.next', 'dist', 'build', CUSTOM_OUTPUT_DIR];
17+
18+
// Extensions focus for code editing
19+
const PREFERRED_EXTENSIONS = [
20+
'.js',
21+
'.jsx',
22+
'.ts',
23+
'.tsx',
24+
'.html',
25+
'.css',
26+
'.scss',
27+
'.json',
28+
'.md',
29+
'.mdx',
30+
];
31+
32+
/**
33+
* Scans a directory recursively to build a tree of files and folders
34+
*/
35+
async function scanDirectory(
36+
dir: string,
37+
maxDepth: number = 10,
38+
currentDepth: number = 0,
39+
): Promise<FileNode[]> {
40+
// Prevents infinite recursion and going too deep
41+
if (currentDepth >= maxDepth) {
42+
return [];
43+
}
44+
45+
try {
46+
const entries = await fs.readdir(dir, { withFileTypes: true });
47+
const nodes: FileNode[] = [];
48+
49+
for (const entry of entries) {
50+
const fullPath = path.join(dir, entry.name);
51+
52+
// Skips ignored directories
53+
if (entry.isDirectory() && IGNORED_DIRECTORIES.includes(entry.name)) {
54+
continue;
55+
}
56+
57+
if (entry.isDirectory()) {
58+
const children = await scanDirectory(fullPath, maxDepth, currentDepth + 1);
59+
if (children.length > 0) {
60+
nodes.push({
61+
id: nanoid(),
62+
name: entry.name,
63+
path: fullPath,
64+
isDirectory: true,
65+
children,
66+
});
67+
}
68+
} else {
69+
const extension = path.extname(entry.name);
70+
nodes.push({
71+
id: nanoid(),
72+
name: entry.name,
73+
path: fullPath,
74+
isDirectory: false,
75+
extension,
76+
});
77+
}
78+
}
79+
80+
// Sorts directories first, then files
81+
return nodes.sort((a, b) => {
82+
if (a.isDirectory && !b.isDirectory) {
83+
return -1;
84+
}
85+
if (!a.isDirectory && b.isDirectory) {
86+
return 1;
87+
}
88+
return a.name.localeCompare(b.name);
89+
});
90+
} catch (error) {
91+
console.error(`Error scanning directory ${dir}:`, error);
92+
return [];
93+
}
94+
}
95+
96+
/**
97+
* Scans project files and returns a tree structure
98+
*/
99+
export async function scanProjectFiles(projectRoot: string): Promise<FileNode[]> {
100+
try {
101+
return await scanDirectory(projectRoot);
102+
} catch (error) {
103+
console.error('Error scanning project files:', error);
104+
return [];
105+
}
106+
}
107+
108+
/**
109+
* Gets a flat list of all files with specified extensions
110+
*/
111+
export async function getProjectFiles(
112+
projectRoot: string,
113+
extensions: string[] = PREFERRED_EXTENSIONS,
114+
): Promise<FileNode[]> {
115+
const allFiles: FileNode[] = [];
116+
117+
async function collectFiles(dir: string): Promise<void> {
118+
try {
119+
const entries = await fs.readdir(dir, { withFileTypes: true });
120+
121+
for (const entry of entries) {
122+
const fullPath = path.join(dir, entry.name);
123+
124+
if (entry.isDirectory()) {
125+
if (!IGNORED_DIRECTORIES.includes(entry.name)) {
126+
await collectFiles(fullPath);
127+
}
128+
} else {
129+
const extension = path.extname(entry.name);
130+
if (extensions.length === 0 || extensions.includes(extension)) {
131+
allFiles.push({
132+
id: nanoid(),
133+
name: entry.name,
134+
path: fullPath,
135+
isDirectory: false,
136+
extension,
137+
});
138+
}
139+
}
140+
}
141+
} catch (error) {
142+
console.error(`Error collecting files from ${dir}:`, error);
143+
}
144+
}
145+
146+
await collectFiles(projectRoot);
147+
return allFiles;
148+
}

apps/studio/electron/main/code/index.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { CodeDiff } from '@onlook/models/code';
2+
import { MainChannels } from '@onlook/models/constants';
23
import type { TemplateNode } from '@onlook/models/element';
3-
import { DEFAULT_IDE } from '@onlook/models/ide';
4+
import { DEFAULT_IDE, IdeType } from '@onlook/models/ide';
45
import { dialog, shell } from 'electron';
6+
import { mainWindow } from '..';
57
import { GENERATE_CODE_OPTIONS } from '../run/helpers';
68
import { PersistentStorage } from '../storage';
79
import { generateCode } from './diff/helpers';
@@ -85,12 +87,45 @@ function getIdeFromUserSettings(): IDE {
8587
export function openInIde(templateNode: TemplateNode) {
8688
const ide = getIdeFromUserSettings();
8789
const command = ide.getCodeCommand(templateNode);
90+
91+
if (ide.type === IdeType.ONLOOK) {
92+
// Send an event to the renderer process to view the file in Onlook's internal IDE
93+
const startTag = templateNode.startTag;
94+
const endTag = templateNode.endTag || startTag;
95+
96+
if (startTag && endTag) {
97+
mainWindow?.webContents.send(MainChannels.VIEW_CODE_IN_ONLOOK, {
98+
filePath: templateNode.path,
99+
startLine: startTag.start.line,
100+
startColumn: startTag.start.column,
101+
endLine: endTag.end.line,
102+
endColumn: endTag.end.column - 1,
103+
});
104+
} else {
105+
mainWindow?.webContents.send(MainChannels.VIEW_CODE_IN_ONLOOK, {
106+
filePath: templateNode.path,
107+
});
108+
}
109+
return;
110+
}
111+
88112
shell.openExternal(command);
89113
}
90114

91115
export function openFileInIde(filePath: string, line?: number) {
92116
const ide = getIdeFromUserSettings();
93117
const command = ide.getCodeFileCommand(filePath, line);
118+
119+
if (ide.type === IdeType.ONLOOK) {
120+
// Send an event to the renderer process to view the file in Onlook's internal IDE
121+
mainWindow?.webContents.send(MainChannels.VIEW_CODE_IN_ONLOOK, {
122+
filePath,
123+
line,
124+
startLine: line,
125+
});
126+
return;
127+
}
128+
94129
shell.openExternal(command);
95130
}
96131

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { MainChannels } from '@onlook/models/constants';
2+
import { ipcMain } from 'electron';
3+
import { scanProjectFiles, getProjectFiles } from '../code/files-scan';
4+
5+
export function listenForFileMessages() {
6+
// Scan all project files and return a tree structure
7+
ipcMain.handle(MainChannels.SCAN_FILES, async (_event, projectRoot: string) => {
8+
const files = await scanProjectFiles(projectRoot);
9+
return files;
10+
});
11+
12+
// Get a flat list of all files with the given extensions
13+
ipcMain.handle(
14+
MainChannels.GET_PROJECT_FILES,
15+
async (
16+
_event,
17+
{ projectRoot, extensions }: { projectRoot: string; extensions?: string[] },
18+
) => {
19+
return await getProjectFiles(projectRoot, extensions);
20+
},
21+
);
22+
}

apps/studio/electron/main/events/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { listenForAuthMessages } from './auth';
1010
import { listenForChatMessages } from './chat';
1111
import { listenForCodeMessages } from './code';
1212
import { listenForCreateMessages } from './create';
13+
import { listenForFileMessages } from './files';
1314
import { listenForHostingMessages } from './hosting';
1415
import { listenForPageMessages } from './page';
1516
import { listenForPaymentMessages } from './payments';
@@ -31,6 +32,7 @@ export function listenForIpcMessages() {
3132
listenForPageMessages();
3233
listenForAssetMessages();
3334
listenForVersionsMessages();
35+
listenForFileMessages();
3436
}
3537

3638
export function removeIpcListeners() {

apps/studio/electron/preload/webview/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { getElementIndex } from './elements/move';
2121
import { drag, endAllDrag, endDrag, startDrag } from './elements/move/drag';
2222
import { getComputedStyleByDomId } from './elements/style';
2323
import { editText, startEditingText, stopEditingText } from './elements/text';
24+
import { onOnlookViewCode, removeOnlookViewCode, viewCodeInOnlook } from './events/code';
2425
import { setWebviewId } from './state';
2526
import { getTheme, setTheme } from './theme';
2627

@@ -63,5 +64,10 @@ export function setApi() {
6364
startEditingText,
6465
editText,
6566
stopEditingText,
67+
68+
// Onlook IDE
69+
onOnlookViewCode,
70+
removeOnlookViewCode,
71+
viewCodeInOnlook,
6672
});
6773
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { MainChannels } from '@onlook/models/constants';
2+
import type { IpcRendererEvent } from 'electron';
3+
import { ipcRenderer } from 'electron';
4+
5+
export function onOnlookViewCode(callback: (data: any) => void) {
6+
const subscription = (_event: IpcRendererEvent, data: any) => callback(data);
7+
ipcRenderer.on(MainChannels.VIEW_CODE_IN_ONLOOK, subscription);
8+
return () => ipcRenderer.removeListener(MainChannels.VIEW_CODE_IN_ONLOOK, subscription);
9+
}
10+
11+
export function removeOnlookViewCode(callback: (data: any) => void) {
12+
ipcRenderer.removeListener(
13+
MainChannels.VIEW_CODE_IN_ONLOOK,
14+
callback as (event: IpcRendererEvent, ...args: any[]) => void,
15+
);
16+
}
17+
18+
export function viewCodeInOnlook(args: any) {
19+
return ipcRenderer.invoke(MainChannels.VIEW_CODE_IN_ONLOOK, args);
20+
}

apps/studio/package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@
3737
},
3838
"dependencies": {
3939
"@ai-sdk/anthropic": "^1.1.17",
40+
"@codemirror/autocomplete": "^6.18.6",
41+
"@codemirror/closebrackets": "^0.19.2",
42+
"@codemirror/lang-css": "^6.0.0",
43+
"@codemirror/lang-html": "^6.0.0",
44+
"@codemirror/lang-javascript": "^6.0.0",
45+
"@codemirror/lang-json": "^6.0.0",
46+
"@codemirror/lang-markdown": "^6.3.2",
47+
"@codemirror/lint": "^6.8.5",
48+
"@codemirror/matchbrackets": "^0.19.4",
49+
"@codemirror/rectangular-selection": "^0.19.2",
50+
"@codemirror/search": "^6.5.10",
51+
"@codemirror/state": "^6.0.0",
52+
"@codemirror/view": "^6.0.0",
4053
"@emotion/react": "^11.13.3",
4154
"@emotion/styled": "^11.13.0",
4255
"@fontsource-variable/inter": "^5.1.0",
@@ -46,6 +59,7 @@
4659
"@parcel/watcher": "^2.5.1",
4760
"@shikijs/monaco": "^1.22.0",
4861
"@supabase/supabase-js": "^2.45.6",
62+
"@uiw/react-codemirror": "^4.21.21",
4963
"@trainloop/sdk": "^0.1.8",
5064
"@types/webfontloader": "^1.6.38",
5165
"@xterm/xterm": "^5.6.0-beta.98",

0 commit comments

Comments
 (0)