From 6b1d9d8f49ad8814fda0de349280abd4a2e37cb3 Mon Sep 17 00:00:00 2001 From: ali00209 Date: Mon, 28 Oct 2024 21:21:58 +0500 Subject: [PATCH 1/5] feat: improve the GitHub push UI --- app/components/workbench/Workbench.client.tsx | 121 +++++++++++++++--- 1 file changed, 101 insertions(+), 20 deletions(-) diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx index 29c722c89..f682dc038 100644 --- a/app/components/workbench/Workbench.client.tsx +++ b/app/components/workbench/Workbench.client.tsx @@ -10,6 +10,7 @@ import { import { IconButton } from '~/components/ui/IconButton'; import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton'; import { Slider, type SliderOptions } from '~/components/ui/Slider'; +import { Dialog, DialogRoot, DialogTitle, DialogDescription, DialogButton } from '~/components/ui/Dialog'; import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench'; import { classNames } from '~/utils/classNames'; import { cubicEasingFn } from '~/utils/easings'; @@ -56,6 +57,11 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => renderLogger.trace('Workbench'); const [isSyncing, setIsSyncing] = useState(false); + const [isGitHubPushing, setIsGitHubPushing] = useState(false); + const [showGitHubDialog, setShowGitHubDialog] = useState(false); + const [githubRepoName, setGithubRepoName] = useState('bolt-generated-project'); + const [githubUsername, setGithubUsername] = useState(''); + const [githubToken, setGithubToken] = useState(''); const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0)); const showWorkbench = useStore(workbenchStore.showWorkbench); @@ -116,6 +122,24 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => } }, []); + const handleGitHubPush = useCallback(async () => { + if (!githubRepoName || !githubUsername || !githubToken) { + toast.error('Please fill in all GitHub details'); + return; + } + + setIsGitHubPushing(true); + try { + await workbenchStore.pushToGitHub(githubRepoName, githubUsername, githubToken); + setShowGitHubDialog(false); + toast.success('Successfully pushed to GitHub!'); + } catch (error) { + toast.error('Failed to push to GitHub'); + } finally { + setIsGitHubPushing(false); + } + }, [githubRepoName, githubUsername, githubToken]); + return ( chatStarted && ( variants={workbenchVariants} className="z-workbench" > + + + +
+
+ Push to GitHub +
+ + +
+

+ Push your project to a new or existing GitHub repository. You'll need a GitHub account and a personal access token with repo permissions. +

+
+ + setGithubRepoName(e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-bolt-elements-background-depth-1 focus:outline-none focus:ring-2 focus:ring-bolt-elements-button-primary-background" + placeholder="bolt-generated-project" + /> +
+
+ + setGithubUsername(e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-bolt-elements-background-depth-1 focus:outline-none focus:ring-2 focus:ring-bolt-elements-button-primary-background" + placeholder="username" + /> +
+
+ + setGithubToken(e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-bolt-elements-background-depth-1 focus:outline-none focus:ring-2 focus:ring-bolt-elements-button-primary-background" + placeholder="ghp_xxxxxxxxxxxx" + /> + + Generate a new token + +
+
+ setShowGitHubDialog(false)}> + Cancel + + + {isGitHubPushing ? ( + <> +
+ Pushing... + + ) : ( + <> +
+ Push to GitHub + + )} + +
+
+ +
+
+
Download Code - {isSyncing ?
:
} + {isSyncing ?
:
} {isSyncing ? 'Syncing...' : 'Sync Files'} { - const repoName = prompt("Please enter a name for your new GitHub repository:", "bolt-generated-project"); - if (!repoName) { - alert("Repository name is required. Push to GitHub cancelled."); - return; - } - const githubUsername = prompt("Please enter your GitHub username:"); - if (!githubUsername) { - alert("GitHub username is required. Push to GitHub cancelled."); - return; - } - const githubToken = prompt("Please enter your GitHub personal access token:"); - if (!githubToken) { - alert("GitHub token is required. Push to GitHub cancelled."); - return; - } - - workbenchStore.pushToGitHub(repoName, githubUsername, githubToken); - }} + onClick={() => setShowGitHubDialog(true)} >
Push to GitHub @@ -230,6 +310,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => ) ); }); + interface ViewProps extends HTMLMotionProps<'div'> { children: JSX.Element; } From a4fc40923e00c5561637bb57a5bba6d8a55367cc Mon Sep 17 00:00:00 2001 From: ali00209 Date: Mon, 11 Nov 2024 18:01:55 +0500 Subject: [PATCH 2/5] feat: better push to github --- app/components/workbench/Workbench.client.tsx | 164 +++++++++++- app/lib/stores/workbench.ts | 240 ++++++++++++------ 2 files changed, 317 insertions(+), 87 deletions(-) diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx index f682dc038..2e01c07e5 100644 --- a/app/components/workbench/Workbench.client.tsx +++ b/app/components/workbench/Workbench.client.tsx @@ -17,6 +17,7 @@ import { cubicEasingFn } from '~/utils/easings'; import { renderLogger } from '~/utils/logger'; import { EditorPanel } from './EditorPanel'; import { Preview } from './Preview'; +import { Octokit } from "@octokit/rest"; interface WorkspaceProps { chatStarted?: boolean; @@ -62,6 +63,11 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => const [githubRepoName, setGithubRepoName] = useState('bolt-generated-project'); const [githubUsername, setGithubUsername] = useState(''); const [githubToken, setGithubToken] = useState(''); + const [isPrivateRepo, setIsPrivateRepo] = useState(false); + const [selectedBranch, setSelectedBranch] = useState('main'); + const [branches, setBranches] = useState([]); + const [isNewBranch, setIsNewBranch] = useState(false); + const [newBranchName, setNewBranchName] = useState(''); const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0)); const showWorkbench = useStore(workbenchStore.showWorkbench); @@ -122,23 +128,95 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => } }, []); + const isValidBranchName = (branchName: string) => { + // Git branch names must not contain these characters: ~ ^ : ? * [ \ and must not end with a dot. + const invalidCharacters = /[~^:?*[\]\\]/; + return branchName.length > 0 && !invalidCharacters.test(branchName) && !branchName.endsWith('.'); + }; + const handleGitHubPush = useCallback(async () => { if (!githubRepoName || !githubUsername || !githubToken) { toast.error('Please fill in all GitHub details'); return; } - + + if (!githubToken.startsWith('ghp_') && !githubToken.startsWith('github_pat_')) { + toast.error('Invalid token format. Please use a GitHub Personal Access Token'); + return; + } + + if (isNewBranch) { + if (!newBranchName) { + toast.error('Please enter a name for the new branch'); + return; + } + if (!isValidBranchName(newBranchName)) { + toast.error('Invalid branch name. Please ensure it does not contain invalid characters or end with a dot.'); + return; + } + } + setIsGitHubPushing(true); try { - await workbenchStore.pushToGitHub(githubRepoName, githubUsername, githubToken); + const repoUrl = await workbenchStore.pushToGitHub( + githubRepoName.trim(), + githubUsername.trim(), + githubToken.trim(), + isPrivateRepo, + isNewBranch ? newBranchName.trim() : undefined, + isNewBranch + ); + + toast.success( +
+ Successfully pushed to GitHub!{' '} + + View Repository + +
+ ); setShowGitHubDialog(false); - toast.success('Successfully pushed to GitHub!'); } catch (error) { - toast.error('Failed to push to GitHub'); + console.error('GitHub push error:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to push to GitHub'; + + // Add specific error handling for common cases + if (errorMessage.includes('Repository does not exist')) { + toast.error('Cannot create a new branch in a non-existent repository. Please create the repository first.'); + } else if (errorMessage.includes('rate limit')) { + toast.error('GitHub API rate limit exceeded. Please try again later.'); + } else { + toast.error(errorMessage); + } } finally { setIsGitHubPushing(false); } - }, [githubRepoName, githubUsername, githubToken]); + }, [githubRepoName, githubUsername, githubToken, isPrivateRepo, isNewBranch, newBranchName]); + + const handleCancelPush = useCallback(() => { + if (isGitHubPushing) { + // Cancel the ongoing push operation + setIsGitHubPushing(false); + toast.info('GitHub push operation cancelled'); + } + setShowGitHubDialog(false); + }, [isGitHubPushing]); + + const fetchBranches = useCallback(async () => { + if (!githubUsername || !githubToken || !githubRepoName) return; + + try { + const octokit = new Octokit({ auth: githubToken }); + const { data } = await octokit.rest.repos.listBranches({ + owner: githubUsername, + repo: githubRepoName + }); + setBranches(data.map(branch => branch.name)); + } catch (error) { + console.error('Error fetching branches:', error); + setBranches([]); + } + }, [githubUsername, githubToken, githubRepoName]); return ( chatStarted && ( @@ -156,11 +234,13 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => Push to GitHub
- +
-

+

Push your project to a new or existing GitHub repository. You'll need a GitHub account and a personal access token with repo permissions. -

+
+ + {/* Repository Name */}
placeholder="bolt-generated-project" />
+ + {/* GitHub Username */}
placeholder="username" />
+ + {/* Repository Visibility */} +
+ +
+ + +
+
+ + {/* Branch Options */} +
+ +
+ + +
+ + {isNewBranch && ( + setNewBranchName(e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-bolt-elements-background-depth-1 focus:outline-none focus:ring-2 focus:ring-bolt-elements-button-primary-background" + placeholder="Enter new branch name" + /> + )} +
+ + {/* Personal Access Token */}
href="https://github.com/settings/tokens/new" target="_blank" rel="noopener noreferrer" - className="text-xs text-bolt-elements-button-primary-background hover:underline mt-1 inline-block" + className="text-xs text-bolt-elements-textSecondary hover:underline mt-1 inline-block" > Generate a new token
- setShowGitHubDialog(false)}> + Cancel diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index c42cc6275..e83e20443 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -335,110 +335,196 @@ export class WorkbenchStore { return syncedFiles; } - async pushToGitHub(repoName: string, githubUsername: string, ghToken: string) { - + async pushToGitHub( + repoName: string, + githubUsername: string, + ghToken: string, + isPrivate: boolean, + branchName?: string, + isNewBranch?: boolean + ) { try { - // Get the GitHub auth token from environment variables - const githubToken = ghToken; - - const owner = githubUsername; - - if (!githubToken) { - throw new Error('GitHub token is not set in environment variables'); - } + // Clean and validate inputs + const cleanUsername = githubUsername.trim().replace(/[@\s]/g, ''); + const cleanRepoName = repoName.trim().replace(/[^a-zA-Z0-9-_]/g, '-'); + const targetBranch = (branchName || 'main').trim(); - // Initialize Octokit with the auth token - const octokit = new Octokit({ auth: githubToken }); + // Initialize Octokit client with auth token + const octokit = new Octokit({ + auth: ghToken, + baseUrl: 'https://api.github.com' + }); - // Check if the repository already exists before creating it - let repo + // Get or create repository + let repoData; try { - repo = await octokit.repos.get({ owner: owner, repo: repoName }); - } catch (error) { - if (error instanceof Error && 'status' in error && error.status === 404) { - // Repository doesn't exist, so create a new one - const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({ - name: repoName, - private: false, - auto_init: true, + // Try to get existing repo first + const { data } = await octokit.rest.repos.get({ + owner: cleanUsername, + repo: cleanRepoName + }); + repoData = data; + + // Update repository visibility if it exists + await octokit.rest.repos.update({ + owner: cleanUsername, + repo: cleanRepoName, + private: isPrivate, + name: cleanRepoName + }); + + } catch (error: any) { + if (error?.response?.status === 404) { + const { data } = await octokit.rest.repos.createForAuthenticatedUser({ + name: cleanRepoName, + private: isPrivate, + auto_init: true }); - repo = newRepo; + repoData = data; + await new Promise(resolve => setTimeout(resolve, 5000)); } else { - console.log('cannot create repo!'); - throw error; // Some other error occurred + throw error; } } - // Get all files + // Get base commit SHA from default branch + let baseCommitSha; + try { + const { data: ref } = await octokit.rest.git.getRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `heads/${repoData.default_branch}` + }); + baseCommitSha = ref.object.sha; + } catch (error) { + console.error('Error getting default branch:', error); + throw new Error('Failed to get default branch. Repository may not be properly initialized.'); + } + + // Create blobs for files in batches to avoid rate limits const files = this.files.get(); if (!files || Object.keys(files).length === 0) { throw new Error('No files found to push'); } - // Create blobs for each file - const blobs = await Promise.all( - Object.entries(files).map(async ([filePath, dirent]) => { + const blobs = []; + const BATCH_SIZE = 3; + const fileEntries = Object.entries(files); + for (let i = 0; i < fileEntries.length; i += BATCH_SIZE) { + const batch = fileEntries.slice(i, i + BATCH_SIZE); + if (i > 0) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + const batchResults = await Promise.all(batch.map(async ([filePath, dirent]) => { if (dirent?.type === 'file' && dirent.content) { - const { data: blob } = await octokit.git.createBlob({ - owner: repo.owner.login, - repo: repo.name, - content: Buffer.from(dirent.content).toString('base64'), - encoding: 'base64', - }); - return { path: filePath.replace(/^\/home\/project\//, ''), sha: blob.sha }; + try { + const { data } = await octokit.rest.git.createBlob({ + owner: cleanUsername, + repo: cleanRepoName, + content: Buffer.from(dirent.content).toString('base64'), + encoding: 'base64' + }); + return { + path: filePath.replace(/^\/home\/project\//, ''), + mode: '100644' as const, + type: 'blob' as const, + sha: data.sha + }; + } catch (error) { + console.error('Error creating blob:', error); + return null; + } } - }) - ); + return null; + })); - const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs - - if (validBlobs.length === 0) { - throw new Error('No valid files to push'); + blobs.push(...batchResults.filter((blob): blob is NonNullable => blob !== null)); } - // Get the latest commit SHA (assuming main branch, update dynamically if needed) - const { data: ref } = await octokit.git.getRef({ - owner: repo.owner.login, - repo: repo.name, - ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch + const { data: tree } = await octokit.rest.git.createTree({ + owner: cleanUsername, + repo: cleanRepoName, + base_tree: baseCommitSha, + tree: blobs }); - const latestCommitSha = ref.object.sha; - // Create a new tree - const { data: newTree } = await octokit.git.createTree({ - owner: repo.owner.login, - repo: repo.name, - base_tree: latestCommitSha, - tree: validBlobs.map((blob) => ({ - path: blob!.path, - mode: '100644', - type: 'blob', - sha: blob!.sha, - })), + const { data: newCommit } = await octokit.rest.git.createCommit({ + owner: cleanUsername, + repo: cleanRepoName, + message: 'Update from Bolt', + tree: tree.sha, + parents: [baseCommitSha] }); - // Create a new commit - const { data: newCommit } = await octokit.git.createCommit({ - owner: repo.owner.login, - repo: repo.name, - message: 'Initial commit from your app', - tree: newTree.sha, - parents: [latestCommitSha], - }); + if (isNewBranch && branchName) { + try { + await octokit.rest.git.createRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `refs/heads/${targetBranch}`, + sha: newCommit.sha + }); + } catch (error: any) { + if (error?.response?.status === 422) { + await octokit.rest.git.updateRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `heads/${targetBranch}`, + sha: newCommit.sha, + force: true + }); + } else { + throw error; + } + } + } else { + try { + const { data: branchRef } = await octokit.rest.git.getRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `heads/${targetBranch}` + }); - // Update the reference - await octokit.git.updateRef({ - owner: repo.owner.login, - repo: repo.name, - ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch - sha: newCommit.sha, - }); + if (branchRef.object.sha !== baseCommitSha) { + throw new Error('Branch has diverged from the base commit. Manual merge or pull request required.'); + } - alert(`Repository created and code pushed: ${repo.html_url}`); + await octokit.rest.git.updateRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `heads/${targetBranch}`, + sha: newCommit.sha + }); + } catch (error: any) { + if (error?.response?.status === 404) { + await octokit.rest.git.createRef({ + owner: cleanUsername, + repo: cleanRepoName, + ref: `refs/heads/${targetBranch}`, + sha: newCommit.sha + }); + } else { + throw error; + } + } + } + + return repoData.html_url; } catch (error) { - console.error('Error pushing to GitHub:', error instanceof Error ? error.message : String(error)); + console.error('GitHub push error:', error); + if (error instanceof Error) { + if (error.message.includes('rate limit')) { + throw new Error('GitHub API rate limit exceeded. Please try again later.'); + } + if (error.message.includes('Resource protected by organization SAML enforcement')) { + throw new Error('This repository is protected by SAML enforcement. Please authorize your token for SSO.'); + } + throw error; + } + throw new Error('An unexpected error occurred while pushing to GitHub'); } } } -export const workbenchStore = new WorkbenchStore(); +export const workbenchStore = new WorkbenchStore(); \ No newline at end of file From 22d4081a4301c073b0e7ada76e0ac6ca9418bc42 Mon Sep 17 00:00:00 2001 From: ali00209 Date: Mon, 11 Nov 2024 18:15:19 +0500 Subject: [PATCH 3/5] fix: issue #225 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3cba1cf56..88904ab61 100644 --- a/package.json +++ b/package.json @@ -117,5 +117,5 @@ "resolutions": { "@typescript-eslint/utils": "^8.0.0-alpha.30" }, - "packageManager": "pnpm@9.12.2+sha512.22721b3a11f81661ae1ec68ce1a7b879425a1ca5b991c975b074ac220b187ce56c708fe5db69f4c962c989452eee76c82877f4ee80f474cebd61ee13461b6228" + "packageManager": "pnpm@9.4.0" } From 7b0f67b111bb3a2895e6bf8f2d88e5dd03d57e13 Mon Sep 17 00:00:00 2001 From: ali00209 Date: Mon, 11 Nov 2024 18:22:40 +0500 Subject: [PATCH 4/5] fix: Error: Process completed with exit code 2. --- app/routes/_index.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 86d73409c..c0b9c97ca 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -3,6 +3,7 @@ import { ClientOnly } from 'remix-utils/client-only'; import { BaseChat } from '~/components/chat/BaseChat'; import { Chat } from '~/components/chat/Chat.client'; import { Header } from '~/components/header/Header'; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from '~/utils/constants'; export const meta: MetaFunction = () => { return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }]; @@ -14,7 +15,16 @@ export default function Index() { return (
- }>{() => } + {}} + provider={DEFAULT_PROVIDER} + setProvider={() => {}} + /> + }> + {() => } +
); } From 45df6d61b7f03202b386b8cf185cfe72a3eb2445 Mon Sep 17 00:00:00 2001 From: ali00209 Date: Wed, 13 Nov 2024 03:40:19 +0500 Subject: [PATCH 5/5] fix: resolve the merge conflicts --- app/lib/stores/workbench.ts | 39 +++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index e83e20443..958fca1a2 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -11,7 +11,9 @@ import { PreviewsStore } from './previews'; import { TerminalStore } from './terminal'; import JSZip from 'jszip'; import { saveAs } from 'file-saver'; -import { Octokit } from "@octokit/rest"; +import { Octokit, type RestEndpointMethodTypes } from "@octokit/rest"; +import * as nodePath from 'node:path'; +import type { WebContainerProcess } from '@webcontainer/api'; export interface ArtifactState { id: string; @@ -39,6 +41,7 @@ export class WorkbenchStore { unsavedFiles: WritableAtom> = import.meta.hot?.data.unsavedFiles ?? atom(new Set()); modifiedFiles = new Set(); artifactIdList: string[] = []; + #boltTerminal: { terminal: ITerminal; process: WebContainerProcess } | undefined; constructor() { if (import.meta.hot) { @@ -76,6 +79,9 @@ export class WorkbenchStore { get showTerminal() { return this.#terminalStore.showTerminal; } + get boltTerminal() { + return this.#terminalStore.boltTerminal; + } toggleTerminal(value?: boolean) { this.#terminalStore.toggleTerminal(value); @@ -84,6 +90,10 @@ export class WorkbenchStore { attachTerminal(terminal: ITerminal) { this.#terminalStore.attachTerminal(terminal); } + attachBoltTerminal(terminal: ITerminal) { + + this.#terminalStore.attachBoltTerminal(terminal); + } onTerminalResize(cols: number, rows: number) { this.#terminalStore.onTerminalResize(cols, rows); @@ -232,7 +242,7 @@ export class WorkbenchStore { id, title, closed: false, - runner: new ActionRunner(webcontainer), + runner: new ActionRunner(webcontainer, () => this.boltTerminal), }); } @@ -258,7 +268,7 @@ export class WorkbenchStore { artifact.runner.addAction(data); } - async runAction(data: ActionCallbackData) { + async runAction(data: ActionCallbackData, isStreaming: boolean = false) { const { messageId } = data; const artifact = this.#getArtifact(messageId); @@ -266,8 +276,29 @@ export class WorkbenchStore { if (!artifact) { unreachable('Artifact not found'); } + if (data.action.type === 'file') { + let wc = await webcontainer + const fullPath = nodePath.join(wc.workdir, data.action.filePath); + if (this.selectedFile.value !== fullPath) { + this.setSelectedFile(fullPath); + } + if (this.currentView.value !== 'code') { + this.currentView.set('code'); + } + const doc = this.#editorStore.documents.get()[fullPath]; + if (!doc) { + await artifact.runner.runAction(data, isStreaming); + } + + this.#editorStore.updateFile(fullPath, data.action.content); - artifact.runner.runAction(data); + if (!isStreaming) { + this.resetCurrentDocument(); + await artifact.runner.runAction(data); + } + } else { + artifact.runner.runAction(data); + } } #getArtifact(id: string) {