Fix sidebar project removal

Remove projects through a daemon RPC so the sidebar can delete the project
record after archiving its active workspaces. Reopening a removed directory
recreates the missing project record so local projects keep their derived
basename, and History skips orphaned archived workspace rows.

Fixes #1583.
This commit is contained in:
Mohamed Boudra
2026-06-19 00:46:40 +07:00
parent f876b80f7a
commit 9a09ccb3d6
20 changed files with 736 additions and 29 deletions
@@ -69,6 +69,7 @@ import {
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSessionStore } from "@/stores/session-store";
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
import {
@@ -113,8 +114,6 @@ import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { archiveWorkspacesOptimistically } from "@/workspace/workspace-archive";
import { selectProjectWorkspacesToArchive } from "@/workspace/project-workspace-archive";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import {
isWeb as platformIsWeb,
@@ -1785,6 +1784,7 @@ function ProjectBlock({
displayName,
iconDataUri,
serverId,
canRemoveProject,
selectionEnabled,
showShortcutBadges,
shortcutIndexByWorkspaceKey,
@@ -1805,6 +1805,7 @@ function ProjectBlock({
displayName: string;
iconDataUri: string | null;
serverId: string | null;
canRemoveProject: boolean;
selectionEnabled: boolean;
showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>;
@@ -1923,26 +1924,24 @@ function ProjectBlock({
toast.error(t("sidebar.project.toasts.hostDisconnected"));
return;
}
if (!canRemoveProject) {
toast.error(t("sidebar.project.toasts.updateHostToRemove"));
return;
}
setIsRemovingProject(true);
void selectProjectWorkspacesToArchive(project.workspaces)
.then((workspaces) => archiveWorkspacesOptimistically({ client, workspaces }))
.then((failures) => {
if (failures.length > 0) {
toast.error(t("sidebar.project.toasts.removeFailed"));
}
setIsRemovingProject(false);
return;
})
void client
.removeProject(project.projectKey)
.catch((error) => {
toast.error(
error instanceof Error ? error.message : t("sidebar.project.toasts.removeFailed"),
);
})
.finally(() => {
setIsRemovingProject(false);
return;
});
})();
}, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]);
}, [isRemovingProject, serverId, displayName, t, toast, project.projectKey, canRemoveProject]);
const handleToggleCollapsed = useCallback(() => {
onToggleCollapsed(project.projectKey);
@@ -2000,6 +1999,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.displayName === next.displayName &&
previous.iconDataUri === next.iconDataUri &&
previous.serverId === next.serverId &&
previous.canRemoveProject === next.canRemoveProject &&
previous.selectionEnabled === next.selectionEnabled &&
previous.showShortcutBadges === next.showShortcutBadges &&
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
@@ -2145,6 +2145,9 @@ function ProjectModeList({
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
const canRemoveProject = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.serverInfo?.features?.projectRemove === true : false,
);
const isWorkspaceRoute = useMemo(
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
@@ -2309,6 +2312,7 @@ function ProjectModeList({
displayName={item.projectName}
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
serverId={serverId}
canRemoveProject={canRemoveProject}
selectionEnabled={selectionEnabled}
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
@@ -2335,6 +2339,7 @@ function ProjectModeList({
onToggleProjectCollapsed,
parentGestureRef,
projectIconByProjectKey,
canRemoveProject,
selectionEnabled,
serverId,
shortcutIndexByWorkspaceKey,
@@ -531,6 +531,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
const setEmptyProjects = useSessionStore((state) => state.setEmptyProjects);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const removeEmptyProject = useSessionStore((state) => state.removeEmptyProject);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const removeWorkspace = useSessionStore((state) => state.removeWorkspace);
const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity);
@@ -1348,6 +1349,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (message.payload.emptyProject) {
addEmptyProject(serverId, normalizeEmptyProjectDescriptor(message.payload.emptyProject));
}
if (message.payload.removedProjectId) {
removeEmptyProject(serverId, message.payload.removedProjectId);
}
return;
}
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
@@ -1771,6 +1775,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
removeWorkspace,
removeWorkspaceSetup,
addEmptyProject,
removeEmptyProject,
setAgentLastActivity,
setPendingPermissions,
setHasHydratedAgents,
+3
View File
@@ -249,6 +249,9 @@ describe("translation resources", () => {
it("includes sidebar and workspace creation keys for the Batch 4C migration", () => {
expect(en.sidebar.workspace.actions.copyPath).toBe("Copy path");
expect(en.sidebar.project.confirmations.removeTitle).toBe("Remove project?");
expect(en.sidebar.project.toasts.updateHostToRemove).toBe(
"Update the host to remove projects.",
);
expect(en.newWorkspace.title).toBe("New workspace");
expect(en.newWorkspace.refPicker.searchPlaceholder).toBe("Search branches and PRs");
expect(en.openProject.tiles.addProject.title).toBe("Add a project");
+1
View File
@@ -787,6 +787,7 @@ export const ar: TranslationResources = {
toasts: {
hostDisconnected: "Host غير متصل",
removeFailed: "فشل في إزالة بعض مساحات العمل",
updateHostToRemove: "قم بتحديث Host لإزالة المشاريع.",
},
empty: {
title: "لا توجد مشاريع حتى الآن",
+1
View File
@@ -794,6 +794,7 @@ export const en = {
toasts: {
hostDisconnected: "Host is not connected",
removeFailed: "Failed to remove some workspaces",
updateHostToRemove: "Update the host to remove projects.",
},
empty: {
title: "No projects yet",
+1
View File
@@ -814,6 +814,7 @@ export const es: TranslationResources = {
toasts: {
hostDisconnected: "Hostno está conectado",
removeFailed: "No se pudieron eliminar algunos espacios de trabajo",
updateHostToRemove: "Actualiza el host para eliminar proyectos.",
},
empty: {
title: "Aún no hay proyectos",
+1
View File
@@ -813,6 +813,7 @@ export const fr: TranslationResources = {
toasts: {
hostDisconnected: "Hostn'est pas connecté",
removeFailed: "Échec de la suppression de certains espaces de travail",
updateHostToRemove: "Mettez à jour le host pour supprimer des projets.",
},
empty: {
title: "Aucun projet pour l'instant",
+1
View File
@@ -806,6 +806,7 @@ export const ru: TranslationResources = {
toasts: {
hostDisconnected: "Host не подключен",
removeFailed: "Не удалось удалить некоторые рабочие области.",
updateHostToRemove: "Обновите host, чтобы удалять проекты.",
},
empty: {
title: "Пока нет проектов",
+1
View File
@@ -779,6 +779,7 @@ export const zhCN: TranslationResources = {
toasts: {
hostDisconnected: "Host 未连接",
removeFailed: "部分 workspace 移除失败",
updateHostToRemove: "更新 host 以移除 projects。",
},
empty: {
title: "还没有 projects",
@@ -48,6 +48,7 @@ function getTestSessionReferences() {
sessions: state.sessions,
session,
workspaces: session.workspaces,
emptyProjects: session.emptyProjects,
};
}
@@ -448,6 +449,48 @@ describe("removeWorkspace", () => {
});
});
describe("removeEmptyProject", () => {
it("removes an empty project by project id", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setEmptyProjects("test-server", [
{
projectId: "project-empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/repo/empty",
projectKind: "git",
},
]);
store.removeEmptyProject("test-server", "project-empty");
expect(getTestSessionReferences().emptyProjects.has("project-empty")).toBe(false);
});
it("preserves identity when removing a missing empty project", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setEmptyProjects("test-server", [
{
projectId: "project-empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/repo/empty",
projectKind: "git",
},
]);
const before = getTestSessionReferences();
store.removeEmptyProject("test-server", "project-missing");
const after = getTestSessionReferences();
expect(after.sessions).toBe(before.sessions);
expect(after.session).toBe(before.session);
expect(after.emptyProjects).toBe(before.emptyProjects);
});
});
describe("patchWorkspaceScripts", () => {
it("preserves workspace entry identity when scripts are content-equal", () => {
const script = {
+19
View File
@@ -460,6 +460,7 @@ interface SessionStoreActions {
removeWorkspace: (serverId: string, workspaceId: string) => void;
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
removeEmptyProject: (serverId: string, projectId: string) => void;
setWorkspaceRestoreStatus: (
serverId: string,
workspaceId: string,
@@ -1265,6 +1266,24 @@ export const useSessionStore = create<SessionStore>()(
});
},
removeEmptyProject: (serverId, projectId) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session?.emptyProjects.has(projectId)) {
return prev;
}
const next = new Map(session.emptyProjects);
next.delete(projectId);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, emptyProjects: next },
},
};
});
},
setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
set((prev) => {
const session = prev.sessions[serverId];