Skip to content

feat: add component tab and creation functionality #1643

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
188 changes: 188 additions & 0 deletions apps/studio/electron/main/code/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function isUppercase(s: string) {
export interface ReactComponentDescriptor {
name: string;
sourceFilePath: string;
isDelete?: boolean;
}

function isExported(node: FunctionDeclaration | VariableStatement | ClassDeclaration) {
Expand Down Expand Up @@ -99,6 +100,7 @@ function extractReactComponentsFromFile(filePath: string) {
exportedComponents.push({
...descriptor,
sourceFilePath: sourceFile.getFilePath(),
isDelete: false,
});
}
});
Expand Down Expand Up @@ -139,5 +141,191 @@ export async function extractComponentsFromDirectory(dir: string) {
allExportedComponents.push(...components);
});

const updatedExportedComponent = checkIfComponentIsUsed(allExportedComponents, files);

const filteredComponents = updatedExportedComponent.filter((component) => {
const fileName = path.basename(component.sourceFilePath).toLowerCase();
return !(fileName === 'page.tsx' || fileName === 'layout.tsx');
});

return filteredComponents;
}

export async function duplicateComponent(filePath: string, componentName: string) {
try {
const directory = path.dirname(filePath);

const project = new Project();
const sourceFile = project.addSourceFileAtPath(filePath);

const baseName = componentName.replace(/\d+$/, '');
const files = await fs.readdir(directory);

const regex = new RegExp(`^${baseName}(\\d+)?.tsx$`);

const existingNumbers = files
.map((file) => {
const match = file.match(regex);
return match && match[1] ? parseInt(match[1], 10) : 0;
})
.filter((num) => num !== null)
.sort((a, b) => a - b);

const nextNumber = existingNumbers.length ? Math.max(...existingNumbers) + 1 : 1;
const newComponentName = `${baseName}${nextNumber}`;

const newFileName = `${newComponentName}.tsx`;
const newFilePath = path.join(directory, newFileName);

const clonedSourceFile = sourceFile.copy(newFilePath);

const nodesToRename = [
...clonedSourceFile.getFunctions(),
...clonedSourceFile.getVariableDeclarations(),
...clonedSourceFile.getClasses(),
];

nodesToRename.forEach((node) => {
if (node.getName() === componentName) {
node.rename(newComponentName);
}
});

await clonedSourceFile.save();

return newFilePath;
} catch (error) {
console.error('Error duplicating component:', error);
throw error;
}
}

export async function renameComponent(newName: string, filePath: string) {
try {
const directory = path.dirname(filePath);
const oldFileName = path.basename(filePath, '.tsx');
const newFilePath = path.join(directory, `${newName}.tsx`);

const project = new Project();
const sourceFile = project.addSourceFileAtPath(filePath);

const nodesToRename = [
...sourceFile.getFunctions(),
...sourceFile.getVariableDeclarations(),
...sourceFile.getClasses(),
];

let renamed = false;

nodesToRename.forEach((node) => {
if (node.getName() === oldFileName) {
node.rename(newName);
renamed = true;
}
});

if (!renamed) {
console.warn(`No matching component named '${oldFileName}' found for renaming.`);
}

await sourceFile.save();
await fs.rename(filePath, newFilePath);

return newFilePath;
} catch (error) {
console.error('Error renaming component:', error);
throw error;
}
}

export async function createNewComponent(componentName: string, filePath: string) {
try {
const dirPath = path.dirname(filePath);

const newfilePath = path.join(dirPath, `${componentName}.tsx`);

const componentTemplate = `export default function ${componentName}() {
return (
<div className="w-full min-h-screen flex items-center justify-center bg-white dark:bg-black transition-colors duration-200 flex-col p-4 gap-[32px]">
<div className="text-center text-gray-900 dark:text-gray-100 p-4">
<h1 className="text-4xl md:text-5xl font-semibold mb-4 tracking-tight">
This is a blank ${componentName}
</h1>
</div>
</div>
);
}`;

await fs.writeFile(newfilePath, componentTemplate, 'utf-8');

return newfilePath;
} catch (error) {
console.error('Error creating component:', error);
throw error;
}
}

function checkIfComponentIsUsed(
allExportedComponents: ReactComponentDescriptor[],
files: string[],
) {
const project = new Project();

files.forEach((filePath) => {
project.addSourceFileAtPath(filePath);
});

const componentFiles = allExportedComponents.map((comp) => comp.sourceFilePath);

const sourceFiles = project
.getSourceFiles()
.filter((file) => componentFiles.includes(file.getFilePath()));

allExportedComponents.forEach((component) => {
const componentName = component.name;

const isUsed = sourceFiles.some((file) => {
if (!file) {
return false;
}

const isImported = file.getImportDeclarations().some((importDecl) => {
const namedImports = importDecl
.getNamedImports()
.some((namedImport) => namedImport.getName() === componentName);
const defaultImport = importDecl.getDefaultImport()?.getText() === componentName;
return namedImports || defaultImport;
});

if (isImported) {
return true;
}

const isJsxUsage = file
.getDescendantsOfKind(ts.SyntaxKind.JsxOpeningElement)
.some((jsxElement) => {
const tagName = jsxElement.getTagNameNode()?.getText();
return tagName === componentName;
});

return isJsxUsage;
});

component.isDelete = !isUsed;
});

return allExportedComponents;
}

export async function deleteComponent(filePath: string) {
try {
await fs.access(filePath);

await fs.unlink(filePath);

console.log(`Component deleted successfully: ${filePath}`);
} catch (error) {
console.error('Error deleting component:', error);
throw error;
}
}
39 changes: 38 additions & 1 deletion apps/studio/electron/main/events/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { TemplateNode } from '@onlook/models/element';
import { ipcMain } from 'electron';
import { openFileInIde, openInIde, pickDirectory, readCodeBlock, writeCode } from '../code/';
import { getTemplateNodeClass } from '../code/classes';
import { extractComponentsFromDirectory } from '../code/components';
import { createNewComponent, deleteComponent, duplicateComponent, extractComponentsFromDirectory, renameComponent } from '../code/components';
import { getCodeDiffs } from '../code/diff';
import { isChildTextEditable } from '../code/diff/text';
import { readFile } from '../code/files';
Expand Down Expand Up @@ -148,4 +148,41 @@ export function listenForCodeMessages() {
const { projectRoot, groupName, colorName } = args;
return deleteTailwindColorGroup(projectRoot, groupName, colorName);
});

ipcMain.handle(MainChannels.DUPLICATE_COMPONENT, async (_, args) => {
const { filePath, componentName } = args as {
filePath: string;
componentName: string
};
const result = duplicateComponent(filePath, componentName);
return result;
});

ipcMain.handle(MainChannels.RENAME_COMPONENT, async (_, args) => {
const { newComponentName , filePath } = args as {
newComponentName: string
filePath: string;
};
const result = renameComponent(newComponentName , filePath);
return result;
});

ipcMain.handle(MainChannels.CREATE_COMPONENT, async (_, args) => {
const { componentName , oid } = args as {
componentName: string
filePath: string;
oid: string;
};
const templateNode = await runManager.getTemplateNode(oid);
const result = createNewComponent(componentName, templateNode?.path || "");
return result;
});

ipcMain.handle(MainChannels.DELETE_COMPONENT, async (_, args) => {
const { filePath } = args as {
filePath: string;
};
const result = deleteComponent(filePath);
return result;
});
}
13 changes: 11 additions & 2 deletions apps/studio/src/lib/editor/engine/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EditorMode, EditorTabValue, SettingsTabValue } from '@/lib/models';
import { EditorMode, EditorTabValue, LeftTabValue, SettingsTabValue } from '@/lib/models';
import type { ProjectsManager } from '@/lib/projects';
import type { UserManager } from '@/lib/user';
import { invokeMainChannel, sendAnalytics } from '@/lib/utils';
Expand Down Expand Up @@ -37,6 +37,7 @@ export class EditorEngine {
private _editorMode: EditorMode = EditorMode.DESIGN;
private _editorPanelTab: EditorTabValue = EditorTabValue.CHAT;
private _settingsTab: SettingsTabValue = SettingsTabValue.DOMAIN;
private _componentPanelTab: LeftTabValue = LeftTabValue.LAYERS;

private canvasManager: CanvasManager;
private chatManager: ChatManager;
Expand All @@ -47,10 +48,10 @@ export class EditorEngine {
private errorManager: ErrorManager;
private imageManager: ImageManager;
private themeManager: ThemeManager;
private projectInfoManager: ProjectInfoManager;

private astManager: AstManager = new AstManager(this);
private historyManager: HistoryManager = new HistoryManager(this);
private projectInfoManager: ProjectInfoManager = new ProjectInfoManager();
private elementManager: ElementManager = new ElementManager(this);
private textEditingManager: TextEditingManager = new TextEditingManager(this);
private actionManager: ActionManager = new ActionManager(this);
Expand All @@ -74,6 +75,7 @@ export class EditorEngine {
this.errorManager = new ErrorManager(this, this.projectsManager);
this.imageManager = new ImageManager(this, this.projectsManager);
this.themeManager = new ThemeManager(this, this.projectsManager);
this.projectInfoManager = new ProjectInfoManager(this.projectsManager);
}

get elements() {
Expand Down Expand Up @@ -160,6 +162,9 @@ export class EditorEngine {
get pages() {
return this.pagesManager;
}
get componentPanelTab() {
return this._componentPanelTab;
}

set mode(mode: EditorMode) {
this._editorMode = mode;
Expand Down Expand Up @@ -192,6 +197,10 @@ export class EditorEngine {
this._publishOpen = open;
}

set componentPanelTab(tab: LeftTabValue) {
this._componentPanelTab = tab;
}

dispose() {
this.overlay.clear();
this.elements.clear();
Expand Down
Loading