diff --git a/src/components/project/MigrateToUnifiedFS/MigrateToUnifiedFS.tsx b/src/components/project/MigrateToUnifiedFS/MigrateToUnifiedFS.tsx index ab3bc09..592d20d 100644 --- a/src/components/project/MigrateToUnifiedFS/MigrateToUnifiedFS.tsx +++ b/src/components/project/MigrateToUnifiedFS/MigrateToUnifiedFS.tsx @@ -86,14 +86,14 @@ const MigrateToUnifiedFS: FC = ({ hasDescription = false }) => { const project = projects[i]; const isLastProject = i === projects.length - 1; - await createProject( - project.projectDetails.name as string, - project.projectDetails.language as ContractLanguage, - 'import', - null, - project.files as Tree[], - isLastProject, - ); + await createProject({ + name: project.projectDetails.name as string, + language: project.projectDetails.language as ContractLanguage, + template: 'import', + file: null, + defaultFiles: project.files as Tree[], + autoActivate: isLastProject, + }); migratedProjects.push(project.projectDetails.name); } diff --git a/src/components/project/NewProject/NewProject.tsx b/src/components/project/NewProject/NewProject.tsx index b15d560..7a116bd 100644 --- a/src/components/project/NewProject/NewProject.tsx +++ b/src/components/project/NewProject/NewProject.tsx @@ -1,5 +1,6 @@ import { Tooltip } from '@/components/ui'; import AppIcon, { AppIconType } from '@/components/ui/icon'; +import { useLogActivity } from '@/hooks/logActivity.hooks'; import { useProject } from '@/hooks/projectV2.hooks'; import { ContractLanguage, @@ -8,6 +9,7 @@ import { } from '@/interfaces/workspace.interface'; import { Analytics } from '@/utility/analytics'; import EventEmitter from '@/utility/eventEmitter'; +import { decodeBase64 } from '@/utility/utils'; import { Button, Form, Input, Modal, Radio, Upload, message } from 'antd'; import { useForm } from 'antd/lib/form/Form'; import type { RcFile } from 'antd/lib/upload'; @@ -43,9 +45,20 @@ const NewProject: FC = ({ const [isActive, setIsActive] = useState(active); const { createProject } = useProject(); const [isLoading, setIsLoading] = useState(false); + const { createLog } = useLogActivity(); const router = useRouter(); - const { importURL, name: projectName, lang: importLanguage } = router.query; + const { + importURL, + name: projectName, + lang: importLanguage, + code: codeToImport, + } = router.query as { + importURL?: string; + name?: string; + lang?: ContractLanguage; + code?: string; + }; const [form] = useForm(); @@ -83,13 +96,13 @@ const NewProject: FC = ({ // files = await downloadRepo(githubUrl as string); } - await createProject( - projectName, + await createProject({ + name: projectName, language, - values.template ?? 'import', - values.file?.file ?? null, - files, - ); + template: values.template ?? 'import', + file: values.file?.file ?? null, + defaultFiles: files, + }); form.resetFields(); closeModal(); @@ -115,7 +128,44 @@ const NewProject: FC = ({ } }; + const importFromCode = async (code: string) => { + try { + const fileName = `main.${importLanguage}`; + if (!importLanguage || !['tact', 'func'].includes(importLanguage)) { + createLog(`Invalid language: ${importLanguage}`, 'error'); + return; + } + await createProject({ + name: 'temp', + language: importLanguage, + template: 'import', + file: null, + defaultFiles: [ + { + id: '', + parent: null, + path: fileName, + type: 'file' as const, + name: fileName, + content: decodeBase64(code), + }, + ], + isTemporary: true, + }); + } catch (error) { + if (error instanceof Error) { + createLog(error.message, 'error'); + return; + } + } + }; + useEffect(() => { + if (codeToImport) { + importFromCode(codeToImport as string); + return; + } + if (!importURL || !active) { return; } @@ -132,7 +182,7 @@ const NewProject: FC = ({ delete finalQueryParam.name; router.replace({ query: finalQueryParam }).catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [importURL, projectName, form]); + }, [importURL, projectName, form, codeToImport]); const closeModal = () => { setIsActive(false); diff --git a/src/components/ui/icon/index.tsx b/src/components/ui/icon/index.tsx index c966a10..0407c01 100644 --- a/src/components/ui/icon/index.tsx +++ b/src/components/ui/icon/index.tsx @@ -8,6 +8,8 @@ import { AiOutlineProject, AiOutlineReload, } from 'react-icons/ai'; +import { BsShare } from 'react-icons/bs'; + import { BsFillPlayFill } from 'react-icons/bs'; import { FaRegClone } from 'react-icons/fa'; import { FiEdit2, FiEye } from 'react-icons/fi'; @@ -64,7 +66,8 @@ export type AppIconType = | 'Clear' | 'Download' | 'Import' - | 'Reload'; + | 'Reload' + | 'Share'; export interface AppIconInterface { name: AppIconType; @@ -103,6 +106,7 @@ const Components = { Download: AiOutlineDownload, Import, Reload: AiOutlineReload, + Share: BsShare, }; const AppIcon: FC = ({ name, className = '' }) => { diff --git a/src/components/workspace/BuildProject/BuildProject.tsx b/src/components/workspace/BuildProject/BuildProject.tsx index b10818e..596cbf8 100644 --- a/src/components/workspace/BuildProject/BuildProject.tsx +++ b/src/components/workspace/BuildProject/BuildProject.tsx @@ -309,7 +309,7 @@ const BuildProject: FC = ({ projectId, contract, updateContract }) => { Analytics.track('Deploy project', { platform: 'IDE', - type: 'TON-func', + type: `TON-${activeProject?.language}`, environment: environment.toLowerCase(), }); createLog( diff --git a/src/components/workspace/Tabs/Tabs.tsx b/src/components/workspace/Tabs/Tabs.tsx index 0888971..64118f9 100644 --- a/src/components/workspace/Tabs/Tabs.tsx +++ b/src/components/workspace/Tabs/Tabs.tsx @@ -1,6 +1,7 @@ import AppIcon from '@/components/ui/icon'; import { useFileTab } from '@/hooks'; import { useProject } from '@/hooks/projectV2.hooks'; +import fileSystem from '@/lib/fs'; import EventEmitter from '@/utility/eventEmitter'; import { fileTypeFromFileName } from '@/utility/utils'; import { FC, useEffect } from 'react'; @@ -9,7 +10,7 @@ import s from './Tabs.module.scss'; const Tabs: FC = () => { const { fileTab, open, close, syncTabSettings, updateFileDirty } = useFileTab(); - const { activeProject } = useProject(); + const { activeProject, setActiveProject } = useProject(); const closeTab = (e: React.MouseEvent, filePath: string) => { e.preventDefault(); @@ -25,6 +26,23 @@ const Tabs: FC = () => { syncTabSettings(); }, [activeProject]); + useEffect(() => { + (async () => { + // If the active project is a temp project, the file tab is active and file does not exist + if (activeProject?.path?.includes('temp')) { + setActiveProject('non-existing-dir'); + try { + if (!fileTab.active) { + return; + } + await fileSystem.exists(fileTab.active); + } catch (error) { + syncTabSettings({ items: [], active: null }); + } + } + })(); + }, []); + useEffect(() => { EventEmitter.on('FILE_SAVED', onFileSave); return () => { diff --git a/src/components/workspace/tree/FileTree/ItemActions.tsx b/src/components/workspace/tree/FileTree/ItemActions.tsx index c17193c..4fd07a7 100644 --- a/src/components/workspace/tree/FileTree/ItemActions.tsx +++ b/src/components/workspace/tree/FileTree/ItemActions.tsx @@ -14,6 +14,7 @@ interface Props { onNewFile?: () => void; onNewDirectory?: () => void; onDelete?: () => void; + onShare?: () => void; } const ItemAction: FC = ({ @@ -23,6 +24,7 @@ const ItemAction: FC = ({ onNewFile, onNewDirectory, onDelete, + onShare, }) => { const rootClassName = cn(s.actionRoot, className, 'actions'); const handleOnClick = ( @@ -51,6 +53,11 @@ const ItemAction: FC = ({ label: 'New Folder', action: onNewDirectory, }, + { + title: 'Share', + label: 'Share', + action: onShare, + }, { title: 'Close', label: 'Delete', diff --git a/src/components/workspace/tree/FileTree/TreeNode.tsx b/src/components/workspace/tree/FileTree/TreeNode.tsx index 1969936..b73b1e3 100644 --- a/src/components/workspace/tree/FileTree/TreeNode.tsx +++ b/src/components/workspace/tree/FileTree/TreeNode.tsx @@ -1,9 +1,10 @@ -import { useFileTab } from '@/hooks'; +import { useFile, useFileTab } from '@/hooks'; import { useLogActivity } from '@/hooks/logActivity.hooks'; import { useProject } from '@/hooks/projectV2.hooks'; import { Project, Tree } from '@/interfaces/workspace.interface'; -import { fileTypeFromFileName } from '@/utility/utils'; +import { encodeBase64, fileTypeFromFileName } from '@/utility/utils'; import { NodeModel } from '@minoru/react-dnd-treeview'; +import { message } from 'antd'; import cn from 'clsx'; import { FC, useState } from 'react'; import s from './FileTree.module.scss'; @@ -32,6 +33,7 @@ const TreeNode: FC = ({ node, depth, isOpen, onToggle }) => { const { deleteProjectFile, renameProjectFile, newFileFolder } = useProject(); const { open: openTab } = useFileTab(); const { createLog } = useLogActivity(); + const { getFile } = useFile(); const disallowedFile = [ 'message.cell.ts', @@ -100,7 +102,12 @@ const TreeNode: FC = ({ node, depth, isOpen, onToggle }) => { if (node.droppable) { return ['Edit', 'NewFile', 'NewFolder', 'Close']; } - return ['Edit', 'Close']; + const options = ['Edit', 'Close']; + const allowedLanguages = ['tact', 'func']; + if (allowedLanguages.includes(fileTypeFromFileName(node.text))) { + options.push('Share'); + } + return options; }; const deleteItemFromNode = async () => { @@ -113,6 +120,32 @@ const TreeNode: FC = ({ node, depth, isOpen, onToggle }) => { await deleteProjectFile(nodePath); }; + const onShare = async () => { + try { + const fileContent = + ((await getFile(node.data?.path as string)) as string) || ''; + const maxAllowedCharacters = 32779; // Maximum allowed characters in a Chrome. Firefox has more limit but we are using less for compatibility + if (!fileContent) { + message.error('File is empty'); + return; + } + if (fileContent && fileContent.length > maxAllowedCharacters) { + message.error( + `File is too large to share. Maximum allowed characters is ${maxAllowedCharacters}`, + ); + return; + } + const language = fileTypeFromFileName(node.text); + const shareableLink = `${window.location.origin}/?code=${encodeBase64(fileContent)}&lang=${language}`; + + navigator.clipboard.writeText(shareableLink); + + message.success("File's shareable link copied to clipboard"); + } catch (error) { + message.error((error as Error).message); + } + }; + const isAllowed = () => { const isEditingItem = document.body.classList.contains( 'editing-file-folder', @@ -168,6 +201,9 @@ const TreeNode: FC = ({ node, depth, isOpen, onToggle }) => { onDelete={() => { deleteItemFromNode().catch(() => {}); }} + onShare={() => { + onShare(); + }} /> )} diff --git a/src/enum/file.ts b/src/enum/file.ts index 483017f..cdc04ea 100644 --- a/src/enum/file.ts +++ b/src/enum/file.ts @@ -31,6 +31,7 @@ export enum FileExtensionToFileType { jsx = FileType.JavaScriptReact, rs = FileType.Rust, fc = FileType.FC, + func = FileType.FC, tact = FileType.TACT, json = FileType.JSON, } diff --git a/src/hooks/projectV2.hooks.ts b/src/hooks/projectV2.hooks.ts index 20d2ca4..502e20d 100644 --- a/src/hooks/projectV2.hooks.ts +++ b/src/hooks/projectV2.hooks.ts @@ -5,8 +5,8 @@ import { import { ABIFormInputValues, ContractLanguage, + CreateProjectParams, ProjectSetting, - ProjectTemplate, Tree, } from '@/interfaces/workspace.interface'; import fileSystem from '@/lib/fs'; @@ -60,21 +60,24 @@ export const useProject = () => { } }; - const createProject = async ( - name: string, - language: ContractLanguage, - template: ProjectTemplate, - file: RcFile | null, - defaultFiles?: Tree[], + const createProject = async ({ + name, + language, + template, + file, + defaultFiles, autoActivate = true, - ) => { - const projectDirectory = await fileSystem.mkdir( - `${baseProjectPath}/${name}`, - { - overwrite: false, - }, - ); - if (!projectDirectory) return; + isTemporary = false, + }: CreateProjectParams) => { + let projectDirectory = `${baseProjectPath}/${name}`; + try { + projectDirectory = (await fileSystem.mkdir(`${baseProjectPath}/${name}`, { + overwrite: isTemporary, + })) as string; + } catch (error) { + /* empty */ + } + if (!name || !projectDirectory) return; let files = template === 'import' && defaultFiles?.length == 0 @@ -114,7 +117,7 @@ export const useProject = () => { template, }; - await writeFiles(projectDirectory, files); + await writeFiles(projectDirectory, files, { isTemporary }); const projectSettingPath = `${projectDirectory}/.ide/setting.json`; if (!(await fileSystem.exists(projectSettingPath))) { @@ -138,14 +141,18 @@ export const useProject = () => { const writeFiles = async ( projectPath: string, files: Pick[], - options?: { overwrite?: boolean }, + options?: { overwrite?: boolean; isTemporary?: boolean }, ) => { await Promise.all( files.map(async (file) => { if (file.type === 'directory') { return fileSystem.mkdir(file.path); } - await fileSystem.writeFile(file.path, file.content ?? '', options); + await fileSystem.writeFile(file.path, file.content ?? '', { + ...options, + virtual: options?.isTemporary ?? false, + overwrite: options?.isTemporary ? true : options?.overwrite, + }); EventEmitter.emit('FORCE_UPDATE_FILE', file.path); return file.path; }), diff --git a/src/interfaces/workspace.interface.ts b/src/interfaces/workspace.interface.ts index 2a5ccb1..d50ef48 100644 --- a/src/interfaces/workspace.interface.ts +++ b/src/interfaces/workspace.interface.ts @@ -1,6 +1,7 @@ import { IFileTab } from '@/state/IDE.context'; import { ABITypeRef } from '@ton/core'; import { Maybe } from '@ton/core/dist/utils/maybe'; +import { RcFile } from 'antd/es/upload'; export interface Tree { id: string; @@ -38,6 +39,16 @@ export interface ABIFormInputValues { type: 'Init' | 'Getter' | 'Setter'; } +export interface CreateProjectParams { + name: string; + language: ContractLanguage; + template: ProjectTemplate; + file: RcFile | null; + defaultFiles?: Tree[]; + autoActivate?: boolean; + isTemporary?: boolean; // Used for temporary projects like code import from URL +} + export interface Project { id: string; userId?: string; diff --git a/src/lib/fs.ts b/src/lib/fs.ts index 9455a60..b117ec7 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -2,11 +2,16 @@ import FS, { PromisifiedFS } from '@isomorphic-git/lightning-fs'; class FileSystem { private fs: PromisifiedFS; + private virtualFiles: Map; constructor(fs: PromisifiedFS) { this.fs = fs; + this.virtualFiles = new Map(); } async readFile(path: string) { + if (this.virtualFiles.has(path)) { + return this.virtualFiles.get(path); + } if (!(await this.exists(path))) { throw new Error(`File not found: ${path}`); } @@ -23,9 +28,14 @@ class FileSystem { async writeFile( path: string, data: string | Uint8Array, - options?: { overwrite?: boolean }, + options?: { overwrite?: boolean; virtual?: boolean }, ) { - const { overwrite } = options ?? {}; + const { overwrite, virtual } = options ?? {}; + + if (!!virtual || this.virtualFiles.has(path)) { + this.virtualFiles.set(path, data); + return; + } const finalPath = overwrite ? path : await this.getUniquePath(path); await this.ensureDirectoryExists(finalPath); return this.fs.writeFile(finalPath, data); @@ -70,21 +80,34 @@ class FileSystem { ) { if (!path) return []; const { recursive, basePath, onlyDir } = options; + let results: string[] = []; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const files = (await this.fs.readdir(path)) ?? []; + results.push(...files); + + const virtualFilesInDir = Array.from(this.virtualFiles.keys()).filter( + (key) => key.startsWith(path), + ); + const virtualFilesNames = virtualFilesInDir.map((filePath) => + filePath.replace(`${path}/`, ''), + ); + + results.push(...virtualFilesNames); + if (!recursive) { - const files = await this.fs.readdir(path); - if (!onlyDir) return files; - const results: string[] = []; - for (const file of files) { - const stat = await this.fs.stat(`${path}/${file}`); + if (!onlyDir) return results; + const dirs: string[] = []; + for (const file of results) { + const stat = await this.stat(`${path}/${file}`); if (stat.isDirectory()) { - results.push(file); + dirs.push(file); } } - return results; + return dirs; } - let results: string[] = []; - const files = await this.readdir(path); - for (const file of files) { + + for (const file of results) { const filePath = `${path}/${file}`; const stat = await this.stat(filePath); if (stat.isDirectory()) { @@ -95,13 +118,12 @@ class FileSystem { }); results = results.concat(nestedFiles); } else { - // Remove the rootPath from the file path - results.push(filePath.replace(basePath + '/', '')); + // Remove the basePath from the file path if provided + results.push(filePath.replace(basePath ? basePath + '/' : '', '')); } } return results; } - async mkdir( path: string, options: { overwrite?: boolean } = { overwrite: true }, @@ -152,10 +174,17 @@ class FileSystem { } async unlink(path: string) { + if (this.virtualFiles.has(path)) { + this.virtualFiles.delete(path); + return; + } return this.fs.unlink(path); } async exists(path: string) { + if (this.virtualFiles.has(path)) { + return true; + } try { await this.fs.stat(path); return true; @@ -165,6 +194,12 @@ class FileSystem { } async stat(path: string) { + if (this.virtualFiles.has(path)) { + return { + isFile: () => true, + isDirectory: () => false, + }; + } return this.fs.stat(path); } @@ -179,7 +214,7 @@ class FileSystem { async copy(oldPath: string, newPath: string) { const data = await this.readFile(oldPath); - await this.writeFile(newPath, data); + await this.writeFile(newPath, data ?? ''); } async copyDir(oldPath: string, newPath: string) {