diff --git a/apps/staged/src/lib/features/projects/ProjectContextMenu.svelte b/apps/staged/src/lib/features/projects/ProjectContextMenu.svelte new file mode 100644 index 00000000..796e1d8d --- /dev/null +++ b/apps/staged/src/lib/features/projects/ProjectContextMenu.svelte @@ -0,0 +1,211 @@ + + + + + diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index 0a610e8d..d8a4dde5 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -24,6 +24,8 @@ import { workspaceLifecycle } from './workspaceLifecycle.svelte'; import { projectRunActionsStore } from '../../stores/projectRunActions.svelte'; import { repoBadgeStore } from '../../stores/repoBadges.svelte'; + import { projectStateStore } from '../../stores/projectState.svelte'; + import { canDeleteProjectWithoutConfirmation } from './projectDeleteSafety'; /** * Merge incoming branches with existing ones, preserving worktreePath when @@ -376,29 +378,14 @@ continue; } - // Check if all branches have merged PRs and no unpushed changes. - // Skip the expensive hasUnpushedCommits check for remote branches — - // their commits live on the workspace and the SSH round-trip (~5s) - // blocks the UI. Treat merged remote branches as safe. - if (branches.length > 0) { - const allSafe = await Promise.all( - branches.map(async (branch) => { - const isMerged = branch.prState === 'MERGED'; - if (!isMerged) return false; - if (branch.branchType === 'remote') return true; - - try { - const hasUnpushed = await commands.hasUnpushedCommits(branch.id); - return !hasUnpushed; - } catch (e) { - return false; - } - }) - ); + const safe = await canDeleteProjectWithoutConfirmation({ + branches, + repoCount, + hasUnpushedCommits: commands.hasUnpushedCommits, + }); - if (allSafe.every((safe) => safe)) { - nextSafe.add(project.id); - } + if (safe) { + nextSafe.add(project.id); } } @@ -420,6 +407,11 @@ showNewProjectModal = true; } + function handleMarkProjectUnread(project: Project) { + if (deletingProjectNames.has(project.id)) return; + projectStateStore.markAsUnread(project.id); + } + async function handleProjectCreated(project: Project) { if (!projects.some((p) => p.id === project.id)) { projects = [...projects, project]; @@ -444,35 +436,12 @@ const branches = branchesByProject.get(project.id) || []; const repoCount = repoCountsByProject.get(project.id) || 0; - // If no repos, safe to delete without confirmation - if (repoCount === 0) { - projectToDelete = project; - // Immediately confirm since it's safe - await confirmDeleteProject(); - return; - } - - // Check if all branches have merged PRs and no unpushed changes. - // Skip the expensive hasUnpushedCommits check for remote branches — - // their commits live on the workspace and the SSH round-trip blocks the UI. - const allSafe = await Promise.all( - branches.map(async (branch) => { - const isMerged = branch.prState === 'MERGED'; - if (!isMerged) return false; - if (branch.branchType === 'remote') return true; - - // Check for unpushed commits - try { - const hasUnpushed = await commands.hasUnpushedCommits(branch.id); - return !hasUnpushed; - } catch (e) { - console.error('Failed to check unpushed commits:', e); - return false; - } - }) - ); - - const isSafeToDelete = branches.length > 0 && allSafe.every((safe) => safe); + const isSafeToDelete = await canDeleteProjectWithoutConfirmation({ + branches, + repoCount, + hasUnpushedCommits: commands.hasUnpushedCommits, + onCheckError: (e) => console.error('Failed to check unpushed commits:', e), + }); if (isSafeToDelete) { // Safe to delete without confirmation @@ -511,6 +480,7 @@ try { await commands.deleteProject(id); + projectStateStore.markAsRead(id); projects = projects.filter((p) => p.id !== id); setProjects(projects); const nextBranches = new Map(branchesByProject); @@ -690,6 +660,8 @@ {reposByProject} projectBranches={branchesByProject} showAllProjectsRow={true} + onMarkProjectUnread={handleMarkProjectUnread} + onRemoveProject={handleDeleteProjectRequest} />
diff --git a/apps/staged/src/lib/features/projects/ProjectsList.svelte b/apps/staged/src/lib/features/projects/ProjectsList.svelte index 61b96630..e3a47cc5 100644 --- a/apps/staged/src/lib/features/projects/ProjectsList.svelte +++ b/apps/staged/src/lib/features/projects/ProjectsList.svelte @@ -26,11 +26,14 @@ import { selectProject } from '../layout/navigation.svelte'; import NewProjectModal from './NewProjectModal.svelte'; import ProjectsSidebar from './ProjectsSidebar.svelte'; + import ProjectContextMenu from './ProjectContextMenu.svelte'; import { getProjectStatus } from './projectStatus'; import SplashScreen from './SplashScreen.svelte'; import Spinner from '../../shared/Spinner.svelte'; import SineWave from '../../shared/SineWave.svelte'; import RepoLabel from '../../shared/RepoLabel.svelte'; + import ConfirmDialog from '../../shared/ConfirmDialog.svelte'; + import { alerts } from '../../shared/alerts.svelte'; import { setProjects } from './projectsSidebarState.svelte'; import { @@ -41,6 +44,7 @@ import { darkMode } from '../../stores/isDark.svelte'; import { repoBadgeStore } from '../../stores/repoBadges.svelte'; import { badgeBg, badgeFg, badgeBgHover } from '../../shared/badgeColors'; + import { canDeleteProjectWithoutConfirmation } from './projectDeleteSafety'; type FilterKind = 'unread' | 'running' | { repo: string; subpath: string }; @@ -51,6 +55,8 @@ let showNewProjectModal = $state(false); let isCommandKeyHeld = $state(false); let deletingProjectNames = $state>(new Map()); + let projectToDelete = $state(null); + let projectMenu = $state<{ project: Project; x: number; y: number } | null>(null); let reposByProject = $state>(new Map()); let reposHydrating = $state(false); let mainPanelEl = $state(null); @@ -399,6 +405,7 @@ } function openProject(projectId: string) { + closeProjectMenu(); if (isProjectDeleting(projectId)) return; if (mainPanelEl) { setProjectsListScrollTop(mainPanelEl.scrollTop); @@ -406,6 +413,73 @@ selectProject(projectId); } + function closeProjectMenu() { + projectMenu = null; + } + + function openProjectMenu(event: MouseEvent, project: Project, deleting: boolean) { + if (deleting) return; + event.preventDefault(); + event.stopPropagation(); + projectMenu = { project, x: event.clientX, y: event.clientY }; + } + + function handleMarkProjectUnread(project: Project) { + if (isProjectDeleting(project.id)) return; + projectStateStore.markAsUnread(project.id); + } + + async function handleRemoveProject(project: Project) { + if (isProjectDeleting(project.id)) return; + + const deleteImmediately = await canDeleteProjectWithoutConfirmation({ + branches: projectBranches.get(project.id) || [], + repoCount: repoCountsByProject.get(project.id) ?? (project.githubRepo ? 1 : 0), + hasUnpushedCommits: commands.hasUnpushedCommits, + onCheckError: (e) => console.error('Failed to check unpushed commits:', e), + }); + + if (deleteImmediately) { + await deleteProject(project); + } else { + projectToDelete = project; + } + } + + async function confirmDeleteProject() { + if (!projectToDelete) return; + await deleteProject(projectToDelete); + } + + async function deleteProject(project: Project) { + if (isProjectDeleting(project.id)) return; + + const id = project.id; + const name = projectDisplayName(project); + const branchesToClear = projectBranches.get(id) || []; + projectToDelete = null; + deletingProjectNames = new Map(deletingProjectNames).set(id, name); + + try { + await commands.deleteProject(id); + projectStateStore.markAsRead(id); + commands.invalidateProjectBranchTimelines(branchesToClear.map((branch) => branch.id)); + await loadProjects(); + } catch (e) { + console.error('Failed to delete project:', e); + const message = e instanceof Error ? e.message : String(e); + alerts.show({ + tone: 'error', + title: 'Unable to delete project', + message, + }); + } finally { + const next = new Map(deletingProjectNames); + next.delete(id); + deletingProjectNames = next; + } + } + function getProjectPrStatus( projectId: string ): 'merged' | 'open' | 'closed' | 'checks_failing' | 'conflict' | null { @@ -504,6 +578,8 @@ {reposByProject} {projectBranches} showAllProjectsRow={true} + onMarkProjectUnread={handleMarkProjectUnread} + onRemoveProject={handleRemoveProject} />
@@ -582,6 +658,8 @@ class="project-card" class:deleting={status.kind === 'deleting'} onclick={() => openProject(project.id)} + oncontextmenu={(event: MouseEvent) => + openProjectMenu(event, project, status.kind === 'deleting')} disabled={status.kind === 'deleting'} title={status.kind === 'deleting' ? 'Project deletion in progress' : undefined} > @@ -705,6 +783,28 @@ (showNewProjectModal = false)} /> {/if} +{#if projectMenu} + {@const menuProject = projectMenu.project} + handleMarkProjectUnread(menuProject)} + onRemoveProject={() => handleRemoveProject(menuProject)} + onClose={closeProjectMenu} + /> +{/if} + +{#if projectToDelete} + (projectToDelete = null)} + /> +{/if} +