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:
Mohamed Boudra
2026-07-11 18:22:31 +02:00
committed by GitHub
parent ada0955334
commit 03dcda7c41
40 changed files with 369 additions and 757 deletions
+1 -1
View File
@@ -218,7 +218,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
+1
View File
@@ -4,6 +4,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
@@ -1,4 +1,5 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { expectOpenedProject } from "./helpers/project-picker-ui";
@@ -11,7 +12,7 @@ function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
const row = page.getByTestId(workspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
@@ -21,10 +22,6 @@ async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promis
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
// user-confirmed archive proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
await archiveItem.click();
@@ -159,17 +156,24 @@ test.describe("Project with no workspaces persists", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
await expect(workspaceRow).toBeVisible({
timeout: 30_000,
});
await workspaceRow.click();
await expect(page.getByTestId("changes-primary-cta")).toHaveAttribute(
"aria-label",
"Archive workspace",
);
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
await archiveWorkspaceFromSidebar(page, workspace.workspaceId);
// The workspace row goes away, but its project parent stays and exposes a
// child row for creating the next workspace.
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
});
expect(existsSync(workspace.repoPath)).toBe(true);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toContainText("New workspace");
+3 -4
View File
@@ -39,10 +39,9 @@ export async function clickArchiveWorkspaceMenuItem(
await archiveItem.click();
}
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
// A clean worktree archives with no prompt; if the host reports unsynced work the app
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
// either way.
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
// A clean workspace archives with no prompt. Managed worktree backing may raise
// a browser confirm for unsynced work, so accept it when present.
page.once("dialog", (dialog) => void dialog.accept());
await clickArchiveWorkspaceMenuItem(page, workspaceId);
}
@@ -0,0 +1,26 @@
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { seedWorkspace } from "./helpers/seed-client";
import { expectWorkspaceAbsentFromSidebar, selectWorkspaceInSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
test.describe("Workspace archive shortcut", () => {
test("archives the selected workspace without removing its local checkout", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "archive-shortcut-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await selectWorkspaceInSidebar(page, workspace.workspaceId);
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Shift+Backspace`);
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
expect(existsSync(workspace.repoPath)).toBe(true);
} finally {
await workspace.cleanup();
}
});
});
@@ -76,7 +76,7 @@ async function clickArchiveAndAnswerWarning(
return warning;
}
test.describe("Worktree archive risk warning", () => {
test.describe("Workspace archive risk warning for worktree backing", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -97,7 +97,7 @@ test.describe("Worktree archive risk warning", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
page,
}) => {
const serverId = getServerId();
+44 -6
View File
@@ -8,11 +8,11 @@ import {
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { getServerId } from "./helpers/server-id";
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
test.describe("Worktree archive", () => {
test.describe("Workspace archive with worktree backing", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -33,9 +33,7 @@ test.describe("Worktree archive", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
page,
}) => {
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
@@ -49,11 +47,51 @@ test.describe("Worktree archive", () => {
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
});
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const first = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `shared-archive-${Date.now()}`,
});
createdWorktreeDirectories.add(first.workspaceDirectory);
const siblingPayload = await client.createWorkspace({
source: {
kind: "directory",
path: first.workspaceDirectory,
},
title: "Second workspace",
});
if (!siblingPayload.workspace) {
throw new Error(siblingPayload.error ?? "Failed to create a workspace on the worktree");
}
const sibling = siblingPayload.workspace;
expect(sibling.workspaceKind).toBe("worktree");
expect(sibling.workspaceDirectory).toBe(first.workspaceDirectory);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: first.workspaceId });
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
await archiveWorkspaceFromSidebar(page, first.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, first.workspaceId);
expect(existsSync(first.workspaceDirectory)).toBe(true);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
await archiveWorkspaceFromSidebar(page, sibling.id);
await expectWorkspaceAbsentFromSidebar(page, sibling.id);
await expect.poll(() => existsSync(first.workspaceDirectory), { timeout: 30_000 }).toBe(false);
});
});
@@ -77,7 +77,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { SyncedLoader } from "@/components/synced-loader";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
@@ -112,7 +111,7 @@ import {
} 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 { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import {
getCurrentProjectRemoveReadiness,
@@ -284,16 +283,6 @@ interface WorkspaceRowInnerProps {
archiveShortcutKeys?: ShortcutKey[][] | null;
}
function getWorkspaceArchiveStatus(
isWorktree: boolean,
archiveStatus: "idle" | "pending" | "success",
isArchivingWorkspace: boolean,
): "idle" | "pending" | "success" {
if (isWorktree) return archiveStatus;
if (isArchivingWorkspace) return "pending";
return "idle";
}
export function PrBadge({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
@@ -1454,20 +1443,7 @@ function WorkspaceRowWithMenu({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
@@ -1479,7 +1455,6 @@ function WorkspaceRowWithMenu({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -1546,7 +1521,7 @@ function WorkspaceRowWithMenu({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -1558,8 +1533,8 @@ function WorkspaceRowWithMenu({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -1584,11 +1559,7 @@ function WorkspaceRowWithMenu({
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={getWorkspaceArchiveStatus(
isWorktree,
worktreeArchiveStatus,
isHidingWorkspace,
)}
archiveStatus={isArchiving ? "pending" : "idle"}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
@@ -23,10 +23,9 @@ import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -330,20 +329,7 @@ function StatusWorkspaceRowWithMenu({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -358,7 +344,6 @@ function StatusWorkspaceRowWithMenu({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -408,7 +393,7 @@ function StatusWorkspaceRowWithMenu({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -420,8 +405,8 @@ function StatusWorkspaceRowWithMenu({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -430,13 +415,6 @@ function StatusWorkspaceRowWithMenu({
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
computedArchiveStatus = "pending";
}
return (
<>
<StatusWorkspaceRowInner
@@ -448,7 +426,7 @@ function StatusWorkspaceRowWithMenu({
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={computedArchiveStatus}
archiveStatus={isArchiving ? "pending" : "idle"}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
@@ -11,14 +11,13 @@ import { DiffStat } from "@/components/diff-stat";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { useToast } from "@/contexts/toast-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { isNative as platformIsNative } from "@/constants/platform";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
@@ -66,20 +65,7 @@ export function SidebarWorkspaceRow({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -94,7 +80,6 @@ export function SidebarWorkspaceRow({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -161,7 +146,7 @@ export function SidebarWorkspaceRow({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -173,8 +158,8 @@ export function SidebarWorkspaceRow({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -183,13 +168,6 @@ export function SidebarWorkspaceRow({
},
});
let archiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
archiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
archiveStatus = "pending";
}
return (
<>
<WorkspaceRowBody
@@ -205,7 +183,7 @@ export function SidebarWorkspaceRow({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={archiveStatus}
archiveStatus={isArchiving ? "pending" : "idle"}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
@@ -1,9 +1,8 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
interface PendingWorkspaceArchive {
workspaceId: string;
workspaceDirectory: string | null;
}
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
@@ -15,7 +14,6 @@ function pendingArchiveKey(input: { serverId: string; workspaceId: string }): st
export function markWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
@@ -26,7 +24,6 @@ export function markWorkspaceArchivePending(input: {
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
workspaceId,
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
});
pendingWorkspaceArchivesByServer.set(serverId, archives);
}
@@ -54,7 +51,6 @@ export function clearWorkspaceArchivePending(input: {
export function isWorkspaceArchivePending(input: {
serverId: string;
workspaceId?: string | null;
workspaceDirectory?: string | null;
}): boolean {
const serverId = input.serverId.trim();
if (!serverId) {
@@ -67,21 +63,7 @@ export function isWorkspaceArchivePending(input: {
}
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
return true;
}
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!workspaceDirectory) {
return false;
}
for (const archive of archives.values()) {
if (archive.workspaceDirectory === workspaceDirectory) {
return true;
}
}
return false;
return Boolean(workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId })));
}
export function shouldSuppressWorkspaceForLocalArchive(input: {
@@ -91,6 +73,5 @@ export function shouldSuppressWorkspaceForLocalArchive(input: {
return isWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspace.id,
workspaceDirectory: input.workspace.workspaceDirectory,
});
}
@@ -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;
-141
View File
@@ -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 -187
View File
@@ -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() {
+16 -7
View File
@@ -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",
+16 -24
View File
@@ -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: {
+84 -39
View File
@@ -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"),
+3 -7
View File
@@ -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,
+5 -9
View File
@@ -619,7 +619,7 @@ export const ar: TranslationResources = {
success: "تم التحديث",
},
archive: {
label: "أرشفة شجرة العمل",
label: "أرشفة مساحة العمل",
pending: "أرشفة...",
success: "مؤرشف",
},
@@ -661,8 +661,6 @@ export const ar: TranslationResources = {
updateNoBase: "التحديث غير متاح لأننا لم نتمكن من تحديد الفرع الأساسي",
updateDirty: "التحديث غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
updateCurrent: "التحديث غير متاح لأن هذا الفرع محدث بالفعل باستخدام{{baseRef}}",
archiveNotWorktree:
"الأرشيف غير متاح هنا لأنه لم يتم إنشاء مساحة العمل هذه كشجرة عمل Paseo",
mergePrNoGithub: "دمج PR غير متاح الآن لأن GitHub غير متصل",
mergePrMissing: "دمج PR غير متاح لأنه لا يوجد طلب سحب حتى الآن",
mergePrDraft: "دمج PR غير متاح لأن طلب السحب لا يزال مسودة",
@@ -685,11 +683,9 @@ export const ar: TranslationResources = {
baseRefUnavailable: "المرجع الأساسي غير متاح",
failedMerge: "فشل الدمج",
failedMergeFromBase: "فشل الدمج من القاعدة",
worktreePathUnavailable: "مسار شجرة العمل غير متوفر",
failedArchive: "فشل في أرشفة شجرة العمل",
},
archiveWarning: {
title: 'الأرشيف "{{worktreeName}}"؟',
title: 'الأرشيف "{{workspaceName}}"؟',
confirm: "أرشيف",
cancel: "يلغي",
uncommittedChanges: "تغييرات غير ملتزم بها",
@@ -839,7 +835,7 @@ export const ar: TranslationResources = {
copyBranchName: "انسخ اسم الفرع",
rename: "إعادة تسمية مساحة العمل",
archive: "أرشيف",
archiveWorktree: "أرشفة شجرة العمل",
archiveWorkspace: "أرشفة مساحة العمل",
hideFromSidebar: "إخفاء من الشريط الجانبي",
archiving: "أرشفة...",
hiding: "إخفاء...",
@@ -862,7 +858,7 @@ export const ar: TranslationResources = {
branchNameCopied: "تم نسخ اسم الفرع",
hostDisconnected: "Host غير متصل",
hideFailed: "فشل في إخفاء مساحة العمل",
archiveFailed: "فشل في أرشفة شجرة العمل",
archiveFailed: "فشل في أرشفة مساحة العمل",
},
},
},
@@ -1605,7 +1601,7 @@ export const ar: TranslationResources = {
openProject: "مشروع مفتوح",
newWorkspace: "مساحة عمل جديدة",
newWorktree: "شجرة عمل جديدة",
archiveWorktree: "أرشفة شجرة العمل",
archiveWorkspace: "أرشفة مساحة العمل",
newTab: "علامة تبويب جديدة",
closeCurrentTab: "إغلاق علامة التبويب الحالية",
jumpToWorkspace: "انتقل إلى مساحة العمل",
+5 -9
View File
@@ -618,7 +618,7 @@ export const en = {
success: "Updated",
},
archive: {
label: "Archive worktree",
label: "Archive workspace",
pending: "Archiving...",
success: "Archived",
},
@@ -667,8 +667,6 @@ export const en = {
"Update isn't available while you have local changes so commit or stash them first",
updateCurrent:
"Update isn't available because this branch is already up to date with {{baseRef}}",
archiveNotWorktree:
"Archive isn't available here because this workspace was not created as a Paseo worktree",
mergePrNoGithub: "Merge PR isn't available right now because GitHub isn't connected",
mergePrMissing: "Merge PR isn't available because there isn't a pull request yet",
mergePrDraft: "Merge PR isn't available because the pull request is still a draft",
@@ -692,11 +690,9 @@ export const en = {
baseRefUnavailable: "Base ref unavailable",
failedMerge: "Failed to merge",
failedMergeFromBase: "Failed to merge from base",
worktreePathUnavailable: "Worktree path unavailable",
failedArchive: "Failed to archive worktree",
},
archiveWarning: {
title: 'Archive "{{worktreeName}}"?',
title: 'Archive "{{workspaceName}}"?',
confirm: "Archive",
cancel: "Cancel",
uncommittedChanges: "Uncommitted changes",
@@ -846,7 +842,7 @@ export const en = {
copyBranchName: "Copy branch name",
rename: "Rename workspace",
archive: "Archive",
archiveWorktree: "Archive worktree",
archiveWorkspace: "Archive workspace",
hideFromSidebar: "Hide from sidebar",
archiving: "Archiving...",
hiding: "Hiding...",
@@ -869,7 +865,7 @@ export const en = {
branchNameCopied: "Branch name copied",
hostDisconnected: "Host is not connected",
hideFailed: "Failed to hide workspace",
archiveFailed: "Failed to archive worktree",
archiveFailed: "Failed to archive workspace",
},
},
},
@@ -1612,7 +1608,7 @@ export const en = {
openProject: "Open project",
newWorkspace: "New workspace",
newWorktree: "New worktree",
archiveWorktree: "Archive worktree",
archiveWorkspace: "Archive workspace",
newTab: "New tab",
closeCurrentTab: "Close current tab",
jumpToWorkspace: "Jump to workspace",
+5 -9
View File
@@ -625,7 +625,7 @@ export const es: TranslationResources = {
success: "Actualizado",
},
archive: {
label: "Árbol de trabajo de archivo",
label: "Archivar espacio de trabajo",
pending: "Archivando...",
success: "Archivado",
},
@@ -679,8 +679,6 @@ export const es: TranslationResources = {
"La actualización no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero",
updateCurrent:
"La actualización no está disponible porque esta rama ya está actualizada con{{baseRef}}",
archiveNotWorktree:
"El archivo no está disponible aquí porque este espacio de trabajo no se creó como un árbol de trabajoPaseo",
mergePrNoGithub:
"FusionarPRno está disponible en este momento porqueGitHubno está conectado",
mergePrMissing:
@@ -712,11 +710,9 @@ export const es: TranslationResources = {
baseRefUnavailable: "Referencia base no disponible",
failedMerge: "No se pudo fusionar",
failedMergeFromBase: "No se pudo fusionar desde la base",
worktreePathUnavailable: "Ruta del árbol de trabajo no disponible",
failedArchive: "No se pudo archivar el árbol de trabajo",
},
archiveWarning: {
title: '¿Archivo "{{worktreeName}}"?',
title: '¿Archivo "{{workspaceName}}"?',
confirm: "Archivo",
cancel: "Cancelar",
uncommittedChanges: "Cambios no confirmados",
@@ -866,7 +862,7 @@ export const es: TranslationResources = {
copyBranchName: "Copiar nombre de sucursal",
rename: "Cambiar nombre del espacio de trabajo",
archive: "Archivo",
archiveWorktree: "Árbol de trabajo de archivo",
archiveWorkspace: "Archivar espacio de trabajo",
hideFromSidebar: "Ocultar de la barra lateral",
archiving: "Archivando...",
hiding: "Ocultación...",
@@ -889,7 +885,7 @@ export const es: TranslationResources = {
branchNameCopied: "Nombre de la sucursal copiado",
hostDisconnected: "Hostno está conectado",
hideFailed: "No se pudo ocultar el espacio de trabajo",
archiveFailed: "No se pudo archivar el árbol de trabajo",
archiveFailed: "No se pudo archivar el espacio de trabajo",
},
},
},
@@ -1647,7 +1643,7 @@ export const es: TranslationResources = {
openProject: "Abrir proyecto",
newWorkspace: "Nuevo espacio de trabajo",
newWorktree: "Nuevo árbol de trabajo",
archiveWorktree: "Árbol de trabajo de archivo",
archiveWorkspace: "Archivar espacio de trabajo",
newTab: "Nueva pestaña",
closeCurrentTab: "Cerrar pestaña actual",
jumpToWorkspace: "Saltar al espacio de trabajo",
+5 -9
View File
@@ -625,7 +625,7 @@ export const fr: TranslationResources = {
success: "Mis à jour",
},
archive: {
label: "Arbre de travail d'archivage",
label: "Archiver l’espace de travail",
pending: "Archivage...",
success: "Archivé",
},
@@ -679,8 +679,6 @@ export const fr: TranslationResources = {
"La mise à jour n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord",
updateCurrent:
"La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}",
archiveNotWorktree:
"L'archive n'est pas disponible ici car cet espace de travail n'a pas été créé en tant qu'arbre de travailPaseo",
mergePrNoGithub:
"La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
mergePrMissing:
@@ -711,11 +709,9 @@ export const fr: TranslationResources = {
baseRefUnavailable: "Réf de base indisponible",
failedMerge: "Échec de la fusion",
failedMergeFromBase: "Échec de la fusion à partir de la base",
worktreePathUnavailable: "Chemin d'accès à l'arbre de travail indisponible",
failedArchive: "Échec de l'archivage de l'arbre de travail",
},
archiveWarning: {
title: "Archiver «{{worktreeName}}»?",
title: "Archiver «{{workspaceName}}»?",
confirm: "Archive",
cancel: "Annuler",
uncommittedChanges: "Modifications non validées",
@@ -865,7 +861,7 @@ export const fr: TranslationResources = {
copyBranchName: "Copier le nom de la branche",
rename: "Renommer l'espace de travail",
archive: "Archive",
archiveWorktree: "Arbre de travail d'archivage",
archiveWorkspace: "Archiver l’espace de travail",
hideFromSidebar: "Masquer de la barre latérale",
archiving: "Archivage...",
hiding: "Dissimulation...",
@@ -888,7 +884,7 @@ export const fr: TranslationResources = {
branchNameCopied: "Nom de la succursale copié",
hostDisconnected: "Hostn'est pas connecté",
hideFailed: "Échec du masquage de l'espace de travail",
archiveFailed: "Échec de l'archivage de l'arbre de travail",
archiveFailed: "Échec de l'archivage de l'espace de travail",
},
},
},
@@ -1650,7 +1646,7 @@ export const fr: TranslationResources = {
openProject: "Projet ouvert",
newWorkspace: "Nouvel espace de travail",
newWorktree: "Nouvel arbre de travail",
archiveWorktree: "Arbre de travail d'archivage",
archiveWorkspace: "Archiver l’espace de travail",
newTab: "Nouvel onglet",
closeCurrentTab: "Fermer l'onglet actuel",
jumpToWorkspace: "Accéder à l'espace de travail",
+5 -9
View File
@@ -623,7 +623,7 @@ export const ja: TranslationResources = {
success: "更新しました",
},
archive: {
label: "ワークツリーをアーカイブ",
label: "ワークスペースをアーカイブ",
pending: "アーカイブ中...",
success: "アーカイブしました",
},
@@ -670,8 +670,6 @@ export const ja: TranslationResources = {
updateDirty:
"ローカルに変更があるため更新は利用できません。先にコミットまたはスタッシュしてください",
updateCurrent: "このブランチはすでに{{baseRef}}と最新の状態のため、更新は利用できません",
archiveNotWorktree:
"このワークスペースはPaseoワークツリーとして作成されていないため、アーカイブはここでは利用できません",
mergePrNoGithub: "GitHubが接続されていないため、PRのマージは現在利用できません",
mergePrMissing: "プルリクエストがまだないため、PRのマージは利用できません",
mergePrDraft: "プルリクエストがまだドラフトのため、PRのマージは利用できません",
@@ -697,11 +695,9 @@ export const ja: TranslationResources = {
baseRefUnavailable: "ベースRefが利用できません",
failedMerge: "マージに失敗しました",
failedMergeFromBase: "ベースからのマージに失敗しました",
worktreePathUnavailable: "ワークツリーパスが利用できません",
failedArchive: "ワークツリーのアーカイブに失敗しました",
},
archiveWarning: {
title: '"{{worktreeName}}"をアーカイブしますか?',
title: '"{{workspaceName}}"をアーカイブしますか?',
confirm: "アーカイブ",
cancel: "キャンセル",
uncommittedChanges: "未コミットの変更",
@@ -851,7 +847,7 @@ export const ja: TranslationResources = {
copyBranchName: "ブランチ名をコピー",
rename: "ワークスペースの名前を変更",
archive: "アーカイブ",
archiveWorktree: "ワークツリーをアーカイブ",
archiveWorkspace: "ワークスペースをアーカイブ",
hideFromSidebar: "サイドバーから非表示",
archiving: "アーカイブ中...",
hiding: "非表示にしています...",
@@ -874,7 +870,7 @@ export const ja: TranslationResources = {
branchNameCopied: "ブランチ名をコピーしました",
hostDisconnected: "ホストが接続されていません",
hideFailed: "ワークスペースの非表示に失敗しました",
archiveFailed: "ワークツリーのアーカイブに失敗しました",
archiveFailed: "ワークスペースのアーカイブに失敗しました",
},
},
},
@@ -1622,7 +1618,7 @@ export const ja: TranslationResources = {
openProject: "プロジェクトを開く",
newWorkspace: "新しいワークスペース",
newWorktree: "新しいワークツリー",
archiveWorktree: "ワークツリーをアーカイブ",
archiveWorkspace: "ワークスペースをアーカイブ",
newTab: "新しいタブ",
closeCurrentTab: "現在のタブを閉じる",
jumpToWorkspace: "ワークスペースにジャンプ",
+5 -9
View File
@@ -623,7 +623,7 @@ export const ptBR: TranslationResources = {
success: "Atualizado",
},
archive: {
label: "Arquivar worktree",
label: "Arquivar workspace",
pending: "Arquivando...",
success: "Arquivado",
},
@@ -675,8 +675,6 @@ export const ptBR: TranslationResources = {
"Atualizar não está disponível enquanto há alterações locais. Faça commit ou stash primeiro",
updateCurrent:
"Atualizar não está disponível porque esta branch já está atualizada com {{baseRef}}",
archiveNotWorktree:
"Arquivar não está disponível aqui porque este workspace não foi criado como um worktree do Paseo",
mergePrNoGithub:
"Merge da PR não está disponível agora porque o GitHub não está conectado",
mergePrMissing: "Merge da PR não está disponível porque ainda não há uma pull request",
@@ -703,11 +701,9 @@ export const ptBR: TranslationResources = {
baseRefUnavailable: "Ref base indisponível",
failedMerge: "Falha ao fazer merge",
failedMergeFromBase: "Falha ao fazer merge da base",
worktreePathUnavailable: "Caminho do worktree indisponível",
failedArchive: "Falha ao arquivar worktree",
},
archiveWarning: {
title: 'Arquivar "{{worktreeName}}"?',
title: 'Arquivar "{{workspaceName}}"?',
confirm: "Arquivar",
cancel: "Cancelar",
uncommittedChanges: "Alterações sem commit",
@@ -857,7 +853,7 @@ export const ptBR: TranslationResources = {
copyBranchName: "Copiar nome da branch",
rename: "Renomear workspace",
archive: "Arquivar",
archiveWorktree: "Arquivar worktree",
archiveWorkspace: "Arquivar workspace",
hideFromSidebar: "Ocultar da barra lateral",
archiving: "Arquivando...",
hiding: "Ocultando...",
@@ -880,7 +876,7 @@ export const ptBR: TranslationResources = {
branchNameCopied: "Nome da branch copiado",
hostDisconnected: "Host não está conectado",
hideFailed: "Falha ao ocultar workspace",
archiveFailed: "Falha ao arquivar worktree",
archiveFailed: "Falha ao arquivar workspace",
},
},
},
@@ -1631,7 +1627,7 @@ export const ptBR: TranslationResources = {
openProject: "Abrir projeto",
newWorkspace: "Novo workspace",
newWorktree: "Novo worktree",
archiveWorktree: "Arquivar worktree",
archiveWorkspace: "Arquivar workspace",
newTab: "Nova aba",
closeCurrentTab: "Fechar aba atual",
jumpToWorkspace: "Ir para workspace",
+5 -9
View File
@@ -624,7 +624,7 @@ export const ru: TranslationResources = {
success: "Обновлено",
},
archive: {
label: "Архив рабочего дерева",
label: "Архивировать рабочее пространство",
pending: "Архивирование...",
success: "В архиве",
},
@@ -675,8 +675,6 @@ export const ru: TranslationResources = {
"Обновление недоступно, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.",
updateCurrent:
"Обновление недоступно, поскольку эта ветка уже обновлена ​​до версии{{baseRef}}.",
archiveNotWorktree:
"Архив здесь недоступен, поскольку это рабочее пространство не было создано как рабочее дерево Paseo.",
mergePrNoGithub: "Объединение PR сейчас недоступно, поскольку GitHub не подключен.",
mergePrMissing: "Объединение PR недоступно, поскольку еще нет запроса на включение",
mergePrDraft:
@@ -704,11 +702,9 @@ export const ru: TranslationResources = {
baseRefUnavailable: "Базовый номер недоступен.",
failedMerge: "Не удалось объединиться",
failedMergeFromBase: "Не удалось объединиться с базой.",
worktreePathUnavailable: "Путь к рабочему дереву недоступен.",
failedArchive: "Не удалось заархивировать рабочее дерево.",
},
archiveWarning: {
title: 'Архив "{{worktreeName}}"?',
title: 'Архив "{{workspaceName}}"?',
confirm: "Архив",
cancel: "Отмена",
uncommittedChanges: "Незафиксированные изменения",
@@ -858,7 +854,7 @@ export const ru: TranslationResources = {
copyBranchName: "Скопировать название ветки",
rename: "Переименовать рабочую область",
archive: "Архив",
archiveWorktree: "Архив рабочего дерева",
archiveWorkspace: "Архивировать рабочее пространство",
hideFromSidebar: "Скрыть с боковой панели",
archiving: "Архивирование...",
hiding: "Скрытие...",
@@ -881,7 +877,7 @@ export const ru: TranslationResources = {
branchNameCopied: "Название филиала скопировано.",
hostDisconnected: "Host не подключен",
hideFailed: "Не удалось скрыть рабочую область.",
archiveFailed: "Не удалось заархивировать рабочее дерево.",
archiveFailed: "Не удалось заархивировать рабочее пространство.",
},
},
},
@@ -1639,7 +1635,7 @@ export const ru: TranslationResources = {
openProject: "Открыть проект",
newWorkspace: "Новое рабочее пространство",
newWorktree: "Новое рабочее дерево",
archiveWorktree: "Архив рабочего дерева",
archiveWorkspace: "Архивировать рабочее пространство",
newTab: "Новая вкладка",
closeCurrentTab: "Закрыть текущую вкладку",
jumpToWorkspace: "Перейти в рабочую область",
+5 -8
View File
@@ -617,7 +617,7 @@ export const zhCN: TranslationResources = {
success: "已更新",
},
archive: {
label: "归档 worktree",
label: "归档工作区",
pending: "正在归档...",
success: "已归档",
},
@@ -656,7 +656,6 @@ export const zhCN: TranslationResources = {
updateNoBase: "无法更新,因为无法确定 base branch",
updateDirty: "有本地变更时无法更新,请先 commit 或 stash",
updateCurrent: "无法更新,因为此分支已与 {{baseRef}} 保持最新",
archiveNotWorktree: "此处无法归档,因为此 workspace 不是作为 Paseo worktree 创建的",
mergePrNoGithub: "当前无法 merge PR,因为 GitHub 未连接",
mergePrMissing: "无法 merge PR,因为还没有 pull request",
mergePrDraft: "无法 merge PR,因为 pull request 仍是 draft",
@@ -679,11 +678,9 @@ export const zhCN: TranslationResources = {
baseRefUnavailable: "Base ref 不可用",
failedMerge: "Merge 失败",
failedMergeFromBase: "从 base merge 失败",
worktreePathUnavailable: "Worktree 路径不可用",
failedArchive: "归档 worktree 失败",
},
archiveWarning: {
title: "归档「{{worktreeName}}」?",
title: "归档「{{workspaceName}}」?",
confirm: "归档",
cancel: "取消",
uncommittedChanges: "未 commit 的变更",
@@ -831,7 +828,7 @@ export const zhCN: TranslationResources = {
copyBranchName: "复制分支名称",
rename: "重命名 workspace",
archive: "归档",
archiveWorktree: "归档 worktree",
archiveWorkspace: "归档工作区",
hideFromSidebar: "从侧边栏隐藏",
archiving: "正在归档...",
hiding: "正在隐藏...",
@@ -853,7 +850,7 @@ export const zhCN: TranslationResources = {
branchNameCopied: "分支名称已复制",
hostDisconnected: "Host 未连接",
hideFailed: "隐藏 workspace 失败",
archiveFailed: "归档 worktree 失败",
archiveFailed: "归档工作区失败",
},
},
},
@@ -1586,7 +1583,7 @@ export const zhCN: TranslationResources = {
openProject: "打开项目",
newWorkspace: "新建 workspace",
newWorktree: "新建 worktree",
archiveWorktree: "归档 worktree",
archiveWorkspace: "归档工作区",
newTab: "新建标签",
closeCurrentTab: "关闭当前标签",
jumpToWorkspace: "跳转到 workspace",
+1 -1
View File
@@ -44,7 +44,7 @@ export type KeyboardActionId =
| "workspace.terminal.new"
| "workspace.new"
| "worktree.new"
| "worktree.archive"
| "workspace.archive"
| "view.toggle.focus"
| "theme.cycle"
| "message-input.action";
@@ -29,7 +29,7 @@ export type KeyboardActionId =
| "sidebar.toggle.right"
| "workspace.new"
| "worktree.new"
| "worktree.archive";
| "workspace.archive";
export type KeyboardActionDefinition =
| { id: "agent.interrupt"; scope: KeyboardActionScope }
@@ -60,7 +60,7 @@ export type KeyboardActionDefinition =
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
| { id: "workspace.new"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "worktree.archive"; scope: KeyboardActionScope };
| { id: "workspace.archive"; scope: KeyboardActionScope };
export interface KeyboardActionHandler {
handlerId: string;
@@ -121,7 +121,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"new-agent": "settings.shortcuts.help.openProject",
"new-workspace": "settings.shortcuts.help.newWorkspace",
"archive-worktree": "settings.shortcuts.help.archiveWorktree",
"archive-workspace": "settings.shortcuts.help.archiveWorkspace",
"workspace-tab-new": "settings.shortcuts.help.newTab",
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
"workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace",
@@ -221,28 +221,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- Archive worktree ---
// --- Archive workspace ---
{
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
id: "worktree-archive-cmd-shift-backspace-mac",
action: "worktree.archive",
action: "workspace.archive",
combo: "Cmd+Shift+Backspace",
when: { mac: true, commandCenter: false },
help: {
id: "archive-worktree",
id: "archive-workspace",
section: "projects",
label: "Archive worktree",
label: "Archive workspace",
keys: ["mod", "shift", "Backspace"],
},
},
{
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
id: "worktree-archive-ctrl-shift-backspace-non-mac",
action: "worktree.archive",
action: "workspace.archive",
combo: "Ctrl+Shift+Backspace",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "archive-worktree",
id: "archive-workspace",
section: "projects",
label: "Archive worktree",
label: "Archive workspace",
keys: ["mod", "shift", "Backspace"],
},
},
@@ -30,7 +30,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
["workspace.new", { id: "workspace.new", scope: "sidebar" }],
["worktree.archive", { id: "worktree.archive", scope: "sidebar" }],
["workspace.archive", { id: "workspace.archive", scope: "sidebar" }],
["worktree.new", { id: "worktree.new", scope: "sidebar" }],
["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }],
["workspace.tab.close.current", { id: "workspace.tab.close-current", scope: "workspace" }],
+1 -1
View File
@@ -46,7 +46,7 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
"workspace.new": { id: "workspace.new", scope: "sidebar" },
"worktree.archive": { id: "worktree.archive", scope: "sidebar" },
"workspace.archive": { id: "workspace.archive", scope: "sidebar" },
"worktree.new": { id: "worktree.new", scope: "sidebar" },
"workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" },
"workspace.tab.close.current": { id: "workspace.tab.close-current", scope: "workspace" },
@@ -77,11 +77,7 @@ function hasPendingArchiveForProject(input: {
}
}
const workspaceDirectory = getHostProjectSourceDirectory(input.project, input.selectedServerId);
return isWorkspaceArchivePending({
serverId: input.selectedServerId,
workspaceDirectory,
});
return false;
}
export function useNewWorkspaceProjectPicker({
@@ -62,7 +62,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { SplitContainer } from "@/components/split-container";
import { RetainedPanel } from "@/components/retained-panel";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ImportSessionSheet } from "@/components/import-session-sheet";
@@ -3276,56 +3276,56 @@ function WorkspaceScreenContent({
hideLabels
/>
) : null}
{!isMobile && isGitCheckout ? (
{!isMobile && workspaceDirectory ? (
<>
{workspaceDirectory ? (
<WorkspaceGitActions
serverId={normalizedServerId}
cwd={workspaceDirectory}
hideLabels={showCompactButtonLabels}
/>
) : null}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
style={explorerToggleStyle}
<WorkspaceActions
serverId={normalizedServerId}
cwd={workspaceDirectory}
hideLabels={showCompactButtonLabels}
/>
{isGitCheckout ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
style={explorerToggleStyle}
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
return (
<>
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
{workspaceDescriptor?.diffStat ? (
<DiffStat
additions={workspaceDescriptor.diffStat.additions}
deletions={workspaceDescriptor.diffStat.deletions}
/>
) : null}
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent
testID="workspace-explorer-toggle-tooltip"
side="left"
align="center"
offset={8}
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
return (
<>
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
{workspaceDescriptor?.diffStat ? (
<DiffStat
additions={workspaceDescriptor.diffStat.additions}
deletions={workspaceDescriptor.diffStat.deletions}
/>
) : null}
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent
testID="workspace-explorer-toggle-tooltip"
side="left"
align="center"
offset={8}
>
<View style={styles.explorerTooltipRow}>
<Text style={styles.explorerTooltipText}>
{t("workspace.tabs.explorer.toggle")}
</Text>
<Shortcut keys={EXPLORER_TOGGLE_KEYS} style={styles.explorerTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
<View style={styles.explorerTooltipRow}>
<Text style={styles.explorerTooltipText}>
{t("workspace.tabs.explorer.toggle")}
</Text>
<Shortcut keys={EXPLORER_TOGGLE_KEYS} style={styles.explorerTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
) : null}
</>
) : null}
{!isMobile && !isGitCheckout ? (
@@ -31,7 +31,7 @@ describe("selectProjectWorkspacesToArchive", () => {
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
worktreeName: "feature/risky",
workspaceName: "feature/risky",
isDirty: true,
aheadOfOrigin: 2,
diffStat: { additions: 5, deletions: 1 },
@@ -73,7 +73,7 @@ describe("selectProjectWorkspacesToArchive", () => {
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
worktreeName: "feature/risky",
workspaceName: "feature/risky",
isDirty: true,
aheadOfOrigin: 2,
diffStat: { additions: 5, deletions: 1 },
@@ -24,7 +24,7 @@ export async function selectProjectWorkspacesToArchive(
for (const workspace of workspaces) {
if (workspace.workspaceKind === "worktree") {
const shouldArchive = await confirmWorktreeArchive({
worktreeName: workspace.name,
workspaceName: workspace.name,
...toWorktreeArchiveRisk(workspace),
});
if (!shouldArchive) {
@@ -2,20 +2,30 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import {
confirmRiskyWorktreeArchive,
DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
const workspaceKey = buildWorkspaceTabPersistenceKey(input);
if (workspaceKey) {
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
}
useWorkspaceTabsStore.getState().purgeWorkspace(input);
}
export interface ArchiveWorkspaceInput {
serverId: string;
workspaceId: string;
workspaceDirectory: string | null | undefined;
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
isDirty?: boolean | null;
@@ -34,7 +44,6 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
const {
serverId,
workspaceId,
workspaceDirectory,
workspaceKind,
name,
isDirty,
@@ -46,37 +55,8 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
} = input;
const { t } = useTranslation();
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const archiveWorktreeRecord = useCallback(() => {
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceDirectory({
workspaceId,
workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
onArchiveStarted();
void archiveWorktree({
serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
workspaceId,
}).catch((error) => {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
);
});
}, [archiveWorktree, onArchiveStarted, serverId, t, toast, workspaceDirectory, workspaceId]);
const archiveNonWorktreeRecord = useCallback(async () => {
const archiveWorkspaceRecord = useCallback(async () => {
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
@@ -84,17 +64,18 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
}
onSetHiding?.(true);
try {
onArchiveStarted();
await archiveWorkspaceOptimistically({
client,
workspace: {
serverId,
workspaceId,
},
afterHide: onArchiveStarted,
});
purgeArchivedWorkspaceState({ serverId, workspaceId });
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
);
} finally {
onSetHiding?.(false);
@@ -106,7 +87,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
if (workspaceKind === "worktree") {
const confirmed = await confirmRiskyWorktreeArchive(
{
worktreeName: name,
workspaceName: name,
isDirty,
aheadOfOrigin,
diffStat,
@@ -116,15 +97,12 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
if (!confirmed) {
return;
}
archiveWorktreeRecord();
return;
}
await archiveNonWorktreeRecord();
await archiveWorkspaceRecord();
})();
}, [
aheadOfOrigin,
archiveNonWorktreeRecord,
archiveWorktreeRecord,
archiveWorkspaceRecord,
diffStat,
isDirty,
name,
@@ -144,23 +144,6 @@ describe("archiveWorkspaceOptimistically", () => {
}),
).toBe(false);
});
it("runs the after-hide hook after local state is hidden", async () => {
const archived = workspace();
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
const client = createClient(vi.fn(async () => archivePayload({ workspaceId: archived.id })));
const afterHide = vi.fn(() => {
expect(storedWorkspace(archived.id)).toBeUndefined();
});
await archiveWorkspaceOptimistically({
client,
workspace: target(),
afterHide,
});
expect(afterHide).toHaveBeenCalledOnce();
});
});
describe("archiveWorkspacesOptimistically", () => {
@@ -183,10 +166,7 @@ describe("archiveWorkspacesOptimistically", () => {
const failures = await archiveWorkspacesOptimistically({
getClient: () => client,
workspaces: [
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
],
workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })],
});
expect(failures).toHaveLength(1);
@@ -219,12 +199,10 @@ describe("archiveWorkspacesOptimistically", () => {
target({
serverId: SERVER_ID,
workspaceId: first.id,
workspaceDirectory: first.workspaceDirectory,
}),
target({
serverId: SECOND_SERVER_ID,
workspaceId: second.id,
workspaceDirectory: second.workspaceDirectory,
}),
],
});
@@ -9,7 +9,6 @@ import { i18n } from "@/i18n/i18next";
export interface WorkspaceArchiveTarget {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}
interface WorkspaceArchiveClient {
@@ -50,7 +49,6 @@ function hideWorkspaceOptimistically(
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return { workspace: snapshot };
@@ -83,10 +81,8 @@ async function archiveWorkspaceOrThrow(input: {
export async function archiveWorkspaceOptimistically(input: {
client: WorkspaceArchiveClient;
workspace: WorkspaceArchiveTarget;
afterHide?: () => void;
}): Promise<void> {
const snapshot = hideWorkspaceOptimistically(input.workspace);
input.afterHide?.();
try {
await archiveWorkspaceOrThrow({