feat(app): redirect to next workspace after archiving

This commit is contained in:
Mohamed Boudra
2026-03-20 13:08:28 +07:00
parent 70fb5f5bfd
commit 77fb74c188
5 changed files with 427 additions and 15 deletions
@@ -73,6 +73,7 @@ import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model'
import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store'
import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store'
import { createNameId } from 'mnemonic-id'
import { buildWorkspaceArchiveRedirectRoute } from '@/utils/workspace-archive-navigation'
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -85,6 +86,7 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) =>
workspace.workspaceKey
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey
const EMPTY_WORKSPACES = new Map()
interface SidebarWorkspaceListProps {
projects: SidebarProjectEntry[]
@@ -789,7 +791,11 @@ function WorkspaceRowWithMenu({
isCreating?: boolean
}) {
const toast = useToast()
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection()
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
const sessionWorkspaces = useSessionStore(
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES
)
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false)
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({
@@ -800,6 +806,27 @@ function WorkspaceRowWithMenu({
)
const isWorktree = workspace.workspaceKind === 'worktree'
const isArchiving = isWorktree ? archiveStatus === 'pending' : isArchivingWorkspace
const redirectAfterArchive = useCallback(() => {
if (
activeWorkspaceSelection?.serverId !== workspace.serverId ||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
) {
return
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(),
}) as any
)
}, [
activeWorkspaceSelection,
sessionWorkspaces,
workspace.serverId,
workspace.workspaceId,
])
const handleArchiveWorktree = useCallback(() => {
if (isArchiving) {
@@ -823,12 +850,22 @@ function WorkspaceRowWithMenu({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
worktreePath: workspace.workspaceId,
}).then(() => {
redirectAfterArchive()
}).catch((error) => {
const message = error instanceof Error ? error.message : 'Failed to archive worktree'
toast.error(message)
})
})()
}, [archiveWorktree, isArchiving, toast, workspace.name, workspace.serverId, workspace.workspaceId])
}, [
archiveWorktree,
isArchiving,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
])
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
@@ -859,13 +896,21 @@ function WorkspaceRowWithMenu({
if (payload.error) {
throw new Error(payload.error)
}
redirectAfterArchive()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to hide workspace')
} finally {
setIsArchivingWorkspace(false)
}
})()
}, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId])
}, [
isArchivingWorkspace,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
])
const handleCopyPath = useCallback(() => {
void Clipboard.setStringAsync(workspace.workspaceId)
@@ -927,7 +972,32 @@ function NonGitProjectRowWithMenuContent({
}) {
const toast = useToast()
const contextMenu = useContextMenu()
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection()
const sessionWorkspaces = useSessionStore(
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES
)
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false)
const redirectAfterArchive = useCallback(() => {
if (
activeWorkspaceSelection?.serverId !== workspace.serverId ||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
) {
return
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(),
}) as any
)
}, [
activeWorkspaceSelection,
sessionWorkspaces,
workspace.serverId,
workspace.workspaceId,
])
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
@@ -958,13 +1028,21 @@ function NonGitProjectRowWithMenuContent({
if (payload.error) {
throw new Error(payload.error)
}
redirectAfterArchive()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to hide workspace')
} finally {
setIsArchivingWorkspace(false)
}
})()
}, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId])
}, [
isArchivingWorkspace,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
])
return (
<>
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
buildWorkspaceArchiveRedirectRoute,
resolveWorkspaceArchiveRedirectWorkspaceId,
} from "@/utils/workspace-archive-navigation";
import type { WorkspaceDescriptor } from "@/stores/session-store";
function workspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project",
projectRootPath: input.projectRootPath ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,
status: input.status ?? "done",
activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null,
};
}
describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
it("redirects an archived worktree to the visible local checkout for the same project", () => {
const workspaces = [
workspace({ id: "/repo", workspaceKind: "local_checkout", name: "main" }),
workspace({ id: "/repo/.paseo/worktrees/feature", name: "feature" }),
];
expect(
resolveWorkspaceArchiveRedirectWorkspaceId({
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
workspaces,
})
).toBe("/repo");
});
it("falls back to the project root path when the root checkout is not in the visible workspace list", () => {
const workspaces = [
workspace({
id: "/repo/.paseo/worktrees/feature",
name: "feature",
projectRootPath: "/repo",
}),
];
expect(
resolveWorkspaceArchiveRedirectWorkspaceId({
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
workspaces,
})
).toBe("/repo");
});
it("falls back to the host root route when no alternate workspace target exists", () => {
const workspaces = [
workspace({
id: "/notes",
projectId: "notes",
projectRootPath: "/notes",
projectKind: "non_git",
workspaceKind: "directory",
}),
];
expect(
buildWorkspaceArchiveRedirectRoute({
serverId: "server-1",
archivedWorkspaceId: "/notes",
workspaces,
})
).toBe("/h/server-1");
});
});
@@ -0,0 +1,67 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function resolveWorkspaceArchiveRedirectWorkspaceId(input: {
archivedWorkspaceId: string;
workspaces: Iterable<WorkspaceDescriptor>;
}): string | null {
const archivedWorkspaceId = trimNonEmpty(input.archivedWorkspaceId);
if (!archivedWorkspaceId) {
return null;
}
const workspaces = Array.from(input.workspaces);
const archivedWorkspace =
workspaces.find((workspace) => workspace.id === archivedWorkspaceId) ?? null;
if (!archivedWorkspace) {
return null;
}
const sameProjectWorkspaces = workspaces.filter(
(workspace) => workspace.projectId === archivedWorkspace.projectId
);
const rootCheckoutWorkspace =
sameProjectWorkspaces.find(
(workspace) =>
workspace.workspaceKind === "local_checkout" &&
workspace.id !== archivedWorkspace.id
) ?? null;
if (rootCheckoutWorkspace) {
return rootCheckoutWorkspace.id;
}
const fallbackProjectRootPath = trimNonEmpty(archivedWorkspace.projectRootPath);
if (fallbackProjectRootPath && fallbackProjectRootPath !== archivedWorkspace.id) {
return fallbackProjectRootPath;
}
const siblingWorkspace =
sameProjectWorkspaces.find((workspace) => workspace.id !== archivedWorkspace.id) ?? null;
return siblingWorkspace?.id ?? null;
}
export function buildWorkspaceArchiveRedirectRoute(input: {
serverId: string;
archivedWorkspaceId: string;
workspaces: Iterable<WorkspaceDescriptor>;
}): string {
const redirectWorkspaceId = resolveWorkspaceArchiveRedirectWorkspaceId({
archivedWorkspaceId: input.archivedWorkspaceId,
workspaces: input.workspaces,
});
if (!redirectWorkspaceId) {
return buildHostRootRoute(input.serverId);
}
return buildHostWorkspaceRoute(input.serverId, redirectWorkspaceId);
}