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:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "انتقل إلى مساحة العمل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "ワークスペースにジャンプ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Перейти в рабочую область",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user