diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0fc7a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +*.log +.next +app +dist diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..43b8484 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,40 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Nextron: Main", + "type": "node", + "request": "attach", + "protocol": "inspector", + "port": 9292, + "skipFiles": ["/**"], + "sourceMapPathOverrides": { + "webpack:///./~/*": "${workspaceFolder}/node_modules/*", + "webpack:///./*": "${workspaceFolder}/*", + "webpack:///*": "*" + } + }, + { + "name": "Nextron: Renderer", + "type": "chrome", + "request": "attach", + "port": 5858, + "timeout": 10000, + "urlFilter": "http://localhost:*", + "webRoot": "${workspaceFolder}/app", + "sourceMapPathOverrides": { + "webpack:///./src/*": "${webRoot}/*" + } + } + ], + "compounds": [ + { + "name": "Nextron: All", + "preLaunchTask": "dev", + "configurations": ["Nextron: Main", "Nextron: Renderer"] + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..5729039 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,21 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "dev", + "isBackground": true, + "problemMatcher": { + "owner": "custom", + "pattern": { + "regexp": "" + }, + "background": { + "beginsPattern": "started server", + "endsPattern": "Debugger listening on" + } + }, + "label": "dev" + } + ] +} diff --git a/README.md b/README.md index c9020f8..a7101ae 100644 --- a/README.md +++ b/README.md @@ -1 +1,74 @@ -# soroban-cli-gui \ No newline at end of file +# SOROBAN CLI GUI + +SOROBAN CLI GUI is a cross platform, electron based application designed to streamline the use of the Soroban CLI. It offers a user-friendly interface for managing projects, identities, networks, and contract methods with ease. + +--- + +## Installation + +To use this application, you must have soroban cli installed on your operating system. + +> This application is compatible with latest soroban v0.23.0, please make sure you have installed this version or newer of soroban! + +- To install soroban, follow the instructions in link below: + - [Install Soroban](https://soroban.stellar.org/docs/getting-started/setup) + +- To verify that soroban properly installed, run: +```soroban --version``` + +Now that you have soroban installed, you can install the SOROBAN CLI GUI application by following the instructions below. + +###  macOS (Apple Silicon | Intel) + +1. Download the latest release for macOS + 1. [Apple Silicon]() + 2. [Intel]() +2. Open the downloaded file and drag the application to Applications folder. + +### 🐧 Linux + +1. Download the latest release for Linux + 1. [App Image]() + 2. [Snap]() + +2. Follow the general instructions to install the application on your Linux distribution. + 1. [App Image](https://docs.appimage.org/introduction/quickstart.html#ref-quickstart) + 2. [Snap](https://snapcraft.io/docs/installing-snapd) + +### 💻 Windows (Not Fully Supported) + +You can still use the SOROBAN CLI GUI application on Windows by following the instructions below. + +1. Install WSL 2 by following the instructions [on microsoft docs](https://learn.microsoft.com/en-us/windows/wsl/install). +2. Once you have WSL installed, you can install soroban cli by following the instructions for Linux. +3. Follow the instructions for Linux to install the SOROBAN CLI GUI application. +--- + +## Key Features + +**Project Management:** This feature allows users to efficiently manage their projects. It includes capabilities to create new projects, add existing ones from your device, and delete projects that are no longer needed. + +**Identity Management:** This component focuses on managing user identities. Users can generate new identities, add existing ones, delete unnecessary identities, and seamlessly switch between different identities. + +**Contract Interactions:** This feature is centered around interactions with contracts (project based). Users can interact with them using a variety of contract commands, arguments, and flags through a user-friendly interface. + +**Network Management:** Network management is facilitated through the ability to add and remove networks. Users can also display the list of networks. + +> **P.S:** Review the [latest release notes](https://github.com/tolgayayci/soroban-cli-gui/releases/tag/v0.1.0) for more information about the features and capabilities of the SOROBAN CLI GUI application. + +## Contributing + +Contributions are welcomed! If you have feature requests, bug notifications or want to contribute some code, please follow the instructions below. +- **Feature Requests:** Use the [feature requests issue](https://github.com/tolgayayci/soroban-cli-gui/issues/new?assignees=tolgayayci&labels=enhancement&projects=&template=feature_request.md&title=%5BFEAT%5D) template. +- **Bug Reports:** Use the [bug reports issue](https://github.com/tolgayayci/soroban-cli-gui/issues/new?assignees=tolgayayci&labels=bug&projects=&template=bug_report.md&title=%5BBUG%5D) template. +- **Code Contributions** + - Fork this repository + - Create a new branch + - Make your changes + - Commit your changes + - Push to the branch that you opened + - Create a new pull request with some details about your changes + +## License + +SOROBAN CLI GUI is released under the **MIT**. See ([LICENSE](https://github.com/tolgayayci/soroban-cli-gui/blob/main/LICENSE)) for more details. diff --git a/components.json b/components.json new file mode 100644 index 0000000..71e2b90 --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "renderer/tailwind.config.js", + "css": "renderer/styles/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "renderer/components", + "utils": "lib/utils" + } +} diff --git a/electron-builder.yml b/electron-builder.yml new file mode 100644 index 0000000..eb56d92 --- /dev/null +++ b/electron-builder.yml @@ -0,0 +1,12 @@ +appId: com.example.nextron +productName: soroban-cli-gui +copyright: Copyright © 2024 Tolga Yaycı +directories: + output: dist + buildResources: resources +files: + - from: . + filter: + - package.json + - app +publish: null diff --git a/main/background.ts b/main/background.ts new file mode 100644 index 0000000..3fceced --- /dev/null +++ b/main/background.ts @@ -0,0 +1,268 @@ +const fixPath = require("fix-path"); +fixPath(); + +import { app, ipcMain, dialog } from "electron"; +import serve from "electron-serve"; + +import { createWindow } from "./helpers"; +import { executeSorobanCommand } from "./helpers/soroban-helper"; +import { handleProjects } from "./helpers/manage-projects"; +import { handleIdentities } from "./helpers/manage-identities"; +import { findContracts } from "./helpers/find-contracts"; + +const path = require("node:path"); +const fs = require("fs"); +const toml = require("toml"); +const { shell } = require("electron"); + +const isProd = process.env.NODE_ENV === "production"; + +const Store = require("electron-store"); + +const schema = { + projects: { + type: "array", + default: [], + items: { + type: "object", + properties: { + name: { type: "string" }, + path: { type: "string" }, + active: { type: "boolean" }, + }, + required: ["name", "path"], + }, + }, + identities: { + type: "array", + default: [], + items: { + type: "object", + properties: { + name: { type: "string" }, + address: { type: "string" }, + }, + }, + }, +}; + +const store = new Store({ schema }); + +async function handleFileOpen() { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ["openDirectory"], + }); + if (!canceled) { + return filePaths[0]; + } +} + +if (isProd) { + serve({ directory: "app" }); +} else { + app.setPath("userData", `${app.getPath("userData")} (development)`); +} + +(async () => { + await app.whenReady(); + + const mainWindow = createWindow("main", { + width: 1500, + height: 700, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + }, + }); + + ipcMain.handle("app:reload", () => { + if (mainWindow) { + mainWindow.reload(); + } + }); + + ipcMain.handle("open-external-link", async (event, url) => { + if (url) { + await shell.openExternal(url); + } + }); + + ipcMain.handle( + "soroban-command", + async (event, command, subcommand, args?, flags?, path?) => { + try { + const result = await executeSorobanCommand( + command, + subcommand, + args, + flags, + path + ); + return result; + } catch (error) { + console.error(`Error while executing Soroban command: ${error}`); + throw error; + } + } + ); + + ipcMain.handle("dialog:openDirectory", handleFileOpen); + + // Store: Projects Handler + ipcMain.handle("store:manageProjects", async (event, action, project) => { + try { + const result = await handleProjects(store, action, project); + return result; + } catch (error) { + console.error("Error on projects:", error); + throw error; + } + }); + + ipcMain.handle("contracts:list", async (event, directoryPath) => { + try { + const contractFiles = findContracts(directoryPath); + return contractFiles; + } catch (error) { + console.error("Error on projects:", error); + return false; + } + }); + + ipcMain.handle( + "store:manageIdentities", + async (event, action, identity, newIdentity?) => { + try { + const result = await handleIdentities( + store, + action, + identity, + newIdentity + ); + return result; + } catch (error) { + console.error("Error on identities:", error); + throw error; + } + } + ); + + ipcMain.handle("is-soroban-project", async (event, directoryPath) => { + try { + const cargoTomlPath = path.join(directoryPath, "Cargo.toml"); + if (!fs.existsSync(cargoTomlPath)) { + return false; + } + + const cargoTomlContent = fs.readFileSync(cargoTomlPath, "utf8"); + const parsedToml = toml.parse(cargoTomlContent); + + if (parsedToml.dependencies && "soroban-sdk" in parsedToml.dependencies) { + return true; + } + + return false; + } catch (error) { + console.error(`Error while checking for Soroban project: ${error}`); + return false; + } + }); + + ipcMain.handle("is-soroban-installed", async (event) => { + try { + if (mainWindow) { + const result = await executeSorobanCommand("--version"); + const isSorobanInstalled = result.trim().startsWith("soroban"); + return isSorobanInstalled; + } else { + console.error("Main window not found"); + } + } catch (error) { + console.error(`Error while checking for Soroban installation: ${error}`); + return false; + } + }); + + // IPC handler for reading the JSON file + ipcMain.handle("json:read", async (event, filePath, directoryPath) => { + try { + const data = fs.readFileSync(path.join(filePath, directoryPath), "utf8"); + return JSON.parse(data); + } catch (error) { + console.error("Failed to read file", error); + return null; // or handle error as needed + } + }); + + // IPC handler for updating the JSON file + ipcMain.handle( + "json:update", + async (event, filePath, directoryPath, jsonContent) => { + try { + fs.writeFileSync( + path.join(filePath, directoryPath), + JSON.stringify(jsonContent, null, 2), + "utf8" + ); + return true; // success + } catch (error) { + console.error("Failed to write file", error); + return false; // or handle error as needed + } + } + ); + + async function retrieveAndStoreIdentities() { + try { + const result = await executeSorobanCommand("keys", "ls"); + const identityNames = result + .split("\n") + .filter( + (identity) => identity.trim() !== "" && identity.trim() !== "*" + ); + + for (const name of identityNames) { + // Create an identity object + const identity = { + name: name, + }; + + // Add each identity to the store + try { + await handleIdentities(store, "add", identity); + } catch (error) { + console.error(`Error adding identity '${name}':`, error); + } + } + } catch (error) { + console.error("Error retrieving identities:", error); + } + } + + ipcMain.handle("identity:refresh", async (event) => { + try { + const envVars = retrieveAndStoreIdentities(); + return envVars; + } catch (error) { + console.error("Failed to read identities from soroban:", error); + return { error }; + } + }); + + await retrieveAndStoreIdentities(); + + if (isProd) { + await mainWindow.loadURL("app://./projects"); + } else { + const port = process.argv[2]; + await mainWindow.loadURL(`http://localhost:${port}/projects`); + mainWindow.webContents.openDevTools(); + } +})(); + +app.on("window-all-closed", () => { + app.quit(); +}); + +ipcMain.on("message", async (event, arg) => { + event.reply("message", `${arg} World!`); +}); diff --git a/main/helpers/create-window.ts b/main/helpers/create-window.ts new file mode 100644 index 0000000..b4deda5 --- /dev/null +++ b/main/helpers/create-window.ts @@ -0,0 +1,86 @@ +import { + screen, + BrowserWindow, + BrowserWindowConstructorOptions, + Rectangle, +} from 'electron' +import Store from 'electron-store' + +export const createWindow = ( + windowName: string, + options: BrowserWindowConstructorOptions +): BrowserWindow => { + const key = 'window-state' + const name = `window-state-${windowName}` + const store = new Store({ name }) + const defaultSize = { + width: options.width, + height: options.height, + } + let state = {} + + const restore = () => store.get(key, defaultSize) + + const getCurrentPosition = () => { + const position = win.getPosition() + const size = win.getSize() + return { + x: position[0], + y: position[1], + width: size[0], + height: size[1], + } + } + + const windowWithinBounds = (windowState, bounds) => { + return ( + windowState.x >= bounds.x && + windowState.y >= bounds.y && + windowState.x + windowState.width <= bounds.x + bounds.width && + windowState.y + windowState.height <= bounds.y + bounds.height + ) + } + + const resetToDefaults = () => { + const bounds = screen.getPrimaryDisplay().bounds + return Object.assign({}, defaultSize, { + x: (bounds.width - defaultSize.width) / 2, + y: (bounds.height - defaultSize.height) / 2, + }) + } + + const ensureVisibleOnSomeDisplay = (windowState) => { + const visible = screen.getAllDisplays().some((display) => { + return windowWithinBounds(windowState, display.bounds) + }) + if (!visible) { + // Window is partially or fully not visible now. + // Reset it to safe defaults. + return resetToDefaults() + } + return windowState + } + + const saveState = () => { + if (!win.isMinimized() && !win.isMaximized()) { + Object.assign(state, getCurrentPosition()) + } + store.set(key, state) + } + + state = ensureVisibleOnSomeDisplay(restore()) + + const win = new BrowserWindow({ + ...state, + ...options, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + ...options.webPreferences, + }, + }) + + win.on('close', saveState) + + return win +} diff --git a/main/helpers/find-contracts.ts b/main/helpers/find-contracts.ts new file mode 100644 index 0000000..b78b534 --- /dev/null +++ b/main/helpers/find-contracts.ts @@ -0,0 +1,28 @@ +const { app, ipcMain } = require("electron"); +const fs = require("fs"); +const path = require("path"); + +// Function to check if the file is a Soroban contract +function isSorobanContract(filePath) { + const content = fs.readFileSync(filePath, "utf8"); + return content.includes("#![no_std]") && content.includes("soroban_sdk::{"); +} + +// Function to recursively find Soroban contract files +export function findContracts(dirPath, arrayOfFiles = []) { + const files = fs.readdirSync(dirPath); + + files.forEach((file) => { + const fullPath = path.join(dirPath, file); + if (fs.statSync(fullPath).isDirectory()) { + arrayOfFiles = findContracts(fullPath, arrayOfFiles); + } else { + // Look specifically for lib.rs files that are Soroban contracts + if (file === "lib.rs" && isSorobanContract(fullPath)) { + arrayOfFiles.push(fullPath); + } + } + }); + + return arrayOfFiles; +} diff --git a/main/helpers/index.ts b/main/helpers/index.ts new file mode 100644 index 0000000..e1b9aad --- /dev/null +++ b/main/helpers/index.ts @@ -0,0 +1 @@ +export * from './create-window' diff --git a/main/helpers/manage-identities.ts b/main/helpers/manage-identities.ts new file mode 100644 index 0000000..50a794c --- /dev/null +++ b/main/helpers/manage-identities.ts @@ -0,0 +1,79 @@ +export function handleIdentities(store, action, identity, newIdentity?) { + let identities = store.get("identities", []); + + switch (action) { + case "add": + if (!identity.name || identities.some((i) => i.name === identity.name)) { + throw new Error("Identity already exists or name is missing"); + } + + identities.push(identity); + break; + + case "rename": + const existingIdentityIndex = identities.findIndex( + (i) => i.name === identity.name + ); + if (existingIdentityIndex === -1) { + throw new Error("Identity to rename not found"); + } + if (identities.some((i) => i.name === newIdentity)) { + throw new Error("New identity name already exists"); + } + identities[existingIdentityIndex] = { + ...identities[existingIdentityIndex], + name: newIdentity, + }; + break; + + case "setActive": + // First, reset the active state for all identities + identities = identities.map((i) => ({ + ...i, + active: false, + })); + + // Then, find and set the specified identity as active + const index = identities.findIndex((i) => i.name === identity.name); + if (index !== -1) { + identities[index].active = true; + } else { + throw new Error("Identity not found"); + } + + // Persist the updated identities list back to the store + store.set("identities", identities); + break; + + case "delete": + const keyToDelete = identity.isInternetIdentity + ? "internetIdentityPrincipal" + : "name"; + identities = identities.filter( + (i) => i[keyToDelete] !== identity[keyToDelete] + ); + break; + + case "get": + if (identity) { + const keyToFind = identity.isInternetIdentity + ? "internetIdentityPrincipal" + : "name"; + const requestedIdentity = identities.find( + (i) => i[keyToFind] === identity[keyToFind] + ); + return requestedIdentity || null; + } + return identities; + + case "list": + // Return all identities + return identities; + + default: + throw new Error("Invalid action"); + } + + store.set("identities", identities); + return identities; +} diff --git a/main/helpers/manage-projects.ts b/main/helpers/manage-projects.ts new file mode 100644 index 0000000..1720241 --- /dev/null +++ b/main/helpers/manage-projects.ts @@ -0,0 +1,57 @@ +export function handleProjects(store, action, project) { + let projects = store.get("projects", []); + + switch (action) { + case "add": + // Check if the project already exists + if (projects.some((p) => p.path === project.path)) { + throw new Error("Project already exists"); + } + projects.push(project); + break; + + case "update": + // Find the index of the project + const existingProjectIndex = projects.findIndex( + (p) => p.path === project.path + ); + if (existingProjectIndex === -1) { + throw new Error("Project not found"); + } + + // Set all projects to inactive + projects.forEach((p) => (p.active = false)); + + // Update the selected project and set it to active + projects[existingProjectIndex] = { ...project, active: true }; + break; + + case "delete": + const projectIndexToRemove = projects.findIndex( + (p) => p.path === project.path + ); + if (projectIndexToRemove === -1) { + throw new Error("Project not found"); + } + projects.splice(projectIndexToRemove, 1); + break; + + case "get": + if (project && project.path) { + // Return the requested project + const requestedProject = projects.find((p) => p.path === project.path); + return requestedProject || null; // Return null if not found + } + // Return all projects if no specific project is requested + return projects; + + default: + throw new Error("Invalid action"); + } + + // Update the store (not necessary for 'get' action) + store.set("projects", projects); + + // Return the updated projects array (not necessary for 'get' action) + return projects; +} diff --git a/main/helpers/soroban-helper.ts b/main/helpers/soroban-helper.ts new file mode 100644 index 0000000..28656af --- /dev/null +++ b/main/helpers/soroban-helper.ts @@ -0,0 +1,47 @@ +import { spawn } from "child_process"; + +export function executeSorobanCommand( + command: string, + subcommand?: string, + args?: string[], + flags?: string[], + path?: string +): Promise { + const argStr = args || []; + const flagStr = flags || []; + const allArgs = [command, subcommand, ...argStr, ...flagStr].filter(Boolean); + + const commandStr = `soroban ${allArgs.join(" ")}`; + + return new Promise((resolve, reject) => { + const child = spawn("soroban", allArgs, { cwd: path, shell: true }); + + let stdoutData = ""; + let stderrData = ""; + + child.stdout.on("data", (data) => { + stdoutData += data; + }); + + child.stderr.on("data", (data) => { + stderrData += data; + }); + + child.on("error", (error) => { + reject(error); + }); + + child.on("close", (code) => { + if (code !== 0) { + reject( + new Error( + `Command "${commandStr}" failed with exit code ${code}: ${stderrData}` + ) + ); + } else { + const combinedOutput = stdoutData + stderrData; + resolve(combinedOutput.trim()); + } + }); + }); +} diff --git a/main/preload.ts b/main/preload.ts new file mode 100644 index 0000000..cdea991 --- /dev/null +++ b/main/preload.ts @@ -0,0 +1,73 @@ +import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron"; + +const handler = { + send(channel: string, value: unknown) { + ipcRenderer.send(channel, value); + }, + on(channel: string, callback: (...args: unknown[]) => void) { + const subscription = (_event: IpcRendererEvent, ...args: unknown[]) => + callback(...args); + ipcRenderer.on(channel, subscription); + + return () => { + ipcRenderer.removeListener(channel, subscription); + }; + }, + + node: process.versions.node, + chrome: process.versions.chrome, + electron: process.versions.electron, + runSorobanCommand: async (command, subcommand, args, flags, path) => { + return ipcRenderer.invoke( + "soroban-command", + command, + subcommand, + args, + flags, + path + ); + }, + openDirectory: async () => { + return ipcRenderer.invoke("dialog:openDirectory"); + }, + manageProjects: async (action, project) => { + return ipcRenderer.invoke("store:manageProjects", action, project); + }, + manageIdentities: async (action, identity, newIdentity) => { + return ipcRenderer.invoke( + "store:manageIdentities", + action, + identity, + newIdentity + ); + }, + isSorobanProject: async (directoryPath) => { + return ipcRenderer.invoke("is-soroban-project", directoryPath); + }, + isSorobanInstalled: async () => { + return ipcRenderer.invoke("is-soroban-installed"); + }, + listContracts: async (directoryPath) => { + return ipcRenderer.invoke("contracts:list", directoryPath); + }, + jsonRead: async (filePath, directoryPath) => { + return ipcRenderer.invoke("json:read", filePath, directoryPath); + }, + jsonWrite: async (filePath, directoryPath, data) => { + return ipcRenderer.invoke("json:update", filePath, directoryPath, data); + }, + reloadApplication: async () => { + console.log("Reloading application"); + return ipcRenderer.invoke("app:reload"); + }, + openExternalLink: async (url) => { + return ipcRenderer.invoke("open-external-link", url); + }, + refreshIdentities: async () => { + return ipcRenderer.invoke("identity:refresh"); + }, +}; + +contextBridge.exposeInMainWorld("sorobanApi", handler); + +export type IpcHandler = typeof handler; diff --git a/package.json b/package.json new file mode 100644 index 0000000..f700c1c --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "private": true, + "name": "soroban-cli-gui", + "description": "Soroban Cli Cross Platform Desktop Application", + "version": "0.1.0", + "author": "Tolga Yaycı ", + "main": "app/background.js", + "scripts": { + "dev": "nextron", + "build": "nextron build", + "build:mac": "nextron build --mac", + "build:mac:universal": "nextron build --mac --universal", + "build:linux": "nextron build --linux", + "build:win32": "nextron build --win --ia32", + "build:win64": "nextron build --win --x64", + "postinstall": "electron-builder install-app-deps" + }, + "dependencies": { + "@hookform/resolvers": "^3.3.4", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-tooltip": "^1.0.7", + "@tanstack/react-table": "^8.11.8", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "cmdk": "^0.2.1", + "electron-serve": "^1.3.0", + "electron-store": "^8.1.0", + "fix-path": "3.0.0", + "lucide-react": "^0.323.0", + "next-themes": "^0.2.1", + "react-hook-form": "^7.50.0", + "react-resizable-panels": "^2.0.3", + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7", + "toml": "^3.0.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/node": "^20.11.16", + "@types/react": "^18.2.52", + "autoprefixer": "^10.4.16", + "electron": "^28.2.1", + "electron-builder": "^24.9.1", + "next": "^12.3.4", + "nextron": "^8.13.0", + "postcss": "^8.4.30", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3" + } +} diff --git a/renderer/components/common/is-soroban-installed.tsx b/renderer/components/common/is-soroban-installed.tsx new file mode 100644 index 0000000..03de5ea --- /dev/null +++ b/renderer/components/common/is-soroban-installed.tsx @@ -0,0 +1,56 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "components/ui/alert-dialog"; + +export default function SorobanNotInstalled() { + async function openExternalLink(url: string) { + try { + await window.sorobanApi.openExternalLink(url); + } catch (error) { + console.error(`Error: ${error}`); + } + } + + async function reloadApplication() { + try { + await window.sorobanApi.reloadApplication(); + } catch (error) { + console.error(`Error: ${error}`); + } + } + + return ( + + + + Soroban is not installed! + + You need to install Soroban to use this application. Please visit + the repository for more information. + + + + reloadApplication() as any}> + Reload Application + + + openExternalLink( + "https://github.com/tolgayayci/soroban-cli-gui" + ) as any + } + > + Visit GitHub + + + + + ); +} diff --git a/renderer/components/common/loading.tsx b/renderer/components/common/loading.tsx new file mode 100644 index 0000000..632fe66 --- /dev/null +++ b/renderer/components/common/loading.tsx @@ -0,0 +1,15 @@ +import Image from "next/image"; + +export default function Loading() { + return ( +
+ Loading... +
+ ); +} diff --git a/renderer/components/contracts/Contracts.tsx b/renderer/components/contracts/Contracts.tsx new file mode 100644 index 0000000..e487d66 --- /dev/null +++ b/renderer/components/contracts/Contracts.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { useProjects } from "hooks/useProjects"; + +import { createContractsColumns } from "components/contracts/contracts-columns"; +import { ContractsDataTable } from "components/contracts/contracts-data-table"; +import NoContracts from "components/contracts/no-contracts"; +import Loading from "components/common/loading"; + +export default function CanistersComponent() { + const [allContracts, setAllContracts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const projects = useProjects(); + + async function checkContracts(projectPath) { + setIsLoading(true); + try { + const contractFiles = await window.sorobanApi.listContracts(projectPath); + + const contractsArray = contractFiles.map((filePath) => { + const name = filePath.split("/").pop().replace(".rs", ""); + + return { + name, + filePath, + projectName: + projects.find((p) => p.path === projectPath)?.name || + "Unknown Project", + projectPath, + }; + }); + + setAllContracts((prevContracts) => [...prevContracts, ...contractsArray]); + } catch (error) { + console.error("Error invoking remote method:", error); + } finally { + setIsLoading(false); + } + } + + useEffect(() => { + if (projects.length) { + setAllContracts([]); + projects.forEach((project) => { + checkContracts(project.path); + }); + } + }, [projects]); + + const columns = createContractsColumns(); + + return ( +
+ {isLoading ? ( + + ) : allContracts.length > 0 ? ( + + ) : ( + + )} +
+ ); +} diff --git a/renderer/components/contracts/command-selector.tsx b/renderer/components/contracts/command-selector.tsx new file mode 100644 index 0000000..bddc8c1 --- /dev/null +++ b/renderer/components/contracts/command-selector.tsx @@ -0,0 +1,386 @@ +import { useState, useEffect } from "react"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectGroup, +} from "components/ui/select"; +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import { commands } from "lib/commands"; +import { Checkbox } from "components/ui/checkbox"; +import { Label } from "components/ui/label"; +import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; +import { Loader2 } from "lucide-react"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "components/ui/accordion"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/ui/tooltip"; + +import { ScrollArea, ScrollBar } from "components/ui/scroll-area"; +import { SelectSeparator } from "components/ui/select"; + +const CliCommandSelector = ({ + path, + setCommandOutput, + setCommandError, +}: { + path: string; + setCommandOutput: (any) => void; + setCommandError: (any) => void; +}) => { + const defaultCommand = commands.length > 0 ? commands[0].value : ""; + + const [selectedCommand, setSelectedCommand] = useState(defaultCommand); + const [commandArgs, setCommandArgs] = useState({}); + const [commandOptions, setCommandOptions] = useState({}); + const [isRunningCommand, setIsRunningCommand] = useState(false); + const [latestCommand, setLatestCommand] = useState(""); + + const updateLatestCommand = () => { + const selectedCommandDetails = commands.find( + (c) => c.value === selectedCommand + ); + if (!selectedCommandDetails) { + setLatestCommand(""); + return; + } + + // Only values of the arguments separated by spaces + const argsString = Object.values(commandArgs) + .filter((value) => value) + .join(" "); + + const optionsString = Object.entries(commandOptions) + .filter(([key, value]) => { + const optionDetails = selectedCommandDetails.options.find( + (o) => o.name === key + ); + return ( + optionDetails && + ((optionDetails.type === "flag" && value) || + (optionDetails.type === "argument" && value)) + ); + }) + .map(([key, value]) => { + const optionDetails = selectedCommandDetails.options.find( + (o) => o.name === key + ); + return ( + optionDetails && + (optionDetails.type === "flag" ? `${key}` : `${key} ${value}`) + ); + }) + .join(" "); + + setLatestCommand( + `soroban contract ${selectedCommandDetails.value} ${argsString} ${optionsString}` + ); + }; + + useEffect(() => { + updateLatestCommand(); + }, [selectedCommand, commandArgs, commandOptions]); + + const handleCommandChange = (commandValue) => { + setSelectedCommand(commandValue); + const command = commands.find((c) => c.value === commandValue); + + // Initialize arguments + if (command && command.args) { + const argsInitialState = {}; + command.args.forEach((arg) => { + argsInitialState[arg.name] = ""; + }); + setCommandArgs(argsInitialState); + } else { + setCommandArgs({}); + } + + // Initialize options + if (command && command.options) { + const optionsInitialState = {}; + command.options.forEach((option) => { + optionsInitialState[option.name] = ""; + }); + setCommandOptions(optionsInitialState); + } else { + setCommandOptions({}); + } + }; + + const handleRunCommand = async () => { + setIsRunningCommand(true); + try { + await runCli(selectedCommand, Object.values(commandArgs)).then(() => { + // toast success message + }); + } catch (error) { + // toast error message + console.error("Error executing command:", error); + } finally { + setIsRunningCommand(false); + } + }; + + const runCli = async (command, args) => { + try { + if (path) { + const selectedCommandDetails = commands.find( + (c) => c.value === command + ); + + // Construct the options array, including -- for options and flags + const optionsArray = selectedCommandDetails.options.reduce( + (acc, option) => { + const value = commandOptions[option.name]; + if (option.type === "flag" && value) { + // If it's a flag and it's set, add the key + acc.push(`${option.name}`); + } else if (option.type === "argument" && value) { + // If it's an argument and it has a value, add both key and value + acc.push(`${option.name}`, value); + } + return acc; + }, + [] + ); + + const processedArgs = args.map((arg) => + isNaN(arg) ? arg : parseInt(arg, 10) + ); + + console.log( + command + " " + processedArgs + " " + optionsArray + " " + path + ); + + const result = await window.sorobanApi.runSorobanCommand( + "contract", + command, + [...processedArgs], // Use processedArgs instead of args + optionsArray, + path + ); + + console.log("Result:", result); + + setCommandError(""); + setCommandOutput(result); + } + } catch (error) { + setCommandError(`${error.message}`); + setCommandOutput(""); // Clear any previous output + throw error; + } + }; + + return ( +
+
+ {latestCommand} +
+ +
+ + + {selectedCommand && + commands.find((c) => c.value === selectedCommand)?.args?.length > + 0 && ( + + + Arguments + + + + {selectedCommand && + commands + .find((c) => c.value === selectedCommand) + ?.args?.map((arg) => ( +
+ +
+
+ + + + + + +
+
+ +

+ {arg.description || + "No description available"} +

+
+
+ { + setCommandArgs({ + ...commandArgs, + [arg.name]: e.target.value, + }); + }} + /> +
+ ))} + +
+
+ )} + {selectedCommand && + commands.find((c) => c.value === selectedCommand)?.options + ?.length > 0 && ( + + + Options & Flags + + + +
+ {selectedCommand && + commands + .find((c) => c.value === selectedCommand) + ?.options?.filter((option) => option.type === "flag") + .map((option, index) => ( +
+ { + setCommandOptions({ + ...commandOptions, + [option.name]: checked, + }); + }} + /> + + + + + + + + +

+ {option.description || + "No description available"} +

+
+
+
+ ))} +
+ + + + {selectedCommand && + commands + .find((c) => c.value === selectedCommand) + ?.options?.filter( + (option) => option.type === "argument" + ) + .map((option) => ( +
+ +
+ + + + + + + +

+ {option.description || + "No description available"} +

+
+
+
+ { + setCommandOptions({ + ...commandOptions, + [option.name]: e.target.value, + }); + }} + /> +
+ ))} +
+
+ )} +
+
+ +
+ {isRunningCommand ? ( + + ) : ( + + )} +
+ ); +}; + +export default CliCommandSelector; diff --git a/renderer/components/contracts/command-status-config.tsx b/renderer/components/contracts/command-status-config.tsx new file mode 100644 index 0000000..ee167b8 --- /dev/null +++ b/renderer/components/contracts/command-status-config.tsx @@ -0,0 +1,107 @@ +import dynamic from "next/dynamic"; +import { useEffect, useState } from "react"; +import Link from "next/link"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "components/ui/accordion"; +import { Button } from "components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "components/ui/alert"; +import { AlertCircle, ThumbsUpIcon, ThumbsDownIcon } from "lucide-react"; +import { ScrollArea, ScrollBar } from "components/ui/scroll-area"; + +export default function CommandStatusConfig({ + canister, + projectPath, + commandOutput, + commandError, +}: { + canister: any; + projectPath: string; + commandOutput: string; + commandError: string; +}) { + const [canisterStatus, setCanisterStatus] = useState(null); + const [accordionValue, setAccordionValue] = useState("status"); + + useEffect(() => { + // Set the accordion to open the last item if there is command output or an error + if (commandOutput || commandError) { + setAccordionValue("output"); + } else { + setAccordionValue("status"); + } + }, [commandOutput, commandError]); + + function parseCliOutput(output) { + const result = {}; + // Replace multiple spaces with a single space and then split the string into parts. + const parts = output.replace(/\s+/g, " ").trim().split(" "); + + let currentKey = ""; + let currentValue = ""; + + parts.forEach((part) => { + if (part.endsWith(":")) { + // If the current part ends with a colon, it's a key. + if (currentKey && currentValue) { + // Save the previous key-value pair. + result[currentKey] = currentValue.trim(); + } + // Start a new key-value pair. + currentKey = part.slice(0, -1); // Remove the colon from the key. + currentValue = ""; + } else { + // Otherwise, it's part of the value. + currentValue += part + " "; + } + }); + + // Don't forget to add the last key-value pair. + if (currentKey && currentValue) { + result[currentKey] = currentValue.trim(); + } + + return result; + } + + return ( + + + + + Canister Output + + +
+ {commandOutput && ( + + + Command Output + {commandOutput} + + )} + {commandError && ( + + + Error + {commandError} + + )} +
+
+
+
+ +
+ ); +} diff --git a/renderer/components/contracts/contract-detail.tsx b/renderer/components/contracts/contract-detail.tsx new file mode 100644 index 0000000..3d109a6 --- /dev/null +++ b/renderer/components/contracts/contract-detail.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import Link from "next/link"; +import { useProject } from "hooks/useProject"; + +import CliCommandSelector from "components/contracts/command-selector"; +import CommandStatusConfig from "components/contracts/command-status-config"; +import { Button } from "components/ui/button"; +import { Separator } from "components/ui/separator"; +import { Avatar, AvatarImage } from "components/ui/avatar"; + +export default function ContractDetail({ + projectPath, +}: { + projectPath: string; +}) { + const [commandOutput, setCommandOutput] = useState(); + const [commandError, setCommandError] = useState(); + + const project = useProject(projectPath); + + if (project) { + return ( + <> +
+
+
+ + + +

{project.name}

+
+
+ + + +
+
+ +
+
+ +
+
+ +
+
+
+ + ); + } else { + return
Contract not found or name is undefined.
; + } +} diff --git a/renderer/components/contracts/contracts-columns.tsx b/renderer/components/contracts/contracts-columns.tsx new file mode 100644 index 0000000..5ff49a4 --- /dev/null +++ b/renderer/components/contracts/contracts-columns.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Button } from "components/ui/button"; +import Link from "next/link"; + +export type Network = { + [key: string]: any; +}; + +export const createContractsColumns = (): ColumnDef[] => { + return [ + { + accessorKey: "name", + header: "Main Contract", + }, + { + accessorKey: "projectName", + header: "Project Name", + }, + { + accessorKey: "details", + header: () =>
Action
, + cell: ({ row }) => ( +
+ + + +
+ ), + }, + ]; +}; diff --git a/renderer/components/contracts/contracts-data-table.tsx b/renderer/components/contracts/contracts-data-table.tsx new file mode 100644 index 0000000..4e12928 --- /dev/null +++ b/renderer/components/contracts/contracts-data-table.tsx @@ -0,0 +1,215 @@ +"use client"; +import { useState } from "react"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, + ColumnFiltersState, + getFilteredRowModel, + SortingState, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "components/ui/table"; + +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; + +import { + ChevronLeftIcon, + ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from "@radix-ui/react-icons"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/ui/select"; + +interface DataTableProps { + columns: ColumnDef[]; + data: any[]; +} + +export function ContractsDataTable({ + columns, + data, +}: DataTableProps) { + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + initialState: { + pagination: { + pageSize: 5, + pageIndex: 0, + }, + }, + state: { + sorting, + columnFilters, + }, + }); + + return ( +
+
+ + table.getColumn("name")?.setFilterValue(event.target.value) + } + className="w-full" + /> +
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No canisters found with the given canister name! + + + )} + +
+
+
+
+
+
+

Rows per page

+ +
+
+
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+
+
+ + + + +
+
+
+
+ ); +} diff --git a/renderer/components/contracts/no-contracts.tsx b/renderer/components/contracts/no-contracts.tsx new file mode 100644 index 0000000..42d3d1d --- /dev/null +++ b/renderer/components/contracts/no-contracts.tsx @@ -0,0 +1,13 @@ +export default function NoContracts() { + return ( +
+ {" "} +
+

+ No contracts found, create or add a project to list existing + contracts. +

+
+
+ ); +} diff --git a/renderer/components/icons.tsx b/renderer/components/icons.tsx new file mode 100644 index 0000000..4088bb1 --- /dev/null +++ b/renderer/components/icons.tsx @@ -0,0 +1,71 @@ +import { + AlertTriangle, + ArrowRight, + Check, + ChevronLeft, + ChevronRight, + Command, + CreditCard, + File, + FileText, + HelpCircle, + Image, + Laptop, + Loader2, + LucideProps, + Moon, + MoreVertical, + Pizza, + Plus, + Settings, + SunMedium, + Trash, + Twitter, + User, + X, + RefreshCwIcon, +} from "lucide-react"; + +export const Icons = { + logo: Command, + close: X, + spinner: Loader2, + chevronLeft: ChevronLeft, + chevronRight: ChevronRight, + trash: Trash, + post: FileText, + page: File, + media: Image, + settings: Settings, + billing: CreditCard, + ellipsis: MoreVertical, + add: Plus, + warning: AlertTriangle, + user: User, + arrowRight: ArrowRight, + help: HelpCircle, + pizza: Pizza, + sun: SunMedium, + moon: Moon, + laptop: Laptop, + reload: RefreshCwIcon, + gitHub: ({ ...props }: LucideProps) => ( + + ), + twitter: Twitter, + check: Check, +}; diff --git a/renderer/components/identities/Identities.tsx b/renderer/components/identities/Identities.tsx new file mode 100644 index 0000000..080d75d --- /dev/null +++ b/renderer/components/identities/Identities.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState, useEffect } from "react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "components/ui/card"; + +import { Avatar, AvatarImage } from "components/ui/avatar"; +import { Alert, AlertDescription, AlertTitle } from "components/ui/alert"; +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; + +import { LucidePersonStanding } from "lucide-react"; +import IdentityModal from "components/identities/identity-modal"; +import { ScrollArea, ScrollBar } from "components/ui/scroll-area"; +import NoIdentities from "components/identities/no-identities"; + +import { FundIdentityModal } from "components/identities/fund-identity-modal"; +import { RemoveIdentityModal } from "components/identities/remove-identity-modal"; + +const IdentityCard = ({ + identity, + activeIdentityName, +}: { + identity: { + name: string; + }; + activeIdentityName: string; +}) => { + const [showFundIdentityDialog, setShowFundIdentityDialog] = useState(false); + const [showRemoveIdentityDialog, setShowRemoveIdentityDialog] = + useState(false); + + return ( + + +
+ + + +
+ {identity.name} + Local Identity +
+
+
+ +
+ + {showFundIdentityDialog && ( + setShowFundIdentityDialog(false)} + /> + )} +
+
+ + {showRemoveIdentityDialog && ( + setShowRemoveIdentityDialog(false)} + /> + )} +
+
+
+ ); +}; + +export default function IdentitiesComponent() { + const [showCreateIdentityDialog, setShowCreateIdentityDialog] = + useState(false); + const [identities, setIdentities] = useState(); + const [searchQuery, setSearchQuery] = useState(""); + const [activeIdentityName, setActiveIdentityName] = useState(""); + + async function checkIdentities() { + try { + const identities = await window.sorobanApi.manageIdentities("list", ""); + + setIdentities(identities); + } catch (error) { + console.log("Error invoking remote method:", error); + } + } + + const handleSearchChange = (e: any) => { + e.preventDefault(); + setSearchQuery(e.target.value); + }; + + // Call checkIdentities when the component mounts + useEffect(() => { + checkIdentities(); + }, []); + + return ( +
+
+ +
+ +
+ + You have {identities?.length ? identities?.length : "0"}{" "} + identities + + + You can add, remove, or edit your identities on this page. + +
+
+ +
+ +
+ + {identities ? ( +
+
+ +
+ +
+ {identities + .filter((identity) => + identity.name + .toLowerCase() + .includes(searchQuery.toLowerCase()) + ) + .map((identity) => ( + + ))} +
+ +
+
+ ) : ( + + )} +
+ ); +} diff --git a/renderer/components/identities/forms/addNewIdentity.ts b/renderer/components/identities/forms/addNewIdentity.ts new file mode 100644 index 0000000..03cbcf0 --- /dev/null +++ b/renderer/components/identities/forms/addNewIdentity.ts @@ -0,0 +1,37 @@ +import * as z from "zod"; + +export const addIdentityFormSchema = z.object({ + identity_name: z + .string() + .min(3, "Identity name must be at least 3 characters long.") + .max(255, "Identity name must be at most 255 characters long.") + .regex( + /^[A-Za-z0-9.\-_@]+$/, + "Only the characters ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_@0123456789 are valid in identity names." + ), + secret_key: z.string(), + seed_phrase: z.string(), + global: z.boolean().optional(), + config_dir: z.string().optional(), +}); + +export async function onAddIdentityFormSubmit( + data: z.infer +) { + try { + const command = "keys"; + const subcommand = "add"; + const args = [data.identity_name]; + const flags = [ + data.secret_key ? `--secret-key "${data.secret_key}"` : null, + data.seed_phrase ? `--seed-phrase "${data.seed_phrase}"` : null, + data.global ? "--global" : null, + data.config_dir ? `--config-dir "${data.config_dir}"` : null, + ].filter(Boolean); + + await window.sorobanApi.runSorobanCommand(command, subcommand, args, flags); + await window.sorobanApi.reloadApplication(); + } catch (error) { + throw error; + } +} diff --git a/renderer/components/identities/forms/createNewIdentity.ts b/renderer/components/identities/forms/createNewIdentity.ts new file mode 100644 index 0000000..037d953 --- /dev/null +++ b/renderer/components/identities/forms/createNewIdentity.ts @@ -0,0 +1,60 @@ +import * as z from "zod"; + +export const newIdentityFormSchema = z.object({ + identity_name: z + .string() + .min(3, "Identity name must be at least 3 characters long.") + .max(255, "Identity name must be at most 255 characters long.") + .regex( + /^[A-Za-z0-9.\-_@]+$/, + "Only the characters ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_@0123456789 are valid in identity names." + ), + seed: z.string().optional(), + as_secret: z.boolean().optional(), + global: z.boolean().optional(), + hd_path: z + .string() + .regex( + /^(m\/)?(\d+'?\/)*(\d+'?)$/, + "HD path must follow a structure like m/44'/0'/0'/0." + ) + .optional(), + default_seed: z.boolean().optional(), + config_dir: z.string().optional(), + rpc_url: z.string().url("RPC URL must be a valid URL.").optional(), + network_passphrase: z.string().optional(), + network: z.string().optional(), +}); + +export async function onNewIdentityFormSubmit( + data: z.infer +) { + try { + const command = "keys"; + const subcommand = "generate"; + const args = [data.identity_name]; + const flags = [ + data.seed ? `--seed "${data.seed}"` : null, + data.as_secret ? "--as-secret" : null, + data.global ? "--global" : null, + data.hd_path ? `--hd-path "${data.hd_path}"` : null, + data.default_seed ? "--default-seed" : null, + data.rpc_url ? `--rpc-url "${data.rpc_url}"` : null, + data.network_passphrase + ? `--network-passphrase "${data.network_passphrase}"` + : null, + data.network ? `--network "${data.network}"` : null, + ].filter(Boolean); + + await window.sorobanApi.runSorobanCommand(command, subcommand, args, flags); + + await window.sorobanApi.manageIdentities("add", { + name: data.identity_name, + active: false, + }); + + await window.sorobanApi.reloadApplication(); + } catch (error) { + console.error(`Error: ${error}`); + } +} diff --git a/renderer/components/identities/forms/fundIdentity.ts b/renderer/components/identities/forms/fundIdentity.ts new file mode 100644 index 0000000..f41a9a7 --- /dev/null +++ b/renderer/components/identities/forms/fundIdentity.ts @@ -0,0 +1,49 @@ +import * as z from "zod"; + +export const fundIdentityFormSchema = z.object({ + identity_name: z + .string() + .min(3, "Identity name must be at least 3 characters long.") + .max(255, "Identity name must be at most 255 characters long.") + .regex( + /^[A-Za-z0-9.\-_@]+$/, + "Only the characters ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_@0123456789 are valid in identity names." + ), + hd_path: z + .string() + .regex( + /^(m\/)?(\d+'?\/)*(\d+'?)$/, + "HD path must follow a structure like m/44'/0'/0'/0." + ) + .optional(), + global: z.boolean().optional(), + rpc_url: z.string().url("RPC URL must be a valid URL.").optional(), + network_passphrase: z.string().optional(), + network_name: z.string().optional(), + config_dir: z.string().optional(), +}); + +export async function onFundIdentityFormSubmit( + data: z.infer +) { + try { + const command = "keys"; + const subcommand = "fund"; + const args = [data.identity_name]; + const flags = [ + data.hd_path ? `--hd-path "${data.hd_path}"` : null, + data.global ? "--global" : null, + data.rpc_url ? `--rpc-url "${data.rpc_url}"` : null, + data.network_passphrase + ? `--network-passphrase "${data.network_passphrase}"` + : null, + data.network_name ? `--network "${data.network_name}"` : null, + data.config_dir ? `--config-dir "${data.config_dir}"` : null, + ].filter(Boolean); + + await window.sorobanApi.runSorobanCommand(command, subcommand, args, flags); + await window.sorobanApi.reloadApplication(); + } catch (error) { + throw error; + } +} diff --git a/renderer/components/identities/forms/removeIdentity.ts b/renderer/components/identities/forms/removeIdentity.ts new file mode 100644 index 0000000..14e0036 --- /dev/null +++ b/renderer/components/identities/forms/removeIdentity.ts @@ -0,0 +1,40 @@ +import * as z from "zod"; + +export const removeIdentityFormSchema = z.object({ + identity_name: z + .string() + .min(3, "Identity name must be at least 3 characters long.") + .max(255, "Identity name must be at most 255 characters long.") + .regex( + /^[A-Za-z0-9.\-_@]+$/, + "Only the characters ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_@0123456789 are valid in identity names." + ), + global: z.boolean().optional(), + config_dir: z.string().optional(), +}); + +export async function onRemoveIdentityFormSubmit( + data: z.infer +) { + try { + const command = "keys"; + const subcommand = "rm"; + const args = [data.identity_name]; + const flags = [ + data.global ? "--global" : null, + data.config_dir ? `--config-dir "${data.config_dir}"` : null, + ].filter(Boolean); + + console.log(command, subcommand, args, flags); + + await window.sorobanApi.runSorobanCommand(command, subcommand, args, flags); + + await window.sorobanApi.manageIdentities("delete", { + name: data.identity_name, + }); + + await window.sorobanApi.reloadApplication(); + } catch (error) { + throw error; + } +} diff --git a/renderer/components/identities/fund-identity-modal.tsx b/renderer/components/identities/fund-identity-modal.tsx new file mode 100644 index 0000000..8889736 --- /dev/null +++ b/renderer/components/identities/fund-identity-modal.tsx @@ -0,0 +1,293 @@ +import { useState } from "react"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "components/ui/accordion"; + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "components/ui/form"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/ui/dialog"; + +import { + fundIdentityFormSchema, + onFundIdentityFormSubmit, +} from "components/identities/forms/fundIdentity"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; + +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import { Checkbox } from "components/ui/checkbox"; +import { Loader2 } from "lucide-react"; + +import { useToast } from "components/ui/use-toast"; + +import { identityFundSuccess, identityFundError } from "lib/notifications"; +import { ScrollArea, ScrollBar } from "components/ui/scroll-area"; + +export const FundIdentityModal = ({ identity, isOpen, onClose }) => { + const [isSubmittingFundIdentity, setIsSubmittingFundIdentity] = + useState(false); + + const fundIdentityForm = useForm>({ + resolver: zodResolver(fundIdentityFormSchema), + defaultValues: { + global: false, + }, + }); + + const { toast } = useToast(); + + const handleFundIdentityFormSubmit = async (data) => { + setIsSubmittingFundIdentity(true); + try { + await onFundIdentityFormSubmit(data).then(() => { + toast(identityFundSuccess(data.identity_name)); + fundIdentityForm.reset(); + }); + } catch (error) { + toast(identityFundError(data.identity_name, error)); + } finally { + setIsSubmittingFundIdentity(false); + } + }; + + async function getDirectoryPath() { + try { + const result = await window.sorobanApi.openDirectory(); + return result; + } catch (error) { + console.error(`Error: ${error}`); + } + } + + return ( + + +
+ + + Fund "{identity.name}" + + Fund an identity on a test network + + + +
+
+
+ ( + + + Identity Name + + {identity ? ( + + + + ) : null} + + + )} + /> +
+
+ ( + + + Network Name + + + + + + + )} + /> +
+
+ ( + + + Network Passphrase + + + + + + + )} + /> +
+
+ ( + + RPC URL + + + + + + )} + /> +
+ + + Options + +
+
+ ( + + + + +
+ Global + + Use global config + +
+
+ )} + /> +
+
+ ( + + + HD Path + + + + + + + )} + /> +
+
+ ( + + + Config Directory (Testing) + + +
+ + +
+
+ +
+ )} + /> +
+
+
+
+
+
+
+ +
+ + + {isSubmittingFundIdentity ? ( + + ) : ( + + )} + +
+ +
+
+ ); +}; diff --git a/renderer/components/identities/identity-modal.tsx b/renderer/components/identities/identity-modal.tsx new file mode 100644 index 0000000..1aeb2b3 --- /dev/null +++ b/renderer/components/identities/identity-modal.tsx @@ -0,0 +1,599 @@ +"use client"; + +import { useState } from "react"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "components/ui/tabs"; +import { Checkbox } from "components/ui/checkbox"; +import { ScrollArea, ScrollBar } from "components/ui/scroll-area"; +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import { Loader2 } from "lucide-react"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "components/ui/accordion"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "components/ui/form"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/ui/dialog"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; + +import { + onNewIdentityFormSubmit, + newIdentityFormSchema, +} from "components/identities/forms/createNewIdentity"; + +import { + addIdentityFormSchema, + onAddIdentityFormSubmit, +} from "components/identities/forms/addNewIdentity"; + +import { useToast } from "components/ui/use-toast"; +import { + identityCreateSuccess, + identityCreateError, + identityAddSuccess, + identityAddError, +} from "lib/notifications"; + +export default function IdentityModal({ + showCreateIdentityDialog, + setShowCreateIdentityDialog, +}) { + const [isSubmittingCreateIdentity, setIsSubmittingCreateIdentity] = + useState(false); + const [isSubmittingAddIdentity, setIsSubmittingAddIdentity] = useState(false); + + const { toast } = useToast(); + + const handleCreateNewIdentity = async (data) => { + try { + await onNewIdentityFormSubmit(data).then((res) => { + //@ts-ignore + if (res) { + toast(identityCreateSuccess(data.identity_name)); + setShowCreateIdentityDialog(false); + } + }); + } catch (error) { + toast(identityCreateError(data.identity_name, error)); + console.log(error); + } finally { + setShowCreateIdentityDialog(false); + } + }; + + const handleAddIdentity = async (data) => { + try { + await onAddIdentityFormSubmit(data).then((res) => { + //@ts-ignore + if (res) { + toast(identityAddSuccess(data.identity_name)); + setShowCreateIdentityDialog(false); + } + }); + } catch (error) { + toast(identityAddError(data.identity_name, error)); + } finally { + setShowCreateIdentityDialog(false); + } + }; + + const newIdentityForm = useForm>({ + resolver: zodResolver(newIdentityFormSchema), + }); + + const addIdentityForm = useForm>({ + resolver: zodResolver(addIdentityFormSchema), + }); + + async function getDirectoryPath() { + try { + const result = await window.sorobanApi.openDirectory(); + return result; + } catch (error) { + console.error(`Error: ${error}`); + } + } + + return ( + setShowCreateIdentityDialog(false)} + > + + + + Generate Identity + Add Identity + + +
+ + + Generate New Identity + + Identities you will add are global. They are not confined to + a specific project context. + + + +
+
+
+ ( + + + Identity Name + + + + + + + )} + /> +
+ +
+ ( + + + Network Passphrase + + + + + + + )} + /> +
+
+ ( + + + Network + + + + + + + )} + /> +
+
+ ( + + + RPC Url + + + + + + + )} + /> +
+ + + Options + +
+
+ ( + + + Seed + + + + + + + )} + /> +
+
+ ( + + + Hd Path + + +
+ + +
+
+ +
+ )} + /> +
+
+ ( + + + + +
+ As Secret + + Output the generated identity as a + secret key + +
+
+ )} + /> +
+
+ ( + + + + +
+ Global + + Use global config + +
+
+ )} + /> +
+
+ ( + + + + +
+ Default Seed + + Generate the default seed phrase. + Useful for testing. Equivalent to + --seed 0000000000000000 + +
+
+ )} + /> +
+
+
+
+ + Testing Options + +
+
+ ( + + + Config Directory + + +
+ + +
+
+ +
+ )} + /> +
+
+
+
+
+
+
+ +
+ + + {isSubmittingCreateIdentity ? ( + + ) : ( + + )} + +
+ +
+ +
+ + + Add Identity + + Add a new identity (keypair, ledger, macOS keychain) + + + +
+
+
+ ( + + + Identity Name + + + + + + + )} + /> +
+
+ ( + + + Seed Phrase + + + + + + + )} + /> +
+
+ ( + + + Secret Key + + + + + + + )} + /> +
+ + + Options + +
+
+ ( + + + + +
+ Global + + Use global config + +
+
+ )} + /> +
+
+ ( + + + Config Directory (Testing) + + +
+ + +
+
+ +
+ )} + /> +
+
+
+
+
+
+
+
+ + + {isSubmittingAddIdentity ? ( + + ) : ( + + )} + +
+ +
+
+
+
+ ); +} diff --git a/renderer/components/identities/identity-switcher.tsx b/renderer/components/identities/identity-switcher.tsx new file mode 100644 index 0000000..bad4218 --- /dev/null +++ b/renderer/components/identities/identity-switcher.tsx @@ -0,0 +1,242 @@ +"use client"; + +import * as React from "react"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { + CaretSortIcon, + CheckIcon, + PlusCircledIcon, + UpdateIcon, +} from "@radix-ui/react-icons"; +import { cn } from "lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "components/ui/avatar"; +import { Button } from "components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "components/ui/command"; +import { Dialog, DialogTrigger } from "components/ui/dialog"; +import { Popover, PopoverContent, PopoverTrigger } from "components/ui/popover"; + +import IdentityModal from "components/identities/identity-modal"; + +const initialGroups = [ + { + label: "Active Identity", + teams: [ + { + label: "", + value: "", + }, + ], + }, + { + label: "Identities", + teams: [ + { + label: "", + value: "", + }, + ], + }, +]; + +function showFirst3Last4(str) { + // Ensure the string is long enough + if (str.length > 10) { + const first3 = str.substring(0, 4); + const last4 = str.substring(str.length - 6); + return `${first3}...${last4}`; + } + return str; +} + +type Team = (typeof initialGroups)[number]["teams"][number]; + +type PopoverTriggerProps = React.ComponentPropsWithoutRef< + typeof PopoverTrigger +>; + +interface TeamSwitcherProps extends PopoverTriggerProps {} + +export default function IdentitySwitcher({ className }: TeamSwitcherProps) { + const [open, setOpen] = React.useState(false); + const [showNewTeamDialog, setShowNewTeamDialog] = React.useState(false); + const [selectedIdentity, setSelectedIdentity] = React.useState( + initialGroups[0].teams[0] + ); + const [updatedGroups, setUpdatedGroups] = useState(initialGroups); + + const router = useRouter(); + + async function checkIdentities() { + try { + await window.sorobanApi.refreshIdentities(); + const identities = await window.sorobanApi.manageIdentities("list", ""); + + const activeIdentity = + identities.find((identity) => identity.active) || identities[0]; + + const identityGroups = [ + { + label: "Active Identity", + teams: activeIdentity + ? [ + { + label: showFirst3Last4(activeIdentity.name), + value: activeIdentity.name, + }, + ] + : [], + }, + { + label: "Identities", + teams: identities.map((identity) => ({ + label: showFirst3Last4(identity.name), + value: identity.name, + })), + }, + ]; + + setUpdatedGroups(identityGroups); + setSelectedIdentity( + identityGroups[0].teams[0] || initialGroups[0].teams[0] + ); + } catch (error) { + console.log("Error invoking remote method:", error); + } + } + + async function changeIdentity(identity) { + try { + await window.sorobanApi.manageIdentities("setActive", { + name: identity, + active: true, + }); + await window.sorobanApi.reloadApplication(); + } catch (error) { + console.log("Error invoking remote method:", error); + } + } + + const hasIdentities = updatedGroups.some((group) => group.teams.length > 0); + + useEffect(() => { + checkIdentities(); + }, []); + + return ( + setShowNewTeamDialog(false)} + > + + + + + + + + + No identity found. + {hasIdentities && + updatedGroups.map((group) => ( + + {group.teams.map((team) => ( + { + if (selectedIdentity.value !== team.value) { + setSelectedIdentity(team); + changeIdentity(team.value); + } + setOpen(false); + }} + className="text-sm" + > + + + SC + + {showFirst3Last4(team.label)} + + + ))} + + ))} + + + + + + { + setOpen(false); + setShowNewTeamDialog(true); + }} + > + + Create Identity + + + + + + + + { + setOpen(false); + router.push("/identities"); + }} + > + + Edit Identities + + + + + + + + + ); +} diff --git a/renderer/components/identities/no-identities.tsx b/renderer/components/identities/no-identities.tsx new file mode 100644 index 0000000..8c48fd0 --- /dev/null +++ b/renderer/components/identities/no-identities.tsx @@ -0,0 +1,13 @@ +export default function NoIdentities() { + return ( +
+ {" "} +
+

+ No identities found, create, login or add existing identities by + clicking the button above +

+
+
+ ); +} diff --git a/renderer/components/identities/remove-identity-modal.tsx b/renderer/components/identities/remove-identity-modal.tsx new file mode 100644 index 0000000..2de47b2 --- /dev/null +++ b/renderer/components/identities/remove-identity-modal.tsx @@ -0,0 +1,214 @@ +import { useState } from "react"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "components/ui/accordion"; + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "components/ui/form"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/ui/dialog"; + +import { + removeIdentityFormSchema, + onRemoveIdentityFormSubmit, +} from "components/identities/forms/removeIdentity"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; + +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import { Checkbox } from "components/ui/checkbox"; +import { Loader2 } from "lucide-react"; + +import { useToast } from "components/ui/use-toast"; + +import { identityRemoveSuccess, identityRemoveError } from "lib/notifications"; + +export const RemoveIdentityModal = ({ identity, isOpen, onClose }) => { + const [isSubmittingRemoveIdentity, setIsSubmittingRemoveIdentity] = + useState(false); + + const removeIdentityForm = useForm>({ + resolver: zodResolver(removeIdentityFormSchema), + defaultValues: { + identity_name: identity.name, + global: false, + }, + }); + + const { toast } = useToast(); + + const handleRemoveIdentityFormSubmit = async (data) => { + setIsSubmittingRemoveIdentity(true); + try { + await onRemoveIdentityFormSubmit(data).then(() => { + toast(identityRemoveSuccess(data.identity_name)); + removeIdentityForm.reset(); + }); + } catch (error) { + toast(identityRemoveError(data.identity_name, error)); + } finally { + setIsSubmittingRemoveIdentity(false); + } + }; + + async function getDirectoryPath() { + try { + const result = await window.sorobanApi.openDirectory(); + return result; + } catch (error) { + console.error(`Error: ${error}`); + } + } + + return ( + + +
+ + + Remove "{identity.name}" + + Remove an identity from Soroban + + +
+
+
+ ( + + + Identity Name + + {identity ? ( + + + + ) : null} + + + )} + /> +
+ + + Options + +
+
+ ( + + + + +
+ Global + + Use global config + +
+
+ )} + /> +
+
+ ( + + + Config Directory (Testing) + + +
+ + +
+
+ +
+ )} + /> +
+
+
+
+
+
+
+ + + {isSubmittingRemoveIdentity ? ( + + ) : ( + + )} + +
+ +
+
+ ); +}; diff --git a/renderer/components/layout.tsx b/renderer/components/layout.tsx new file mode 100644 index 0000000..49b9667 --- /dev/null +++ b/renderer/components/layout.tsx @@ -0,0 +1,157 @@ +// Import necessary components and hooks +import * as React from "react"; +import { useRouter } from "next/router"; +import Image from "next/image"; +import { SideNav } from "components/sidebar-nav"; +import { ModeToggle } from "components/toggle-mode"; +import { ReloadToggle } from "components/toggle-reload"; +import IdentitySwitcher from "components/identities/identity-switcher"; +import { Toaster } from "components/ui/toaster"; +import { cn } from "lib/utils"; +import { TooltipProvider } from "components/ui/tooltip"; +import { Separator } from "components/ui/separator"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "components/ui/resizable"; + +import { + HomeIcon, + DatabaseIcon, + NetworkIcon, + CircuitBoardIcon, + SettingsIcon, +} from "lucide-react"; + +import { useTheme } from "next-themes"; + +interface LayoutProps { + children: React.ReactNode; +} + +export default function Layout({ children }: LayoutProps) { + const router = useRouter(); + const { theme, setTheme } = useTheme(); + + // Set initial layout and collapsed state + const defaultLayout = [15, 85]; + const [isCollapsed, setIsCollapsed] = React.useState(false); + + const navCollapsedSize = 4; + + const handleCollapse = React.useCallback(() => { + setIsCollapsed((prevState) => !prevState); // Toggle the collapsed state + document.cookie = `react-resizable-panels:collapsed=${JSON.stringify( + isCollapsed + )}`; + }, []); + + return ( + <> +
+ {" "} +
+ {theme === "dark" ? ( + solana_logo_dark + ) : ( + solana_logo_light + )} +
+ + + +
+
+ + { + document.cookie = `react-resizable-panels:layout=${JSON.stringify( + sizes + )}`; + }} + className="h-full items-stretch" + > + +
+
+ + +
+
+
+ + +
{children}
+
+
+
+
+ + + ); +} diff --git a/renderer/components/projects/Projects.tsx b/renderer/components/projects/Projects.tsx new file mode 100644 index 0000000..0e7d1dc --- /dev/null +++ b/renderer/components/projects/Projects.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "components/ui/form"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/ui/dialog"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "components/ui/card"; + +import { Avatar, AvatarImage } from "components/ui/avatar"; +import { Alert, AlertDescription, AlertTitle } from "components/ui/alert"; +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import { Loader2 } from "lucide-react"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; + +import { CodeIcon } from "lucide-react"; +import { ScrollArea, ScrollBar } from "components/ui/scroll-area"; +import ProjectModal from "components/projects/project-modal"; +import NoProjects from "components/projects/no-project"; + +import { + removeProjectFormSchema, + onRemoveProjectFormSubmit, +} from "components/projects/forms/removeProject"; + +import { useToast } from "components/ui/use-toast"; +import { projectRemoveSuccess, projectRemoveError } from "lib/notifications"; + +const ProjectCard = ({ + project, + onProjectChange, +}: { + project: { + name: string; + path: string; + active: boolean; + }; + onProjectChange: () => void; +}) => { + const [showRemoveProjectDialog, setShowRemoveProjectDialog] = useState(false); + const [isSubmittingRemoveProject, setIsSubmittingRemoveProject] = + useState(false); + + const { toast } = useToast(); + + const removeProjectForm = useForm>({ + resolver: zodResolver(removeProjectFormSchema), + defaultValues: { + project_name: project.name, + path: project.path, + }, + }); + + const handleRemoveProjectFormSubmit = async (data) => { + setIsSubmittingRemoveProject(true); + try { + await onRemoveProjectFormSubmit(data).then(() => { + toast(projectRemoveSuccess(data.project_name)); + setShowRemoveProjectDialog(false); + removeProjectForm.reset(); + onProjectChange(); + }); + } catch (error) { + console.error(error); + } finally { + setIsSubmittingRemoveProject(false); + } + }; + + return ( + + +
+ + + +
+ {project.name} + + {project.path.split("/").slice(-2)[0] + + "/" + + project.path.split("/").slice(-2)[1]} + +
+
+
+ + + + + + setShowRemoveProjectDialog(false)} + > + +
+ + + Remove "{project.name}" + + You can remove your project on application, this doesn't + remove your project folder on your system. + + +
+
+
+ ( + + + Project Name + + + + + + + )} + /> +
+
+ ( + + Path + + + + + + )} + /> +
+
+
+ + + {isSubmittingRemoveProject ? ( + + ) : ( + + )} + +
+ +
+
+
+
+ ); +}; + +export default function ProjectsComponent() { + const [showCreateProjectDialog, setShowCreateProjectDialog] = useState(false); + const [projects, setProjects] = useState(); + const [searchQuery, setSearchQuery] = useState(""); + + async function checkProjects() { + try { + const projects = await window.sorobanApi.manageProjects("get", ""); + + setProjects(projects); + } catch (error) { + console.log("Error invoking remote method:", error); + } + } + + const refreshProjects = async () => { + await checkProjects(); + }; + + const handleSearchChange = (e: any) => { + e.preventDefault(); + setSearchQuery(e.target.value); + }; + + useEffect(() => { + checkProjects(); + }, []); + + return ( +
+
+ +
+ +
+ + You have {projects?.length ? projects?.length : "0"} projects + + + You can add, remove, or edit your projects on this page. + +
+
+ +
+ +
+ {projects?.length > 0 ? ( +
+
+ +
+ +
+ {projects + .filter((project) => + project.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .map((project) => ( + + ))} +
+ +
+
+ ) : ( + + )} +
+ ); +} diff --git a/renderer/components/projects/forms/addExistingProject.ts b/renderer/components/projects/forms/addExistingProject.ts new file mode 100644 index 0000000..51a80e6 --- /dev/null +++ b/renderer/components/projects/forms/addExistingProject.ts @@ -0,0 +1,36 @@ +import * as z from "zod"; + +export const addExistingProjectFormSchema = z.object({ + project_name: z + .string() + .min(3, { message: "Project name must be at least 3 characters long" }) + .max(40, { message: "Project name must be at most 40 characters long" }) + .regex(/^[A-Za-z0-9]+$/, { + message: "Project name must only contain letters and digits", + }), + path: z + .string() + .min(3, { + message: "You must select a path", + }) + .max(255), +}); + +export async function onAddExistingProjectForm( + data: z.infer +) { + try { + const result = await window.sorobanApi + .isSorobanProject(data.path) + .then(async () => { + await window.sorobanApi.manageProjects("add", { + name: data.project_name, + path: data.path, + }); + }); + + return result; + } catch (error) { + throw error; + } +} diff --git a/renderer/components/projects/forms/createNewProject.ts b/renderer/components/projects/forms/createNewProject.ts new file mode 100644 index 0000000..e8154b3 --- /dev/null +++ b/renderer/components/projects/forms/createNewProject.ts @@ -0,0 +1,68 @@ +import * as z from "zod"; + +export const createNewProjectFormSchema = z.object({ + project_name: z + .string() + .min(3, { message: "Project name must be at least 3 characters long" }) + .max(40, { message: "Project name must be at most 40 characters long" }) + .regex(/^[A-Za-z0-9]+$/, { + message: "Project name must only contain letters and digits", + }), + path: z + .string() + .min(3, { + message: "You must select a path", + }) + .max(255), + include_examples: z.boolean(), + with_example: z + .enum([ + "account", + "alloc", + "atomic-multiswap", + "atomic-swap", + "auth", + "cross-contract", + "custom-types", + "deep-contract-auth", + "deployer", + "errors", + "events", + "fuzzing", + "increment", + "liquidity-pool", + "logging", + "simple-account", + "single-offer", + "timelock", + "token", + "upgradeable-contract", + ]) + .optional(), +}); + +export async function onCreateNewProjectForm( + data: z.infer +) { + try { + const command = "contract"; + const subcommand = "init"; + const args = [data.path]; + const flags = [ + data.include_examples ? `-w ${data.with_example}` : null, + ].filter(Boolean); + + const result = await window.sorobanApi + .runSorobanCommand(command, subcommand, args, flags, data.path) + .then(async () => { + await window.sorobanApi.manageProjects("add", { + name: data.project_name, + path: data.path + "/" + data.project_name, + }); + }); + + return result; + } catch (error) { + throw error; + } +} diff --git a/renderer/components/projects/forms/removeProject.ts b/renderer/components/projects/forms/removeProject.ts new file mode 100644 index 0000000..3e61859 --- /dev/null +++ b/renderer/components/projects/forms/removeProject.ts @@ -0,0 +1,21 @@ +import * as z from "zod"; + +export const removeProjectFormSchema = z.object({ + project_name: z + .string() + .min(3, "Project name must be at least 3 characters long.") + .max(50, "Project name must be at most 50 characters long."), + path: z.string(), +}); + +export async function onRemoveProjectFormSubmit( + data: z.infer +) { + try { + await window.sorobanApi.manageProjects("delete", { + path: data.path, + }); + } catch (error) { + throw error; + } +} diff --git a/renderer/components/projects/forms/renameProject.ts b/renderer/components/projects/forms/renameProject.ts new file mode 100644 index 0000000..d8a7a60 --- /dev/null +++ b/renderer/components/projects/forms/renameProject.ts @@ -0,0 +1,44 @@ +import * as z from "zod"; + +export const renameProjectFormSchema = z.object({ + from_project_name: z + .string() + .min(3, "Project name must be at least 3 characters long.") + .max(50, "Project name must be at most 50 characters long."), + to_project_name: z + .string() + .min(3, "Project name must be at least 3 characters long.") + .max(50, "Project name must be at most 50 characters long.") + .regex( + /^[A-Za-z0-9.\-_@]+$/, + "Only the characters ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_@0123456789 are valid in project names." + ), + path: z.string(), +}); + +export async function onRenameProjectFormSubmit( + data: z.infer +) { + try { + const { to_project_name, path } = data; + + const existingProject = await window.sorobanApi.manageProjects("get", { + path: path, + }); + + if (!existingProject) { + throw new Error("Project not found"); + } + + const updatedProject = { ...existingProject, name: to_project_name }; + + const result = await window.sorobanApi.manageProjects( + "update", + updatedProject + ); + + return result; + } catch (error) { + throw error; + } +} diff --git a/renderer/components/projects/no-project.tsx b/renderer/components/projects/no-project.tsx new file mode 100644 index 0000000..25e4741 --- /dev/null +++ b/renderer/components/projects/no-project.tsx @@ -0,0 +1,10 @@ +export default function NoProjects() { + return ( +
+ {" "} +
+

No project found, create a new one

+
+
+ ); +} diff --git a/renderer/components/projects/project-modal.tsx b/renderer/components/projects/project-modal.tsx new file mode 100644 index 0000000..58ccea6 --- /dev/null +++ b/renderer/components/projects/project-modal.tsx @@ -0,0 +1,432 @@ +"use client"; + +import { useState } from "react"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "components/ui/form"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogTrigger, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/ui/dialog"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/ui/select"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { Switch } from "components/ui/switch"; +import { ScrollArea, ScrollBar } from "components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "components/ui/tabs"; +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import { Loader2 } from "lucide-react"; + +import { + createNewProjectFormSchema, + onCreateNewProjectForm, +} from "components/projects/forms/createNewProject"; + +import { + addExistingProjectFormSchema, + onAddExistingProjectForm, +} from "components/projects/forms/addExistingProject"; + +import { useToast } from "components/ui/use-toast"; +import { + projectCreateSuccess, + projectCreateError, + projectImportSuccess, + projectImportError, +} from "lib/notifications"; + +export default function ProjectModal({ + showNewProjectDialog, + setShowNewProjectDialog, + onProjectChange, +}) { + const [isSubmittingNewProject, setIsSubmittingNewProject] = useState(false); + const [isSubmittingExistingProject, setIsSubmittingExistingProject] = + useState(false); + + const { toast } = useToast(); + + const createNewProjectform = useForm< + z.infer + >({ + resolver: zodResolver(createNewProjectFormSchema), + defaultValues: { + include_examples: false, + }, + }); + + const addExistingProjectForm = useForm< + z.infer + >({ + resolver: zodResolver(addExistingProjectFormSchema), + }); + + // Modify your form submit handler to use setIsSubmitting + const handleNewProjectFormSubmit = async (data) => { + setIsSubmittingNewProject(true); + try { + await onCreateNewProjectForm(data).then(() => { + toast(projectCreateSuccess(data.project_name)); + setShowNewProjectDialog(false); + createNewProjectform.reset(); + onProjectChange(); + }); + } catch (error) { + toast(projectCreateError(data.project_name, error)); + console.log(error); + } finally { + setIsSubmittingNewProject(false); + } + }; + + const handleExistingProjectFormSubmit = async (data) => { + setIsSubmittingExistingProject(true); + try { + const result = await window.sorobanApi.isSorobanProject( + data.path as string + ); + + if (result) { + await onAddExistingProjectForm(data).then(async () => { + toast(projectImportSuccess(data.project_name)); + setShowNewProjectDialog(false); + addExistingProjectForm.reset(); + onProjectChange(); + }); + } else { + toast(projectImportError(data.project_name)); + setShowNewProjectDialog(false); + addExistingProjectForm.reset(); + onProjectChange(); + } + } catch (error) { + throw error; + } finally { + setIsSubmittingExistingProject(false); + } + }; + + async function getDirectoryPath() { + try { + const result = await window.sorobanApi.openDirectory(); + return result; + } catch (error) { + console.error(`Error: ${error}`); + } + } + + return ( + setShowNewProjectDialog(false)} + > + + + + New Project + Import Existing + + +
+ + + Create Project + + Initialize a Soroban project with an example contract + + + +
+
+
+ ( + + + Project Name + + + + + + + )} + /> +
+
+ ( + + + Project Path + + +
+ + +
+
+ +
+ )} + /> +
+
+ Options +
+ ( + +
+ + With Example + + + Specify Soroban example contracts to + include. A hello-world contract will be + included by default + +
+ + + +
+ )} + /> +
+ {createNewProjectform.watch("include_examples") && ( +
+ ( + + + + )} + /> +
+ )} +
+
+
+ +
+ + + {isSubmittingNewProject ? ( + + ) : ( + + )} + +
+ +
+ +
+ + + Import Existing Project + + Import existing Soroban project from your computer + + +
+
+
+ ( + + + Project Name + + + + + + + )} + /> +
+
+ ( + + + Project Path + + +
+ + +
+
+ +
+ )} + /> +
+
+
+ + + {isSubmittingExistingProject ? ( + + ) : ( + + )} + +
+ +
+
+
+
+ ); +} diff --git a/renderer/components/settings/Settings.tsx b/renderer/components/settings/Settings.tsx new file mode 100644 index 0000000..97cb195 --- /dev/null +++ b/renderer/components/settings/Settings.tsx @@ -0,0 +1,385 @@ +"use client"; +import { useState, useEffect } from "react"; +import Link from "next/link"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "components/ui/accordion"; + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "components/ui/form"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/ui/dialog"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "components/ui/card"; + +import { Checkbox } from "components/ui/checkbox"; + +import { Avatar, AvatarImage } from "components/ui/avatar"; +import { Alert, AlertDescription, AlertTitle } from "components/ui/alert"; +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import { Loader2 } from "lucide-react"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; + +import { CodeIcon } from "lucide-react"; +import { ScrollArea, ScrollBar } from "components/ui/scroll-area"; +import NetworkModal from "components/settings/network-modal"; +import NoNetworks from "./no-network"; + +import { + removeNetworkFormSchema, + onRemoveNetworkFormSubmit, +} from "components/settings/forms/removeNetwork"; + +import { useToast } from "components/ui/use-toast"; +import { networkRemoveSuccess, networkRemoveError } from "lib/notifications"; + +const NetworkCard = ({ + network, + onNetworkChange, +}: { + network: { + name: string; + rpc_url: string; + }; + onNetworkChange: () => void; +}) => { + const [showRemoveNetworkDialog, setShowRemoveNetworkDialog] = useState(false); + const [isSubmittingRemoveNetwork, setIsSubmittingRemoveNetwork] = + useState(false); + + const { toast } = useToast(); + + const removeNetworkForm = useForm>({ + resolver: zodResolver(removeNetworkFormSchema), + defaultValues: { + network_name: network.name, + global: false, + }, + }); + + const handleRemoveNetworkFormSubmit = async (data) => { + setIsSubmittingRemoveNetwork(true); + try { + await onRemoveNetworkFormSubmit(data).then(() => { + toast(networkRemoveSuccess(data.network_name)); + setShowRemoveNetworkDialog(false); + removeNetworkForm.reset(); + onNetworkChange(); + }); + } catch (error) { + toast(networkRemoveError(data.network_name, error)); + } finally { + setIsSubmittingRemoveNetwork(false); + } + }; + + async function getDirectoryPath() { + try { + const result = await window.sorobanApi.openDirectory(); + return result; + } catch (error) { + console.error(`Error: ${error}`); + } + } + + return ( + + +
+ + + +
+ {network.name} + + {network.rpc_url} + +
+
+
+ + + setShowRemoveNetworkDialog(false)} + > + +
+ + + Remove "{network.name}" + + Remove this network from Soroban + + +
+
+
+ ( + + + Network Name + + + + + + + )} + /> +
+ + + Options + +
+
+ ( + + + + +
+ Global + + Use global config + +
+
+ )} + /> +
+
+ ( + + + Config Directory (Testing) + + +
+ + +
+
+ +
+ )} + /> +
+
+
+
+
+
+
+ + + {isSubmittingRemoveNetwork ? ( + + ) : ( + + )} + +
+ +
+
+
+
+ ); +}; + +export default function SettingsComponent() { + const [showCreateProjectDialog, setShowCreateProjectDialog] = useState(false); + const [networks, setNetworks] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + + async function checkNetworks() { + try { + const output = await window.sorobanApi.runSorobanCommand( + "network", + "ls", + ["--long"] + ); + + const networkBlockPattern = /Name:\s*(\S+)[\s\S]*?rpc_url:\s*"([^"]+)"/g; + let match; + const networks = []; + + while ((match = networkBlockPattern.exec(output)) !== null) { + if (match.index === networkBlockPattern.lastIndex) { + networkBlockPattern.lastIndex++; + } + + const networkInfo = { + name: match[1], + rpc_url: match[2], + }; + + networks.push(networkInfo); + } + + setNetworks(networks); + } catch (error) { + console.log("Error listing networks:", error); + setNetworks([]); + } + } + + const refreshNetworks = async () => { + await checkNetworks(); + }; + + const handleSearchChange = (e: any) => { + e.preventDefault(); + setSearchQuery(e.target.value); + }; + + useEffect(() => { + checkNetworks(); + }, []); + + return ( +
+
+ +
+ +
+ + You have {networks?.length ? networks?.length : "0"} networks + + + You can add or remove your networks on this page. + +
+
+ +
+ +
+ {networks?.length > 0 ? ( +
+
+ +
+ +
+ {networks + .filter((network) => + network.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .map((network) => ( + + ))} +
+ +
+
+ ) : ( + + )} +
+ ); +} diff --git a/renderer/components/settings/forms/createNetwork.ts b/renderer/components/settings/forms/createNetwork.ts new file mode 100644 index 0000000..7affcdb --- /dev/null +++ b/renderer/components/settings/forms/createNetwork.ts @@ -0,0 +1,38 @@ +import * as z from "zod"; + +// Updated schema to include rpc-url and network-passphrase +export const createNetworkFormSchema = z.object({ + network_name: z + .string() + .min(3, "Network name must be at least 3 characters long.") + .max(50, "Network name must be at most 50 characters long.") + .regex( + /^[A-Za-z0-9]+$/, + "Network name must include only letters and numbers, no special characters or spaces." + ), + rpc_url: z.string().url("RPC URL must be a valid URL."), + network_passphrase: z.string().min(1, "Network passphrase is required."), + global: z.boolean().optional(), + config_dir: z.string().optional(), +}); + +export async function onCreateNetworkFormSubmit( + data: z.infer +) { + try { + const command = "network"; + const subcommand = "add"; + const args = [data.network_name]; + const flags = [ + `--rpc-url "${data.rpc_url}"`, + `--network-passphrase "${data.network_passphrase}"`, + data.global ? "--global" : null, + data.config_dir ? `--config-dir "${data.config_dir}"` : null, + ].filter(Boolean); + + await window.sorobanApi.runSorobanCommand(command, subcommand, args, flags); + await window.sorobanApi.reloadApplication(); + } catch (error) { + throw error; + } +} diff --git a/renderer/components/settings/forms/removeNetwork.ts b/renderer/components/settings/forms/removeNetwork.ts new file mode 100644 index 0000000..f2c35f3 --- /dev/null +++ b/renderer/components/settings/forms/removeNetwork.ts @@ -0,0 +1,29 @@ +import * as z from "zod"; + +export const removeNetworkFormSchema = z.object({ + network_name: z + .string() + .min(3, "Network name must be at least 3 characters long.") + .max(50, "Network name must be at most 50 characters long."), + global: z.boolean().optional(), + config_dir: z.string().optional(), +}); + +export async function onRemoveNetworkFormSubmit( + data: z.infer +) { + try { + const command = "network"; + const subcommand = "rm"; + const args = [data.network_name]; + const flags = [ + data.global ? "--global" : null, + data.config_dir ? `--config-dir "${data.config_dir}"` : null, + ].filter(Boolean); + + await window.sorobanApi.runSorobanCommand(command, subcommand, args, flags); + await window.sorobanApi.reloadApplication(); + } catch (error) { + throw error; + } +} diff --git a/renderer/components/settings/network-modal.tsx b/renderer/components/settings/network-modal.tsx new file mode 100644 index 0000000..d5c5347 --- /dev/null +++ b/renderer/components/settings/network-modal.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useState } from "react"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "components/ui/accordion"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "components/ui/form"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogTrigger, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/ui/dialog"; + +import { Checkbox } from "components/ui/checkbox"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { ScrollArea, ScrollBar } from "components/ui/scroll-area"; +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import { Loader2 } from "lucide-react"; + +import { + createNetworkFormSchema, + onCreateNetworkFormSubmit, +} from "components/settings/forms/createNetwork"; + +import { useToast } from "components/ui/use-toast"; +import { networkCreateError, networkCreateSuccess } from "lib/notifications"; + +export default function NetworkModal({ + showNewNetworkDialog, + setShowNewNetworkDialog, + onNetworkChange, +}) { + const [isSubmittingNewNetwork, setIsSubmittingNewNetwork] = useState(false); + + const { toast } = useToast(); + + const createNewNetworkform = useForm>( + { + resolver: zodResolver(createNetworkFormSchema), + defaultValues: { + global: false, + }, + } + ); + + const handleNewNetworkFormSubmit = async (data) => { + setIsSubmittingNewNetwork(true); + try { + await onCreateNetworkFormSubmit(data).then(() => { + toast(networkCreateSuccess(data.network_name)); + setShowNewNetworkDialog(false); + createNewNetworkform.reset(); + onNetworkChange(); + }); + } catch (error) { + toast(networkCreateError(data.network_name, error)); + } finally { + setIsSubmittingNewNetwork(false); + } + }; + + async function getDirectoryPath() { + try { + const result = await window.sorobanApi.openDirectory(); + return result; + } catch (error) { + console.error(`Error: ${error}`); + } + } + + return ( + setShowNewNetworkDialog(false)} + > + +
+ + + Create New Network + + Add a new network for Soroban + + + +
+
+
+ ( + + + Network Name + + + + + + + )} + /> +
+
+ ( + + RPC URL + + + + + + )} + /> +
+
+ ( + + + Network Passphrase + + + + + + + )} + /> +
+ + + + Options + +
+
+ ( + + + + +
+ Global + + Use global config + +
+
+ )} + /> +
+
+ ( + + + Config Directory (Testing) + + +
+ + +
+
+ +
+ )} + /> +
+
+
+
+
+
+
+ +
+ + + {isSubmittingNewNetwork ? ( + + ) : ( + + )} + +
+ +
+
+ ); +} diff --git a/renderer/components/settings/no-network.tsx b/renderer/components/settings/no-network.tsx new file mode 100644 index 0000000..153766a --- /dev/null +++ b/renderer/components/settings/no-network.tsx @@ -0,0 +1,12 @@ +export default function NoNetworks() { + return ( +
+ {" "} +
+

+ No networks found, add a network to list existing networks +

+
+
+ ); +} diff --git a/renderer/components/sidebar-nav.tsx b/renderer/components/sidebar-nav.tsx new file mode 100644 index 0000000..48ccd8f --- /dev/null +++ b/renderer/components/sidebar-nav.tsx @@ -0,0 +1,86 @@ +"use client"; + +import Link from "next/link"; +import { cn } from "lib/utils"; +import { buttonVariants } from "components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "components/ui/tooltip"; + +interface NavProps { + isCollapsed: boolean; + links: { + title: string; + label?: string; + icon: any; // Ideally, specify a more precise type + variant: "default" | "ghost"; + href: string; // Assuming each link has an href property + onClick?: () => void; // Assuming you might have onClick handlers + }[]; +} + +export function SideNav({ links, isCollapsed }: NavProps) { + return ( +
+ +
+ ); +} diff --git a/renderer/components/theme-provider.tsx b/renderer/components/theme-provider.tsx new file mode 100644 index 0000000..b0ff266 --- /dev/null +++ b/renderer/components/theme-provider.tsx @@ -0,0 +1,9 @@ +"use client"; + +import * as React from "react"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import { type ThemeProviderProps } from "next-themes/dist/types"; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/renderer/components/toggle-mode.tsx b/renderer/components/toggle-mode.tsx new file mode 100644 index 0000000..034a6e5 --- /dev/null +++ b/renderer/components/toggle-mode.tsx @@ -0,0 +1,43 @@ +"use client"; + +import * as React from "react"; +import { useTheme } from "next-themes"; + +import { Button } from "components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/ui/dropdown-menu"; +import { Icons } from "components/icons"; + +export function ModeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + + Light + + setTheme("dark")}> + + Dark + + setTheme("system")}> + + System + + + + ); +} diff --git a/renderer/components/toggle-reload.tsx b/renderer/components/toggle-reload.tsx new file mode 100644 index 0000000..2d77910 --- /dev/null +++ b/renderer/components/toggle-reload.tsx @@ -0,0 +1,24 @@ +"use client"; + +import * as React from "react"; + +import { Button } from "components/ui/button"; +import { Icons } from "components/icons"; + +export function ReloadToggle() { + async function reloadApplication() { + try { + await window.sorobanApi.reloadApplication(); + } catch (error) { + console.error(`Error: ${error}`); + } + } + + return ( + + ); +} diff --git a/renderer/components/ui/accordion.tsx b/renderer/components/ui/accordion.tsx new file mode 100644 index 0000000..3a794af --- /dev/null +++ b/renderer/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/renderer/components/ui/alert-dialog.tsx b/renderer/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..2f697e2 --- /dev/null +++ b/renderer/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "lib/utils"; +import { buttonVariants } from "components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/renderer/components/ui/alert.tsx b/renderer/components/ui/alert.tsx new file mode 100644 index 0000000..1ecfc4a --- /dev/null +++ b/renderer/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/renderer/components/ui/avatar.tsx b/renderer/components/ui/avatar.tsx new file mode 100644 index 0000000..61b224c --- /dev/null +++ b/renderer/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/renderer/components/ui/button.tsx b/renderer/components/ui/button.tsx new file mode 100644 index 0000000..2601bd3 --- /dev/null +++ b/renderer/components/ui/button.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/renderer/components/ui/card.tsx b/renderer/components/ui/card.tsx new file mode 100644 index 0000000..28da0ca --- /dev/null +++ b/renderer/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/renderer/components/ui/checkbox.tsx b/renderer/components/ui/checkbox.tsx new file mode 100644 index 0000000..9776db9 --- /dev/null +++ b/renderer/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; + +import { cn } from "lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/renderer/components/ui/command.tsx b/renderer/components/ui/command.tsx new file mode 100644 index 0000000..9da8f82 --- /dev/null +++ b/renderer/components/ui/command.tsx @@ -0,0 +1,153 @@ +import * as React from "react"; +import { type DialogProps } from "@radix-ui/react-dialog"; +import { Command as CommandPrimitive } from "cmdk"; +import { Search } from "lucide-react"; + +import { cn } from "lib/utils"; +import { Dialog, DialogContent } from "components/ui/dialog"; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/renderer/components/ui/dialog.tsx b/renderer/components/ui/dialog.tsx new file mode 100644 index 0000000..043205c --- /dev/null +++ b/renderer/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/renderer/components/ui/dropdown-menu.tsx b/renderer/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..d0f6815 --- /dev/null +++ b/renderer/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/renderer/components/ui/form.tsx b/renderer/components/ui/form.tsx new file mode 100644 index 0000000..75bef16 --- /dev/null +++ b/renderer/components/ui/form.tsx @@ -0,0 +1,177 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "lib/utils"; +import { Label } from "components/ui/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +