diff --git a/src/components/project/CloneProject/CloneProject.module.scss b/src/components/project/CloneProject/CloneProject.module.scss new file mode 100644 index 0000000..b6c459e --- /dev/null +++ b/src/components/project/CloneProject/CloneProject.module.scss @@ -0,0 +1,25 @@ +.root { + color: var(--color-warning); + display: flex; + align-items: center; + cursor: pointer; + animation: blinker 500ms infinite alternate; + + .saveIcon { + width: 1.2rem; + height: 1.2rem; + } +} + +.form { + margin-top: 2rem; +} + +@keyframes blinker { + from { + opacity: 70%; + } + to { + opacity: 100%; + } +} diff --git a/src/components/project/CloneProject/CloneProject.tsx b/src/components/project/CloneProject/CloneProject.tsx new file mode 100644 index 0000000..35073a3 --- /dev/null +++ b/src/components/project/CloneProject/CloneProject.tsx @@ -0,0 +1,111 @@ +import AppIcon from '@/components/ui/icon'; +import { baseProjectPath, useProject } from '@/hooks/projectV2.hooks'; +import fileSystem from '@/lib/fs'; +import { Button, Form, Input, message, Modal, Tooltip } from 'antd'; +import cloneDeep from 'lodash.clonedeep'; +import { FC, useState } from 'react'; +import s from './CloneProject.module.scss'; + +const CloneProject: FC = () => { + const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const { activeProject, projectFiles, createProject } = useProject(); + + const storeAsNewProject = async ({ name }: { name: string }) => { + try { + setIsSaving(true); + + const files = cloneDeep(projectFiles); + const finalFiles = []; + for (const file of files) { + if (file.path.includes('.ide')) { + continue; + } + if (file.type === 'file') { + file.content = (await fileSystem.readFile(file.path)) as string; + } + file.path = file.path.replace(`${activeProject?.path as string}/`, ''); + finalFiles.push(file); + } + if (finalFiles.length === 0) { + message.error('No files to save'); + return; + } + + await createProject({ + name: name, + language: activeProject?.language ?? 'tact', + template: 'import', + file: null, + defaultFiles: finalFiles, + }); + setIsSaveModalOpen(false); + fileSystem.clearVirtualFiles(); + } catch (error) { + if (error instanceof Error) { + message.error(error.message); + return; + } + message.error('Failed to save a project'); + } finally { + setIsSaving(false); + } + }; + + if (!activeProject || activeProject.path !== `${baseProjectPath}/temp`) { + return null; + } + + return ( + <> + +
{ + setIsSaveModalOpen(true); + }} + > + +
+
+ { + setIsSaveModalOpen(false); + }} + footer={null} + > +
+ + + + + + + +
+
+ + ); +}; + +export default CloneProject; diff --git a/src/components/project/CloneProject/index.ts b/src/components/project/CloneProject/index.ts new file mode 100644 index 0000000..6fd915d --- /dev/null +++ b/src/components/project/CloneProject/index.ts @@ -0,0 +1 @@ +export { default } from './CloneProject'; 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..9c31775 100644 --- a/src/components/project/NewProject/NewProject.tsx +++ b/src/components/project/NewProject/NewProject.tsx @@ -1,6 +1,8 @@ import { Tooltip } from '@/components/ui'; import AppIcon, { AppIconType } from '@/components/ui/icon'; -import { useProject } from '@/hooks/projectV2.hooks'; +import { useFileTab } from '@/hooks'; +import { useLogActivity } from '@/hooks/logActivity.hooks'; +import { baseProjectPath, useProject } from '@/hooks/projectV2.hooks'; import { ContractLanguage, ProjectTemplate, @@ -8,6 +10,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 +46,21 @@ const NewProject: FC = ({ const [isActive, setIsActive] = useState(active); const { createProject } = useProject(); const [isLoading, setIsLoading] = useState(false); + const { createLog } = useLogActivity(); + const { open: openTab } = useFileTab(); 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 +98,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 +130,49 @@ const NewProject: FC = ({ } }; + const importFromCode = async (code: string) => { + try { + const defaultFileName = `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: defaultFileName, + type: 'file' as const, + name: defaultFileName, + content: decodeBase64(code), + }, + ], + isTemporary: true, + }); + const finalQueryParam = router.query; + delete finalQueryParam.code; + delete finalQueryParam.lang; + router.replace({ query: finalQueryParam }).catch(() => {}); + openTab(defaultFileName, `${baseProjectPath}/temp/${defaultFileName}`); + } 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 +189,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/project/index.ts b/src/components/project/index.ts index 1e71957..b9f045f 100644 --- a/src/components/project/index.ts +++ b/src/components/project/index.ts @@ -1,3 +1,4 @@ +export { default as CloneProject } from './CloneProject'; export { default as DownloadProject } from './DownloadProject'; export { default as MigrateToUnifiedFS } from './MigrateToUnifiedFS'; export { default as NewProject } from './NewProject'; diff --git a/src/components/ui/icon/index.tsx b/src/components/ui/icon/index.tsx index f16941d..6adecab 100644 --- a/src/components/ui/icon/index.tsx +++ b/src/components/ui/icon/index.tsx @@ -10,7 +10,10 @@ import { AiOutlinePlus, AiOutlineProject, AiOutlineReload, + AiOutlineSave, } 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'; @@ -70,6 +73,8 @@ export type AppIconType = | 'Download' | 'Import' | 'Reload' + | 'Share' + | 'Save' | 'GitBranch'; export interface AppIconInterface { @@ -111,6 +116,8 @@ const Components = { Download: AiOutlineDownload, Import, Reload: AiOutlineReload, + Share: BsShare, + Save: AiOutlineSave, GitBranch: AiOutlineBranches, }; 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/ContractInteraction/FuncContractInteraction.tsx b/src/components/workspace/ContractInteraction/FuncContractInteraction.tsx index 9a6b23e..edab0f7 100644 --- a/src/components/workspace/ContractInteraction/FuncContractInteraction.tsx +++ b/src/components/workspace/ContractInteraction/FuncContractInteraction.tsx @@ -170,6 +170,10 @@ const FuncContractInteraction: FC = ({ src="/html/tonweb.html" sandbox="allow-scripts allow-same-origin" /> +

+ You are using code that has been imported from an external source. + Exercise caution with the contract code before executing it. +

Below options will be used to send internal message and call getter method on contract after the contract is deployed. diff --git a/src/components/workspace/ContractInteraction/TactContractInteraction.tsx b/src/components/workspace/ContractInteraction/TactContractInteraction.tsx index d9fd82c..6dcb147 100644 --- a/src/components/workspace/ContractInteraction/TactContractInteraction.tsx +++ b/src/components/workspace/ContractInteraction/TactContractInteraction.tsx @@ -16,6 +16,10 @@ const TactContractInteraction: FC = ({ return (

+

+ You are using code that has been imported from an external source. + Exercise caution with the contract code before executing it. +

Below options will be used to call receiver and call getter method on contract after the contract is deployed. diff --git a/src/components/workspace/Tabs/Tabs.tsx b/src/components/workspace/Tabs/Tabs.tsx index 0888971..bbcfa2e 100644 --- a/src/components/workspace/Tabs/Tabs.tsx +++ b/src/components/workspace/Tabs/Tabs.tsx @@ -2,7 +2,7 @@ import AppIcon from '@/components/ui/icon'; import { useFileTab } from '@/hooks'; import { useProject } from '@/hooks/projectV2.hooks'; import EventEmitter from '@/utility/eventEmitter'; -import { fileTypeFromFileName } from '@/utility/utils'; +import { delay, fileTypeFromFileName } from '@/utility/utils'; import { FC, useEffect } from 'react'; import s from './Tabs.module.scss'; @@ -22,7 +22,10 @@ const Tabs: FC = () => { }; useEffect(() => { - syncTabSettings(); + (async () => { + await delay(200); + syncTabSettings(); + })(); }, [activeProject]); useEffect(() => { diff --git a/src/components/workspace/project/ManageProject/ManageProject.tsx b/src/components/workspace/project/ManageProject/ManageProject.tsx index 1ffee0d..5fb4bc3 100644 --- a/src/components/workspace/project/ManageProject/ManageProject.tsx +++ b/src/components/workspace/project/ManageProject/ManageProject.tsx @@ -1,4 +1,5 @@ import { + CloneProject, DownloadProject, MigrateToUnifiedFS, NewProject, @@ -9,6 +10,7 @@ import { baseProjectPath, useProject } from '@/hooks/projectV2.hooks'; import { Project } from '@/interfaces/workspace.interface'; import EventEmitter from '@/utility/eventEmitter'; import { Button, Modal, Select, message } from 'antd'; +import Router from 'next/router'; import { FC, useEffect, useState } from 'react'; import s from './ManageProject.module.scss'; @@ -28,6 +30,7 @@ const ManageProject: FC = () => { await deleteProject(id); setActiveProject(null); setIsDeleteConfirmOpen(false); + Router.push('/'); } catch (error) { await message.error('Failed to delete project'); } @@ -45,6 +48,7 @@ const ManageProject: FC = () => { <> Projects

+ 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 1e70fc3..ed1b2b9 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -2,8 +2,10 @@ 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(); } get fsInstance() { @@ -11,9 +13,14 @@ class FileSystem { } async readFile(path: string) { + if (this.virtualFiles.has(path)) { + return this.virtualFiles.get(path) as string; + } + if (!(await this.exists(path))) { throw new Error(`File not found: ${path}`); } + return this.fs.readFile(path, 'utf8'); } @@ -27,9 +34,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); @@ -74,21 +86,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()) { @@ -99,13 +124,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 }, @@ -156,10 +180,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; @@ -169,6 +200,12 @@ class FileSystem { } async stat(path: string) { + if (this.virtualFiles.has(path)) { + return { + isFile: () => true, + isDirectory: () => false, + }; + } return this.fs.stat(path); } @@ -228,6 +265,10 @@ class FileSystem { await this.rmdir(path); } + clearVirtualFiles() { + this.virtualFiles.clear(); + } + async du(path = '/') { return this.fs.du(path); } diff --git a/src/styles/global.scss b/src/styles/global.scss index 4f462d1..b08d25a 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -122,3 +122,7 @@ hr { background: rgb(61, 61, 61); border: 0; } + +.color-warn { + color: var(--color-warning); +}