Skip to content
Draft
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
3 changes: 2 additions & 1 deletion apps/mobile/src/features/home/thread-swipe-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ interface ThreadSwipeAction {
readonly title?: string;
};
readonly onPress: () => void;
readonly tone?: "primary" | "secondary" | "danger";
}

interface ThreadSwipeSecondaryAction extends ThreadSwipeAction {
Expand Down Expand Up @@ -712,7 +713,7 @@ export function ThreadSwipeActions(props: {
<SwipeActionButton
accessibilityLabel={props.primaryAction.accessibilityLabel}
actionsWidth={actionsWidth}
tone="primary"
tone={props.primaryAction.tone ?? "primary"}
compact={props.compact}
entryRange={
secondaryAction === null
Expand Down
57 changes: 57 additions & 0 deletions apps/mobile/src/features/projects/remove-project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { EnvironmentId, ProjectId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { buildRemoveProjectsConfirmation } from "./remove-project";

const member = (id: string, overrides?: { readonly environmentLabel?: string | null }) => ({
environmentId: EnvironmentId.make(`environment-${id}`),
id: ProjectId.make(`project-${id}`),
title: `Project ${id}`,
workspaceRoot: `/home/user/${id}`,
...overrides,
});

describe("buildRemoveProjectsConfirmation", () => {
it("describes a single project with its path, environment, and thread count", () => {
const confirmation = buildRemoveProjectsConfirmation({
members: [member("a", { environmentLabel: "Mac mini" })],
groupTitle: "Group",
threadCount: 3,
});

expect(confirmation.title).toBe("Remove project “Project a”?");
expect(confirmation.confirmText).toBe("Remove");
expect(confirmation.message).toBe(
[
"This deletes its 3 threads and permanently clears their conversation history, including archived threads.",
"Path: /home/user/a",
"Environment: Mac mini",
"Only the project entry is removed. Files on disk are not touched.",
"This action cannot be undone.",
].join("\n"),
);
});

it("uses singular thread copy and skips the environment line when unknown", () => {
const confirmation = buildRemoveProjectsConfirmation({
members: [member("a")],
groupTitle: "Group",
threadCount: 1,
});

expect(confirmation.message).toContain("This deletes its 1 thread and");
expect(confirmation.message).not.toContain("Environment:");
});

it("names the group and counts entries when several checkouts go at once", () => {
const confirmation = buildRemoveProjectsConfirmation({
members: [member("a"), member("b")],
groupTitle: "shared-repo",
threadCount: 0,
});

expect(confirmation.title).toBe("Remove project “shared-repo”?");
expect(confirmation.message).toContain("This removes 2 grouped project entries.");
expect(confirmation.message).not.toContain("Path:");
});
});
44 changes: 44 additions & 0 deletions apps/mobile/src/features/projects/remove-project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";

export interface RemoveProjectsConfirmation {
readonly title: string;
readonly message: string;
readonly confirmText: string;
}

export interface RemoveProjectsMember extends Pick<
EnvironmentProject,
"environmentId" | "id" | "title" | "workspaceRoot"
> {
readonly environmentLabel?: string | null;
}

/** Copy for the destructive confirmation before removing a grouped project. */
export function buildRemoveProjectsConfirmation(input: {
readonly members: ReadonlyArray<RemoveProjectsMember>;
readonly groupTitle: string;
readonly threadCount: number;
}): RemoveProjectsConfirmation {
const singleMember = input.members.length === 1 ? input.members[0]! : null;
const targetLabel = singleMember?.title ?? input.groupTitle;
const lines = [
input.threadCount > 0
? `This deletes its ${input.threadCount} thread${input.threadCount === 1 ? "" : "s"} and permanently clears their conversation history, including archived threads.`
: "This permanently clears any archived conversation history.",
...(singleMember
? [
`Path: ${singleMember.workspaceRoot}`,
...(singleMember.environmentLabel
? [`Environment: ${singleMember.environmentLabel}`]
: []),
]
: [`This removes ${input.members.length} grouped project entries.`]),
"Only the project entry is removed. Files on disk are not touched.",
"This action cannot be undone.",
];
return {
title: `Remove project “${targetLabel}”?`,
message: lines.join("\n"),
confirmText: "Remove",
};
}
97 changes: 97 additions & 0 deletions apps/mobile/src/features/projects/useConfirmRemoveProjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback } from "react";
import { Alert } from "react-native";

import { showConfirmDialog } from "../../components/ConfirmDialogHost";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { useThreadShells } from "../../state/entities";
import { projectEnvironment } from "../../state/projects";
import { useAtomCommand } from "../../state/use-atom-command";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { buildRemoveProjectsConfirmation, type RemoveProjectsConfirmation } from "./remove-project";

function confirmRemoval(confirmation: RemoveProjectsConfirmation): Promise<boolean> {
return new Promise((resolve) => {
if (process.env.EXPO_OS === "ios") {
Alert.alert(
confirmation.title,
confirmation.message,
[
{ text: "Cancel", style: "cancel", onPress: () => resolve(false) },
{ text: confirmation.confirmText, style: "destructive", onPress: () => resolve(true) },
],
{ onDismiss: () => resolve(false) },
);
return;
}
showConfirmDialog({
title: confirmation.title,
message: confirmation.message,
confirmText: confirmation.confirmText,
destructive: true,
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
});
}

/**
* Confirms and removes project entries from their environments. The server
* deletes the projects' threads along the way (`force`), so the confirmation
* spells out how many threads go with them. Every confirmed member is
* attempted even if one fails, so a transient failure does not strand later
* entries. Resolves true once every member is gone.
*/
export function useConfirmRemoveProjects(): (
members: ReadonlyArray<EnvironmentProject>,
options: { readonly groupTitle: string },
) => Promise<boolean> {
const threads = useThreadShells();
const { savedConnectionsById } = useSavedRemoteConnections();
const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });

return useCallback(
async (members, options) => {
if (members.length === 0) return false;
const memberKeys = new Set(
members.map((member) => scopedProjectKey(member.environmentId, member.id)),
);
const threadCount = threads.filter((thread) =>
memberKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)),
).length;
const confirmed = await confirmRemoval(
buildRemoveProjectsConfirmation({
members: members.map((member) => ({
...member,
environmentLabel: savedConnectionsById[member.environmentId]?.environmentLabel ?? null,
})),
groupTitle: options.groupTitle,
threadCount,
}),
);
if (!confirmed) return false;

const failures: string[] = [];
for (const member of members) {
const result = await deleteProject({
environmentId: member.environmentId,
input: { projectId: member.id, force: true },
});
if (AsyncResult.isFailure(result)) {
const error = Cause.squash(result.cause);
failures.push(
`${member.workspaceRoot}: ${error instanceof Error ? error.message : "An error occurred."}`,
);
}
}
if (failures.length > 0) {
Alert.alert("Some project entries could not be removed", failures.join("\n"));
return false;
}
return true;
},
[deleteProject, savedConnectionsById, threads],
);
}
Loading
Loading