From 8de4384d20fd9a281c8142957d513f290c957a5a Mon Sep 17 00:00:00 2001 From: Musti7even Date: Wed, 17 Dec 2025 17:02:32 -0800 Subject: [PATCH 1/6] M --- ...0002_add_run_config_status_to_projects.sql | 8 + src/main/ipc/githubIpc.ts | 183 ++++++++ src/main/preload.ts | 10 + src/main/services/GitHubService.ts | 180 ++++++++ src/main/services/ProjectRunConfigService.ts | 217 +++++++++ src/main/settings.ts | 23 + src/renderer/App.tsx | 150 ++++++- src/renderer/components/LeftSidebar.tsx | 4 + src/renderer/components/NewProjectModal.tsx | 418 ++++++++++++++++++ src/renderer/components/SidebarEmptyState.tsx | 48 +- src/renderer/hooks/useProjectRunConfig.ts | 83 ++++ src/renderer/types/electron-api.d.ts | 29 ++ src/renderer/types/global.d.ts | 29 ++ 13 files changed, 1366 insertions(+), 16 deletions(-) create mode 100644 drizzle/0002_add_run_config_status_to_projects.sql create mode 100644 src/main/services/ProjectRunConfigService.ts create mode 100644 src/renderer/components/NewProjectModal.tsx create mode 100644 src/renderer/hooks/useProjectRunConfig.ts diff --git a/drizzle/0002_add_run_config_status_to_projects.sql b/drizzle/0002_add_run_config_status_to_projects.sql new file mode 100644 index 00000000..20605428 --- /dev/null +++ b/drizzle/0002_add_run_config_status_to_projects.sql @@ -0,0 +1,8 @@ +ALTER TABLE `projects` ADD COLUMN `run_config_status` text; +--> statement-breakpoint +ALTER TABLE `projects` ADD COLUMN `run_config_error` text; +--> statement-breakpoint +ALTER TABLE `projects` ADD COLUMN `run_config_provider` text; +--> statement-breakpoint +ALTER TABLE `projects` ADD COLUMN `run_config_updated_at` text; + diff --git a/src/main/ipc/githubIpc.ts b/src/main/ipc/githubIpc.ts index 6db26227..1911f627 100644 --- a/src/main/ipc/githubIpc.ts +++ b/src/main/ipc/githubIpc.ts @@ -7,6 +7,7 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import * as path from 'path'; import * as fs from 'fs'; +import { homedir } from 'os'; const execAsync = promisify(exec); const githubService = new GitHubService(); @@ -333,4 +334,186 @@ export function registerGithubIpc() { }; } }); + + ipcMain.handle('github:getOwners', async () => { + try { + const owners = await githubService.getOwners(); + return { success: true, owners }; + } catch (error) { + log.error('Failed to get owners:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get owners', + }; + } + }); + + ipcMain.handle( + 'github:validateRepoName', + async (_, name: string, owner: string) => { + try { + // First validate format + const formatValidation = githubService.validateRepositoryName(name); + if (!formatValidation.valid) { + return { + success: true, + valid: false, + exists: false, + error: formatValidation.error, + }; + } + + // Then check if it exists + const exists = await githubService.checkRepositoryExists(owner, name); + if (exists) { + return { + success: true, + valid: true, + exists: true, + error: `Repository ${owner}/${name} already exists`, + }; + } + + return { + success: true, + valid: true, + exists: false, + }; + } catch (error) { + log.error('Failed to validate repo name:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Validation failed', + }; + } + } + ); + + ipcMain.handle( + 'github:createNewProject', + async ( + _, + params: { + name: string; + description?: string; + owner: string; + isPrivate: boolean; + gitignoreTemplate?: string; + } + ) => { + let githubRepoCreated = false; + let localDirCreated = false; + let repoUrl: string | undefined; + let localPath: string | undefined; + + try { + const { name, description, owner, isPrivate, gitignoreTemplate } = params; + + // Validate inputs + const formatValidation = githubService.validateRepositoryName(name); + if (!formatValidation.valid) { + return { + success: false, + error: formatValidation.error || 'Invalid repository name', + }; + } + + // Check if repo already exists + const exists = await githubService.checkRepositoryExists(owner, name); + if (exists) { + return { + success: false, + error: `Repository ${owner}/${name} already exists`, + }; + } + + // Get project directory from settings + const { getAppSettings } = await import('../settings'); + const settings = getAppSettings(); + const projectDir = + settings.projects?.defaultDirectory || path.join(homedir(), 'emdash-projects'); + + // Ensure project directory exists + if (!fs.existsSync(projectDir)) { + fs.mkdirSync(projectDir, { recursive: true }); + } + + localPath = path.join(projectDir, name); + if (fs.existsSync(localPath)) { + return { + success: false, + error: `Directory ${localPath} already exists`, + }; + } + + // Create GitHub repository + const repoInfo = await githubService.createRepository({ + name, + description, + owner, + isPrivate, + }); + githubRepoCreated = true; + repoUrl = repoInfo.url; + + // Clone repository + const cloneResult = await githubService.cloneRepository(repoUrl, localPath); + if (!cloneResult.success) { + // Cleanup: delete GitHub repo on clone failure + try { + await execAsync(`gh repo delete ${owner}/${name} --yes`, { + timeout: 10000, + }); + } catch (cleanupError) { + log.warn('Failed to cleanup GitHub repo after clone failure:', cleanupError); + } + return { + success: false, + error: cloneResult.error || 'Failed to clone repository', + }; + } + localDirCreated = true; + + // Initialize project (create README, commit, push) + await githubService.initializeNewProject({ + repoUrl, + localPath, + name, + description, + }); + + // TODO: Add .gitignore if template specified (for future enhancement) + + return { + success: true, + projectPath: localPath, + repoUrl, + fullName: repoInfo.fullName, + defaultBranch: repoInfo.defaultBranch, + }; + } catch (error) { + log.error('Failed to create new project:', error); + + // Cleanup on failure + if (localDirCreated && localPath && fs.existsSync(localPath)) { + try { + fs.rmSync(localPath, { recursive: true, force: true }); + } catch (cleanupError) { + log.warn('Failed to cleanup local directory:', cleanupError); + } + } + + // Note: We don't delete the GitHub repo automatically here + // as the user might want to keep it for manual setup + // The error message will inform them + + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create project', + githubRepoCreated, // Inform frontend about orphaned repo + repoUrl, + }; + } + } + ); } diff --git a/src/main/preload.ts b/src/main/preload.ts index 4bfa33bb..a09cd013 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -236,6 +236,16 @@ contextBridge.exposeInMainWorld('electronAPI', { githubGetRepositories: () => ipcRenderer.invoke('github:getRepositories'), githubCloneRepository: (repoUrl: string, localPath: string) => ipcRenderer.invoke('github:cloneRepository', repoUrl, localPath), + githubGetOwners: () => ipcRenderer.invoke('github:getOwners'), + githubValidateRepoName: (name: string, owner: string) => + ipcRenderer.invoke('github:validateRepoName', name, owner), + githubCreateNewProject: (params: { + name: string; + description?: string; + owner: string; + isPrivate: boolean; + gitignoreTemplate?: string; + }) => ipcRenderer.invoke('github:createNewProject', params), githubListPullRequests: (projectPath: string) => ipcRenderer.invoke('github:listPullRequests', { projectPath }), githubCreatePullRequestWorktree: (args: { diff --git a/src/main/services/GitHubService.ts b/src/main/services/GitHubService.ts index d098054a..86b06d43 100644 --- a/src/main/services/GitHubService.ts +++ b/src/main/services/GitHubService.ts @@ -747,6 +747,186 @@ export class GitHubService { return safeBranch; } + /** + * Validate repository name format + */ + validateRepositoryName(name: string): { valid: boolean; error?: string } { + if (!name || name.trim().length === 0) { + return { valid: false, error: 'Repository name is required' }; + } + + const trimmed = name.trim(); + + // Check length + if (trimmed.length > 100) { + return { valid: false, error: 'Repository name must be 100 characters or less' }; + } + + // Check for valid characters (alphanumeric, hyphens, underscores, dots) + // GitHub allows: a-z, A-Z, 0-9, -, _, . + if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) { + return { + valid: false, + error: 'Repository name can only contain letters, numbers, hyphens, underscores, and dots', + }; + } + + // Cannot start or end with hyphen, dot, or underscore + if (/^[-._]|[-._]$/.test(trimmed)) { + return { + valid: false, + error: 'Repository name cannot start or end with a hyphen, dot, or underscore', + }; + } + + // Cannot be all dots + if (/^\.+$/.test(trimmed)) { + return { valid: false, error: 'Repository name cannot be all dots' }; + } + + // Reserved names (basic ones, GitHub has more) + const reserved = ['con', 'prn', 'aux', 'nul', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9']; + if (reserved.includes(trimmed.toLowerCase())) { + return { valid: false, error: 'Repository name is reserved' }; + } + + return { valid: true }; + } + + /** + * Check if a repository exists for the given owner and name + */ + async checkRepositoryExists(owner: string, name: string): Promise { + try { + await this.execGH(`gh repo view ${owner}/${name}`); + return true; + } catch { + return false; + } + } + + /** + * Get available owners (user + organizations) + */ + async getOwners(): Promise> { + try { + // Get current user + const { stdout: userStdout } = await this.execGH('gh api user'); + const user = JSON.parse(userStdout); + + const owners: Array<{ login: string; type: 'User' | 'Organization' }> = [ + { login: user.login, type: 'User' }, + ]; + + // Get organizations + try { + const { stdout: orgsStdout } = await this.execGH('gh api user/orgs'); + const orgs = JSON.parse(orgsStdout); + if (Array.isArray(orgs)) { + for (const org of orgs) { + owners.push({ login: org.login, type: 'Organization' }); + } + } + } catch (error) { + // If orgs fetch fails, just continue with user only + console.warn('Failed to fetch organizations:', error); + } + + return owners; + } catch (error) { + console.error('Failed to get owners:', error); + throw error; + } + } + + /** + * Create a new GitHub repository + */ + async createRepository(params: { + name: string; + description?: string; + owner: string; + isPrivate: boolean; + }): Promise<{ url: string; defaultBranch: string; fullName: string }> { + try { + const { name, description, owner, isPrivate } = params; + + // Build gh repo create command + const visibilityFlag = isPrivate ? '--private' : '--public'; + let command = `gh repo create ${owner}/${name} ${visibilityFlag} --confirm`; + + if (description && description.trim()) { + // Escape description for shell + const desc = JSON.stringify(description.trim()); + command += ` --description ${desc}`; + } + + await this.execGH(command); + + // Get repository details + const { stdout } = await this.execGH( + `gh repo view ${owner}/${name} --json name,nameWithOwner,url,defaultBranchRef` + ); + const repoInfo = JSON.parse(stdout); + + return { + url: repoInfo.url || `https://github.com/${repoInfo.nameWithOwner}`, + defaultBranch: repoInfo.defaultBranchRef?.name || 'main', + fullName: repoInfo.nameWithOwner || `${owner}/${name}`, + }; + } catch (error) { + console.error('Failed to create repository:', error); + throw error; + } + } + + /** + * Initialize a new project with initial files and commit + */ + async initializeNewProject(params: { + repoUrl: string; + localPath: string; + name: string; + description?: string; + }): Promise { + const { repoUrl, localPath, name, description } = params; + + try { + // Ensure the directory exists (clone should have created it, but just in case) + if (!fs.existsSync(localPath)) { + throw new Error('Local path does not exist after clone'); + } + + // Create README.md + const readmePath = path.join(localPath, 'README.md'); + const readmeContent = description + ? `# ${name}\n\n${description}\n` + : `# ${name}\n`; + fs.writeFileSync(readmePath, readmeContent, 'utf8'); + + // Initialize git, add files, commit, and push + const execOptions = { cwd: localPath }; + + // Add and commit + await execAsync('git add README.md', execOptions); + await execAsync('git commit -m "Initial commit"', execOptions); + + // Push to origin + await execAsync('git push -u origin main', execOptions).catch(async () => { + // If main branch doesn't exist, try master + try { + await execAsync('git push -u origin master', execOptions); + } catch { + // If both fail, let the error propagate + throw new Error('Failed to push to remote repository'); + } + }); + } catch (error) { + console.error('Failed to initialize new project:', error); + throw error; + } + } + /** * Clone a repository to local workspace */ diff --git a/src/main/services/ProjectRunConfigService.ts b/src/main/services/ProjectRunConfigService.ts new file mode 100644 index 00000000..2a2f4fb4 --- /dev/null +++ b/src/main/services/ProjectRunConfigService.ts @@ -0,0 +1,217 @@ +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import path from 'node:path'; +import { log } from '../lib/logger'; +import { runConfigGenerationService } from './RunConfigGenerationService'; +import { databaseService } from './DatabaseService'; + +const PROJECT_CONFIG_PATH = '.emdash/config.json'; + +export type ProjectRunConfigStatus = 'idle' | 'generating' | 'ready' | 'failed'; + +export type ProjectRunConfigState = { + projectId: string; + status: ProjectRunConfigStatus; + exists: boolean; + provider?: string | null; + error?: string | null; + updatedAt?: string | null; +}; + +type ProjectRunConfigEvent = { type: 'config'; state: ProjectRunConfigState }; + +/** + * Project-level run config generation and status tracking. + * - Uses local CLI providers via RunConfigGenerationService. + * - Writes `.emdash/config.json` at project root on success. + * - Remembers `failed` in-memory until explicitly forced (DB persistence added later). + */ +class ProjectRunConfigService extends EventEmitter { + private readonly states = new Map(); + + private computeExists(projectPath: string): boolean { + try { + return fs.existsSync(path.join(projectPath, PROJECT_CONFIG_PATH)); + } catch { + return false; + } + } + + getStatus(projectId: string, projectPath: string): ProjectRunConfigState { + const exists = this.computeExists(projectPath); + const current = this.states.get(projectId); + if (exists) { + const next: ProjectRunConfigState = { + projectId, + status: 'ready', + exists: true, + provider: current?.provider ?? null, + error: null, + updatedAt: current?.updatedAt ?? new Date().toISOString(), + }; + this.states.set(projectId, next); + return next; + } + return ( + current ?? { + projectId, + status: 'idle', + exists: false, + provider: null, + error: null, + updatedAt: null, + } + ); + } + + /** + * Ensure `.emdash/config.json` exists for a project. + * - If it exists, returns ready. + * - If last attempt failed and `force !== true`, returns failed without retrying. + */ + async ensureProjectConfig(args: { + projectId: string; + projectPath: string; + preferredProvider?: string; + force?: boolean; + }): Promise { + const { projectId, projectPath, preferredProvider, force } = args; + const exists = this.computeExists(projectPath); + if (exists) { + const ready = this.getStatus(projectId, projectPath); + try { + await databaseService.updateProjectRunConfigMeta(projectId, { + status: 'ready', + error: null, + provider: preferredProvider ?? ready.provider ?? null, + }); + } catch {} + this.emit('event', { type: 'config', state: ready } satisfies ProjectRunConfigEvent); + return ready; + } + + // Load persisted status to avoid auto-retrying after a failure. + let persisted = null as Awaited> | null; + try { + persisted = await databaseService.getProjectRunConfigMeta(projectId); + } catch {} + + const current = this.states.get(projectId); + if (current?.status === 'generating') { + return current; + } + if (!force) { + if (current?.status === 'failed') return current; + if (persisted?.status === 'failed') { + const failed: ProjectRunConfigState = { + projectId, + status: 'failed', + exists: false, + provider: persisted.provider ?? preferredProvider ?? null, + error: persisted.error ?? 'Run config generation previously failed.', + updatedAt: persisted.updatedAt ?? null, + }; + this.states.set(projectId, failed); + this.emit('event', { type: 'config', state: failed } satisfies ProjectRunConfigEvent); + return failed; + } + } + + const generating: ProjectRunConfigState = { + projectId, + status: 'generating', + exists: false, + provider: preferredProvider ?? current?.provider ?? persisted?.provider ?? null, + error: null, + updatedAt: new Date().toISOString(), + }; + this.states.set(projectId, generating); + try { + await databaseService.updateProjectRunConfigMeta(projectId, { + status: 'generating', + error: null, + provider: generating.provider ?? null, + }); + } catch {} + this.emit('event', { type: 'config', state: generating } satisfies ProjectRunConfigEvent); + + try { + const generated = await runConfigGenerationService.generateRunConfig( + projectPath, + preferredProvider + ); + + if (!generated?.config) { + const failed: ProjectRunConfigState = { + projectId, + status: 'failed', + exists: false, + provider: preferredProvider ?? generating.provider ?? null, + error: + 'AI generation failed. Please check that your CLI coding agent is available and configured.', + updatedAt: new Date().toISOString(), + }; + this.states.set(projectId, failed); + try { + await databaseService.updateProjectRunConfigMeta(projectId, { + status: 'failed', + error: failed.error ?? null, + provider: failed.provider ?? null, + }); + } catch {} + this.emit('event', { type: 'config', state: failed } satisfies ProjectRunConfigEvent); + return failed; + } + + const configPath = path.join(projectPath, PROJECT_CONFIG_PATH); + const configDir = path.dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(configPath, JSON.stringify(generated.config, null, 2), 'utf8'); + + const ready: ProjectRunConfigState = { + projectId, + status: 'ready', + exists: true, + provider: preferredProvider ?? generating.provider ?? null, + error: null, + updatedAt: new Date().toISOString(), + }; + this.states.set(projectId, ready); + try { + await databaseService.updateProjectRunConfigMeta(projectId, { + status: 'ready', + error: null, + provider: ready.provider ?? null, + }); + } catch {} + this.emit('event', { type: 'config', state: ready } satisfies ProjectRunConfigEvent); + return ready; + } catch (error) { + log.error('Failed to ensure project run config', { projectId, projectPath, error }); + const failed: ProjectRunConfigState = { + projectId, + status: 'failed', + exists: false, + provider: preferredProvider ?? generating.provider ?? null, + error: error instanceof Error ? error.message : String(error), + updatedAt: new Date().toISOString(), + }; + this.states.set(projectId, failed); + try { + await databaseService.updateProjectRunConfigMeta(projectId, { + status: 'failed', + error: failed.error ?? null, + provider: failed.provider ?? null, + }); + } catch {} + this.emit('event', { type: 'config', state: failed } satisfies ProjectRunConfigEvent); + return failed; + } + } +} + +export const projectRunConfigService = new ProjectRunConfigService(); + + diff --git a/src/main/settings.ts b/src/main/settings.ts index e44252f9..5fb89d57 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,6 +1,7 @@ import { app } from 'electron'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { dirname, join } from 'path'; +import { homedir } from 'os'; import type { ProviderId } from '@shared/providers/registry'; import { isValidProviderId } from '@shared/providers/registry'; @@ -35,6 +36,9 @@ export interface AppSettings { autoGenerateName: boolean; autoApproveByDefault: boolean; }; + projects?: { + defaultDirectory: string; + }; } const DEFAULT_SETTINGS: AppSettings = { @@ -64,6 +68,9 @@ const DEFAULT_SETTINGS: AppSettings = { autoGenerateName: true, autoApproveByDefault: false, }, + projects: { + defaultDirectory: join(homedir(), 'emdash-projects'), + }, }; function getSettingsPath(): string { @@ -212,5 +219,21 @@ function normalizeSettings(input: AppSettings): AppSettings { ), }; + // Projects + const projects = (input as any)?.projects || {}; + let defaultDir = String( + projects?.defaultDirectory ?? DEFAULT_SETTINGS.projects!.defaultDirectory + ).trim(); + if (!defaultDir) { + defaultDir = DEFAULT_SETTINGS.projects!.defaultDirectory; + } + // Resolve ~ to home directory if present + if (defaultDir.startsWith('~')) { + defaultDir = join(homedir(), defaultDir.slice(1)); + } + out.projects = { + defaultDirectory: defaultDir, + }; + return out; } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index eb6a1031..3285c5ae 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Button } from './components/ui/button'; -import { FolderOpen } from 'lucide-react'; +import { FolderOpen, Plus } from 'lucide-react'; import LeftSidebar from './components/LeftSidebar'; import ProjectMainView from './components/ProjectMainView'; import WorkspaceModal from './components/WorkspaceModal'; +import { NewProjectModal } from './components/NewProjectModal'; import ChatInterface from './components/ChatInterface'; import MultiAgentWorkspace from './components/MultiAgentWorkspace'; import { Toaster } from './components/ui/toaster'; @@ -122,6 +123,9 @@ const AppContent: React.FC = () => { const [projects, setProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(null); const [showWorkspaceModal, setShowWorkspaceModal] = useState(false); + const [showNewProjectModal, setShowNewProjectModal] = useState(false); + const [autoOpenWorkspaceAfterNewProject, setAutoOpenWorkspaceAfterNewProject] = + useState(false); const [showHomeView, setShowHomeView] = useState(true); const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false); const [activeWorkspace, setActiveWorkspace] = useState(null); @@ -633,6 +637,120 @@ const AppContent: React.FC = () => { } }; + const handleNewProjectSuccess = useCallback( + async (projectPath: string) => { + const { captureTelemetry } = await import('./lib/telemetryClient'); + captureTelemetry('new_project_created'); + try { + const gitInfo = await window.electronAPI.getGitInfo(projectPath); + const canonicalPath = gitInfo.rootPath || gitInfo.path || projectPath; + const repoKey = normalizePathForComparison(canonicalPath); + const existingProject = projects.find( + (project) => getProjectRepoKey(project) === repoKey + ); + + if (existingProject) { + activateProjectView(existingProject); + setAutoOpenWorkspaceAfterNewProject(true); + setShowWorkspaceModal(true); + return; + } + + const remoteUrl = gitInfo.remote || ''; + const isGithubRemote = /github\.com[:/]/i.test(remoteUrl); + const projectName = + canonicalPath.split(/[/\\]/).filter(Boolean).pop() || 'Unknown Project'; + + const baseProject: Project = { + id: Date.now().toString(), + name: projectName, + path: canonicalPath, + repoKey, + gitInfo: { + isGitRepo: true, + remote: gitInfo.remote || undefined, + branch: gitInfo.branch || undefined, + baseRef: computeBaseRef(gitInfo.baseRef, gitInfo.remote, gitInfo.branch), + }, + workspaces: [], + }; + + if (isAuthenticated && isGithubRemote) { + const githubInfo = await window.electronAPI.connectToGitHub(canonicalPath); + if (githubInfo.success) { + const projectWithGithub = withRepoKey({ + ...baseProject, + githubInfo: { + repository: githubInfo.repository || '', + connected: true, + }, + }); + + const saveResult = await window.electronAPI.saveProject(projectWithGithub); + if (saveResult.success) { + captureTelemetry('project_added_success', { source: 'new_project' }); + setProjects((prev) => [...prev, projectWithGithub]); + activateProjectView(projectWithGithub); + setAutoOpenWorkspaceAfterNewProject(true); + setShowWorkspaceModal(true); + } else { + const { log } = await import('./lib/logger'); + log.error('Failed to save project:', saveResult.error); + toast({ + title: 'Project Created', + description: 'Repository created but failed to save to database.', + variant: 'destructive', + }); + } + } else { + const projectWithoutGithub = withRepoKey({ + ...baseProject, + githubInfo: { + repository: '', + connected: false, + }, + }); + + const saveResult = await window.electronAPI.saveProject(projectWithoutGithub); + if (saveResult.success) { + captureTelemetry('project_added_success', { source: 'new_project' }); + setProjects((prev) => [...prev, projectWithoutGithub]); + activateProjectView(projectWithoutGithub); + setAutoOpenWorkspaceAfterNewProject(true); + setShowWorkspaceModal(true); + } + } + } else { + const projectWithoutGithub = withRepoKey({ + ...baseProject, + githubInfo: { + repository: '', + connected: false, + }, + }); + + const saveResult = await window.electronAPI.saveProject(projectWithoutGithub); + if (saveResult.success) { + captureTelemetry('project_added_success', { source: 'new_project' }); + setProjects((prev) => [...prev, projectWithoutGithub]); + activateProjectView(projectWithoutGithub); + setAutoOpenWorkspaceAfterNewProject(true); + setShowWorkspaceModal(true); + } + } + } catch (error) { + const { log } = await import('./lib/logger'); + log.error('Failed to load new project:', error); + toast({ + title: 'Project Created', + description: 'Repository created but failed to load. Please try opening it manually.', + variant: 'destructive', + }); + } + }, + [projects, isAuthenticated, activateProjectView, normalizePathForComparison, toast] + ); + const handleGithubConnect = async () => { setGithubLoading(true); setGithubStatusMessage(undefined); @@ -1547,11 +1665,20 @@ const AppContent: React.FC = () => { handleOpenProject(); }} size="lg" + variant="outline" className="min-w-[200px]" > Open Project + @@ -1615,10 +1742,18 @@ const AppContent: React.FC = () => {
- +
@@ -1694,6 +1829,7 @@ const AppContent: React.FC = () => { onSelectProject={handleSelectProject} onGoHome={handleGoHome} onOpenProject={handleOpenProject} + onNewProject={() => setShowNewProjectModal(true)} onSelectWorkspace={handleSelectWorkspace} activeWorkspace={activeWorkspace || undefined} onReorderProjects={handleReorderProjects} @@ -1761,13 +1897,21 @@ const AppContent: React.FC = () => { /> setShowWorkspaceModal(false)} + onClose={() => { + setShowWorkspaceModal(false); + setAutoOpenWorkspaceAfterNewProject(false); + }} onCreateWorkspace={handleCreateWorkspace} projectName={selectedProject?.name || ''} defaultBranch={selectedProject?.gitInfo.branch || 'main'} existingNames={(selectedProject?.workspaces || []).map((w) => w.name)} projectPath={selectedProject?.path} /> + setShowNewProjectModal(false)} + onSuccess={handleNewProjectSuccess} + /> void; onGoHome: () => void; onOpenProject?: () => void; + onNewProject?: () => void; onSelectWorkspace?: (workspace: Workspace) => void; activeWorkspace?: Workspace | null; onReorderProjects?: (sourceId: string, targetId: string) => void; @@ -57,6 +58,7 @@ const LeftSidebar: React.FC = ({ onSelectProject, onGoHome, onOpenProject, + onNewProject, onSelectWorkspace, activeWorkspace, onReorderProjects, @@ -162,6 +164,8 @@ const LeftSidebar: React.FC = ({ description="Open a project to start creating worktrees and running coding agents." actionLabel={onOpenProject ? 'Open Project' : undefined} onAction={onOpenProject} + secondaryActionLabel={onNewProject ? 'New Project' : undefined} + onSecondaryAction={onNewProject} /> )} diff --git a/src/renderer/components/NewProjectModal.tsx b/src/renderer/components/NewProjectModal.tsx new file mode 100644 index 00000000..cc4f466a --- /dev/null +++ b/src/renderer/components/NewProjectModal.tsx @@ -0,0 +1,418 @@ +import React, { useCallback, useEffect, useState, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; +import { Button } from './ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Spinner } from './ui/spinner'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ui/accordion'; +import { X, Settings } from 'lucide-react'; +import { Separator } from './ui/separator'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './ui/select'; + +interface NewProjectModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (projectPath: string) => void; +} + +interface Owner { + login: string; + type: 'User' | 'Organization'; +} + +export const NewProjectModal: React.FC = ({ + isOpen, + onClose, + onSuccess, +}) => { + const [repoName, setRepoName] = useState(''); + const [description, setDescription] = useState(''); + const [owner, setOwner] = useState(''); + const [owners, setOwners] = useState([]); + const [isPrivate, setIsPrivate] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + const [validationError, setValidationError] = useState(null); + const [isValidating, setIsValidating] = useState(false); + const [progress, setProgress] = useState(''); + const [touched, setTouched] = useState(false); + const shouldReduceMotion = useReducedMotion(); + const validationTimeoutRef = useRef(null); + + // Load owners on mount + useEffect(() => { + if (!isOpen) return; + + let cancel = false; + (async () => { + try { + const result = await window.electronAPI.githubGetOwners(); + if (cancel) return; + if (result.success && result.owners) { + setOwners(result.owners); + // Set default owner to current user + const user = result.owners.find((o) => o.type === 'User'); + if (user) { + setOwner(user.login); + } + } + } catch (error) { + console.error('Failed to load owners:', error); + } + })(); + + return () => { + cancel = true; + }; + }, [isOpen]); + + // Reset form on open + useEffect(() => { + if (!isOpen) return; + + setRepoName(''); + setDescription(''); + setIsPrivate(false); + setError(null); + setValidationError(null); + setIsValidating(false); + setProgress(''); + setTouched(false); + setShowAdvanced(false); + }, [isOpen]); + + // Validate repository name + useEffect(() => { + if (!repoName.trim() || !owner) { + setValidationError(null); + return; + } + + // Clear existing timeout + if (validationTimeoutRef.current) { + clearTimeout(validationTimeoutRef.current); + } + + setIsValidating(true); + validationTimeoutRef.current = setTimeout(async () => { + try { + const result = await window.electronAPI.githubValidateRepoName(repoName.trim(), owner); + setIsValidating(false); + if (!result.success || !result.valid) { + setValidationError(result.error || 'Invalid repository name'); + } else if (result.exists) { + setValidationError(`Repository ${owner}/${repoName.trim()} already exists`); + } else { + setValidationError(null); + } + } catch (error) { + setIsValidating(false); + setValidationError(null); // Don't block on validation errors + } + }, 500); // Debounce 500ms + + return () => { + if (validationTimeoutRef.current) { + clearTimeout(validationTimeoutRef.current); + } + }; + }, [repoName, owner]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setTouched(true); + setError(null); + + if (!repoName.trim()) { + setError('Repository name is required'); + return; + } + + if (validationError) { + setError(validationError); + return; + } + + if (!owner) { + setError('Please select an owner'); + return; + } + + setIsCreating(true); + setProgress('Creating repository on GitHub...'); + + try { + const result = await window.electronAPI.githubCreateNewProject({ + name: repoName.trim(), + description: description.trim() || undefined, + owner, + isPrivate, + }); + + if (result.success && result.projectPath) { + setProgress(''); + onSuccess(result.projectPath); + onClose(); + } else { + let errorMessage = result.error || 'Failed to create project'; + if (result.githubRepoCreated && result.repoUrl) { + errorMessage += `\n\nNote: The GitHub repository was created but setup failed. You can clone it manually: ${result.repoUrl}`; + } + setError(errorMessage); + setProgress(''); + } + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to create project'); + setProgress(''); + } finally { + setIsCreating(false); + } + }, + [repoName, description, owner, isPrivate, validationError, onSuccess, onClose] + ); + + const repoFullPath = owner && repoName.trim() ? `${owner}/${repoName.trim()}` : ''; + + return createPortal( + + {isOpen && ( + + e.stopPropagation()} + initial={shouldReduceMotion ? false : { opacity: 0, y: 8, scale: 0.995 }} + animate={{ opacity: 1, y: 0, scale: 1 }} + exit={ + shouldReduceMotion + ? { opacity: 1, y: 0, scale: 1 } + : { opacity: 0, y: 6, scale: 0.995 } + } + transition={ + shouldReduceMotion ? { duration: 0 } : { duration: 0.2, ease: [0.22, 1, 0.36, 1] } + } + className="mx-4 w-full max-w-md transform-gpu will-change-transform" + > + + + + New Project + + Create a new GitHub repository and get started + + + + + + {isCreating && progress ? ( +
+
+ +
+

{progress}

+

+ This may take a few seconds... +

+
+
+
+ ) : ( +
+
+ + setRepoName(e.target.value)} + onBlur={() => setTouched(true)} + placeholder="my-awesome-project" + className={`w-full ${ + touched && (error || validationError) + ? 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive' + : '' + }`} + aria-invalid={touched && !!(error || validationError)} + disabled={isCreating} + autoFocus + /> + {repoFullPath && ( +

+ Will create: {repoFullPath} +

+ )} + {touched && (validationError || error) && ( +

+ {validationError || error} +

+ )} + {isValidating && ( +

Validating...

+ )} +
+ +
+ + setDescription(e.target.value)} + placeholder="A brief description of your project" + disabled={isCreating} + /> +
+ +
+ + +
+ +
+ +
+ + +
+
+ + + + { + e.preventDefault(); + setShowAdvanced((prev) => !prev); + }} + > + + + Advanced options + + + +
+

+ Additional options coming soon (templates, .gitignore, license) +

+
+
+
+
+ + {error && !validationError && ( +
+ {error.split('\n').map((line, i) => ( +

{line}

+ ))} +
+ )} + +
+ + +
+
+ )} +
+
+
+
+ )} +
, + document.body + ); +}; + diff --git a/src/renderer/components/SidebarEmptyState.tsx b/src/renderer/components/SidebarEmptyState.tsx index fb5e6a4b..8849bc41 100644 --- a/src/renderer/components/SidebarEmptyState.tsx +++ b/src/renderer/components/SidebarEmptyState.tsx @@ -8,9 +8,18 @@ type Props = { description?: string; actionLabel?: string; onAction?: () => void; + secondaryActionLabel?: string; + onSecondaryAction?: () => void; }; -const SidebarEmptyState: React.FC = ({ title, description, actionLabel, onAction }) => { +const SidebarEmptyState: React.FC = ({ + title, + description, + actionLabel, + onAction, + secondaryActionLabel, + onSecondaryAction, +}) => { return (
@@ -20,18 +29,31 @@ const SidebarEmptyState: React.FC = ({ title, description, actionLabel, o {description} ) : null} - {actionLabel && onAction ? ( - - + {(actionLabel && onAction) || (secondaryActionLabel && onSecondaryAction) ? ( + + {actionLabel && onAction && ( + + )} + {secondaryActionLabel && onSecondaryAction && ( + + )} ) : null} diff --git a/src/renderer/hooks/useProjectRunConfig.ts b/src/renderer/hooks/useProjectRunConfig.ts new file mode 100644 index 00000000..365733f7 --- /dev/null +++ b/src/renderer/hooks/useProjectRunConfig.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +export type ProjectRunConfigStatus = 'idle' | 'generating' | 'ready' | 'failed'; + +type ProjectRunConfigState = { + projectId: string; + status: ProjectRunConfigStatus; + exists: boolean; + provider?: string | null; + error?: string | null; + updatedAt?: string | null; +}; + +export function useProjectRunConfig(args: { + projectId: string | null; + projectPath: string | null; + preferredProvider: string | null; +}) { + const { projectId, projectPath, preferredProvider } = args; + + const [state, setState] = useState(null); + + const status: ProjectRunConfigStatus = state?.status ?? 'idle'; + const error = state?.error ?? null; + + // In future we may support env setup at the project level; keep a placeholder for now. + const env = useMemo | null>(() => null, []); + + const refresh = useCallback(async () => { + if (!projectId || !projectPath) return null; + const res = await window.electronAPI.worktreeRunGetProjectConfigStatus({ + projectId, + projectPath, + }); + if (res?.ok && res.state) { + setState(res.state); + return res.state; + } + return null; + }, [projectId, projectPath]); + + const ensure = useCallback( + async ({ force }: { force: boolean }) => { + if (!projectId || !projectPath) return null; + const res = await window.electronAPI.worktreeRunEnsureProjectConfig({ + projectId, + projectPath, + preferredProvider: preferredProvider || undefined, + force, + }); + if (res?.ok && res.state) { + setState(res.state); + return res.state; + } + return null; + }, + [projectId, projectPath, preferredProvider] + ); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + const off = window.electronAPI.onWorktreeRunEvent((event) => { + if (event?.type !== 'config' || !event?.state) return; + if (!projectId) return; + if (event.state.projectId !== projectId) return; + setState(event.state); + }); + return () => off?.(); + }, [projectId]); + + return { + status, + error, + env, + refresh, + ensure, + }; +} + + diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 4b68092a..dfbe0d9b 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -493,6 +493,35 @@ declare global { repoUrl: string, localPath: string ) => Promise<{ success: boolean; error?: string }>; + githubGetOwners: () => Promise<{ + success: boolean; + owners?: Array<{ login: string; type: 'User' | 'Organization' }>; + error?: string; + }>; + githubValidateRepoName: ( + name: string, + owner: string + ) => Promise<{ + success: boolean; + valid?: boolean; + exists?: boolean; + error?: string; + }>; + githubCreateNewProject: (params: { + name: string; + description?: string; + owner: string; + isPrivate: boolean; + gitignoreTemplate?: string; + }) => Promise<{ + success: boolean; + projectPath?: string; + repoUrl?: string; + fullName?: string; + defaultBranch?: string; + githubRepoCreated?: boolean; + error?: string; + }>; githubCheckCLIInstalled: () => Promise; githubInstallCLI: () => Promise<{ success: boolean; error?: string }>; githubListPullRequests: ( diff --git a/src/renderer/types/global.d.ts b/src/renderer/types/global.d.ts index 55a3bd4e..781542ca 100644 --- a/src/renderer/types/global.d.ts +++ b/src/renderer/types/global.d.ts @@ -157,6 +157,35 @@ declare global { repoUrl: string, localPath: string ) => Promise<{ success: boolean; error?: string }>; + githubGetOwners: () => Promise<{ + success: boolean; + owners?: Array<{ login: string; type: 'User' | 'Organization' }>; + error?: string; + }>; + githubValidateRepoName: ( + name: string, + owner: string + ) => Promise<{ + success: boolean; + valid?: boolean; + exists?: boolean; + error?: string; + }>; + githubCreateNewProject: (params: { + name: string; + description?: string; + owner: string; + isPrivate: boolean; + gitignoreTemplate?: string; + }) => Promise<{ + success: boolean; + projectPath?: string; + repoUrl?: string; + fullName?: string; + defaultBranch?: string; + githubRepoCreated?: boolean; + error?: string; + }>; githubListPullRequests: ( projectPath: string ) => Promise<{ success: boolean; prs?: any[]; error?: string }>; From b87c64956ec6d7093c287723e3c259b0c7924902 Mon Sep 17 00:00:00 2001 From: Musti7even Date: Thu, 18 Dec 2025 12:29:03 -0800 Subject: [PATCH 2/6] M --- src/main/services/ProjectRunConfigService.ts | 26 +- src/main/telemetry.ts | 4 + src/renderer/App.tsx | 274 +++++++++++---- src/renderer/components/CloneFromUrlModal.tsx | 327 ++++++++++++++++++ src/renderer/components/GithubStatus.tsx | 7 + src/renderer/components/LeftSidebar.tsx | 3 + src/renderer/components/NewProjectModal.tsx | 90 +---- .../components/RequirementsNotice.tsx | 1 - src/renderer/hooks/useGithubAuth.ts | 14 +- src/renderer/types/electron-api.d.ts | 9 + 10 files changed, 591 insertions(+), 164 deletions(-) create mode 100644 src/renderer/components/CloneFromUrlModal.tsx diff --git a/src/main/services/ProjectRunConfigService.ts b/src/main/services/ProjectRunConfigService.ts index 2a2f4fb4..48909ccb 100644 --- a/src/main/services/ProjectRunConfigService.ts +++ b/src/main/services/ProjectRunConfigService.ts @@ -2,9 +2,17 @@ import { EventEmitter } from 'node:events'; import fs from 'node:fs'; import path from 'node:path'; import { log } from '../lib/logger'; -import { runConfigGenerationService } from './RunConfigGenerationService'; +// TODO: Implement RunConfigGenerationService +// import { runConfigGenerationService } from './RunConfigGenerationService'; import { databaseService } from './DatabaseService'; +// Temporary stub until RunConfigGenerationService is implemented +const runConfigGenerationService = { + generateRunConfig: async (_projectPath: string, _preferredProvider?: string) => { + return { config: null, provider: null }; + }, +}; + const PROJECT_CONFIG_PATH = '.emdash/config.json'; export type ProjectRunConfigStatus = 'idle' | 'generating' | 'ready' | 'failed'; @@ -80,7 +88,8 @@ class ProjectRunConfigService extends EventEmitter { if (exists) { const ready = this.getStatus(projectId, projectPath); try { - await databaseService.updateProjectRunConfigMeta(projectId, { + // TODO: Add updateProjectRunConfigMeta to DatabaseService + await (databaseService as any).updateProjectRunConfigMeta?.(projectId, { status: 'ready', error: null, provider: preferredProvider ?? ready.provider ?? null, @@ -91,9 +100,10 @@ class ProjectRunConfigService extends EventEmitter { } // Load persisted status to avoid auto-retrying after a failure. - let persisted = null as Awaited> | null; + // TODO: Add getProjectRunConfigMeta to DatabaseService + let persisted = null as any; try { - persisted = await databaseService.getProjectRunConfigMeta(projectId); + persisted = await (databaseService as any).getProjectRunConfigMeta?.(projectId); } catch {} const current = this.states.get(projectId); @@ -127,7 +137,7 @@ class ProjectRunConfigService extends EventEmitter { }; this.states.set(projectId, generating); try { - await databaseService.updateProjectRunConfigMeta(projectId, { + await (databaseService as any).updateProjectRunConfigMeta?.(projectId, { status: 'generating', error: null, provider: generating.provider ?? null, @@ -153,7 +163,7 @@ class ProjectRunConfigService extends EventEmitter { }; this.states.set(projectId, failed); try { - await databaseService.updateProjectRunConfigMeta(projectId, { + await (databaseService as any).updateProjectRunConfigMeta?.(projectId, { status: 'failed', error: failed.error ?? null, provider: failed.provider ?? null, @@ -180,7 +190,7 @@ class ProjectRunConfigService extends EventEmitter { }; this.states.set(projectId, ready); try { - await databaseService.updateProjectRunConfigMeta(projectId, { + await (databaseService as any).updateProjectRunConfigMeta?.(projectId, { status: 'ready', error: null, provider: ready.provider ?? null, @@ -200,7 +210,7 @@ class ProjectRunConfigService extends EventEmitter { }; this.states.set(projectId, failed); try { - await databaseService.updateProjectRunConfigMeta(projectId, { + await (databaseService as any).updateProjectRunConfigMeta?.(projectId, { status: 'failed', error: failed.error ?? null, provider: failed.provider ?? null, diff --git a/src/main/telemetry.ts b/src/main/telemetry.ts index 423ae272..1049702c 100644 --- a/src/main/telemetry.ts +++ b/src/main/telemetry.ts @@ -35,6 +35,10 @@ type TelemetryEvent = // Project management | 'project_add_clicked' // left sidebar button to add projects | 'project_open_clicked' // button in the center to open Projects (Home View) + | 'project_create_clicked' // button in the center to create a new project (Home View) + | 'project_clone_clicked' // button in the center to clone a project from GitHub (Home View) + | 'project_create_success' // when a project is successfully created from the homepage + | 'project_clone_success' // when a project is successfully cloned from the homepage | 'project_added_success' // when a project is added successfully (both entrypoint buttons) | 'project_deleted' | 'project_view_opened' // when a user opens a project and see the Task overview in main screen (not the sidebar) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3285c5ae..f6483aa2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Button } from './components/ui/button'; -import { FolderOpen, Plus } from 'lucide-react'; +import { FolderOpen, Plus, Download } from 'lucide-react'; import LeftSidebar from './components/LeftSidebar'; import ProjectMainView from './components/ProjectMainView'; import WorkspaceModal from './components/WorkspaceModal'; import { NewProjectModal } from './components/NewProjectModal'; +import { CloneFromUrlModal } from './components/CloneFromUrlModal'; import ChatInterface from './components/ChatInterface'; import MultiAgentWorkspace from './components/MultiAgentWorkspace'; import { Toaster } from './components/ui/toaster'; @@ -116,6 +117,7 @@ const AppContent: React.FC = () => { user, checkStatus, login: githubLogin, + isInitialized: isGithubInitialized, } = useGithubAuth(); const [githubLoading, setGithubLoading] = useState(false); const [githubStatusMessage, setGithubStatusMessage] = useState(); @@ -124,6 +126,7 @@ const AppContent: React.FC = () => { const [selectedProject, setSelectedProject] = useState(null); const [showWorkspaceModal, setShowWorkspaceModal] = useState(false); const [showNewProjectModal, setShowNewProjectModal] = useState(false); + const [showCloneModal, setShowCloneModal] = useState(false); const [autoOpenWorkspaceAfterNewProject, setAutoOpenWorkspaceAfterNewProject] = useState(false); const [showHomeView, setShowHomeView] = useState(true); @@ -134,11 +137,7 @@ const AppContent: React.FC = () => { const [showSettings, setShowSettings] = useState(false); const [showCommandPalette, setShowCommandPalette] = useState(false); const [showFirstLaunchModal, setShowFirstLaunchModal] = useState(false); - const showGithubRequirement = !ghInstalled || !isAuthenticated; - // Show agent requirements block if we have status data and none of the CLI providers are detected locally. - const showAgentRequirement = - Object.keys(installedProviders).length > 0 && - Object.values(installedProviders).every((v) => v === false); + const showGithubRequirement = isGithubInitialized && (!ghInstalled || !isAuthenticated); const deletingWorkspaceIdsRef = useRef>(new Set()); const normalizePathForComparison = useCallback( @@ -637,6 +636,115 @@ const AppContent: React.FC = () => { } }; + const handleCloneSuccess = useCallback( + async (projectPath: string) => { + const { captureTelemetry } = await import('./lib/telemetryClient'); + captureTelemetry('project_cloned'); + try { + const gitInfo = await window.electronAPI.getGitInfo(projectPath); + const canonicalPath = gitInfo.rootPath || gitInfo.path || projectPath; + const repoKey = normalizePathForComparison(canonicalPath); + const existingProject = projects.find( + (project) => getProjectRepoKey(project) === repoKey + ); + + if (existingProject) { + activateProjectView(existingProject); + return; + } + + const remoteUrl = gitInfo.remote || ''; + const isGithubRemote = /github\.com[:/]/i.test(remoteUrl); + const projectName = + canonicalPath.split(/[/\\]/).filter(Boolean).pop() || 'Unknown Project'; + + const baseProject: Project = { + id: Date.now().toString(), + name: projectName, + path: canonicalPath, + repoKey, + gitInfo: { + isGitRepo: true, + remote: gitInfo.remote || undefined, + branch: gitInfo.branch || undefined, + baseRef: computeBaseRef(gitInfo.baseRef, gitInfo.remote, gitInfo.branch), + }, + workspaces: [], + }; + + if (isAuthenticated && isGithubRemote) { + const githubInfo = await window.electronAPI.connectToGitHub(canonicalPath); + if (githubInfo.success) { + const projectWithGithub = withRepoKey({ + ...baseProject, + githubInfo: { + repository: githubInfo.repository || '', + connected: true, + }, + }); + + const saveResult = await window.electronAPI.saveProject(projectWithGithub); + if (saveResult.success) { + captureTelemetry('project_clone_success'); + captureTelemetry('project_added_success', { source: 'clone' }); + setProjects((prev) => [...prev, projectWithGithub]); + activateProjectView(projectWithGithub); + } else { + const { log } = await import('./lib/logger'); + log.error('Failed to save project:', saveResult.error); + toast({ + title: 'Project Cloned', + description: 'Repository cloned but failed to save to database.', + variant: 'destructive', + }); + } + } else { + const projectWithoutGithub = withRepoKey({ + ...baseProject, + githubInfo: { + repository: '', + connected: false, + }, + }); + + const saveResult = await window.electronAPI.saveProject(projectWithoutGithub); + if (saveResult.success) { + captureTelemetry('project_clone_success'); + captureTelemetry('project_added_success', { source: 'clone' }); + setProjects((prev) => [...prev, projectWithoutGithub]); + activateProjectView(projectWithoutGithub); + } + } + } else { + const projectWithoutGithub = withRepoKey({ + ...baseProject, + githubInfo: { + repository: '', + connected: false, + }, + }); + + const saveResult = await window.electronAPI.saveProject(projectWithoutGithub); + if (saveResult.success) { + captureTelemetry('project_clone_success'); + captureTelemetry('project_added_success', { source: 'clone' }); + setProjects((prev) => [...prev, projectWithoutGithub]); + activateProjectView(projectWithoutGithub); + } + } + } catch (error) { + const { log } = await import('./lib/logger'); + log.error('Failed to load cloned project:', error); + toast({ + title: 'Project Cloned', + description: 'Repository cloned but failed to load. Please try opening it manually.', + variant: 'destructive', + }); + } + }, + [projects, isAuthenticated, activateProjectView, normalizePathForComparison, toast, computeBaseRef, withRepoKey] + ); + const handleNewProjectSuccess = useCallback( async (projectPath: string) => { const { captureTelemetry } = await import('./lib/telemetryClient'); @@ -651,8 +759,6 @@ const AppContent: React.FC = () => { if (existingProject) { activateProjectView(existingProject); - setAutoOpenWorkspaceAfterNewProject(true); - setShowWorkspaceModal(true); return; } @@ -688,11 +794,19 @@ const AppContent: React.FC = () => { const saveResult = await window.electronAPI.saveProject(projectWithGithub); if (saveResult.success) { + captureTelemetry('project_create_success'); captureTelemetry('project_added_success', { source: 'new_project' }); - setProjects((prev) => [...prev, projectWithGithub]); + toast({ + title: 'Project created successfully!', + description: `${projectWithGithub.name} has been added to your workspace.`, + }); + // Add to beginning of list + setProjects((prev) => { + const updated = [projectWithGithub, ...prev]; + saveProjectOrder(updated); + return updated; + }); activateProjectView(projectWithGithub); - setAutoOpenWorkspaceAfterNewProject(true); - setShowWorkspaceModal(true); } else { const { log } = await import('./lib/logger'); log.error('Failed to save project:', saveResult.error); @@ -713,11 +827,19 @@ const AppContent: React.FC = () => { const saveResult = await window.electronAPI.saveProject(projectWithoutGithub); if (saveResult.success) { + captureTelemetry('project_create_success'); captureTelemetry('project_added_success', { source: 'new_project' }); - setProjects((prev) => [...prev, projectWithoutGithub]); + toast({ + title: 'Project created successfully!', + description: `${projectWithoutGithub.name} has been added to your workspace.`, + }); + // Add to beginning of list + setProjects((prev) => { + const updated = [projectWithoutGithub, ...prev]; + saveProjectOrder(updated); + return updated; + }); activateProjectView(projectWithoutGithub); - setAutoOpenWorkspaceAfterNewProject(true); - setShowWorkspaceModal(true); } } } else { @@ -731,8 +853,18 @@ const AppContent: React.FC = () => { const saveResult = await window.electronAPI.saveProject(projectWithoutGithub); if (saveResult.success) { + captureTelemetry('project_create_success'); captureTelemetry('project_added_success', { source: 'new_project' }); - setProjects((prev) => [...prev, projectWithoutGithub]); + toast({ + title: 'Project created successfully!', + description: `${projectWithoutGithub.name} has been added to your workspace.`, + }); + // Add to beginning of list + setProjects((prev) => { + const updated = [projectWithoutGithub, ...prev]; + saveProjectOrder(updated); + return updated; + }); activateProjectView(projectWithoutGithub); setAutoOpenWorkspaceAfterNewProject(true); setShowWorkspaceModal(true); @@ -748,7 +880,7 @@ const AppContent: React.FC = () => { }); } }, - [projects, isAuthenticated, activateProjectView, normalizePathForComparison, toast] + [projects, isAuthenticated, activateProjectView, normalizePathForComparison, toast, computeBaseRef, withRepoKey, getProjectRepoKey] ); const handleGithubConnect = async () => { @@ -1497,8 +1629,8 @@ const AppContent: React.FC = () => { }); }; - const needsGhInstall = !ghInstalled; - const needsGhAuth = ghInstalled && !isAuthenticated; + const needsGhInstall = isGithubInitialized && !ghInstalled; + const needsGhAuth = isGithubInitialized && ghInstalled && !isAuthenticated; const handleReorderProjectsFull = (newOrder: Project[]) => { setProjects(() => { @@ -1618,7 +1750,7 @@ const AppContent: React.FC = () => { if (showHomeView) { return (
-
+
@@ -1644,19 +1776,18 @@ const AppContent: React.FC = () => { />
-

+

Run multiple Coding Agents in parallel

-
- - + + + +
+

Create New Project

+
+ + +
@@ -1718,46 +1873,7 @@ const AppContent: React.FC = () => { ); } - return ( -
-
-
-
- Emdash -
-

- Run multiple Coding Agents in parallel -

- -
- -
- - -
-
-
- ); + return null; }; return ( @@ -1840,6 +1956,7 @@ const AppContent: React.FC = () => { onGithubConnect={handleGithubConnect} githubLoading={githubLoading} githubStatusMessage={githubStatusMessage} + githubInitialized={isGithubInitialized} onSidebarContextChange={handleSidebarContextChange} onCreateWorkspaceForProject={handleStartCreateWorkspaceFromSidebar} isCreatingWorkspace={isCreatingWorkspace} @@ -1912,6 +2029,11 @@ const AppContent: React.FC = () => { onClose={() => setShowNewProjectModal(false)} onSuccess={handleNewProjectSuccess} /> + setShowCloneModal(false)} + onSuccess={handleCloneSuccess} + /> void; + onSuccess: (projectPath: string) => void; +} + +export const CloneFromUrlModal: React.FC = ({ + isOpen, + onClose, + onSuccess, +}) => { + const [repoUrl, setRepoUrl] = useState(''); + const [directoryName, setDirectoryName] = useState(''); + const [isCloning, setIsCloning] = useState(false); + const [error, setError] = useState(null); + const [progress, setProgress] = useState(''); + const [touched, setTouched] = useState(false); + const shouldReduceMotion = useReducedMotion(); + + // Clean URL by removing hash, query params, and trailing slashes + const cleanUrl = useCallback((url: string): string => { + return url + .trim() + .replace(/#.*$/, '') // Remove hash/fragment + .replace(/\?.*$/, '') // Remove query parameters + .replace(/\/+$/, ''); // Remove trailing slashes + }, []); + + // Parse repo name from URL for directory name suggestion + useEffect(() => { + if (!repoUrl.trim()) { + setDirectoryName(''); + return; + } + + try { + const cleanedUrl = cleanUrl(repoUrl); + // Try to extract repo name from various URL formats + let repoName = ''; + + // Handle https://github.com/owner/repo.git or https://github.com/owner/repo + const httpsMatch = cleanedUrl.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i); + if (httpsMatch) { + repoName = httpsMatch[2]; + } else { + // Handle git@github.com:owner/repo.git + const sshMatch = cleanedUrl.match(/:([^/]+)\/([^/]+?)(?:\.git)?$/); + if (sshMatch) { + repoName = sshMatch[2]; + } else { + // Handle ssh://git@host/path/to/repo.git + const sshUrlMatch = cleanedUrl.match(/\/([^/]+?)(?:\.git)?\/?$/); + if (sshUrlMatch) { + repoName = sshUrlMatch[1]; + } else { + // Fallback: take last segment after splitting by / or : + const segments = cleanedUrl.split(/[/:]/).filter(Boolean); + if (segments.length > 0) { + repoName = segments[segments.length - 1].replace(/\.git$/, ''); + } + } + } + } + + if (repoName && !directoryName) { + setDirectoryName(repoName); + } + } catch (e) { + // Ignore parsing errors + } + }, [repoUrl, directoryName, cleanUrl]); + + // Reset form on open + useEffect(() => { + if (!isOpen) return; + + setRepoUrl(''); + setDirectoryName(''); + setError(null); + setProgress(''); + setTouched(false); + }, [isOpen]); + + const validateUrl = (url: string): { valid: boolean; error?: string } => { + const cleaned = cleanUrl(url); + if (!cleaned) { + return { valid: false, error: 'Repository URL is required' }; + } + + const trimmed = cleaned; + + // Check for common Git URL patterns + const patterns = [ + /^https?:\/\/.+/i, // https:// or http:// + /^git@.+:.+/i, // git@host:path + /^ssh:\/\/.+/i, // ssh:// + ]; + + const isValid = patterns.some((pattern) => pattern.test(trimmed)); + if (!isValid) { + return { + valid: false, + error: 'Please enter a valid Git URL (https://, git@, or ssh://)', + }; + } + + return { valid: true }; + }; + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setTouched(true); + setError(null); + + const cleanedUrl = cleanUrl(repoUrl); + const validation = validateUrl(cleanedUrl); + if (!validation.valid) { + setError(validation.error || 'Invalid URL'); + return; + } + + if (!directoryName.trim()) { + setError('Directory name is required'); + return; + } + + setIsCloning(true); + setProgress('Cloning repository...'); + + try { + // Get default directory from settings + const settingsResult = await window.electronAPI.getSettings(); + const defaultDir = + settingsResult.success && settingsResult.settings?.projects?.defaultDirectory + ? settingsResult.settings.projects.defaultDirectory + : '~/emdash-projects'; + const localPath = `${defaultDir}/${directoryName.trim()}`; + + setProgress(`Cloning to ${localPath}...`); + + const cloneResult = await window.electronAPI.githubCloneRepository( + cleanedUrl, + localPath + ); + + if (!cloneResult.success) { + throw new Error(cloneResult.error || 'Failed to clone repository'); + } + + setProgress('Repository cloned successfully'); + await new Promise((resolve) => setTimeout(resolve, 500)); // Brief pause for UX + + onSuccess(localPath); + onClose(); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to clone repository'; + setError(errorMessage); + setProgress(''); + } finally { + setIsCloning(false); + } + }, + [repoUrl, directoryName, onSuccess, onClose] + ); + + if (!isOpen) return null; + + return createPortal( + + {isOpen && ( + { + if (e.target === e.currentTarget) { + onClose(); + } + }} + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" + initial={shouldReduceMotion ? undefined : { opacity: 0 }} + animate={{ opacity: 1 }} + exit={shouldReduceMotion ? undefined : { opacity: 0 }} + transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.2 }} + > + e.stopPropagation()} + initial={shouldReduceMotion ? false : { opacity: 0, y: 8, scale: 0.995 }} + animate={{ opacity: 1, y: 0, scale: 1 }} + exit={ + shouldReduceMotion + ? { opacity: 1, y: 0, scale: 1 } + : { opacity: 0, y: 6, scale: 0.995 } + } + transition={ + shouldReduceMotion ? { duration: 0 } : { duration: 0.2, ease: [0.22, 1, 0.36, 1] } + } + className="mx-4 w-full max-w-md transform-gpu will-change-transform" + > + + + + Clone from URL + + + + + {isCloning && progress ? ( +
+
+ +
+

{progress}

+

+ This may take a few moments... +

+
+
+
+ ) : ( +
+
+ + setRepoUrl(e.target.value)} + onBlur={() => setTouched(true)} + placeholder="https://github.com/owner/repo.git" + className={`w-full ${ + touched && error + ? 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive' + : '' + }`} + aria-invalid={touched && !!error} + disabled={isCloning} + autoFocus + /> + {touched && error && !repoUrl.trim() && ( +

{error}

+ )} +
+ +
+ + setDirectoryName(e.target.value)} + placeholder="my-project" + disabled={isCloning} + className="w-full" + /> +

+ Local directory name (auto-detected from URL) +

+
+ + {error && repoUrl.trim() && ( +
+ {error.split('\n').map((line, i) => ( +

{line}

+ ))} +
+ )} + +
+ + +
+
+ )} +
+
+
+
+ )} +
, + document.body + ); +}; + diff --git a/src/renderer/components/GithubStatus.tsx b/src/renderer/components/GithubStatus.tsx index f565f23e..e2977134 100644 --- a/src/renderer/components/GithubStatus.tsx +++ b/src/renderer/components/GithubStatus.tsx @@ -14,6 +14,7 @@ export function GithubStatus({ onConnect, isLoading = false, statusMessage, + isInitialized = false, }: { installed?: boolean; authenticated?: boolean; @@ -22,7 +23,13 @@ export function GithubStatus({ onConnect?: () => void; isLoading?: boolean; statusMessage?: string; + isInitialized?: boolean; }) { + // Not initialized - don't show anything to avoid flash of incorrect state + if (!isInitialized) { + return null; + } + // Not installed - show install button if (!installed) { return ( diff --git a/src/renderer/components/LeftSidebar.tsx b/src/renderer/components/LeftSidebar.tsx index e4362229..b592d6a7 100644 --- a/src/renderer/components/LeftSidebar.tsx +++ b/src/renderer/components/LeftSidebar.tsx @@ -40,6 +40,7 @@ interface LeftSidebarProps { onGithubConnect?: () => void; githubLoading?: boolean; githubStatusMessage?: string; + githubInitialized?: boolean; onSidebarContextChange?: (state: { open: boolean; isMobile: boolean; @@ -69,6 +70,7 @@ const LeftSidebar: React.FC = ({ onGithubConnect, githubLoading = false, githubStatusMessage, + githubInitialized = false, onSidebarContextChange, onCreateWorkspaceForProject, isCreatingWorkspace, @@ -122,6 +124,7 @@ const LeftSidebar: React.FC = ({ onConnect={onGithubConnect} isLoading={githubLoading} statusMessage={githubStatusMessage} + isInitialized={githubInitialized} /> ); diff --git a/src/renderer/components/NewProjectModal.tsx b/src/renderer/components/NewProjectModal.tsx index cc4f466a..4068595e 100644 --- a/src/renderer/components/NewProjectModal.tsx +++ b/src/renderer/components/NewProjectModal.tsx @@ -2,20 +2,12 @@ import React, { useCallback, useEffect, useState, useRef } from 'react'; import { createPortal } from 'react-dom'; import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import { Button } from './ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Input } from './ui/input'; import { Label } from './ui/label'; import { Spinner } from './ui/spinner'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ui/accordion'; -import { X, Settings } from 'lucide-react'; +import { X } from 'lucide-react'; import { Separator } from './ui/separator'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from './ui/select'; interface NewProjectModalProps { isOpen: boolean; @@ -38,7 +30,6 @@ export const NewProjectModal: React.FC = ({ const [owner, setOwner] = useState(''); const [owners, setOwners] = useState([]); const [isPrivate, setIsPrivate] = useState(false); - const [showAdvanced, setShowAdvanced] = useState(false); const [isCreating, setIsCreating] = useState(false); const [error, setError] = useState(null); const [validationError, setValidationError] = useState(null); @@ -87,7 +78,6 @@ export const NewProjectModal: React.FC = ({ setIsValidating(false); setProgress(''); setTouched(false); - setShowAdvanced(false); }, [isOpen]); // Validate repository name @@ -144,7 +134,7 @@ export const NewProjectModal: React.FC = ({ } if (!owner) { - setError('Please select an owner'); + setError('Unable to determine GitHub account. Please ensure you are authenticated.'); return; } @@ -160,7 +150,9 @@ export const NewProjectModal: React.FC = ({ }); if (result.success && result.projectPath) { - setProgress(''); + setProgress('Repository created successfully! Adding to workspace...'); + // Brief delay to show success message + await new Promise((resolve) => setTimeout(resolve, 500)); onSuccess(result.projectPath); onClose(); } else { @@ -222,13 +214,10 @@ export const NewProjectModal: React.FC = ({ New Project - - Create a new GitHub repository and get started - - + {isCreating && progress ? (
@@ -262,18 +251,12 @@ export const NewProjectModal: React.FC = ({ disabled={isCreating} autoFocus /> - {repoFullPath && ( -

- Will create: {repoFullPath} -

- )} {touched && (validationError || error) && ( -

- {validationError || error} -

- )} - {isValidating && ( -

Validating...

+
+

+ {validationError || error} +

+
)}
@@ -290,27 +273,9 @@ export const NewProjectModal: React.FC = ({ />
-
- - -
-
-
+
- - - { - e.preventDefault(); - setShowAdvanced((prev) => !prev); - }} - > - - - Advanced options - - - -
-

- Additional options coming soon (templates, .gitignore, license) -

-
-
-
-
- {error && !validationError && (
{error.split('\n').map((line, i) => ( diff --git a/src/renderer/components/RequirementsNotice.tsx b/src/renderer/components/RequirementsNotice.tsx index 2335c99a..7ad13411 100644 --- a/src/renderer/components/RequirementsNotice.tsx +++ b/src/renderer/components/RequirementsNotice.tsx @@ -4,7 +4,6 @@ type Props = { showGithubRequirement: boolean; needsGhInstall: boolean; needsGhAuth: boolean; - showAgentRequirement?: boolean; }; const RequirementsNotice: React.FC = ({ diff --git a/src/renderer/hooks/useGithubAuth.ts b/src/renderer/hooks/useGithubAuth.ts index e9e28260..4d1fcea8 100644 --- a/src/renderer/hooks/useGithubAuth.ts +++ b/src/renderer/hooks/useGithubAuth.ts @@ -17,6 +17,7 @@ export function useGithubAuth() { ); const [user, setUser] = useState(() => cachedGithubStatus?.user ?? null); const [isLoading, setIsLoading] = useState(false); + const [isInitialized, setIsInitialized] = useState(() => !!cachedGithubStatus); const syncCache = useCallback( (next: { installed: boolean; authenticated: boolean; user: GithubUser | null }) => { @@ -24,6 +25,7 @@ export function useGithubAuth() { setInstalled(next.installed); setAuthenticated(next.authenticated); setUser(next.user); + setIsInitialized(true); }, [] ); @@ -70,8 +72,15 @@ export function useGithubAuth() { }, [syncCache]); useEffect(() => { - if (cachedGithubStatus) return; - checkStatus(); + // If we have cached status, mark as initialized but still refresh in background + if (cachedGithubStatus) { + setIsInitialized(true); + // Still refresh in background to ensure we have the latest status + void checkStatus(); + return; + } + // No cache - check status immediately + void checkStatus(); }, [checkStatus]); return { @@ -79,6 +88,7 @@ export function useGithubAuth() { authenticated, user, isLoading, + isInitialized, checkStatus, login, logout, diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index dfbe0d9b..95bad0dc 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -45,6 +45,9 @@ declare global { autoGenerateName: boolean; autoApproveByDefault: boolean; }; + projects?: { + defaultDirectory: string; + }; }; error?: string; }>; @@ -65,6 +68,9 @@ declare global { autoGenerateName?: boolean; autoApproveByDefault?: boolean; }; + projects?: { + defaultDirectory?: string; + }; }> ) => Promise<{ success: boolean; @@ -84,6 +90,9 @@ declare global { autoGenerateName: boolean; autoApproveByDefault: boolean; }; + projects?: { + defaultDirectory: string; + }; }; error?: string; }>; From 97cd0051502993c03f02369f44773b34428feda2 Mon Sep 17 00:00:00 2001 From: Musti7even Date: Thu, 18 Dec 2025 13:14:46 -0800 Subject: [PATCH 3/6] feat: improve homepage UX and toast notifications - Add authentication checks with user-friendly toast notifications - Refactor toast layout for better visual hierarchy - Fix GitHub status component text truncation in sidebar - Remove unused RequirementsNotice component from homepage --- src/renderer/App.tsx | 33 +++++++++++++++++++----- src/renderer/components/GithubStatus.tsx | 24 ++++++++--------- src/renderer/components/LeftSidebar.tsx | 12 ++++----- src/renderer/components/ui/toast.tsx | 2 +- src/renderer/components/ui/toaster.tsx | 14 +++++----- 5 files changed, 53 insertions(+), 32 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f6483aa2..d27eb1a8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -10,8 +10,8 @@ import ChatInterface from './components/ChatInterface'; import MultiAgentWorkspace from './components/MultiAgentWorkspace'; import { Toaster } from './components/ui/toaster'; import useUpdateNotifier from './hooks/useUpdateNotifier'; -import RequirementsNotice from './components/RequirementsNotice'; import { useToast } from './hooks/use-toast'; +import { ToastAction } from './components/ui/toast'; import { useGithubAuth } from './hooks/useGithubAuth'; import { useTheme } from './hooks/useTheme'; import { ThemeProvider } from './components/ThemeProvider'; @@ -1750,7 +1750,7 @@ const AppContent: React.FC = () => { if (showHomeView) { return (
-
+
@@ -1779,11 +1779,6 @@ const AppContent: React.FC = () => {

Run multiple Coding Agents in parallel

-
@@ -1809,6 +1804,18 @@ const AppContent: React.FC = () => { const { captureTelemetry } = await import('./lib/telemetryClient'); captureTelemetry('project_create_clicked'); })(); + if (!isAuthenticated || !ghInstalled) { + toast({ + title: 'GitHub authentication required', + variant: 'destructive', + action: ( + + Connect GitHub + + ), + }); + return; + } setShowNewProjectModal(true); }} className="group flex flex-col items-start justify-between rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm transition-all hover:bg-muted/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" @@ -1825,6 +1832,18 @@ const AppContent: React.FC = () => { const { captureTelemetry } = await import('./lib/telemetryClient'); captureTelemetry('project_clone_clicked'); })(); + if (!isAuthenticated || !ghInstalled) { + toast({ + title: 'GitHub authentication required', + variant: 'destructive', + action: ( + + Connect GitHub + + ), + }); + return; + } setShowCloneModal(true); }} className="group flex flex-col items-start justify-between rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm transition-all hover:bg-muted/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" diff --git a/src/renderer/components/GithubStatus.tsx b/src/renderer/components/GithubStatus.tsx index e2977134..40f61645 100644 --- a/src/renderer/components/GithubStatus.tsx +++ b/src/renderer/components/GithubStatus.tsx @@ -38,21 +38,21 @@ export function GithubStatus({ disabled={isLoading} variant="default" size="sm" - className={`w-full items-center justify-start gap-2 py-2 ${className}`} + className={`w-full items-center justify-start gap-2 py-2 min-w-0 overflow-hidden ${className}`} > {isLoading ? ( <> - - + + {statusMessage || 'Installing GitHub CLI...'} ) : ( <> -
- Connect GitHub - Install & sign in +
+ Connect GitHub + Install & sign in
)} @@ -68,21 +68,21 @@ export function GithubStatus({ disabled={isLoading} variant="default" size="sm" - className={`w-full items-center justify-start gap-2 py-2 ${className}`} + className={`w-full items-center justify-start gap-2 py-2 min-w-0 overflow-hidden ${className}`} > {isLoading ? ( <> - - + + {statusMessage || 'Connecting to GitHub...'} ) : ( <> -
- Connect GitHub - Sign in with GitHub +
+ Connect GitHub + Sign in with GitHub
)} diff --git a/src/renderer/components/LeftSidebar.tsx b/src/renderer/components/LeftSidebar.tsx index b592d6a7..485f22db 100644 --- a/src/renderer/components/LeftSidebar.tsx +++ b/src/renderer/components/LeftSidebar.tsx @@ -351,9 +351,9 @@ const LeftSidebar: React.FC = ({ )} - - - + + + { @@ -363,15 +363,15 @@ const LeftSidebar: React.FC = ({ e.preventDefault(); handleGithubProfileClick(); }} - className={`flex w-full items-center justify-start gap-2 px-2 py-2 text-sm text-muted-foreground focus-visible:outline-none focus-visible:ring-0 ${ + className={`flex w-full items-center justify-start gap-2 px-2 py-2 text-sm text-muted-foreground focus-visible:outline-none focus-visible:ring-0 min-w-0 overflow-hidden ${ githubProfileUrl ? 'hover:bg-black/5 dark:hover:bg-white/5' : 'cursor-default hover:bg-transparent' }`} aria-label={githubProfileUrl ? 'Open GitHub profile' : undefined} > -
-
{renderGithubStatus()}
+
+
{renderGithubStatus()}
diff --git a/src/renderer/components/ui/toast.tsx b/src/renderer/components/ui/toast.tsx index 3dd5138b..e467de78 100644 --- a/src/renderer/components/ui/toast.tsx +++ b/src/renderer/components/ui/toast.tsx @@ -25,7 +25,7 @@ const ToastViewport = React.forwardRef< ToastViewport.displayName = ToastPrimitives.Viewport.displayName; const toastVariants = cva( - 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', + 'group pointer-events-auto relative flex w-full flex-col overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', { variants: { variant: { diff --git a/src/renderer/components/ui/toaster.tsx b/src/renderer/components/ui/toaster.tsx index 16a4a446..86eaf35f 100644 --- a/src/renderer/components/ui/toaster.tsx +++ b/src/renderer/components/ui/toaster.tsx @@ -19,16 +19,18 @@ export function Toaster() { {toasts.map(function ({ id, title, description, action, variant, ...props }) { return ( -
+
{variant === 'destructive' && ( - + )} -
- {title && {title}} - {description && {description}} +
+
+ {title && {title}} + {description && {description}} +
+ {action &&
{action}
}
- {action} ); From 4531fa952ebc8140b69347e1afe7e88e0bbeb1f4 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:07:04 +0100 Subject: [PATCH 4/6] formar --- src/main/ipc/githubIpc.ts | 61 +++++++++---------- src/main/services/GitHubService.ts | 29 +++++++-- src/main/services/ProjectRunConfigService.ts | 2 - src/renderer/App.tsx | 57 ++++++++++------- src/renderer/components/CloneFromUrlModal.tsx | 10 +-- src/renderer/components/GithubStatus.tsx | 28 ++++++--- src/renderer/components/LeftSidebar.tsx | 8 +-- src/renderer/components/NewProjectModal.tsx | 19 ++---- src/renderer/components/SidebarEmptyState.tsx | 2 +- src/renderer/components/ui/toaster.tsx | 6 +- src/renderer/hooks/useProjectRunConfig.ts | 2 - 11 files changed, 123 insertions(+), 101 deletions(-) diff --git a/src/main/ipc/githubIpc.ts b/src/main/ipc/githubIpc.ts index 1911f627..3277fcc4 100644 --- a/src/main/ipc/githubIpc.ts +++ b/src/main/ipc/githubIpc.ts @@ -348,46 +348,43 @@ export function registerGithubIpc() { } }); - ipcMain.handle( - 'github:validateRepoName', - async (_, name: string, owner: string) => { - try { - // First validate format - const formatValidation = githubService.validateRepositoryName(name); - if (!formatValidation.valid) { - return { - success: true, - valid: false, - exists: false, - error: formatValidation.error, - }; - } - - // Then check if it exists - const exists = await githubService.checkRepositoryExists(owner, name); - if (exists) { - return { - success: true, - valid: true, - exists: true, - error: `Repository ${owner}/${name} already exists`, - }; - } - + ipcMain.handle('github:validateRepoName', async (_, name: string, owner: string) => { + try { + // First validate format + const formatValidation = githubService.validateRepositoryName(name); + if (!formatValidation.valid) { return { success: true, - valid: true, + valid: false, exists: false, + error: formatValidation.error, }; - } catch (error) { - log.error('Failed to validate repo name:', error); + } + + // Then check if it exists + const exists = await githubService.checkRepositoryExists(owner, name); + if (exists) { return { - success: false, - error: error instanceof Error ? error.message : 'Validation failed', + success: true, + valid: true, + exists: true, + error: `Repository ${owner}/${name} already exists`, }; } + + return { + success: true, + valid: true, + exists: false, + }; + } catch (error) { + log.error('Failed to validate repo name:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Validation failed', + }; } - ); + }); ipcMain.handle( 'github:createNewProject', diff --git a/src/main/services/GitHubService.ts b/src/main/services/GitHubService.ts index 86b06d43..4bc9e602 100644 --- a/src/main/services/GitHubService.ts +++ b/src/main/services/GitHubService.ts @@ -785,7 +785,30 @@ export class GitHubService { } // Reserved names (basic ones, GitHub has more) - const reserved = ['con', 'prn', 'aux', 'nul', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9']; + const reserved = [ + 'con', + 'prn', + 'aux', + 'nul', + 'com1', + 'com2', + 'com3', + 'com4', + 'com5', + 'com6', + 'com7', + 'com8', + 'com9', + 'lpt1', + 'lpt2', + 'lpt3', + 'lpt4', + 'lpt5', + 'lpt6', + 'lpt7', + 'lpt8', + 'lpt9', + ]; if (reserved.includes(trimmed.toLowerCase())) { return { valid: false, error: 'Repository name is reserved' }; } @@ -899,9 +922,7 @@ export class GitHubService { // Create README.md const readmePath = path.join(localPath, 'README.md'); - const readmeContent = description - ? `# ${name}\n\n${description}\n` - : `# ${name}\n`; + const readmeContent = description ? `# ${name}\n\n${description}\n` : `# ${name}\n`; fs.writeFileSync(readmePath, readmeContent, 'utf8'); // Initialize git, add files, commit, and push diff --git a/src/main/services/ProjectRunConfigService.ts b/src/main/services/ProjectRunConfigService.ts index 48909ccb..8858552b 100644 --- a/src/main/services/ProjectRunConfigService.ts +++ b/src/main/services/ProjectRunConfigService.ts @@ -223,5 +223,3 @@ class ProjectRunConfigService extends EventEmitter { } export const projectRunConfigService = new ProjectRunConfigService(); - - diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d27eb1a8..7db5429b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -644,9 +644,7 @@ const AppContent: React.FC = () => { const gitInfo = await window.electronAPI.getGitInfo(projectPath); const canonicalPath = gitInfo.rootPath || gitInfo.path || projectPath; const repoKey = normalizePathForComparison(canonicalPath); - const existingProject = projects.find( - (project) => getProjectRepoKey(project) === repoKey - ); + const existingProject = projects.find((project) => getProjectRepoKey(project) === repoKey); if (existingProject) { activateProjectView(existingProject); @@ -655,8 +653,7 @@ const AppContent: React.FC = () => { const remoteUrl = gitInfo.remote || ''; const isGithubRemote = /github\.com[:/]/i.test(remoteUrl); - const projectName = - canonicalPath.split(/[/\\]/).filter(Boolean).pop() || 'Unknown Project'; + const projectName = canonicalPath.split(/[/\\]/).filter(Boolean).pop() || 'Unknown Project'; const baseProject: Project = { id: Date.now().toString(), @@ -742,7 +739,15 @@ const AppContent: React.FC = () => { }); } }, - [projects, isAuthenticated, activateProjectView, normalizePathForComparison, toast, computeBaseRef, withRepoKey] + [ + projects, + isAuthenticated, + activateProjectView, + normalizePathForComparison, + toast, + computeBaseRef, + withRepoKey, + ] ); const handleNewProjectSuccess = useCallback( @@ -753,9 +758,7 @@ const AppContent: React.FC = () => { const gitInfo = await window.electronAPI.getGitInfo(projectPath); const canonicalPath = gitInfo.rootPath || gitInfo.path || projectPath; const repoKey = normalizePathForComparison(canonicalPath); - const existingProject = projects.find( - (project) => getProjectRepoKey(project) === repoKey - ); + const existingProject = projects.find((project) => getProjectRepoKey(project) === repoKey); if (existingProject) { activateProjectView(existingProject); @@ -764,8 +767,7 @@ const AppContent: React.FC = () => { const remoteUrl = gitInfo.remote || ''; const isGithubRemote = /github\.com[:/]/i.test(remoteUrl); - const projectName = - canonicalPath.split(/[/\\]/).filter(Boolean).pop() || 'Unknown Project'; + const projectName = canonicalPath.split(/[/\\]/).filter(Boolean).pop() || 'Unknown Project'; const baseProject: Project = { id: Date.now().toString(), @@ -880,7 +882,16 @@ const AppContent: React.FC = () => { }); } }, - [projects, isAuthenticated, activateProjectView, normalizePathForComparison, toast, computeBaseRef, withRepoKey, getProjectRepoKey] + [ + projects, + isAuthenticated, + activateProjectView, + normalizePathForComparison, + toast, + computeBaseRef, + withRepoKey, + getProjectRepoKey, + ] ); const handleGithubConnect = async () => { @@ -1750,7 +1761,7 @@ const AppContent: React.FC = () => { if (showHomeView) { return (
-
+
@@ -1776,7 +1787,7 @@ const AppContent: React.FC = () => { />
-

+

Run multiple Coding Agents in parallel

@@ -1792,9 +1803,9 @@ const AppContent: React.FC = () => { }} className="group flex flex-col items-start justify-between rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm transition-all hover:bg-muted/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" > - -
-

Open project

+ +
+

Open project

@@ -1820,9 +1831,9 @@ const AppContent: React.FC = () => { }} className="group flex flex-col items-start justify-between rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm transition-all hover:bg-muted/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" > - -
-

Create New Project

+ +
+

Create New Project

@@ -1848,9 +1859,9 @@ const AppContent: React.FC = () => { }} className="group flex flex-col items-start justify-between rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm transition-all hover:bg-muted/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" > - -
-

Clone from GitHub

+ +
+

Clone from GitHub

diff --git a/src/renderer/components/CloneFromUrlModal.tsx b/src/renderer/components/CloneFromUrlModal.tsx index 40e39bb6..cb097096 100644 --- a/src/renderer/components/CloneFromUrlModal.tsx +++ b/src/renderer/components/CloneFromUrlModal.tsx @@ -48,7 +48,7 @@ export const CloneFromUrlModal: React.FC = ({ const cleanedUrl = cleanUrl(repoUrl); // Try to extract repo name from various URL formats let repoName = ''; - + // Handle https://github.com/owner/repo.git or https://github.com/owner/repo const httpsMatch = cleanedUrl.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i); if (httpsMatch) { @@ -150,10 +150,7 @@ export const CloneFromUrlModal: React.FC = ({ setProgress(`Cloning to ${localPath}...`); - const cloneResult = await window.electronAPI.githubCloneRepository( - cleanedUrl, - localPath - ); + const cloneResult = await window.electronAPI.githubCloneRepository(cleanedUrl, localPath); if (!cloneResult.success) { throw new Error(cloneResult.error || 'Failed to clone repository'); @@ -272,7 +269,7 @@ export const CloneFromUrlModal: React.FC = ({ disabled={isCloning} className="w-full" /> -

+

Local directory name (auto-detected from URL)

@@ -324,4 +321,3 @@ export const CloneFromUrlModal: React.FC = ({ document.body ); }; - diff --git a/src/renderer/components/GithubStatus.tsx b/src/renderer/components/GithubStatus.tsx index 40f61645..4ccf4771 100644 --- a/src/renderer/components/GithubStatus.tsx +++ b/src/renderer/components/GithubStatus.tsx @@ -38,21 +38,25 @@ export function GithubStatus({ disabled={isLoading} variant="default" size="sm" - className={`w-full items-center justify-start gap-2 py-2 min-w-0 overflow-hidden ${className}`} + className={`w-full min-w-0 items-center justify-start gap-2 overflow-hidden py-2 ${className}`} > {isLoading ? ( <> - + {statusMessage || 'Installing GitHub CLI...'} ) : ( <> -
- Connect GitHub - Install & sign in +
+ + Connect GitHub + + + Install & sign in +
)} @@ -68,21 +72,25 @@ export function GithubStatus({ disabled={isLoading} variant="default" size="sm" - className={`w-full items-center justify-start gap-2 py-2 min-w-0 overflow-hidden ${className}`} + className={`w-full min-w-0 items-center justify-start gap-2 overflow-hidden py-2 ${className}`} > {isLoading ? ( <> - + {statusMessage || 'Connecting to GitHub...'} ) : ( <> -
- Connect GitHub - Sign in with GitHub +
+ + Connect GitHub + + + Sign in with GitHub +
)} diff --git a/src/renderer/components/LeftSidebar.tsx b/src/renderer/components/LeftSidebar.tsx index 485f22db..49a7e56b 100644 --- a/src/renderer/components/LeftSidebar.tsx +++ b/src/renderer/components/LeftSidebar.tsx @@ -351,7 +351,7 @@ const LeftSidebar: React.FC = ({ )} - + = ({ e.preventDefault(); handleGithubProfileClick(); }} - className={`flex w-full items-center justify-start gap-2 px-2 py-2 text-sm text-muted-foreground focus-visible:outline-none focus-visible:ring-0 min-w-0 overflow-hidden ${ + className={`flex w-full min-w-0 items-center justify-start gap-2 overflow-hidden px-2 py-2 text-sm text-muted-foreground focus-visible:outline-none focus-visible:ring-0 ${ githubProfileUrl ? 'hover:bg-black/5 dark:hover:bg-white/5' : 'cursor-default hover:bg-transparent' }`} aria-label={githubProfileUrl ? 'Open GitHub profile' : undefined} > -
-
{renderGithubStatus()}
+
+
{renderGithubStatus()}
diff --git a/src/renderer/components/NewProjectModal.tsx b/src/renderer/components/NewProjectModal.tsx index 4068595e..40212725 100644 --- a/src/renderer/components/NewProjectModal.tsx +++ b/src/renderer/components/NewProjectModal.tsx @@ -20,11 +20,7 @@ interface Owner { type: 'User' | 'Organization'; } -export const NewProjectModal: React.FC = ({ - isOpen, - onClose, - onSuccess, -}) => { +export const NewProjectModal: React.FC = ({ isOpen, onClose, onSuccess }) => { const [repoName, setRepoName] = useState(''); const [description, setDescription] = useState(''); const [owner, setOwner] = useState(''); @@ -253,9 +249,7 @@ export const NewProjectModal: React.FC = ({ /> {touched && (validationError || error) && (
-

- {validationError || error} -

+

{validationError || error}

)}
@@ -276,7 +270,7 @@ export const NewProjectModal: React.FC = ({
-