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
77 changes: 74 additions & 3 deletions src/renderer/actions/threadActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useWorktreeDeleteStore } from "@/renderer/state/worktreeDeleteStore";
import {
archiveThread,
deleteThread,
requestDeleteThread,
openNewThread,
openThread,
reopenPaneThreadsIfInactive,
Expand Down Expand Up @@ -657,7 +658,9 @@ describe("threadActions", () => {
});
useAppStore.setState((state) => ({ ...state, threads: [thread] }));

deleteThread(thread.id, worktreePath, thread.projectId);
requestDeleteThread(thread.id, worktreePath, thread.projectId, {
anchorPosition: { x: 240, y: 120 },
});

expect(useAppStore.getState().threads).toHaveLength(1);
expect(bridge.closeThread).not.toHaveBeenCalled();
Expand All @@ -667,11 +670,80 @@ describe("threadActions", () => {
projectId: thread.projectId,
worktreePath,
worktreeBranch: "poracode/feature",
anchorPosition: { x: 240, y: 120 },
});
});

it("routes local worktree deletion through the group action", () => {
it("asks again for the legacy thread-only preference instead of deleting a worktree", () => {
localStorage.setItem("poracode-delete-worktree-pref", "thread-only");
const worktreePath = "/repo/.worktrees/feature";
const thread = makeThread({
worktreePath,
worktreeBranch: "poracode/feature",
});
useAppStore.setState((state) => ({ ...state, threads: [thread] }));

requestDeleteThread(thread.id, worktreePath, thread.projectId, {
anchorPosition: { x: 240, y: 120 },
});

expect(useAppStore.getState().threads).toHaveLength(1);
expect(deleteWorktreeGroup).not.toHaveBeenCalled();
expect(useWorktreeDeleteStore.getState().dialog?.kind).toBe("single-thread");
});

it("confirms a thread that has no worktree without naming one", () => {
const thread = makeThread({});
useAppStore.setState((state) => ({ ...state, threads: [thread] }));

requestDeleteThread(thread.id, undefined, thread.projectId, {
anchorPosition: { x: 240, y: 120 },
});

expect(useAppStore.getState().threads).toHaveLength(1);
expect(bridge.closeThread).not.toHaveBeenCalled();
expect(useWorktreeDeleteStore.getState().dialog).toEqual({
kind: "single-thread",
threadId: thread.id,
projectId: thread.projectId,
anchorPosition: { x: 240, y: 120 },
});
});

it("confirms a shared-worktree thread without promising to remove the worktree", () => {
const worktreePath = "/repo/.worktrees/feature";
const firstThread = makeThread({ id: "thread-a", worktreePath });
const secondThread = makeThread({ id: "thread-b", worktreePath });
useAppStore.setState((state) => ({ ...state, threads: [firstThread, secondThread] }));

requestDeleteThread(firstThread.id, worktreePath, firstThread.projectId, {
anchorPosition: { x: 240, y: 120 },
});

expect(useAppStore.getState().threads).toHaveLength(2);
expect(useWorktreeDeleteStore.getState().dialog).toEqual({
kind: "single-thread",
threadId: firstThread.id,
projectId: firstThread.projectId,
anchorPosition: { x: 240, y: 120 },
});
});

it("skips the confirmation entirely once the user opted out", () => {
localStorage.setItem("poracode-delete-worktree-pref", "thread-and-worktree");
const thread = makeThread({});
useAppStore.setState((state) => ({ ...state, threads: [thread] }));

requestDeleteThread(thread.id, undefined, thread.projectId, {
anchorPosition: { x: 240, y: 120 },
});

expect(useAppStore.getState().threads).toEqual([]);
expect(bridge.closeThread).toHaveBeenCalledWith({ threadId: thread.id });
expect(useWorktreeDeleteStore.getState().dialog).toBeNull();
});

it("routes local worktree deletion through the group action", () => {
const worktreePath = "/repo/.worktrees/feature";
const project = useAppStore.getState().addProject({
kind: "posix",
Expand All @@ -691,7 +763,6 @@ describe("threadActions", () => {
});

it("routes remote worktree deletion through the remote-aware group action", () => {
localStorage.setItem("poracode-delete-worktree-pref", "thread-and-worktree");
const worktreePath = "/repo/.worktrees/feature";
const localProject = useAppStore.getState().addProject({
kind: "posix",
Expand Down
89 changes: 61 additions & 28 deletions src/renderer/actions/threadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import {
} from "@/renderer/state/chatRuntimePersister";
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
import { useSidebarUiStore } from "@/renderer/state/sidebarUiStore";
import { shouldConfirmThreadDelete } from "@/renderer/state/threadDeletePreference";
import { getActiveWorkspaceId, getLastWorkspaceProjectId } from "@/renderer/state/workspaceStore";
import { useWorktreeDeleteStore } from "@/renderer/state/worktreeDeleteStore";
import { readWorktreeDeletePref } from "@/renderer/views/MainView/parts/Sidebar/parts/DeleteWorktreeDialog";
import { buildSidebarProjectRows } from "@/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows";
import { resolveWorktreeBranch } from "@/renderer/utils/gitHelpers";
import { closeThreads } from "@/renderer/utils/shellUtils";
Expand Down Expand Up @@ -627,48 +627,81 @@ function deleteThreadOnly(threadId: string): void {
.catch(() => undefined);
}

/** True when `threadId` is the only thread still using `worktreePath`. */
function ownsWorktreeAlone(threadId: string, worktreePath: string): boolean {
return !useAppStore
.getState()
.threads.some(
(candidate) => candidate.worktreePath === worktreePath && candidate.id !== threadId,
);
}

/**
* Deletes a thread, also removing its worktree directory when this was the last
* thread using it. Confirmation is the caller's job — see `requestDeleteThread`
* for the interactive entry point.
*/
export function deleteThread(threadId: string, worktreePath?: string, projectId?: string): void {
if (findExperimentByThreadId(threadId)) return;
if (!worktreePath) {
// No worktree, or siblings still use it — drop the thread and keep the directory.
if (!worktreePath || !ownsWorktreeAlone(threadId, worktreePath)) {
deleteThreadOnly(threadId);
return;
}

const allThreads = useAppStore.getState().threads;
const siblings = allThreads.filter((t) => t.worktreePath === worktreePath && t.id !== threadId);

// Other threads still use this worktree — delete the thread without offering worktree removal.
if (siblings.length > 0) {
deleteThreadOnly(threadId);
return;
}

const pref = readWorktreeDeletePref();
if (pref === "thread-only") {
deleteThreadOnly(threadId);
const project = useAppStore.getState().projects.find((p) => p.id === projectId);
if (!project) {
useAppStore.getState().deleteThread(threadId);
return;
}
deleteWorktreeGroup(project.id, worktreePath, [threadId]);
}

if (pref === "thread-and-worktree") {
const project = useAppStore.getState().projects.find((p) => p.id === projectId);
if (project) {
deleteWorktreeGroup(project.id, worktreePath, [threadId]);
return;
}
useAppStore.getState().deleteThread(threadId);
/**
* Sidebar-initiated delete: asks first unless the user turned confirmation off,
* then routes through `deleteThread`. Every thread is confirmed the same way,
* whether or not it owns a worktree — the popover only names the worktree when
* deleting this thread is what would take the directory with it.
*/
export function requestDeleteThread(
threadId: string,
worktreePath: string | undefined,
projectId: string | undefined,
options?: {
anchorPosition?: { x: number; y: number };
returnFocusElement?: HTMLElement;
},
): void {
if (findExperimentByThreadId(threadId)) return;
if (!shouldConfirmThreadDelete()) {
deleteThread(threadId, worktreePath, projectId);
return;
}

const thread = allThreads.find((t) => t.id === threadId);
const thread = useAppStore.getState().threads.find((candidate) => candidate.id === threadId);
// Named in the confirmation only when this delete is what removes the directory.
const worktreeToRemove =
worktreePath !== undefined &&
projectId !== undefined &&
ownsWorktreeAlone(threadId, worktreePath)
? {
worktreePath,
worktreeBranch:
resolveWorktreeBranch(projectId, worktreePath, thread?.worktreeBranch) ??
worktreePath.split(/[/\\]/).pop() ??
worktreePath,
}
: {};
useWorktreeDeleteStore.getState().setDialog({
kind: "single-thread",
threadId,
projectId: projectId!,
worktreePath,
worktreeBranch:
resolveWorktreeBranch(projectId!, worktreePath, thread?.worktreeBranch) ??
worktreePath.split(/[/\\]/).pop() ??
worktreePath,
...(projectId !== undefined ? { projectId } : {}),
...worktreeToRemove,
anchorPosition: options?.anchorPosition ?? {
x: window.innerWidth / 2,
y: window.innerHeight / 2,
},
...(options?.returnFocusElement ? { returnFocusElement: options.returnFocusElement } : {}),
});
}

Expand Down
95 changes: 95 additions & 0 deletions src/renderer/components/common/ConfirmationPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { ComponentProps, ReactNode } from "react";
import { Popover } from "@heroui/react";
import { useLingui } from "@lingui/react/macro";
import { Button } from "./Button";

type ButtonVariant = ComponentProps<typeof Button>["variant"];
type PopoverPlacement = ComponentProps<typeof Popover.Content>["placement"];

export interface ConfirmationPopoverAction {
label: string;
variant?: ButtonVariant;
className?: string;
onPress: () => void;
}

type ConfirmationPopoverProps = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
title: string;
body: ReactNode;
actions: readonly ConfirmationPopoverAction[];
placement?: PopoverPlacement;
className?: string;
children?: ReactNode;
returnFocusElement?: HTMLElement | null;
} & (
| { trigger: ReactNode; anchorPosition?: never }
| { trigger?: never; anchorPosition: { x: number; y: number } }
);

export function ConfirmationPopover(props: ConfirmationPopoverProps) {
const { t } = useLingui();
const handleOpenChange = (isOpen: boolean) => {
props.onOpenChange(isOpen);
if (!isOpen && props.returnFocusElement?.isConnected) {
requestAnimationFrame(() => props.returnFocusElement?.focus({ preventScroll: true }));
}
};
const trigger =
props.trigger ??
(props.anchorPosition ? (
<Button
isIconOnly
isDisabled
size="sm"
variant="ghost"
aria-label={props.title}
className="size-px min-w-0 p-0 opacity-0"
/>
) : null);
const popover = (
<Popover isOpen={props.isOpen} onOpenChange={handleOpenChange}>
{trigger}
<Popover.Content
placement={props.placement ?? "top end"}
className={props.className ?? "w-64"}
>
<Popover.Dialog className="p-3 normal-case tracking-normal">
<Popover.Arrow />
<Popover.Heading className="text-sm font-medium text-foreground">
{props.title}
</Popover.Heading>
<div className="mt-1 text-xs font-normal text-muted">{props.body}</div>
{props.children ? <div className="mt-3">{props.children}</div> : null}
<div className="mt-3 flex flex-wrap justify-end gap-2">
<Button size="sm" variant="ghost" onPress={() => handleOpenChange(false)}>
{t`Cancel`}
</Button>
{props.actions.map((action) => (
<Button
key={action.label}
size="sm"
variant={action.variant ?? "primary"}
{...(action.className ? { className: action.className } : {})}
onPress={action.onPress}
>
{action.label}
</Button>
))}
</div>
</Popover.Dialog>
</Popover.Content>
</Popover>
);

if (!props.anchorPosition) return popover;
return (
<div
className="fixed z-50 size-0"
style={{ left: props.anchorPosition.x, top: props.anchorPosition.y }}
>
{popover}
</div>
);
}
19 changes: 15 additions & 4 deletions src/renderer/components/common/ContextMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,14 @@ describe("ContextMenu", () => {
});

it("dispatches an item's trailing action without dispatching the row", async () => {
const onAction = vi.fn<(key: string) => void>();
const onAction =
vi.fn<
(
key: string,
anchorPosition?: { x: number; y: number },
returnFocusElement?: HTMLElement,
) => void
>();
render(
<ContextMenu
items={[
Expand All @@ -60,15 +67,19 @@ describe("ContextMenu", () => {
</ContextMenu>,
);

fireEvent.contextMenu(screen.getByRole("button", { name: "Row" }));
const row = screen.getByRole("button", { name: "Row" });
fireEvent.contextMenu(row, {
clientX: 240,
clientY: 120,
});
const stopButton = await screen.findByRole("button", { name: "Stop Run" });
expect(stopButton).toHaveClass("[--button-bg-hover:var(--row-hover)]");
fireEvent.pointerDown(stopButton, { pointerId: 1, pointerType: "mouse", button: 0 });
fireEvent.pointerUp(stopButton, { pointerId: 1, pointerType: "mouse", button: 0 });
fireEvent.click(stopButton);

expect(onAction).toHaveBeenCalledWith("stop");
expect(onAction).not.toHaveBeenCalledWith("run");
expect(onAction).toHaveBeenCalledWith("stop", { x: 240, y: 120 }, row);
expect(onAction).not.toHaveBeenCalledWith("run", expect.anything());
await vi.waitFor(() => {
expect(screen.queryByRole("menuitem", { name: "Run" })).not.toBeInTheDocument();
});
Expand Down
Loading