From 06147e18d18cb04b4d0b455fa256c96643e0f59c Mon Sep 17 00:00:00 2001 From: Nithishvb Date: Thu, 20 Mar 2025 01:05:21 +0530 Subject: [PATCH 1/3] feat: add component tab and creation functionality --- apps/studio/electron/main/code/components.ts | 188 ++++++++++++++++ apps/studio/electron/main/events/code.ts | 39 +++- apps/studio/src/lib/editor/engine/index.ts | 13 +- .../lib/editor/engine/projectinfo/index.ts | 87 +++++++- apps/studio/src/lib/models.ts | 7 +- .../editor/LayersPanel/ComponentsTab.tsx | 207 +++++++++++++++++- .../src/routes/editor/LayersPanel/index.tsx | 33 ++- .../routes/editor/RightClickMenu/index.tsx | 10 +- packages/models/src/constants/ipc.ts | 6 +- 9 files changed, 573 insertions(+), 17 deletions(-) diff --git a/apps/studio/electron/main/code/components.ts b/apps/studio/electron/main/code/components.ts index 0d67ad2ef..327e1f6f0 100644 --- a/apps/studio/electron/main/code/components.ts +++ b/apps/studio/electron/main/code/components.ts @@ -17,6 +17,7 @@ function isUppercase(s: string) { export interface ReactComponentDescriptor { name: string; sourceFilePath: string; + isDelete?: boolean; } function isExported(node: FunctionDeclaration | VariableStatement | ClassDeclaration) { @@ -99,6 +100,7 @@ function extractReactComponentsFromFile(filePath: string) { exportedComponents.push({ ...descriptor, sourceFilePath: sourceFile.getFilePath(), + isDelete: false, }); } }); @@ -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 ( +
+
+

+ This is a blank ${componentName} +

+
+
+ ); +}`; + + 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; + } +} diff --git a/apps/studio/electron/main/events/code.ts b/apps/studio/electron/main/events/code.ts index 039e2df9a..ac4330395 100644 --- a/apps/studio/electron/main/events/code.ts +++ b/apps/studio/electron/main/events/code.ts @@ -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'; @@ -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; + }); } diff --git a/apps/studio/src/lib/editor/engine/index.ts b/apps/studio/src/lib/editor/engine/index.ts index 8c6458487..c695c032c 100644 --- a/apps/studio/src/lib/editor/engine/index.ts +++ b/apps/studio/src/lib/editor/engine/index.ts @@ -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'; @@ -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; @@ -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); @@ -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() { @@ -160,6 +162,9 @@ export class EditorEngine { get pages() { return this.pagesManager; } + get componentPanelTab() { + return this._componentPanelTab; + } set mode(mode: EditorMode) { this._editorMode = mode; @@ -192,6 +197,10 @@ export class EditorEngine { this._publishOpen = open; } + set componentPanelTab(tab: LeftTabValue) { + this._componentPanelTab = tab; + } + dispose() { this.overlay.clear(); this.elements.clear(); diff --git a/apps/studio/src/lib/editor/engine/projectinfo/index.ts b/apps/studio/src/lib/editor/engine/projectinfo/index.ts index a2f86c86e..0ae63e4e3 100644 --- a/apps/studio/src/lib/editor/engine/projectinfo/index.ts +++ b/apps/studio/src/lib/editor/engine/projectinfo/index.ts @@ -1,13 +1,98 @@ import { makeAutoObservable } from 'mobx'; import type { ReactComponentDescriptor } from '/electron/main/code/components'; +import { invokeMainChannel } from '@/lib/utils'; +import { MainChannels } from '@onlook/models/constants'; +import type { ProjectsManager } from '@/lib/projects'; +export const CREATE_NEW_COMPONENT = 'creare-new-component'; export class ProjectInfoManager { private projectComponents: ReactComponentDescriptor[]; - constructor() { + constructor(private projectsManager: ProjectsManager) { makeAutoObservable(this); + this.scanComponents(); this.projectComponents = []; } + async scanComponents() { + const projectRoot = this.projectsManager.project?.folderPath; + + if (!projectRoot) { + console.warn('No project root found'); + this.projectComponents = []; + return; + } + + const components = (await invokeMainChannel( + MainChannels.GET_COMPONENTS, + projectRoot, + )) as ReactComponentDescriptor[]; + this.projectComponents = components; + } + + async duplicateComponent(filePath: string, componentName: string) { + try { + await invokeMainChannel(MainChannels.DUPLICATE_COMPONENT, { + filePath, + componentName, + }); + this.scanComponents(); + } catch (error) { + console.error('Failed to duplicate page:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(errorMessage); + } + } + + async renameComponent(newComponentName: string, filePath: string) { + try { + await invokeMainChannel(MainChannels.RENAME_COMPONENT, { + newComponentName, + filePath, + }); + this.scanComponents(); + } catch (error) { + console.error('Failed to duplicate page:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(errorMessage); + } + } + + async createUntitledComponent() { + const newComponentObj: ReactComponentDescriptor = { + name: '', + sourceFilePath: this.projectsManager.project?.folderPath + '/app', + }; + this.projectComponents = [...this.projectComponents, newComponentObj]; + window.dispatchEvent(new Event(CREATE_NEW_COMPONENT)); + } + + async createComponent(componentName: string, oid: string) { + try { + await invokeMainChannel(MainChannels.CREATE_COMPONENT, { + componentName, + oid, + }); + this.scanComponents(); + } catch (error) { + console.error('Failed to duplicate page:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(errorMessage); + } + } + + async deleteComponent(filePath: string) { + try { + await invokeMainChannel(MainChannels.DELETE_COMPONENT, { + filePath, + }); + this.scanComponents(); + } catch (error) { + console.error('Failed to duplicate page:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(errorMessage); + } + } + get components() { return this.projectComponents; } diff --git a/apps/studio/src/lib/models.ts b/apps/studio/src/lib/models.ts index 45c051e08..f8c359ed0 100644 --- a/apps/studio/src/lib/models.ts +++ b/apps/studio/src/lib/models.ts @@ -16,7 +16,12 @@ export enum EditorMode { export enum EditorTabValue { STYLES = 'styles', CHAT = 'chat', - PROPS = 'properties', + PROPS = 'properties' +} + +export enum LeftTabValue { + LAYERS = 'layers', + COMPONENTS = 'components', } export enum SettingsTabValue { diff --git a/apps/studio/src/routes/editor/LayersPanel/ComponentsTab.tsx b/apps/studio/src/routes/editor/LayersPanel/ComponentsTab.tsx index 151e88905..398c40212 100644 --- a/apps/studio/src/routes/editor/LayersPanel/ComponentsTab.tsx +++ b/apps/studio/src/routes/editor/LayersPanel/ComponentsTab.tsx @@ -2,9 +2,19 @@ import { useEditorEngine } from '@/components/Context'; import { MainChannels } from '@onlook/models/constants'; import { Button } from '@onlook/ui/button'; import { observer } from 'mobx-react-lite'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { ReactComponentDescriptor } from '/electron/main/code/components'; import { invokeMainChannel } from '@/lib/utils'; +import { Icons } from '@onlook/ui/icons/index'; +import { Input } from '@onlook/ui/input'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@onlook/ui/dropdown-menu'; +import { CREATE_NEW_COMPONENT } from '@/lib/editor/engine/projectinfo'; +import { LeftTabValue } from '@/lib/models'; function ScanComponentsButton() { const editorEngine = useEditorEngine(); @@ -33,20 +43,205 @@ function ScanComponentsButton() { } const ComponentsTab = observer(({ components }: { components: ReactComponentDescriptor[] }) => { + const editorEngine = useEditorEngine(); + const [activeDropdown, setActiveDropdown] = useState(null); + const [editingIndex, setEditingIndex] = useState(null); + const [renameVal, setRenameVal] = useState(''); + const [isCreateAction, setIsCreateAction] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (editorEngine.componentPanelTab !== LeftTabValue.COMPONENTS) { + editorEngine.projectInfo.scanComponents(); + } + }, []); + + useEffect(() => { + const createHandler = () => { + setIsCreateAction(true); + }; + + window.addEventListener(CREATE_NEW_COMPONENT, createHandler); + return () => window.removeEventListener(CREATE_NEW_COMPONENT, createHandler); + }, []); + + useEffect(() => { + if ((editingIndex !== null || isCreateAction) && inputRef.current) { + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, 200); + } + }, [editingIndex, isCreateAction]); + + const handleDelete = async (filePath: string) => { + try { + await editorEngine.projectInfo.deleteComponent(filePath); + } catch (error) { + console.error('Failed to duplicate page:', error); + } + }; + + const handleRename = (index: number) => { + setEditingIndex(index); + }; + + const handleDuplicate = async (filePath: string, componentName: string) => { + try { + await editorEngine.projectInfo.duplicateComponent(filePath, componentName); + } catch (error) { + console.error('Failed to duplicate page:', error); + } + }; + + const handleKeyDown = async (e: React.KeyboardEvent, filePath: string) => { + if (e.key === 'Enter') { + setEditingIndex(null); + if (isCreateAction) { + const element = editorEngine.elements.selected[0]; + if (renameVal) { + await editorEngine.projectInfo.createComponent(renameVal, element.oid || ''); + } + } else { + if (renameVal) { + await editorEngine.projectInfo.renameComponent(renameVal, filePath); + } + } + setRenameVal(''); + setIsCreateAction(false); + } else if (e.key === 'Escape') { + setEditingIndex(null); + setRenameVal(''); + setIsCreateAction(false); + } + }; + + const handleBlur = async () => { + setEditingIndex(null); + setRenameVal(''); + setIsCreateAction(false); + await editorEngine.projectInfo.scanComponents(); + }; + return ( -
+
{components.length === 0 ? (
) : ( - components.map((component) => ( + components.map((component, index) => (
-
{component.name}
-
{component.sourceFilePath}
+
+
+
+ + {editingIndex === index || !component.name ? ( + setRenameVal(e.target.value)} + onKeyDown={(e) => + handleKeyDown(e, component.sourceFilePath) + } + onBlur={handleBlur} + placeholder={component.name || 'Untitled component'} + className="bg-transparent border-none text-white h-5 px-0 focus-visible:bg-transparent placeholder:text-gray-500" + /> + ) : ( + + {component.name} + + )} +
+
+ + setActiveDropdown(isOpen ? component.name : null) + } + > + + + + + + + + + + + + + + + +
+
+
)) )} diff --git a/apps/studio/src/routes/editor/LayersPanel/index.tsx b/apps/studio/src/routes/editor/LayersPanel/index.tsx index 5130c67e9..7a2c859ea 100644 --- a/apps/studio/src/routes/editor/LayersPanel/index.tsx +++ b/apps/studio/src/routes/editor/LayersPanel/index.tsx @@ -1,9 +1,9 @@ import { useEditorEngine } from '@/components/Context'; -import { EditorMode } from '@/lib/models'; +import { EditorMode, LeftTabValue } from '@/lib/models'; import { Icons } from '@onlook/ui/icons'; import { cn } from '@onlook/ui/utils'; import { observer } from 'mobx-react-lite'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import BrandTab from './BrandTab'; import ComponentsTab from './ComponentsTab'; @@ -15,7 +15,7 @@ import PagesTab from './PageTab'; import WindowsTab from './WindowsTab'; import ZoomControls from './ZoomControls'; -const COMPONENT_DISCOVERY_ENABLED = false; +const COMPONENT_DISCOVERY_ENABLED = true; export const LayersPanel = observer(() => { const editorEngine = useEditorEngine(); @@ -33,6 +33,14 @@ export const LayersPanel = observer(() => { const [isContentPanelOpen, setIsContentPanelOpen] = useState(false); const [isLocked, setIsLocked] = useState(false); + useEffect(() => { + if(editorEngine.componentPanelTab === LeftTabValue.COMPONENTS){ + setSelectedTab(TabValue.COMPONENTS); + setIsContentPanelOpen(true); + setIsLocked(true); + } + }, [editorEngine.componentPanelTab]); + const handleMouseEnter = (tab: TabValue) => { if (isLocked) { return; @@ -78,6 +86,7 @@ export const LayersPanel = observer(() => { setIsContentPanelOpen(true); setIsLocked(true); } + editorEngine.componentPanelTab = LeftTabValue.LAYERS; }; return ( @@ -89,7 +98,7 @@ export const LayersPanel = observer(() => { onMouseLeave={handleMouseLeave} > {/* Left sidebar with tabs */} -
+
+ +