feat(app): redirect to next workspace after archiving
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
const baseURL =
|
||||
process.env.E2E_BASE_URL ??
|
||||
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
globalSetup: "./e2e/global-setup.ts",
|
||||
testDir: './e2e',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
@@ -14,17 +13,17 @@ export default defineConfig({
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [["list"]],
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "Desktop Safari",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
name: 'Desktop Safari',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user