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:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -787,6 +787,7 @@ export const ar: TranslationResources = {
|
||||
toasts: {
|
||||
hostDisconnected: "Host غير متصل",
|
||||
removeFailed: "فشل في إزالة بعض مساحات العمل",
|
||||
updateHostToRemove: "قم بتحديث Host لإزالة المشاريع.",
|
||||
},
|
||||
empty: {
|
||||
title: "لا توجد مشاريع حتى الآن",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -806,6 +806,7 @@ export const ru: TranslationResources = {
|
||||
toasts: {
|
||||
hostDisconnected: "Host не подключен",
|
||||
removeFailed: "Не удалось удалить некоторые рабочие области.",
|
||||
updateHostToRemove: "Обновите host, чтобы удалять проекты.",
|
||||
},
|
||||
empty: {
|
||||
title: "Пока нет проектов",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user