Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve the GitHub push UI #111

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 245 additions & 20 deletions app/components/workbench/Workbench.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ 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';
import { renderLogger } from '~/utils/logger';
import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
import { Octokit } from "@octokit/rest";

interface WorkspaceProps {
chatStarted?: boolean;
Expand Down Expand Up @@ -56,6 +58,16 @@ 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 [isPrivateRepo, setIsPrivateRepo] = useState(false);
const [selectedBranch, setSelectedBranch] = useState('main');
const [branches, setBranches] = useState<string[]>([]);
const [isNewBranch, setIsNewBranch] = useState(false);
const [newBranchName, setNewBranchName] = useState('');

const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
Expand Down Expand Up @@ -116,6 +128,96 @@ 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 {
const repoUrl = await workbenchStore.pushToGitHub(
githubRepoName.trim(),
githubUsername.trim(),
githubToken.trim(),
isPrivateRepo,
isNewBranch ? newBranchName.trim() : undefined,
isNewBranch
);

toast.success(
<div>
Successfully pushed to GitHub!{' '}
<a href={repoUrl} target="_blank" rel="noopener noreferrer" className="underline">
View Repository
</a>
</div>
);
setShowGitHubDialog(false);
} catch (error) {
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, 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 && (
<motion.div
Expand All @@ -124,6 +226,146 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
variants={workbenchVariants}
className="z-workbench"
>
<DialogRoot open={showGitHubDialog} onOpenChange={setShowGitHubDialog}>
<Dialog>
<DialogTitle>
<div className="flex items-center gap-2">
<div className="i-ph:github-logo text-xl" />
Push to GitHub
</div>
</DialogTitle>
<DialogDescription asChild>
<div className="flex flex-col gap-4">
<div className="text-sm text-bolt-elements-textSecondary">
Push your project to a new or existing GitHub repository. You'll need a GitHub account and a personal access token with repo permissions.
</div>

{/* Repository Name */}
<div>
<label className="block text-sm font-medium mb-1">Repository Name</label>
<input
type="text"
value={githubRepoName}
onChange={(e) => 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"
/>
</div>

{/* GitHub Username */}
<div>
<label className="block text-sm font-medium mb-1">GitHub Username</label>
<input
type="text"
value={githubUsername}
onChange={(e) => 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"
/>
</div>

{/* Repository Visibility */}
<div>
<label className="block text-sm font-medium mb-2">Repository Visibility</label>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
checked={!isPrivateRepo}
onChange={() => setIsPrivateRepo(false)}
className="mr-2"
/>
Public
</label>
<label className="flex items-center">
<input
type="radio"
checked={isPrivateRepo}
onChange={() => setIsPrivateRepo(true)}
className="mr-2"
/>
Private
</label>
</div>
</div>

{/* Branch Options */}
<div>
<label className="block text-sm font-medium mb-2">Branch Options</label>
<div className="flex gap-4 mb-2">
<label className="flex items-center">
<input
type="radio"
checked={!isNewBranch}
onChange={() => setIsNewBranch(false)}
className="mr-2"
/>
Default Branch (main)
</label>
<label className="flex items-center">
<input
type="radio"
checked={isNewBranch}
onChange={() => setIsNewBranch(true)}
className="mr-2"
/>
New Branch
</label>
</div>

{isNewBranch && (
<input
type="text"
value={newBranchName}
onChange={(e) => 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"
/>
)}
</div>

{/* Personal Access Token */}
<div>
<label className="block text-sm font-medium mb-1">Personal Access Token</label>
<input
type="password"
value={githubToken}
onChange={(e) => 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"
/>
<a
href="https://github.com/settings/tokens/new"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-bolt-elements-textSecondary hover:underline mt-1 inline-block"
>
Generate a new token
</a>
</div>
<div className="flex justify-end gap-2 mt-2">
<DialogButton type="secondary" onClick={handleCancelPush}>
Cancel
</DialogButton>
<DialogButton type="primary" onClick={handleGitHubPush}>
{isGitHubPushing ? (
<>
<div className="i-ph:spinner animate-spin mr-2" />
Pushing...
</>
) : (
<>
<div className="i-ph:github-logo mr-2" />
Push to GitHub
</>
)}
</DialogButton>
</div>
</div>
</DialogDescription>
</Dialog>
</DialogRoot>

<div
className={classNames(
'fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[var(--workbench-inner-width)] mr-4 z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
Expand All @@ -150,7 +392,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
Download Code
</PanelHeaderButton>
<PanelHeaderButton className="mr-1 text-sm" onClick={handleSyncFiles} disabled={isSyncing}>
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
{isSyncing ? <div className="i-ph:spinner animate-spin" /> : <div className="i-ph:cloud-arrow-down" />}
{isSyncing ? 'Syncing...' : 'Sync Files'}
</PanelHeaderButton>
<PanelHeaderButton
Expand All @@ -164,25 +406,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
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)}
>
<div className="i-ph:github-logo" />
Push to GitHub
Expand Down Expand Up @@ -230,6 +454,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
)
);
});

interface ViewProps extends HTMLMotionProps<'div'> {
children: JSX.Element;
}
Expand Down
Loading
Loading