Archive any workspace from the app (#2003)
* feat(app): expose archive for every workspace Workspace actions now archive one workspace by id. The daemon remains responsible for backing-specific cleanup, including removing a managed worktree only after its final workspace is archived. * refactor(app): clarify archive descriptor name * fix(app): preserve workspace archive safety * fix(app): allow archive without upstream * refactor(app): remove stale archive callback * test(app): cover workspace archive ownership
This commit is contained in:
@@ -48,7 +48,7 @@ function GitActionMenuItem({
|
||||
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
|
||||
const trailing = useMemo(
|
||||
() =>
|
||||
action.id === "archive-worktree" && archiveShortcutKeys ? (
|
||||
action.id === "archive-workspace" && archiveShortcutKeys ? (
|
||||
<Shortcut chord={archiveShortcutKeys} />
|
||||
) : undefined,
|
||||
[action.id, archiveShortcutKeys],
|
||||
@@ -58,7 +58,7 @@ function GitActionMenuItem({
|
||||
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
testID={
|
||||
action.id === "archive-worktree"
|
||||
action.id === "archive-workspace"
|
||||
? "workspace-archive-action"
|
||||
: `changes-menu-${action.id}`
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
|
||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||
if (action.status === "pending") return action.pendingLabel;
|
||||
|
||||
@@ -2,16 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
__resetCheckoutGitActionsStoreForTests,
|
||||
isLocalWorktreeArchivePending,
|
||||
useCheckoutGitActionsStore,
|
||||
} from "@/git/actions-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
isWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
@@ -31,33 +25,13 @@ function createDeferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">) {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project",
|
||||
projectRootPath: input.projectRootPath ?? "/tmp/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/tmp/repo/worktrees/feature",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "worktree",
|
||||
name: input.name ?? input.id,
|
||||
status: input.status ?? "done",
|
||||
archivingAt: input.archivingAt ?? null,
|
||||
statusEnteredAt: null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
} satisfies WorkspaceDescriptor;
|
||||
}
|
||||
|
||||
describe("checkout-git-actions-store", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/tmp/repo/worktrees/feature";
|
||||
const workspaceId = "ws-feature";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -65,7 +39,6 @@ describe("checkout-git-actions-store", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -294,118 +267,4 @@ describe("checkout-git-actions-store", () => {
|
||||
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
appQueryClient.setQueryData(
|
||||
["sidebarPaseoWorktreeList", serverId, "/tmp"],
|
||||
[{ worktreePath: cwd }, { worktreePath: "/tmp/other" }],
|
||||
);
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId });
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
|
||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
|
||||
{ worktreePath: "/tmp/other" },
|
||||
]);
|
||||
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
|
||||
|
||||
deferred.resolve({});
|
||||
await archive;
|
||||
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("archives on the server even when its workspace cannot be resolved", async () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(async () => ({})),
|
||||
};
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
|
||||
await useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
// The server archive is keyed by worktreePath and must run regardless.
|
||||
expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd });
|
||||
// The optimistic client-side mark is never keyed by the path.
|
||||
expect(isWorkspaceArchivePending({ serverId, workspaceId: cwd })).toBe(false);
|
||||
});
|
||||
|
||||
it("restores an optimistically hidden worktree when archive fails", async () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }];
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
|
||||
|
||||
await expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId }),
|
||||
).rejects.toThrow("archive failed");
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(
|
||||
featureWorkspace,
|
||||
);
|
||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual(
|
||||
listSnapshot,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports local archive pending only while the archive action is in flight", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
|
||||
|
||||
deferred.resolve({});
|
||||
await archive;
|
||||
|
||||
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { create } from "zustand";
|
||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -36,8 +24,7 @@ export type CheckoutGitAsyncActionId =
|
||||
| "enable-pr-auto-merge-rebase"
|
||||
| "disable-pr-auto-merge"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
| "merge-from-base";
|
||||
|
||||
type CheckoutKey = string;
|
||||
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
|
||||
@@ -89,119 +76,6 @@ function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
|
||||
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
|
||||
}
|
||||
|
||||
function invalidateWorktreeList() {
|
||||
void appQueryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
|
||||
});
|
||||
void appQueryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList",
|
||||
});
|
||||
}
|
||||
|
||||
function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const worktreePath = input.worktreePath.trim();
|
||||
if (!serverId || !worktreePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removeFromList = (current: unknown) => {
|
||||
if (!Array.isArray(current)) {
|
||||
return current;
|
||||
}
|
||||
const filtered = current.filter((entry) => entry?.worktreePath !== worktreePath);
|
||||
return filtered.length === current.length ? current : filtered;
|
||||
};
|
||||
|
||||
appQueryClient.setQueriesData(
|
||||
{
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) &&
|
||||
query.queryKey[0] === "paseoWorktreeList" &&
|
||||
query.queryKey[1] === serverId,
|
||||
},
|
||||
removeFromList,
|
||||
);
|
||||
|
||||
appQueryClient.setQueriesData(
|
||||
{
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) &&
|
||||
query.queryKey[0] === "sidebarPaseoWorktreeList" &&
|
||||
query.queryKey[1] === serverId,
|
||||
},
|
||||
removeFromList,
|
||||
);
|
||||
}
|
||||
|
||||
interface WorktreeArchiveSnapshot {
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
worktreeLists: Array<[QueryKey, unknown]>;
|
||||
}
|
||||
|
||||
function isWorktreeListQuery(input: { queryKey: QueryKey; serverId: string }): boolean {
|
||||
return (
|
||||
Array.isArray(input.queryKey) &&
|
||||
(input.queryKey[0] === "paseoWorktreeList" ||
|
||||
input.queryKey[0] === "sidebarPaseoWorktreeList") &&
|
||||
input.queryKey[1] === input.serverId
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotWorktreeArchiveState(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | undefined;
|
||||
}): WorktreeArchiveSnapshot {
|
||||
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
|
||||
const workspaceKey = input.workspaceId
|
||||
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: input.workspaceId })
|
||||
: null;
|
||||
return {
|
||||
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
|
||||
worktreeLists: appQueryClient.getQueriesData({
|
||||
predicate: (query) =>
|
||||
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function removeWorktreeFromSessionStore(input: { serverId: string; workspaceId: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
useSessionStore.getState().removeWorkspace(serverId, workspaceId);
|
||||
}
|
||||
|
||||
function restoreWorktreeArchiveState(input: {
|
||||
serverId: string;
|
||||
snapshot: WorktreeArchiveSnapshot;
|
||||
}): void {
|
||||
if (input.snapshot.workspace) {
|
||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]);
|
||||
}
|
||||
|
||||
for (const [queryKey, data] of input.snapshot.worktreeLists) {
|
||||
appQueryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
|
||||
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (workspaceKey) {
|
||||
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
|
||||
}
|
||||
useWorkspaceTabsStore.getState().purgeWorkspace({ serverId, workspaceId });
|
||||
}
|
||||
|
||||
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const inFlight = new Map<string, Promise<unknown>>();
|
||||
|
||||
@@ -209,16 +83,6 @@ function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): stri
|
||||
return `${key}::${actionId}`;
|
||||
}
|
||||
|
||||
export function isLocalWorktreeArchivePending(input: { serverId: string; cwd: string }): boolean {
|
||||
return (
|
||||
useCheckoutGitActionsStore.getState().getStatus({
|
||||
serverId: input.serverId,
|
||||
cwd: input.cwd,
|
||||
actionId: "archive-worktree",
|
||||
}) === "pending"
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckoutGitActionsStoreState {
|
||||
statusByCheckout: Record<CheckoutKey, StatusMap>;
|
||||
|
||||
@@ -247,12 +111,6 @@ interface CheckoutGitActionsStoreState {
|
||||
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
archiveWorktree: (params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
worktreePath: string;
|
||||
workspaceId?: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
async function runCheckoutAction({
|
||||
@@ -490,50 +348,6 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "archive-worktree",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const snapshot = snapshotWorktreeArchiveState({ serverId, workspaceId });
|
||||
// The server archive is keyed by worktreePath and must always run. The
|
||||
// optimistic client-side updates are keyed by workspace id, so they only
|
||||
// apply when the caller passes the workspace id and it resolves in the
|
||||
// local store.
|
||||
const workspace = snapshot.workspace;
|
||||
if (workspace) {
|
||||
markWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
||||
try {
|
||||
const payload = await client.archivePaseoWorktree({
|
||||
worktreePath,
|
||||
...(workspaceId !== undefined ? { workspaceId } : {}),
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
if (workspace) {
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
restoreWorktreeArchiveState({ serverId, snapshot });
|
||||
throw error;
|
||||
}
|
||||
invalidateWorktreeList();
|
||||
if (workspace) {
|
||||
purgeArchivedWorkspaceState({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export function __resetCheckoutGitActionsStoreForTests() {
|
||||
|
||||
@@ -122,7 +122,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"archive-worktree": {
|
||||
"archive-workspace": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
@@ -272,6 +272,7 @@ describe("git-actions-policy", () => {
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
"archive-workspace",
|
||||
]);
|
||||
expect(
|
||||
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
||||
@@ -352,12 +353,16 @@ describe("git-actions-policy", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("only shows archive worktree for paseo worktrees", () => {
|
||||
const hidden = buildGitActions(createInput());
|
||||
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||
it("offers archive workspace regardless of its backing", () => {
|
||||
const directory = buildGitActions(createInput({ isGit: false }));
|
||||
const localCheckout = buildGitActions(createInput({ hasUncommittedChanges: true }));
|
||||
const worktree = buildGitActions(
|
||||
createInput({ hasUncommittedChanges: true, isPaseoOwnedWorktree: true }),
|
||||
);
|
||||
|
||||
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
|
||||
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
|
||||
expect(directory.primary?.id).toBe("archive-workspace");
|
||||
expect(localCheckout.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
|
||||
expect(worktree.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
|
||||
});
|
||||
|
||||
it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => {
|
||||
@@ -541,6 +546,7 @@ describe("git-actions-policy", () => {
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -717,6 +723,7 @@ describe("git-actions-policy", () => {
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -757,6 +764,7 @@ describe("git-actions-policy", () => {
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"enable-pr-auto-merge-squash",
|
||||
"archive-workspace",
|
||||
]);
|
||||
expect(
|
||||
actions.secondary.some((action) =>
|
||||
@@ -899,6 +907,7 @@ describe("git-actions-policy", () => {
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-pr-merge",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -952,7 +961,7 @@ describe("git-actions-policy", () => {
|
||||
.filter((action) => !action.startsGroup)
|
||||
.map((action) => action.id);
|
||||
|
||||
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-worktree"]);
|
||||
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-workspace"]);
|
||||
expect(nonGroupStarters).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
|
||||
@@ -23,7 +23,7 @@ export type GitActionId =
|
||||
| "disable-pr-auto-merge"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
| "archive-workspace";
|
||||
|
||||
export interface GitAction {
|
||||
id: GitActionId;
|
||||
@@ -189,10 +189,6 @@ export function narrowPullRequestState(state: string | null | undefined): "open"
|
||||
}
|
||||
|
||||
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
if (!input.isGit) {
|
||||
return { primary: null, secondary: [], menu: [] };
|
||||
}
|
||||
|
||||
const allActions = new Map<GitActionId, GitAction>();
|
||||
|
||||
allActions.set("commit", {
|
||||
@@ -282,43 +278,39 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
handler: input.runtime["merge-from-base"].handler,
|
||||
});
|
||||
|
||||
allActions.set("archive-worktree", {
|
||||
id: "archive-worktree",
|
||||
allActions.set("archive-workspace", {
|
||||
id: "archive-workspace",
|
||||
label: i18n.t("workspace.git.actions.archive.label"),
|
||||
pendingLabel: i18n.t("workspace.git.actions.archive.pending"),
|
||||
successLabel: i18n.t("workspace.git.actions.archive.success"),
|
||||
disabled: input.runtime["archive-worktree"].disabled,
|
||||
status: input.runtime["archive-worktree"].status,
|
||||
unavailableMessage:
|
||||
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
|
||||
? undefined
|
||||
: i18n.t("workspace.git.actions.unavailable.archiveNotWorktree"),
|
||||
icon: input.runtime["archive-worktree"].icon,
|
||||
disabled: input.runtime["archive-workspace"].disabled,
|
||||
status: input.runtime["archive-workspace"].status,
|
||||
icon: input.runtime["archive-workspace"].icon,
|
||||
startsGroup: true,
|
||||
handler: input.runtime["archive-worktree"].handler,
|
||||
handler: input.runtime["archive-workspace"].handler,
|
||||
});
|
||||
|
||||
const primaryActionId = getPrimaryActionId(input);
|
||||
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
|
||||
|
||||
const secondaryIds = [...REMOTE_ACTION_IDS];
|
||||
if (!input.isOnBaseBranch) {
|
||||
const secondaryIds = input.isGit ? [...REMOTE_ACTION_IDS] : [];
|
||||
if (input.isGit && !input.isOnBaseBranch) {
|
||||
secondaryIds.push(...getFeatureActionIds(input));
|
||||
}
|
||||
if (input.isPaseoOwnedWorktree) {
|
||||
secondaryIds.push("archive-worktree");
|
||||
}
|
||||
secondaryIds.push("archive-workspace");
|
||||
|
||||
return {
|
||||
primary,
|
||||
secondary: secondaryIds.map((id) => allActions.get(id)!),
|
||||
secondary: secondaryIds
|
||||
.filter((id) => id !== "archive-workspace" || primaryActionId !== "archive-workspace")
|
||||
.map((id) => allActions.get(id)!),
|
||||
menu: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
|
||||
return "archive-worktree";
|
||||
if (input.shouldPromoteArchive) {
|
||||
return "archive-workspace";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "commit";
|
||||
@@ -350,7 +342,7 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||
return "pr";
|
||||
}
|
||||
return null;
|
||||
return "archive-workspace";
|
||||
}
|
||||
|
||||
function getPullRequestActionIds(filter: {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
useActiveWorkspaceSelection,
|
||||
type ActiveWorkspaceSelection,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
@@ -97,15 +98,12 @@ function extractGitCommitCounts(gitStatus: CheckoutStatusPayload | null): GitCom
|
||||
}
|
||||
|
||||
function computeShouldPromoteArchive(input: {
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
hasUncommittedChanges: boolean;
|
||||
postShipArchiveSuggested: boolean;
|
||||
isMergedPullRequest: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
input.isPaseoOwnedWorktree &&
|
||||
!input.hasUncommittedChanges &&
|
||||
(input.postShipArchiveSuggested || input.isMergedPullRequest)
|
||||
!input.hasUncommittedChanges && (input.postShipArchiveSuggested || input.isMergedPullRequest)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,7 +129,6 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
|
||||
shouldPromoteArchive: computeShouldPromoteArchive({
|
||||
isPaseoOwnedWorktree,
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isMergedPullRequest,
|
||||
@@ -173,6 +170,52 @@ interface UseWorkspaceScreenArchiveControllerInput {
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
function resolveArchiveWorkspaceDescriptor(input: {
|
||||
workspaces: Map<string, WorkspaceDescriptor> | undefined;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): WorkspaceDescriptor | null {
|
||||
const activeWorkspaceKey = input.activeWorkspaceSelection
|
||||
? resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces: input.workspaces,
|
||||
workspaceId: input.activeWorkspaceSelection.workspaceId,
|
||||
})
|
||||
: null;
|
||||
if (activeWorkspaceKey) {
|
||||
return input.workspaces?.get(activeWorkspaceKey) ?? null;
|
||||
}
|
||||
if (!input.workspaceDirectory) {
|
||||
return null;
|
||||
}
|
||||
for (const candidate of input.workspaces?.values() ?? []) {
|
||||
if (candidate.workspaceDirectory === input.workspaceDirectory) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveWorkspaceArchiveRisk(
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
gitStatus: CheckoutStatusPayload | null,
|
||||
): { isDirty: boolean | null | undefined; aheadOfOrigin: number | null | undefined } {
|
||||
return {
|
||||
isDirty: gitStatus?.isDirty ?? workspace?.gitRuntime?.isDirty,
|
||||
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? workspace?.gitRuntime?.aheadOfOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
function canArchiveWorkspace(
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
risk: ReturnType<typeof resolveWorkspaceArchiveRisk>,
|
||||
): boolean {
|
||||
return (
|
||||
workspace !== null &&
|
||||
(workspace.workspaceKind !== "worktree" ||
|
||||
(risk.isDirty !== undefined && risk.aheadOfOrigin !== undefined))
|
||||
);
|
||||
}
|
||||
|
||||
function useWorkspaceScreenArchiveController({
|
||||
serverId,
|
||||
activeWorkspaceSelection,
|
||||
@@ -182,28 +225,28 @@ function useWorkspaceScreenArchiveController({
|
||||
t,
|
||||
}: UseWorkspaceScreenArchiveControllerInput) {
|
||||
const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
||||
const archiveWorkspaceRecord = useMemo(() => {
|
||||
if (!workspaceDirectory) {
|
||||
return null;
|
||||
}
|
||||
for (const candidate of sessionWorkspaces?.values() ?? []) {
|
||||
if (candidate.workspaceDirectory === workspaceDirectory) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [sessionWorkspaces, workspaceDirectory]);
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const workspaceDescriptor = useMemo(
|
||||
() =>
|
||||
resolveArchiveWorkspaceDescriptor({
|
||||
workspaces: sessionWorkspaces,
|
||||
activeWorkspaceSelection,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
[activeWorkspaceSelection, sessionWorkspaces, workspaceDirectory],
|
||||
);
|
||||
const archiveRisk = resolveWorkspaceArchiveRisk(workspaceDescriptor, gitStatus);
|
||||
|
||||
return useWorkspaceArchive({
|
||||
const controller = useWorkspaceArchive({
|
||||
serverId,
|
||||
workspaceId: activeWorkspaceSelection?.workspaceId ?? archiveWorkspaceRecord?.id ?? "",
|
||||
workspaceDirectory,
|
||||
workspaceKind: gitStatus?.isPaseoOwnedWorktree ? "worktree" : "local_checkout",
|
||||
name: archiveWorkspaceRecord?.name ?? branchLabel,
|
||||
isDirty: gitStatus?.isDirty,
|
||||
aheadOfOrigin: gitStatus?.aheadOfOrigin,
|
||||
diffStat: archiveWorkspaceRecord?.diffStat ?? null,
|
||||
workspaceId: workspaceDescriptor?.id ?? "",
|
||||
workspaceKind: workspaceDescriptor?.workspaceKind ?? "directory",
|
||||
name: workspaceDescriptor?.name ?? branchLabel,
|
||||
isDirty: archiveRisk.isDirty,
|
||||
aheadOfOrigin: archiveRisk.aheadOfOrigin,
|
||||
diffStat: workspaceDescriptor?.diffStat ?? null,
|
||||
warningLabels: getWorktreeArchiveWarningLabels(t),
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
onArchiveStarted: () => {
|
||||
if (!activeWorkspaceSelection) {
|
||||
return;
|
||||
@@ -215,6 +258,12 @@ function useWorkspaceScreenArchiveController({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...controller,
|
||||
isArchiving: workspaceDescriptor?.archivingAt != null || isHidingWorkspace,
|
||||
canArchive: canArchiveWorkspace(workspaceDescriptor, archiveRisk),
|
||||
};
|
||||
}
|
||||
|
||||
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
||||
@@ -342,9 +391,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
const mergeFromBaseStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
|
||||
);
|
||||
const archiveStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
|
||||
);
|
||||
|
||||
const runCommit = useCheckoutGitActionsStore((s) => s.commit);
|
||||
const runPull = useCheckoutGitActionsStore((s) => s.pull);
|
||||
@@ -533,7 +579,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
t,
|
||||
});
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
archiveController.archive();
|
||||
}, [archiveController]);
|
||||
|
||||
@@ -678,11 +724,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
icon: icons.mergeFromBase,
|
||||
handler: handleMergeFromBase,
|
||||
},
|
||||
"archive-worktree": {
|
||||
disabled: isActionDisabled(actionsDisabled, archiveStatus),
|
||||
status: archiveStatus,
|
||||
"archive-workspace": {
|
||||
disabled: !archiveController.canArchive || archiveController.isArchiving,
|
||||
status: archiveController.isArchiving ? "pending" : "idle",
|
||||
icon: icons.archive,
|
||||
handler: handleArchiveWorktree,
|
||||
handler: handleArchiveWorkspace,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -723,7 +769,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
disablePrAutoMergeStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
archiveController.canArchive,
|
||||
archiveController.isArchiving,
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
@@ -734,7 +781,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
handleDisablePrAutoMerge,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
handleArchiveWorkspace,
|
||||
icons,
|
||||
baseRef,
|
||||
],
|
||||
@@ -890,7 +937,7 @@ function getTranslatedGitActionLabels(
|
||||
pendingLabel: t("workspace.git.actions.mergeFromBase.pending"),
|
||||
successLabel: t("workspace.git.actions.mergeFromBase.success"),
|
||||
};
|
||||
case "archive-worktree":
|
||||
case "archive-workspace":
|
||||
return {
|
||||
label: t("workspace.git.actions.archive.label"),
|
||||
pendingLabel: t("workspace.git.actions.archive.pending"),
|
||||
@@ -945,8 +992,6 @@ function translateGitActionUnavailableMessage(
|
||||
"workspace.git.actions.unavailable.updateNoBase",
|
||||
"Update isn't available while you have local changes so commit or stash them first":
|
||||
"workspace.git.actions.unavailable.updateDirty",
|
||||
"Archive isn't available here because this workspace was not created as a Paseo worktree":
|
||||
"workspace.git.actions.unavailable.archiveNotWorktree",
|
||||
"Merge PR isn't available right now because GitHub isn't connected":
|
||||
"workspace.git.actions.unavailable.mergePrNoGithub",
|
||||
"Merge PR isn't available because there isn't a pull request yet":
|
||||
@@ -979,7 +1024,7 @@ function getWorktreeArchiveWarningLabels(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): WorktreeArchiveWarningLabels {
|
||||
return {
|
||||
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
|
||||
title: (workspaceName) => t("workspace.git.actions.archiveWarning.title", { workspaceName }),
|
||||
confirm: t("workspace.git.actions.archiveWarning.confirm"),
|
||||
cancel: t("workspace.git.actions.archiveWarning.cancel"),
|
||||
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface WorkspaceGitActionsProps {
|
||||
interface WorkspaceActionsProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
hideLabels?: boolean;
|
||||
@@ -47,16 +47,12 @@ const ICONS = {
|
||||
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
|
||||
};
|
||||
|
||||
export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) {
|
||||
const { gitActions, isGit } = useGitActions({
|
||||
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
|
||||
const { gitActions } = useGitActions({
|
||||
serverId,
|
||||
cwd,
|
||||
icons: ICONS,
|
||||
});
|
||||
|
||||
if (!isGit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
toWorktreeArchiveRisk,
|
||||
} from "@/git/worktree-archive-warning";
|
||||
|
||||
describe("worktree archive warning", () => {
|
||||
describe("workspace archive warning for worktree backing", () => {
|
||||
it("does not require a confirmation for clean and pushed worktrees", () => {
|
||||
expect(
|
||||
buildWorktreeArchiveConfirmationMessage({
|
||||
worktreeName: "feature",
|
||||
workspaceName: "feature",
|
||||
isDirty: false,
|
||||
aheadOfOrigin: 0,
|
||||
diffStat: null,
|
||||
@@ -51,7 +51,7 @@ describe("worktree archive warning", () => {
|
||||
it("includes every archive risk in the confirmation copy", () => {
|
||||
expect(
|
||||
buildWorktreeArchiveConfirmationMessage({
|
||||
worktreeName: "risky-feature",
|
||||
workspaceName: "risky-feature",
|
||||
isDirty: true,
|
||||
aheadOfOrigin: 1,
|
||||
diffStat: { additions: 1, deletions: 3 },
|
||||
|
||||
@@ -14,11 +14,11 @@ export interface WorktreeArchiveRiskInput {
|
||||
}
|
||||
|
||||
export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
|
||||
worktreeName: string;
|
||||
workspaceName: string;
|
||||
}
|
||||
|
||||
export interface WorktreeArchiveWarningLabels {
|
||||
title: (worktreeName: string) => string;
|
||||
title: (workspaceName: string) => string;
|
||||
confirm: string;
|
||||
cancel: string;
|
||||
uncommittedChanges: string;
|
||||
@@ -29,7 +29,7 @@ export interface WorktreeArchiveWarningLabels {
|
||||
}
|
||||
|
||||
export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabels = {
|
||||
title: (worktreeName) => i18n.t("workspace.git.actions.archiveWarning.title", { worktreeName }),
|
||||
title: (workspaceName) => i18n.t("workspace.git.actions.archiveWarning.title", { workspaceName }),
|
||||
confirm: i18n.t("workspace.git.actions.archiveWarning.confirm"),
|
||||
cancel: i18n.t("workspace.git.actions.archiveWarning.cancel"),
|
||||
uncommittedChanges: i18n.t("workspace.git.actions.archiveWarning.uncommittedChanges"),
|
||||
@@ -123,7 +123,7 @@ export async function confirmRiskyWorktreeArchive(
|
||||
}
|
||||
|
||||
return await confirmDialog({
|
||||
title: labels.title(input.worktreeName),
|
||||
title: labels.title(input.workspaceName),
|
||||
message,
|
||||
confirmLabel: labels.confirm,
|
||||
cancelLabel: labels.cancel,
|
||||
|
||||
Reference in New Issue
Block a user