Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions apps/staged/src/lib/features/projects/ProjectContextMenu.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { Mail, Trash2 } from 'lucide-svelte';

interface Props {
x: number;
y: number;
onMarkAsUnread: () => void;
onRemoveProject: () => void;
onClose: () => void;
}

let { x, y, onMarkAsUnread, onRemoveProject, onClose }: Props = $props();

let menuEl = $state<HTMLDivElement | null>(null);
let left = $state(0);
let top = $state(0);
let positioned = $state(false);
const viewportPadding = 8;

async function placeMenu() {
positioned = false;
left = x;
top = y;
await tick();
if (!menuEl) return;

const rect = menuEl.getBoundingClientRect();
left = Math.max(viewportPadding, Math.min(x, window.innerWidth - rect.width - viewportPadding));
top = Math.max(
viewportPadding,
Math.min(y, window.innerHeight - rect.height - viewportPadding)
);
positioned = true;
await tick();
focusItem(0);
}

function handlePointerDown(event: PointerEvent) {
if (menuEl?.contains(event.target as Node)) return;
onClose();
}

function getMenuItems(): HTMLButtonElement[] {
if (!menuEl) return [];
return Array.from(menuEl.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'));
}

function focusItem(index: number) {
const items = getMenuItems();
if (items.length === 0) return;
const clamped = Math.max(0, Math.min(index, items.length - 1));
items[clamped].focus();
}

function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}

const items = getMenuItems();
if (items.length === 0) return;
const active = document.activeElement as HTMLElement;
const currentIndex = items.indexOf(active as HTMLButtonElement);

switch (event.key) {
case 'ArrowDown':
event.preventDefault();
focusItem(currentIndex < 0 ? 0 : (currentIndex + 1) % items.length);
break;
case 'ArrowUp':
event.preventDefault();
focusItem(
currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length
);
break;
case 'Home':
event.preventDefault();
focusItem(0);
break;
case 'End':
event.preventDefault();
focusItem(items.length - 1);
break;
case 'Tab':
// Trap focus within menu; close instead of tabbing out
event.preventDefault();
onClose();
break;
}
}

function handleMarkAsUnread() {
onMarkAsUnread();
onClose();
}

function handleRemoveProject() {
onRemoveProject();
onClose();
}

onMount(() => {
void placeMenu();
window.addEventListener('pointerdown', handlePointerDown, true);
window.addEventListener('keydown', handleKeydown, true);
window.addEventListener('scroll', onClose, true);
window.addEventListener('resize', onClose);

return () => {
window.removeEventListener('pointerdown', handlePointerDown, true);
window.removeEventListener('keydown', handleKeydown, true);
window.removeEventListener('scroll', onClose, true);
window.removeEventListener('resize', onClose);
};
});

$effect(() => {
x;
y;
void placeMenu();
});
</script>

<div
class="project-context-menu"
role="menu"
aria-label="Project actions"
style:left={`${left}px`}
style:top={`${top}px`}
style:opacity={positioned ? '1' : '0'}
bind:this={menuEl}
>
<button
type="button"
class="menu-item"
role="menuitem"
tabindex="-1"
onclick={handleMarkAsUnread}
>
<Mail size={14} />
Mark as Unread
</button>
<button
type="button"
class="menu-item danger"
role="menuitem"
tabindex="-1"
onclick={handleRemoveProject}
>
<Trash2 size={14} />
Remove Project
</button>
</div>

<style>
.project-context-menu {
position: fixed;
z-index: 1100;
min-width: 172px;
padding: 4px;
border: 1px solid var(--border-muted);
border-radius: 8px;
background: var(--bg-elevated);
box-shadow: var(--shadow-elevated);
}

.menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 30px;
padding: 6px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
font-size: var(--size-sm);
font-weight: 500;
text-align: left;
cursor: pointer;
white-space: nowrap;
}

.menu-item:hover,
.menu-item:focus-visible {
background: var(--bg-hover);
outline: none;
}

.menu-item :global(svg) {
color: var(--text-muted);
flex-shrink: 0;
}

.menu-item.danger {
color: var(--ui-danger);
}

.menu-item.danger :global(svg) {
color: var(--ui-danger);
}

.menu-item.danger:hover,
.menu-item.danger:focus-visible {
background: var(--ui-danger-bg);
}
</style>
74 changes: 23 additions & 51 deletions apps/staged/src/lib/features/projects/ProjectHome.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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];
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -690,6 +660,8 @@
{reposByProject}
projectBranches={branchesByProject}
showAllProjectsRow={true}
onMarkProjectUnread={handleMarkProjectUnread}
onRemoveProject={handleDeleteProjectRequest}
/>

<div class="main-panel" class:no-pad={!loading && !hasContent}>
Expand Down
Loading