feat: open Changes as a workspace tab (#2298)
Open the complete working comparison in a desktop workspace tab while preserving inline sidebar diffs when the tab is closed. Working and commit comparisons share one diff panel, and workspace layout is the sole owner of live tab persistence and identity. Refs #1520
This commit is contained in:
@@ -28,26 +28,20 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
|
||||
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await panel.getByTestId("commit-diff-layout-split").click();
|
||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await layoutToggle.click();
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
||||
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await commitRow.click();
|
||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
|
||||
"Switch to unified diff",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
interface WorkspaceFixtureOptions {
|
||||
@@ -241,6 +242,80 @@ test("changes file actions open from the kebab and right-click", async ({ page }
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const changesTabToggle = page.getByTestId("changes-open-tab");
|
||||
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
|
||||
await changesTabToggle.click();
|
||||
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
|
||||
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
|
||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await workingDiffLayoutToggle.click();
|
||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
|
||||
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
|
||||
await page.getByTestId("working-diff-toggle-wrap-lines").click();
|
||||
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
|
||||
"Scroll long lines",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
|
||||
"zz-deleted.ts",
|
||||
);
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("Deleted");
|
||||
|
||||
await changesTabToggle.click();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -266,6 +341,11 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Collapse all" }).click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Expand all" }).click();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
|
||||
@@ -495,7 +575,7 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
|
||||
}
|
||||
return { id: createdWorkspace.workspace.id };
|
||||
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
|
||||
@@ -99,7 +99,7 @@ import {
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
|
||||
@@ -4,10 +4,8 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
||||
import { mountBrowserAutomationHandler } from "./handler";
|
||||
import type { DesktopHostBridge } from "@/desktop/host";
|
||||
import { useBrowserStore } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
|
||||
@@ -6,11 +6,8 @@ import {
|
||||
resizeResidentBrowserWebview,
|
||||
} from "@/components/browser-webview-resident";
|
||||
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
|
||||
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
|
||||
export type ClientSlashCommandExecution = "immediate" | "insert";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
|
||||
@@ -36,10 +36,8 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
|
||||
@@ -76,7 +76,7 @@ import {
|
||||
type SplitPane,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import { resolveFocusedChatTarget } from "./focused-chat-target";
|
||||
|
||||
function layoutWithTarget(
|
||||
target: import("@/stores/workspace-tabs-store").WorkspaceTab["target"],
|
||||
target: import("@/workspace-tabs/model").WorkspaceTab["target"],
|
||||
): WorkspaceLayout {
|
||||
return {
|
||||
root: {
|
||||
|
||||
+561
-388
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ interface UseCheckoutDiffQueryOptions {
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
enabled?: boolean;
|
||||
queryScope?: string;
|
||||
}
|
||||
|
||||
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
@@ -44,6 +45,7 @@ export function useCheckoutDiffQuery({
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled = true,
|
||||
queryScope,
|
||||
}: UseCheckoutDiffQueryOptions) {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const normalizedCompare = useMemo(
|
||||
@@ -53,10 +55,17 @@ export function useCheckoutDiffQuery({
|
||||
const compareMode = normalizedCompare.mode;
|
||||
const compareBaseRef = normalizedCompare.baseRef;
|
||||
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||
const queryKey = useMemo(
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||
);
|
||||
const queryKey = useMemo(() => {
|
||||
const comparisonKey = checkoutDiffQueryKey(
|
||||
serverId,
|
||||
cwd,
|
||||
compareMode,
|
||||
compareBaseRef,
|
||||
compareIgnoreWhitespace,
|
||||
);
|
||||
const normalizedScope = queryScope?.trim();
|
||||
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
|
||||
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
|
||||
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
|
||||
const routeEnabled = Boolean(enabled && isConnected && cwd);
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
buildWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import {
|
||||
buildReviewDraftKey,
|
||||
buildReviewDraftScopeKey,
|
||||
useInlineReviewController,
|
||||
useResolvedDiffMode,
|
||||
useReviewAttachmentSnapshot,
|
||||
useSetDiffModeOverride,
|
||||
} from "@/review";
|
||||
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
|
||||
interface UseWorkingDiffOptions {
|
||||
serverId: string;
|
||||
workspaceId?: string;
|
||||
cwd: string;
|
||||
ignoreWhitespace: boolean;
|
||||
enabled: boolean;
|
||||
queryScope?: string;
|
||||
}
|
||||
|
||||
export function useWorkingDiff({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
ignoreWhitespace,
|
||||
enabled,
|
||||
queryScope,
|
||||
}: UseWorkingDiffOptions) {
|
||||
const {
|
||||
status,
|
||||
isLoading: isStatusLoading,
|
||||
isError: isStatusError,
|
||||
error: statusError,
|
||||
} = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const gitStatus = status && status.isGit ? status : null;
|
||||
const isGit = Boolean(gitStatus);
|
||||
const notGit = status !== null && !status.isGit && !status.error;
|
||||
const statusErrorMessage =
|
||||
status?.error?.message ??
|
||||
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
||||
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
|
||||
const currentBranchName =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
||||
|
||||
const reviewDraftScopeKey = useMemo(
|
||||
() =>
|
||||
buildReviewDraftScopeKey({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
}),
|
||||
[baseRef, cwd, ignoreWhitespace, serverId, workspaceId],
|
||||
);
|
||||
const diffMode = useResolvedDiffMode({
|
||||
scopeKey: reviewDraftScopeKey,
|
||||
hasUncommittedChanges,
|
||||
});
|
||||
const setDiffModeOverride = useSetDiffModeOverride();
|
||||
const selectDiffMode = useCallback(
|
||||
(mode: "uncommitted" | "base") => {
|
||||
setDiffModeOverride({
|
||||
scopeKey: reviewDraftScopeKey,
|
||||
override: { serverId, cwd, mode, isDirtyAtSelection: hasUncommittedChanges },
|
||||
});
|
||||
},
|
||||
[cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride],
|
||||
);
|
||||
const selectUncommitted = useCallback(() => selectDiffMode("uncommitted"), [selectDiffMode]);
|
||||
const selectBase = useCallback(() => selectDiffMode("base"), [selectDiffMode]);
|
||||
|
||||
const {
|
||||
files,
|
||||
payloadError: diffPayloadError,
|
||||
isLoading: isDiffLoading,
|
||||
} = useCheckoutDiffQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
mode: diffMode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled: enabled && isGit,
|
||||
queryScope,
|
||||
});
|
||||
const reviewDraftKey = useMemo(
|
||||
() =>
|
||||
buildReviewDraftKey({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
mode: diffMode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
}),
|
||||
[baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId],
|
||||
);
|
||||
const reviewActions = useInlineReviewController({ reviewDraftKey });
|
||||
const reviewAttachment = useReviewAttachmentSnapshot({
|
||||
key: reviewDraftKey,
|
||||
diffFiles: files,
|
||||
cwd,
|
||||
mode: diffMode,
|
||||
baseRef,
|
||||
});
|
||||
|
||||
return {
|
||||
status,
|
||||
isStatusLoading,
|
||||
isGit,
|
||||
notGit,
|
||||
statusErrorMessage,
|
||||
baseRef,
|
||||
currentBranchName,
|
||||
diffMode,
|
||||
selectUncommitted,
|
||||
selectBase,
|
||||
files,
|
||||
diffPayloadError,
|
||||
isDiffLoading,
|
||||
reviewActions,
|
||||
reviewAttachment,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePublishWorkingDiffAttachment({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
attachment,
|
||||
enabled,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId?: string;
|
||||
cwd: string;
|
||||
attachment: ReturnType<typeof useWorkingDiff>["reviewAttachment"];
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const scopeKey = useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
|
||||
[cwd, serverId, workspaceId],
|
||||
);
|
||||
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
||||
(state) => state.setWorkspaceAttachments,
|
||||
);
|
||||
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
||||
(state) => state.clearWorkspaceAttachments,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const attachments = attachment ? [attachment] : [];
|
||||
setWorkspaceAttachments({ scopeKey, attachments });
|
||||
return () => {
|
||||
const current = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey];
|
||||
if (current === attachments) {
|
||||
clearWorkspaceAttachments({ scopeKey });
|
||||
}
|
||||
};
|
||||
}, [attachment, clearWorkspaceAttachments, enabled, scopeKey, setWorkspaceAttachments]);
|
||||
}
|
||||
@@ -758,6 +758,8 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "فتح علامة تبويب التغييرات",
|
||||
closeChangesTab: "إغلاق علامة تبويب التغييرات",
|
||||
binaryFile: "ملف ثنائي",
|
||||
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
|
||||
unified: "الفرق الموحدة",
|
||||
@@ -1494,6 +1496,8 @@ export const ar: TranslationResources = {
|
||||
changesLabel: "التغييرات",
|
||||
changesSubtitle: "فروقات شجرة العمل",
|
||||
commitSubtitle: "فروقات الالتزام",
|
||||
uncommittedSubtitle: "تغييرات غير ملتزم بها",
|
||||
baseSubtitle: "مقارنة مع {{baseRef}}",
|
||||
directoryMissing: "لم يتم العثور على دليل Workspace.",
|
||||
empty: "لا توجد تغييرات",
|
||||
loadError: "فشل تحميل الفروقات",
|
||||
|
||||
@@ -768,6 +768,8 @@ export const en = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "Open Changes tab",
|
||||
closeChangesTab: "Close Changes tab",
|
||||
binaryFile: "Binary file",
|
||||
tooLarge: "Diff too large to display",
|
||||
unified: "Unified diff",
|
||||
@@ -1505,6 +1507,8 @@ export const en = {
|
||||
changesLabel: "Changes",
|
||||
changesSubtitle: "Working tree diff",
|
||||
commitSubtitle: "Commit diff",
|
||||
uncommittedSubtitle: "Uncommitted changes",
|
||||
baseSubtitle: "Compared with {{baseRef}}",
|
||||
directoryMissing: "Workspace directory not found.",
|
||||
empty: "No changes",
|
||||
loadError: "Failed to load diff",
|
||||
|
||||
@@ -789,6 +789,8 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "Abrir la pestaña Cambios",
|
||||
closeChangesTab: "Cerrar la pestaña Cambios",
|
||||
binaryFile: "archivo binario",
|
||||
tooLarge: "La diferencia es demasiado grande para mostrarse",
|
||||
unified: "Diferencia unificada",
|
||||
@@ -1537,6 +1539,8 @@ export const es: TranslationResources = {
|
||||
changesLabel: "Cambios",
|
||||
changesSubtitle: "Diferencias del árbol de trabajo",
|
||||
commitSubtitle: "Diferencias del commit",
|
||||
uncommittedSubtitle: "Cambios sin confirmar",
|
||||
baseSubtitle: "Comparado con {{baseRef}}",
|
||||
directoryMissing: "No se encontró el directorio de Workspace.",
|
||||
empty: "Sin cambios",
|
||||
loadError: "No se pudieron cargar las diferencias",
|
||||
|
||||
@@ -788,6 +788,8 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "Ouvrir l'onglet Modifications",
|
||||
closeChangesTab: "Fermer l'onglet Modifications",
|
||||
binaryFile: "Fichier binaire",
|
||||
tooLarge: "Diff trop grand pour être affiché",
|
||||
unified: "Différentiel unifié",
|
||||
@@ -1540,6 +1542,8 @@ export const fr: TranslationResources = {
|
||||
changesLabel: "Modifications",
|
||||
changesSubtitle: "Différences de l'arbre de travail",
|
||||
commitSubtitle: "Différences du commit",
|
||||
uncommittedSubtitle: "Modifications non validées",
|
||||
baseSubtitle: "Comparé à {{baseRef}}",
|
||||
directoryMissing: "Répertoire Workspace introuvable.",
|
||||
empty: "Aucune modification",
|
||||
loadError: "Échec du chargement des différences",
|
||||
|
||||
@@ -769,6 +769,8 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "変更タブを開く",
|
||||
closeChangesTab: "変更タブを閉じる",
|
||||
binaryFile: "バイナリファイル",
|
||||
tooLarge: "差分が大きすぎて表示できません",
|
||||
unified: "ユニファイド差分",
|
||||
@@ -1510,6 +1512,8 @@ export const ja: TranslationResources = {
|
||||
changesLabel: "変更",
|
||||
changesSubtitle: "作業ツリーの差分",
|
||||
commitSubtitle: "コミット差分",
|
||||
uncommittedSubtitle: "未コミットの変更",
|
||||
baseSubtitle: "{{baseRef}} との比較",
|
||||
directoryMissing: "ワークスペースディレクトリが見つかりません。",
|
||||
empty: "変更はありません",
|
||||
loadError: "差分の読み込みに失敗しました",
|
||||
|
||||
@@ -780,6 +780,8 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "Abrir a aba Alterações",
|
||||
closeChangesTab: "Fechar a aba Alterações",
|
||||
binaryFile: "Arquivo binário",
|
||||
tooLarge: "Diff grande demais para exibir",
|
||||
unified: "Diff unificado",
|
||||
@@ -1523,6 +1525,8 @@ export const ptBR: TranslationResources = {
|
||||
changesLabel: "Alterações",
|
||||
changesSubtitle: "Diff da árvore de trabalho",
|
||||
commitSubtitle: "Diff do commit",
|
||||
uncommittedSubtitle: "Alterações não commitadas",
|
||||
baseSubtitle: "Comparado com {{baseRef}}",
|
||||
directoryMissing: "Diretório do workspace não encontrado.",
|
||||
empty: "Nenhuma alteração",
|
||||
loadError: "Falha ao carregar diff",
|
||||
|
||||
@@ -780,6 +780,8 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "Открыть вкладку «Изменения»",
|
||||
closeChangesTab: "Закрыть вкладку «Изменения»",
|
||||
binaryFile: "Бинарный файл",
|
||||
tooLarge: "Разница слишком велика для отображения",
|
||||
unified: "Единый дифференциал",
|
||||
@@ -1528,6 +1530,8 @@ export const ru: TranslationResources = {
|
||||
changesLabel: "Изменения",
|
||||
changesSubtitle: "Различия рабочего дерева",
|
||||
commitSubtitle: "Различия коммита",
|
||||
uncommittedSubtitle: "Незафиксированные изменения",
|
||||
baseSubtitle: "Сравнение с {{baseRef}}",
|
||||
directoryMissing: "Каталог Workspace не найден.",
|
||||
empty: "Нет изменений",
|
||||
loadError: "Не удалось загрузить различия",
|
||||
|
||||
@@ -750,6 +750,8 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "打开“更改”标签页",
|
||||
closeChangesTab: "关闭“更改”标签页",
|
||||
binaryFile: "二进制文件",
|
||||
tooLarge: "Diff 过大,无法显示",
|
||||
unified: "Unified diff",
|
||||
@@ -1475,6 +1477,8 @@ export const zhCN: TranslationResources = {
|
||||
changesLabel: "更改",
|
||||
changesSubtitle: "工作区差异",
|
||||
commitSubtitle: "提交差异",
|
||||
uncommittedSubtitle: "未提交的更改",
|
||||
baseSubtitle: "与 {{baseRef}} 比较",
|
||||
directoryMissing: "未找到 workspace 目录。",
|
||||
empty: "没有更改",
|
||||
loadError: "加载差异失败",
|
||||
|
||||
@@ -73,7 +73,7 @@ import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
useHideFinishedProviderSubagents,
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitCommitHorizontal } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { SharedDiffView } from "@/git/diff-pane";
|
||||
import { useCommitDiffFiles } from "@/git/use-diff-files";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
|
||||
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||
|
||||
function CommitDiffPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const { settings } = useAppSettings();
|
||||
const { preferences, updatePreferences } = useChangesPreferences();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
|
||||
|
||||
const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({
|
||||
serverId,
|
||||
cwd: cwd ?? "",
|
||||
sha: target.sha,
|
||||
enabled: Boolean(cwd),
|
||||
});
|
||||
const canUseSplitLayout = isWeb && !isCompact;
|
||||
const effectiveLayout = canUseSplitLayout ? preferences.layout : "unified";
|
||||
const layoutOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: "unified" as const,
|
||||
label: t("workspace.git.diff.unified"),
|
||||
testID: "commit-diff-layout-unified",
|
||||
},
|
||||
{
|
||||
value: "split" as const,
|
||||
label: t("workspace.git.diff.split"),
|
||||
testID: "commit-diff-layout-split",
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: "unified" | "split") => {
|
||||
void updatePreferences({ layout });
|
||||
},
|
||||
[updatePreferences],
|
||||
);
|
||||
const displayPreferences = useMemo(
|
||||
() => ({
|
||||
layout: effectiveLayout,
|
||||
wrapLines: preferences.wrapLines,
|
||||
codeFontSize: settings.codeFontSize,
|
||||
monoFontFamily: settings.monoFontFamily,
|
||||
}),
|
||||
[effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily],
|
||||
);
|
||||
const commitMode = useMemo(() => ({ kind: "commit" as const }), []);
|
||||
|
||||
if (!cwd) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.mutedText}>{t("panels.diff.directoryMissing")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (capabilityMissing) {
|
||||
return (
|
||||
<View style={styles.centerState} testID="commit-diff-capability-missing">
|
||||
<Text style={styles.mutedText}>{t("panels.diff.capabilityMissing")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
let bodyContent: ReactNode;
|
||||
if (error) {
|
||||
bodyContent = (
|
||||
<View style={styles.centerState} testID="commit-diff-error">
|
||||
<Text style={styles.errorText}>{t("panels.diff.loadError")}</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (isLoading && files.length === 0) {
|
||||
bodyContent = (
|
||||
<View style={styles.centerState} testID="commit-diff-loading">
|
||||
<Text style={styles.mutedText}>{t("workspace.tabs.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (files.length === 0) {
|
||||
bodyContent = (
|
||||
<View style={styles.centerState} testID="commit-diff-empty">
|
||||
<Text style={styles.mutedText}>{t("panels.diff.empty")}</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
bodyContent = (
|
||||
<SharedDiffView files={files} displayPreferences={displayPreferences} mode={commitMode} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="commit-diff-panel">
|
||||
{canUseSplitLayout ? (
|
||||
<View style={styles.toolbar} testID="commit-diff-toolbar">
|
||||
<SegmentedControl
|
||||
options={layoutOptions}
|
||||
value={preferences.layout}
|
||||
onValueChange={handleLayoutChange}
|
||||
size="sm"
|
||||
testID="commit-diff-layout-control"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.body}>{bodyContent}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function useCommitDiffPanelDescriptor(
|
||||
target: Extract<WorkspaceTabTarget, { kind: "commit_diff" }>,
|
||||
): PanelDescriptor {
|
||||
const { t } = useTranslation();
|
||||
return {
|
||||
label: target.sha.slice(0, 7),
|
||||
subtitle: t("panels.diff.commitSubtitle"),
|
||||
tooltip: target.sha,
|
||||
titleState: "ready",
|
||||
icon: ThemedGitCommitHorizontal,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
|
||||
kind: "commit_diff",
|
||||
component: CommitDiffPanel,
|
||||
useDescriptor: useCommitDiffPanelDescriptor,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
toolbar: {
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
flexShrink: 0,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingTop: theme.spacing[16],
|
||||
},
|
||||
mutedText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.destructive,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useCallback, useMemo, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileDiff, GitCommitHorizontal } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import {
|
||||
DiffFilesToolbar,
|
||||
DiffLayoutToggle,
|
||||
DiffModeMenu,
|
||||
DiffOptionsMenu,
|
||||
resolveDiffLayout,
|
||||
SharedDiffView,
|
||||
} from "@/git/diff-pane";
|
||||
import { useCommitDiffFiles } from "@/git/use-diff-files";
|
||||
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
const ThemedFileDiff = withUnistyles(FileDiff);
|
||||
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||
|
||||
function useDiffPanelPreferences() {
|
||||
const { settings } = useAppSettings();
|
||||
const { preferences, updatePreferences } = useChangesPreferences();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const canUseSplitLayout = isWeb && !isCompact;
|
||||
const effectiveLayout = resolveDiffLayout(preferences.layout, canUseSplitLayout);
|
||||
const displayPreferences = useMemo(
|
||||
() => ({
|
||||
layout: effectiveLayout,
|
||||
wrapLines: preferences.wrapLines,
|
||||
codeFontSize: settings.codeFontSize,
|
||||
monoFontFamily: settings.monoFontFamily,
|
||||
}),
|
||||
[effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily],
|
||||
);
|
||||
const toggleLayout = useCallback(() => {
|
||||
void updatePreferences({ layout: preferences.layout === "unified" ? "split" : "unified" });
|
||||
}, [preferences.layout, updatePreferences]);
|
||||
const toggleWrapLines = useCallback(() => {
|
||||
void updatePreferences({ wrapLines: !preferences.wrapLines });
|
||||
}, [preferences.wrapLines, updatePreferences]);
|
||||
const toggleHideWhitespace = useCallback(() => {
|
||||
void updatePreferences({ hideWhitespace: !preferences.hideWhitespace });
|
||||
}, [preferences.hideWhitespace, updatePreferences]);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
isCompact,
|
||||
canUseSplitLayout,
|
||||
displayPreferences,
|
||||
toggleLayout,
|
||||
toggleWrapLines,
|
||||
toggleHideWhitespace,
|
||||
};
|
||||
}
|
||||
|
||||
function PanelState({
|
||||
message,
|
||||
tone = "muted",
|
||||
testID,
|
||||
}: {
|
||||
message: string;
|
||||
tone?: "muted" | "error";
|
||||
testID?: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.centerState} testID={testID}>
|
||||
<Text style={tone === "error" ? styles.errorText : styles.mutedText}>{message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkingDiffBody({
|
||||
cwd,
|
||||
isConnected,
|
||||
workingDiff,
|
||||
hideWhitespace,
|
||||
displayPreferences,
|
||||
mode,
|
||||
}: {
|
||||
cwd: string | null | undefined;
|
||||
isConnected: boolean;
|
||||
workingDiff: ReturnType<typeof useWorkingDiff>;
|
||||
hideWhitespace: boolean;
|
||||
displayPreferences: ReturnType<typeof useDiffPanelPreferences>["displayPreferences"];
|
||||
mode: Extract<ComponentProps<typeof SharedDiffView>["mode"], { kind: "working_tab" }>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!cwd) {
|
||||
return <PanelState message={t("panels.diff.directoryMissing")} />;
|
||||
}
|
||||
if (!isConnected) {
|
||||
return <PanelState message={t("workspace.terminal.hostDisconnected")} />;
|
||||
}
|
||||
if (workingDiff.isStatusLoading) {
|
||||
return <PanelState message={t("workspace.git.diff.checkingRepository")} />;
|
||||
}
|
||||
if (workingDiff.statusErrorMessage) {
|
||||
return (
|
||||
<PanelState
|
||||
message={workingDiff.statusErrorMessage}
|
||||
tone="error"
|
||||
testID="working-diff-error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (workingDiff.notGit) {
|
||||
return <PanelState message={t("workspace.git.diff.notRepository")} />;
|
||||
}
|
||||
if (workingDiff.diffPayloadError) {
|
||||
return (
|
||||
<PanelState message={t("panels.diff.loadError")} tone="error" testID="working-diff-error" />
|
||||
);
|
||||
}
|
||||
if (workingDiff.isDiffLoading && workingDiff.files.length === 0) {
|
||||
return <PanelState message={t("workspace.tabs.loading")} testID="working-diff-loading" />;
|
||||
}
|
||||
if (workingDiff.files.length === 0) {
|
||||
return (
|
||||
<PanelState
|
||||
message={
|
||||
hideWhitespace ? t("workspace.git.diff.emptyHiddenWhitespace") : t("panels.diff.empty")
|
||||
}
|
||||
testID="working-diff-empty"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SharedDiffView files={workingDiff.files} displayPreferences={displayPreferences} mode={mode} />
|
||||
);
|
||||
}
|
||||
|
||||
function WorkingDiffPanel() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { serverId, workspaceId, tabId, target } = usePaneContext();
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const panelPreferences = useDiffPanelPreferences();
|
||||
const [expandedPaths, setExpandedPaths] = useState<string[] | null>(null);
|
||||
invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target");
|
||||
|
||||
const workingDiff = useWorkingDiff({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd: cwd ?? "",
|
||||
ignoreWhitespace: panelPreferences.preferences.hideWhitespace,
|
||||
enabled: Boolean(cwd) && isActive,
|
||||
queryScope: `working-diff-tab:${tabId}`,
|
||||
});
|
||||
usePublishWorkingDiffAttachment({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd: cwd ?? "",
|
||||
attachment: workingDiff.reviewAttachment,
|
||||
enabled: Boolean(cwd) && isActive,
|
||||
});
|
||||
|
||||
const refreshSupported = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
||||
);
|
||||
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
|
||||
const isRefreshing =
|
||||
useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd: cwd ?? "", actionId: "refresh" }),
|
||||
) === "pending";
|
||||
const refresh = useCallback(() => {
|
||||
if (!cwd || isRefreshing) {
|
||||
return;
|
||||
}
|
||||
void runRefresh({ serverId, cwd }).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
||||
});
|
||||
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
|
||||
|
||||
const expandedPathSet = useMemo(
|
||||
() => (expandedPaths === null ? null : new Set(expandedPaths)),
|
||||
[expandedPaths],
|
||||
);
|
||||
const allFilesExpanded =
|
||||
workingDiff.files.length > 0 &&
|
||||
(expandedPathSet === null || workingDiff.files.every((file) => expandedPathSet.has(file.path)));
|
||||
const toggleExpandAll = useCallback(() => {
|
||||
setExpandedPaths(allFilesExpanded ? [] : null);
|
||||
}, [allFilesExpanded]);
|
||||
const mode = useMemo(
|
||||
() => ({
|
||||
kind: "working_tab" as const,
|
||||
expandedPaths,
|
||||
reviewActions: workingDiff.reviewActions,
|
||||
focusPath: target.focusPath,
|
||||
focusRequestId: target.focusRequestId,
|
||||
onExpandedPathsChange: setExpandedPaths,
|
||||
}),
|
||||
[expandedPaths, target.focusPath, target.focusRequestId, workingDiff.reviewActions],
|
||||
);
|
||||
|
||||
const baseRefLabel = workingDiff.baseRef?.replace(/^refs\/(heads|remotes)\//, "") ?? "";
|
||||
return (
|
||||
<View style={styles.container} testID="working-diff-panel">
|
||||
<View style={styles.toolbar}>
|
||||
<DiffModeMenu
|
||||
diffMode={workingDiff.diffMode}
|
||||
committedDescription={baseRefLabel || undefined}
|
||||
testIDPrefix="working-diff"
|
||||
onSelectUncommitted={workingDiff.selectUncommitted}
|
||||
onSelectBase={workingDiff.selectBase}
|
||||
/>
|
||||
<View style={styles.toolbarActions} testID="working-diff-toolbar">
|
||||
{panelPreferences.canUseSplitLayout ? (
|
||||
<DiffLayoutToggle
|
||||
layout={panelPreferences.preferences.layout}
|
||||
isMobile={panelPreferences.isCompact}
|
||||
testID="working-diff-toggle-layout"
|
||||
onToggle={panelPreferences.toggleLayout}
|
||||
/>
|
||||
) : null}
|
||||
{workingDiff.files.length > 0 ? (
|
||||
<DiffFilesToolbar
|
||||
allFileDiffsExpanded={allFilesExpanded}
|
||||
isMobile={panelPreferences.isCompact}
|
||||
testID="working-diff-toggle-expand-all"
|
||||
onToggleExpandAll={toggleExpandAll}
|
||||
/>
|
||||
) : null}
|
||||
<DiffOptionsMenu
|
||||
hideWhitespace={panelPreferences.preferences.hideWhitespace}
|
||||
isMobile={panelPreferences.isCompact}
|
||||
isRefreshing={isRefreshing}
|
||||
refreshSupported={refreshSupported}
|
||||
testIDPrefix="working-diff"
|
||||
wrapLines={panelPreferences.preferences.wrapLines}
|
||||
onRefresh={refresh}
|
||||
onToggleHideWhitespace={panelPreferences.toggleHideWhitespace}
|
||||
onToggleWrapLines={panelPreferences.toggleWrapLines}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
<WorkingDiffBody
|
||||
cwd={cwd}
|
||||
isConnected={isConnected}
|
||||
workingDiff={workingDiff}
|
||||
hideWhitespace={panelPreferences.preferences.hideWhitespace}
|
||||
displayPreferences={panelPreferences.displayPreferences}
|
||||
mode={mode}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CommitDiffPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const panelPreferences = useDiffPanelPreferences();
|
||||
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
|
||||
const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({
|
||||
serverId,
|
||||
cwd: cwd ?? "",
|
||||
sha: target.sha,
|
||||
enabled: Boolean(cwd),
|
||||
});
|
||||
const mode = useMemo(() => ({ kind: "commit" as const }), []);
|
||||
|
||||
let body: ReactNode;
|
||||
if (!cwd) {
|
||||
body = <PanelState message={t("panels.diff.directoryMissing")} />;
|
||||
} else if (capabilityMissing) {
|
||||
body = (
|
||||
<PanelState
|
||||
message={t("panels.diff.capabilityMissing")}
|
||||
testID="commit-diff-capability-missing"
|
||||
/>
|
||||
);
|
||||
} else if (error) {
|
||||
body = (
|
||||
<PanelState message={t("panels.diff.loadError")} tone="error" testID="commit-diff-error" />
|
||||
);
|
||||
} else if (isLoading && files.length === 0) {
|
||||
body = <PanelState message={t("workspace.tabs.loading")} testID="commit-diff-loading" />;
|
||||
} else if (files.length === 0) {
|
||||
body = <PanelState message={t("panels.diff.empty")} testID="commit-diff-empty" />;
|
||||
} else {
|
||||
body = (
|
||||
<SharedDiffView
|
||||
files={files}
|
||||
displayPreferences={panelPreferences.displayPreferences}
|
||||
mode={mode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="commit-diff-panel">
|
||||
{panelPreferences.canUseSplitLayout ? (
|
||||
<View style={styles.toolbar}>
|
||||
<View style={styles.toolbarActions} testID="commit-diff-toolbar">
|
||||
<DiffLayoutToggle
|
||||
layout={panelPreferences.preferences.layout}
|
||||
isMobile={panelPreferences.isCompact}
|
||||
testID="commit-diff-toggle-layout"
|
||||
onToggle={panelPreferences.toggleLayout}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.body}>{body}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function useWorkingDiffPanelDescriptor(): PanelDescriptor {
|
||||
const { t } = useTranslation();
|
||||
return {
|
||||
label: t("panels.diff.changesLabel"),
|
||||
subtitle: t("panels.diff.changesSubtitle"),
|
||||
tooltip: t("panels.diff.changesSubtitle"),
|
||||
titleState: "ready",
|
||||
icon: ThemedFileDiff,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function useCommitDiffPanelDescriptor(
|
||||
target: Extract<WorkspaceTabTarget, { kind: "commit_diff" }>,
|
||||
): PanelDescriptor {
|
||||
const { t } = useTranslation();
|
||||
return {
|
||||
label: target.sha.slice(0, 7),
|
||||
subtitle: t("panels.diff.commitSubtitle"),
|
||||
tooltip: target.sha,
|
||||
titleState: "ready",
|
||||
icon: ThemedGitCommitHorizontal,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const workingDiffPanelRegistration: PanelRegistration<"working_diff"> = {
|
||||
kind: "working_diff",
|
||||
component: WorkingDiffPanel,
|
||||
useDescriptor: useWorkingDiffPanelDescriptor,
|
||||
};
|
||||
|
||||
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
|
||||
kind: "commit_diff",
|
||||
component: CommitDiffPanel,
|
||||
useDescriptor: useCommitDiffPanelDescriptor,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
toolbar: {
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
flexShrink: 0,
|
||||
},
|
||||
toolbarActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingTop: theme.spacing[16],
|
||||
},
|
||||
mutedText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.destructive,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, type ReactNode } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
|
||||
export interface PaneContextValue {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
|
||||
export interface PanelIconProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { agentPanelRegistration } from "@/panels/agent-panel";
|
||||
import { browserPanelRegistration } from "@/panels/browser-panel";
|
||||
import { commitDiffPanelRegistration } from "@/panels/commit-diff-panel";
|
||||
import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel";
|
||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||
import { filePanelRegistration } from "@/panels/file-panel";
|
||||
import { registerPanel } from "@/panels/panel-registry";
|
||||
@@ -22,5 +22,6 @@ export function ensurePanelsRegistered(): void {
|
||||
registerPanel(browserPanelRegistration);
|
||||
registerPanel(filePanelRegistration);
|
||||
registerPanel(commitDiffPanelRegistration);
|
||||
registerPanel(workingDiffPanelRegistration);
|
||||
panelsRegistered = true;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import invariant from "tiny-invariant";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
|
||||
@@ -78,7 +78,7 @@ import type { MessagePayload } from "@/composer/types";
|
||||
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
||||
import {
|
||||
getWorkspaceNamingAttachments,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { selectVisibleAgentIds } from "./visible-agent-ids";
|
||||
|
||||
test("selects only the active agent tab in every visible pane", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { deriveWorkspacePaneState } from "./workspace-pane-state";
|
||||
|
||||
export function selectVisibleAgentIds(input: {
|
||||
|
||||
@@ -444,7 +444,7 @@ interface WorkspaceDesktopTabsRowProps {
|
||||
|
||||
function getFallbackTabLabel(
|
||||
tab: WorkspaceTabDescriptor,
|
||||
labels: { newAgent: string; setup: string; terminal: string; agent: string },
|
||||
labels: { newAgent: string; setup: string; terminal: string; agent: string; changes: string },
|
||||
): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return labels.newAgent;
|
||||
@@ -458,6 +458,9 @@ function getFallbackTabLabel(
|
||||
if (tab.target.kind === "file") {
|
||||
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
|
||||
}
|
||||
if (tab.target.kind === "working_diff") {
|
||||
return labels.changes;
|
||||
}
|
||||
return labels.agent;
|
||||
}
|
||||
|
||||
@@ -836,6 +839,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
setup: t("workspace.tabs.fallback.setup"),
|
||||
terminal: t("workspace.tabs.fallback.terminal"),
|
||||
agent: t("workspace.tabs.fallback.agent"),
|
||||
changes: t("panels.diff.changesLabel"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
} from "@/workspace/file-open";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
interface OpenWorkspaceFileFromExplorerInput {
|
||||
filePath: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resolveSideFileOpenPlacement,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
|
||||
function createTab(tabId: string, target: WorkspaceTab["target"]): WorkspaceTab {
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type SplitPane,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
|
||||
@@ -74,14 +74,17 @@ import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
getFocusedBrowserId,
|
||||
type WorkspaceLayout,
|
||||
useWorkspaceLayoutStore,
|
||||
useWorkspaceLayoutStoreHydrated,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceTab,
|
||||
type WorkspaceTabTarget,
|
||||
} from "@/workspace-tabs/model";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
@@ -345,6 +348,7 @@ function getFallbackTabOptionLabel(
|
||||
terminal: string;
|
||||
browser: string;
|
||||
agent: string;
|
||||
changes: string;
|
||||
},
|
||||
): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
@@ -362,6 +366,9 @@ function getFallbackTabOptionLabel(
|
||||
if (tab.target.kind === "file") {
|
||||
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
|
||||
}
|
||||
if (tab.target.kind === "working_diff") {
|
||||
return labels.changes;
|
||||
}
|
||||
if (tab.target.kind === "commit_diff") {
|
||||
return tab.target.sha.slice(0, 7);
|
||||
}
|
||||
@@ -376,6 +383,7 @@ function getFallbackTabOptionDescription(
|
||||
agent: string;
|
||||
terminal: string;
|
||||
browser: string;
|
||||
changes: string;
|
||||
},
|
||||
): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
@@ -399,6 +407,9 @@ function getFallbackTabOptionDescription(
|
||||
if (tab.target.kind === "commit_diff") {
|
||||
return tab.target.sha.slice(0, 7);
|
||||
}
|
||||
if (tab.target.kind === "working_diff") {
|
||||
return labels.changes;
|
||||
}
|
||||
return tab.target.path;
|
||||
}
|
||||
|
||||
@@ -671,6 +682,7 @@ function MobileWorkspaceTabOption({
|
||||
terminal: t("workspace.tabs.fallback.terminal"),
|
||||
browser: t("workspace.tabs.fallback.browser"),
|
||||
agent: t("workspace.tabs.fallback.agent"),
|
||||
changes: t("panels.diff.changesLabel"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
@@ -2514,6 +2526,7 @@ function WorkspaceScreenContent({
|
||||
terminal: t("workspace.tabs.fallback.terminal"),
|
||||
browser: t("workspace.tabs.fallback.browser"),
|
||||
agent: t("workspace.tabs.fallback.agent"),
|
||||
changes: t("panels.diff.changesLabel"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import {
|
||||
buildWorkspaceDesktopTabActions,
|
||||
buildWorkspaceTabMenuEntries,
|
||||
} from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
function createAgentTab(): WorkspaceTabDescriptor {
|
||||
@@ -253,6 +256,37 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(onCopyFilePath).toHaveBeenCalledWith("/some/path.ts");
|
||||
});
|
||||
|
||||
it("uses a Changes close id for the working diff tab", () => {
|
||||
const actions = buildWorkspaceDesktopTabActions({
|
||||
tab: {
|
||||
key: "working_diff_abc",
|
||||
tabId: "working_diff_abc",
|
||||
kind: "working_diff",
|
||||
target: {
|
||||
kind: "working_diff",
|
||||
focusPath: "src/example.ts",
|
||||
focusRequestId: 1,
|
||||
},
|
||||
},
|
||||
index: 0,
|
||||
tabCount: 1,
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onCopyFilePath: vi.fn(),
|
||||
onReloadAgent: vi.fn(),
|
||||
onRenameTab: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsToLeft: vi.fn(),
|
||||
onCloseTabsToRight: vi.fn(),
|
||||
onCloseOtherTabs: vi.fn(),
|
||||
});
|
||||
|
||||
expect(actions.closeButtonTestId).toMatch(/^workspace-working-diff-close-/);
|
||||
expect(actions.menuEntries).not.toContainEqual(
|
||||
expect.objectContaining({ kind: "item", key: "copy-file-path" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the same rename entry shape for agent and terminal tabs", () => {
|
||||
const terminalTab: WorkspaceTabDescriptor = {
|
||||
key: "terminal_abc",
|
||||
|
||||
@@ -144,6 +144,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "commit_diff") {
|
||||
return `workspace-commit-diff-close-${encodeFilePathForPathSegment(tab.target.sha)}`;
|
||||
}
|
||||
if (tab.target.kind === "working_diff") {
|
||||
return `workspace-working-diff-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
|
||||
describe("deriveWorkspaceTabModel", () => {
|
||||
it("keeps normalized tabs in stored order and preserves targets", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
type WorkspaceDerivedTab,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
export interface WorkspaceTabDescriptor {
|
||||
key: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import {
|
||||
navigateToLastWorkspace,
|
||||
navigateToWorkspace,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-identity";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
|
||||
|
||||
export interface RouteSelectionInput {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
|
||||
export interface PendingWorkspaceDraftSubmission {
|
||||
serverId: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
|
||||
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
|
||||
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
|
||||
@@ -285,9 +285,14 @@ function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null {
|
||||
|
||||
const tab = value as WorkspaceTab;
|
||||
const target = normalizeWorkspaceTabTarget(tab.target);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const tabId =
|
||||
trimNonEmpty(tab.tabId) ?? (target ? buildDeterministicWorkspaceTabId(target) : null);
|
||||
if (!target || !tabId) {
|
||||
target.kind === "working_diff"
|
||||
? buildDeterministicWorkspaceTabId(target)
|
||||
: (trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target));
|
||||
if (!tabId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,8 @@ vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
createWorkspaceLayoutStore,
|
||||
@@ -27,8 +26,10 @@ import {
|
||||
getFocusedBrowserId,
|
||||
getTreeDepth,
|
||||
insertSplit,
|
||||
normalizeLayout,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
stripEphemeralTabsFromLayout,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
@@ -1128,6 +1129,73 @@ describe("workspace-layout-store actions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists working diff tabs while stripping commit diff tabs", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, {
|
||||
kind: "working_diff",
|
||||
focusPath: "src/a.ts",
|
||||
});
|
||||
store.openTabFocused(workspaceKey, { kind: "commit_diff", sha: "abc123" });
|
||||
|
||||
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
|
||||
expect(partialize).toBeTypeOf("function");
|
||||
if (!partialize) {
|
||||
throw new Error("Workspace layout partialize function is missing");
|
||||
}
|
||||
const currentState = workspaceLayoutStore.getState();
|
||||
const layout = stripEphemeralTabsFromLayout(currentState.layoutByWorkspace[workspaceKey]);
|
||||
const persisted = partialize(currentState);
|
||||
|
||||
expect(persisted).toEqual({
|
||||
layoutByWorkspace: { [workspaceKey]: layout },
|
||||
splitSizesByWorkspace: currentState.splitSizesByWorkspace,
|
||||
});
|
||||
expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([
|
||||
{
|
||||
kind: "working_diff",
|
||||
focusPath: "src/a.ts",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("canonicalizes comparison-specific working diff tab ids from persisted layouts", () => {
|
||||
const legacyTabId = "working_diff_uncommitted_0_n";
|
||||
const layout = normalizeLayout({
|
||||
root: {
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "main",
|
||||
tabIds: [legacyTabId],
|
||||
focusedTabId: legacyTabId,
|
||||
tabs: [
|
||||
{
|
||||
tabId: legacyTabId,
|
||||
createdAt: 1,
|
||||
target: {
|
||||
kind: "working_diff",
|
||||
focusPath: "src/a.ts",
|
||||
mode: "uncommitted",
|
||||
baseRef: null,
|
||||
ignoreWhitespace: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
focusedPaneId: "main",
|
||||
});
|
||||
|
||||
expect(collectAllTabs(layout.root)).toEqual([
|
||||
{
|
||||
tabId: "working_diff",
|
||||
target: { kind: "working_diff", focusPath: "src/a.ts" },
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
|
||||
useWorkspaceLayoutIds(
|
||||
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
|
||||
|
||||
@@ -2,11 +2,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceTab,
|
||||
type WorkspaceTabTarget,
|
||||
} from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import {
|
||||
defaultWorkspaceLayoutIds,
|
||||
type WorkspaceLayoutIdSource,
|
||||
@@ -47,7 +43,6 @@ import {
|
||||
} from "@/stores/workspace-layout-actions";
|
||||
import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
|
||||
|
||||
export { buildWorkspaceTabPersistenceKey };
|
||||
export {
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { create } from "zustand";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
export type WorkspaceCreationMethod = "open_project" | "create_worktree";
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
type WorkspaceAgentVisibility,
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { selectSubagentsForParent } from "@/subagents/select";
|
||||
import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
import { useWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
import { useSessionStore, type Agent } from "./session-store";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
applyCloseTab,
|
||||
applyEnsureTab,
|
||||
applyFocusTab,
|
||||
applyOpenDraftTab,
|
||||
applyOpenOrFocusTab,
|
||||
applyPurgeWorkspace,
|
||||
applyReorderTabs,
|
||||
applyRetargetTab,
|
||||
initialWorkspaceTabsCoreState,
|
||||
migrateWorkspaceTabsState,
|
||||
partializeWorkspaceTabsState,
|
||||
selectWorkspaceTabs,
|
||||
type WorkspaceTab,
|
||||
type WorkspaceTabsCoreState,
|
||||
type WorkspaceTabTarget,
|
||||
} from "./state";
|
||||
|
||||
export { buildWorkspaceTabPersistenceKey } from "./state";
|
||||
export type { WorkspaceDraftTabSetup, WorkspaceTab, WorkspaceTabTarget } from "./state";
|
||||
|
||||
interface WorkspaceTabsState extends WorkspaceTabsCoreState {
|
||||
openDraftTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
draftId: string;
|
||||
}) => string | null;
|
||||
ensureTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => string | null;
|
||||
openOrFocusTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => string | null;
|
||||
focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||
closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||
retargetTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => string | null;
|
||||
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
|
||||
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
|
||||
purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
|
||||
}
|
||||
|
||||
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialWorkspaceTabsCoreState,
|
||||
openDraftTab: ({ serverId, workspaceId, draftId }) => {
|
||||
let resolved: string | null = null;
|
||||
set((state) => {
|
||||
const result = applyOpenDraftTab(state, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
draftId,
|
||||
now: Date.now(),
|
||||
});
|
||||
resolved = result.tabId;
|
||||
return result.state;
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
ensureTab: ({ serverId, workspaceId, target }) => {
|
||||
let resolved: string | null = null;
|
||||
set((state) => {
|
||||
const result = applyEnsureTab(state, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
now: Date.now(),
|
||||
});
|
||||
resolved = result.tabId;
|
||||
return result.state;
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||
let resolved: string | null = null;
|
||||
set((state) => {
|
||||
const result = applyOpenOrFocusTab(state, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
now: Date.now(),
|
||||
});
|
||||
resolved = result.tabId;
|
||||
return result.state;
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
focusTab: (input) => set((state) => applyFocusTab(state, input)),
|
||||
closeTab: (input) => set((state) => applyCloseTab(state, input)),
|
||||
retargetTab: ({ serverId, workspaceId, tabId, target }) => {
|
||||
let resolved: string | null = null;
|
||||
set((state) => {
|
||||
const result = applyRetargetTab(state, { serverId, workspaceId, tabId, target });
|
||||
resolved = result.tabId;
|
||||
return result.state;
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
reorderTabs: (input) => set((state) => applyReorderTabs(state, input)),
|
||||
getWorkspaceTabs: (input) => selectWorkspaceTabs(get(), input),
|
||||
purgeWorkspace: (input) => set((state) => applyPurgeWorkspace(state, input)),
|
||||
}),
|
||||
{
|
||||
name: "workspace-tabs-state",
|
||||
version: 5,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => partializeWorkspaceTabsState(state, { now: Date.now() }),
|
||||
migrate: (persistedState) =>
|
||||
migrateWorkspaceTabsState(persistedState, { now: Date.now() }) as WorkspaceTabsState,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,432 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyCloseTab,
|
||||
applyEnsureTab,
|
||||
applyFocusTab,
|
||||
applyOpenDraftTab,
|
||||
applyOpenOrFocusTab,
|
||||
applyRetargetTab,
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
initialWorkspaceTabsCoreState,
|
||||
migrateWorkspaceTabsState,
|
||||
type WorkspaceTabsCoreState,
|
||||
} from "./state";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "/repo/worktree";
|
||||
const WORKSPACE_KEY = `${SERVER_ID}:${WORKSPACE_ID}`;
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
function emptyState(): WorkspaceTabsCoreState {
|
||||
return {
|
||||
uiTabsByWorkspace: {},
|
||||
tabOrderByWorkspace: {},
|
||||
focusedTabIdByWorkspace: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildWorkspaceTabPersistenceKey", () => {
|
||||
it("preserves opaque workspace ids instead of normalizing them like paths", () => {
|
||||
expect(
|
||||
buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: " setup\\workspace\\ ",
|
||||
}),
|
||||
).toBe("server-1:setup\\workspace\\");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace-tabs-store reducers", () => {
|
||||
it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => {
|
||||
const draftTabId = "draft_123";
|
||||
|
||||
let state = emptyState();
|
||||
state = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "left" },
|
||||
now: NOW,
|
||||
}).state;
|
||||
state = applyOpenDraftTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: draftTabId,
|
||||
now: NOW,
|
||||
}).state;
|
||||
state = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "right" },
|
||||
now: NOW,
|
||||
}).state;
|
||||
state = applyFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: draftTabId,
|
||||
});
|
||||
|
||||
const beforeOrder = state.tabOrderByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
|
||||
const retargeted = applyRetargetTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: draftTabId,
|
||||
target: { kind: "agent", agentId: "created" },
|
||||
});
|
||||
|
||||
const afterOrder = retargeted.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
const tabs = retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null;
|
||||
|
||||
expect(retargeted.tabId).toBe(draftTabId);
|
||||
expect(afterOrder).toEqual(beforeOrder);
|
||||
expect(retargeted.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(draftTabId);
|
||||
expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" });
|
||||
});
|
||||
|
||||
it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => {
|
||||
let state = emptyState();
|
||||
const ensured = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
now: NOW,
|
||||
});
|
||||
state = ensured.state;
|
||||
expect(ensured.tabId).toBe("terminal_term-1");
|
||||
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBeUndefined();
|
||||
|
||||
const focused = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
now: NOW,
|
||||
});
|
||||
expect(focused.tabId).toBe("terminal_term-1");
|
||||
expect(focused.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe("terminal_term-1");
|
||||
});
|
||||
|
||||
it("ensureTab deduplicates by target when a retargeted tab already exists", () => {
|
||||
const draftTabId = "draft_x";
|
||||
|
||||
let state = emptyState();
|
||||
state = applyOpenDraftTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: draftTabId,
|
||||
now: NOW,
|
||||
}).state;
|
||||
state = applyRetargetTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: draftTabId,
|
||||
target: { kind: "agent", agentId: "created-agent" },
|
||||
}).state;
|
||||
|
||||
const ensured = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "created-agent" },
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
const tabs = ensured.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
const order = ensured.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
const matchingTabs = tabs.filter(
|
||||
(tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent",
|
||||
);
|
||||
|
||||
expect(ensured.tabId).toBe(draftTabId);
|
||||
expect(matchingTabs).toHaveLength(1);
|
||||
expect(order).toEqual([draftTabId]);
|
||||
});
|
||||
|
||||
it("openDraftTab creates a draft tab and deduplicates by draftId", () => {
|
||||
let state = emptyState();
|
||||
const first = applyOpenDraftTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: "draft-1",
|
||||
now: NOW,
|
||||
});
|
||||
state = first.state;
|
||||
const second = applyOpenDraftTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: "draft-2",
|
||||
now: NOW,
|
||||
});
|
||||
state = second.state;
|
||||
|
||||
expect(first.tabId).toBe("draft-1");
|
||||
expect(second.tabId).toBe("draft-2");
|
||||
expect(state.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual([first.tabId, second.tabId]);
|
||||
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toEqual([
|
||||
{
|
||||
tabId: "draft-1",
|
||||
target: { kind: "draft", draftId: "draft-1" },
|
||||
createdAt: NOW,
|
||||
},
|
||||
{
|
||||
tabId: "draft-2",
|
||||
target: { kind: "draft", draftId: "draft-2" },
|
||||
createdAt: NOW,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps draft setup on a retargeted tab", () => {
|
||||
let state = emptyState();
|
||||
const ensured = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
now: NOW,
|
||||
});
|
||||
expect(ensured.tabId).toBe("agent_agent-1");
|
||||
state = ensured.state;
|
||||
|
||||
const retargeted = applyRetargetTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: ensured.tabId!,
|
||||
target: {
|
||||
kind: "draft",
|
||||
draftId: "draft-replacement",
|
||||
setup: {
|
||||
provider: "mock",
|
||||
cwd: "/repo/worktree",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
thinkingOptionId: null,
|
||||
featureValues: { effort: "high" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({
|
||||
kind: "draft",
|
||||
draftId: "draft-replacement",
|
||||
setup: {
|
||||
provider: "mock",
|
||||
cwd: "/repo/worktree",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
thinkingOptionId: null,
|
||||
featureValues: { effort: "high" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("updates an existing draft tab when the setup changes", () => {
|
||||
let state = emptyState();
|
||||
const first = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "draft", draftId: "draft-1" },
|
||||
now: NOW,
|
||||
});
|
||||
state = first.state;
|
||||
const second = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: {
|
||||
kind: "draft",
|
||||
draftId: "draft-1",
|
||||
setup: {
|
||||
provider: "mock",
|
||||
cwd: "/repo/worktree",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
},
|
||||
now: NOW,
|
||||
});
|
||||
state = second.state;
|
||||
|
||||
expect(second.tabId).toBe(first.tabId);
|
||||
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
|
||||
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({
|
||||
kind: "draft",
|
||||
draftId: "draft-1",
|
||||
setup: {
|
||||
provider: "mock",
|
||||
cwd: "/repo/worktree",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("retargeting a background draft keeps the currently focused tab focused", () => {
|
||||
const draftTabId = "draft_background";
|
||||
|
||||
let state = emptyState();
|
||||
state = applyOpenDraftTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: draftTabId,
|
||||
now: NOW,
|
||||
}).state;
|
||||
const file = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
|
||||
now: NOW,
|
||||
});
|
||||
state = file.state;
|
||||
|
||||
state = applyRetargetTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: draftTabId,
|
||||
target: { kind: "agent", agentId: "created-agent" },
|
||||
}).state;
|
||||
|
||||
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(file.tabId);
|
||||
});
|
||||
|
||||
it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => {
|
||||
let state = emptyState();
|
||||
const fileResult = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
|
||||
now: NOW,
|
||||
});
|
||||
state = fileResult.state;
|
||||
const terminalResult = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
now: NOW,
|
||||
});
|
||||
state = terminalResult.state;
|
||||
|
||||
expect(fileResult.tabId).toBe("file_/repo/worktree/src/index.ts");
|
||||
expect(terminalResult.tabId).toBe("terminal_term-1");
|
||||
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(terminalResult.tabId);
|
||||
|
||||
const reopened = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(reopened.tabId).toBe(fileResult.tabId);
|
||||
expect(reopened.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(fileResult.tabId);
|
||||
});
|
||||
|
||||
it("builds a deterministic setup tab keyed by workspace id", () => {
|
||||
const result = applyOpenOrFocusTab(initialWorkspaceTabsCoreState, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "setup", workspaceId: WORKSPACE_ID },
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(result.tabId).toBe(`setup_${WORKSPACE_ID}`);
|
||||
expect(result.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(result.tabId);
|
||||
});
|
||||
|
||||
it("opens a commit diff tab with a commit-specific id", () => {
|
||||
let state = emptyState();
|
||||
const commit = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "commit_diff", sha: "abc123" },
|
||||
now: NOW,
|
||||
});
|
||||
state = commit.state;
|
||||
|
||||
expect(commit.tabId).toBe("commit_diff_abc123");
|
||||
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("closeTab focuses the most-recent remaining tab when the focused tab is removed", () => {
|
||||
let state = emptyState();
|
||||
const first = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "left" },
|
||||
now: NOW,
|
||||
});
|
||||
state = first.state;
|
||||
const second = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "right" },
|
||||
now: NOW,
|
||||
});
|
||||
state = second.state;
|
||||
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(second.tabId);
|
||||
|
||||
state = applyCloseTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: second.tabId!,
|
||||
});
|
||||
|
||||
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(first.tabId);
|
||||
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateWorkspaceTabsState commit diff coercion", () => {
|
||||
// This legacy store no longer enforces the "commit diff tabs are ephemeral"
|
||||
// guarantee — that now lives in the workspace-layout store's partialize (see
|
||||
// stripEphemeralTabsFromLayout). This migration only needs to carry old commit
|
||||
// diff targets forward to the dedicated `commit_diff` tab shape.
|
||||
it("migrates a legacy commit diff tab to the dedicated target shape", () => {
|
||||
const persisted = {
|
||||
state: {
|
||||
uiTabsByWorkspace: {
|
||||
[WORKSPACE_KEY]: [
|
||||
{
|
||||
tabId: "commit_diff_abc123",
|
||||
target: { kind: "diff", diffTarget: { kind: "commit", sha: "abc123" } },
|
||||
createdAt: NOW,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = migrateWorkspaceTabsState(persisted, { now: NOW });
|
||||
const tabs = migrated.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.target).toEqual({ kind: "commit_diff", sha: "abc123" });
|
||||
expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual(["commit_diff_abc123"]);
|
||||
});
|
||||
|
||||
it("drops a legacy working diff tab during migration", () => {
|
||||
const persisted = {
|
||||
state: {
|
||||
uiTabsByWorkspace: {
|
||||
[WORKSPACE_KEY]: [
|
||||
{
|
||||
tabId: "diff_working:base:main",
|
||||
target: {
|
||||
kind: "diff",
|
||||
diffTarget: { kind: "working", mode: "base", baseRef: "main" },
|
||||
},
|
||||
createdAt: NOW,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = migrateWorkspaceTabsState(persisted, { now: NOW });
|
||||
|
||||
expect(migrated.uiTabsByWorkspace[WORKSPACE_KEY]).toBeUndefined();
|
||||
expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,799 +0,0 @@
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceDraftTabSetup,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/workspace-tabs/identity";
|
||||
import type { WorkspaceFileTabTarget } from "@/workspace/file-open";
|
||||
|
||||
export interface WorkspaceDraftTabSetup {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
modeId: string | null;
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "browser"; browserId: string }
|
||||
| WorkspaceFileTabTarget
|
||||
| { kind: "setup"; workspaceId: string }
|
||||
| { kind: "commit_diff"; sha: string };
|
||||
|
||||
export interface WorkspaceTab {
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabsCoreState {
|
||||
uiTabsByWorkspace: Record<string, WorkspaceTab[]>;
|
||||
tabOrderByWorkspace: Record<string, string[]>;
|
||||
focusedTabIdByWorkspace: Record<string, string>;
|
||||
}
|
||||
|
||||
export const initialWorkspaceTabsCoreState: WorkspaceTabsCoreState = {
|
||||
uiTabsByWorkspace: {},
|
||||
tabOrderByWorkspace: {},
|
||||
focusedTabIdByWorkspace: {},
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabPersistenceKey(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): string | null {
|
||||
const serverId = trimNonEmpty(input.serverId);
|
||||
const workspaceId = trimNonEmpty(input.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
// workspaceId is opaque; do not parse this key back into a path.
|
||||
return `${serverId}:${workspaceId}`;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isPlainRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeTabOrder(list: unknown): string[] {
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
const next: string[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const value of list) {
|
||||
const tabId = trimNonEmpty(typeof value === "string" ? value : null);
|
||||
if (!tabId || used.has(tabId)) {
|
||||
continue;
|
||||
}
|
||||
used.add(tabId);
|
||||
next.push(tabId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function ensureInOrder(input: { current: string[]; tabId: string }): string[] {
|
||||
if (input.current.includes(input.tabId)) {
|
||||
return input.current;
|
||||
}
|
||||
return [...input.current, input.tabId];
|
||||
}
|
||||
|
||||
function retargetTabAtIndex(
|
||||
tab: WorkspaceTab,
|
||||
index: number,
|
||||
targetIndex: number,
|
||||
normalizedTarget: WorkspaceTabTarget,
|
||||
): WorkspaceTab {
|
||||
return index === targetIndex ? { ...tab, target: normalizedTarget } : tab;
|
||||
}
|
||||
|
||||
function buildNextTabsForEnsure(args: {
|
||||
currentTabs: WorkspaceTab[];
|
||||
existingIndex: number;
|
||||
effectiveTabId: string;
|
||||
normalizedTarget: WorkspaceTabTarget;
|
||||
createdAt: number;
|
||||
}): WorkspaceTab[] {
|
||||
const { currentTabs, existingIndex, effectiveTabId, normalizedTarget, createdAt } = args;
|
||||
if (existingIndex < 0) {
|
||||
return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt }];
|
||||
}
|
||||
const existing = currentTabs[existingIndex];
|
||||
if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) {
|
||||
return currentTabs;
|
||||
}
|
||||
return currentTabs.map((tab, index) =>
|
||||
retargetTabAtIndex(tab, index, existingIndex, normalizedTarget),
|
||||
);
|
||||
}
|
||||
|
||||
export interface EnsureTabInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
now: number;
|
||||
}
|
||||
|
||||
export interface EnsureTabResult {
|
||||
state: WorkspaceTabsCoreState;
|
||||
tabId: string | null;
|
||||
}
|
||||
|
||||
export function applyEnsureTab(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: EnsureTabInput,
|
||||
): EnsureTabResult {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
||||
if (!key || !normalizedTarget) {
|
||||
return { state, tabId: null };
|
||||
}
|
||||
|
||||
const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget);
|
||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||
const tabWithSameTarget =
|
||||
currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null;
|
||||
const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
|
||||
|
||||
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
|
||||
const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId });
|
||||
const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId);
|
||||
const nextTabs = buildNextTabsForEnsure({
|
||||
currentTabs,
|
||||
existingIndex,
|
||||
effectiveTabId,
|
||||
normalizedTarget,
|
||||
createdAt: input.now,
|
||||
});
|
||||
|
||||
const uiTabsByWorkspace =
|
||||
nextTabs === currentTabs
|
||||
? state.uiTabsByWorkspace
|
||||
: { ...state.uiTabsByWorkspace, [key]: nextTabs };
|
||||
const tabOrderByWorkspace =
|
||||
nextOrder === currentOrder
|
||||
? state.tabOrderByWorkspace
|
||||
: { ...state.tabOrderByWorkspace, [key]: nextOrder };
|
||||
|
||||
if (
|
||||
uiTabsByWorkspace === state.uiTabsByWorkspace &&
|
||||
tabOrderByWorkspace === state.tabOrderByWorkspace
|
||||
) {
|
||||
return { state, tabId: effectiveTabId };
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, uiTabsByWorkspace, tabOrderByWorkspace },
|
||||
tabId: effectiveTabId,
|
||||
};
|
||||
}
|
||||
|
||||
export interface FocusTabInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
export function applyFocusTab(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: FocusTabInput,
|
||||
): WorkspaceTabsCoreState {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const normalizedTabId = trimNonEmpty(input.tabId);
|
||||
if (!key || !normalizedTabId) {
|
||||
return state;
|
||||
}
|
||||
if (state.focusedTabIdByWorkspace[key] === normalizedTabId) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
focusedTabIdByWorkspace: {
|
||||
...state.focusedTabIdByWorkspace,
|
||||
[key]: normalizedTabId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOpenOrFocusTab(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: EnsureTabInput,
|
||||
): EnsureTabResult {
|
||||
const ensured = applyEnsureTab(state, input);
|
||||
if (!ensured.tabId) {
|
||||
return ensured;
|
||||
}
|
||||
const focused = applyFocusTab(ensured.state, {
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
tabId: ensured.tabId,
|
||||
});
|
||||
return { state: focused, tabId: ensured.tabId };
|
||||
}
|
||||
|
||||
export interface OpenDraftTabInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
draftId: string;
|
||||
now: number;
|
||||
}
|
||||
|
||||
export function applyOpenDraftTab(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: OpenDraftTabInput,
|
||||
): EnsureTabResult {
|
||||
const normalizedDraftId = trimNonEmpty(input.draftId);
|
||||
if (!normalizedDraftId) {
|
||||
return { state, tabId: null };
|
||||
}
|
||||
return applyOpenOrFocusTab(state, {
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
target: { kind: "draft", draftId: normalizedDraftId },
|
||||
now: input.now,
|
||||
});
|
||||
}
|
||||
|
||||
export interface CloseTabInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
export function applyCloseTab(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: CloseTabInput,
|
||||
): WorkspaceTabsCoreState {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const normalizedTabId = trimNonEmpty(input.tabId);
|
||||
if (!key || !normalizedTabId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||
const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId);
|
||||
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
|
||||
const nextOrder = currentOrder.filter((value) => value !== normalizedTabId);
|
||||
|
||||
let nextUiTabsByWorkspace: Record<string, WorkspaceTab[]>;
|
||||
if (nextTabs.length === 0) {
|
||||
const { [key]: _removed, ...rest } = state.uiTabsByWorkspace;
|
||||
nextUiTabsByWorkspace = rest;
|
||||
} else if (nextTabs.length === currentTabs.length) {
|
||||
nextUiTabsByWorkspace = state.uiTabsByWorkspace;
|
||||
} else {
|
||||
nextUiTabsByWorkspace = { ...state.uiTabsByWorkspace, [key]: nextTabs };
|
||||
}
|
||||
|
||||
let nextTabOrderByWorkspace: Record<string, string[]>;
|
||||
if (nextOrder.length === 0) {
|
||||
const { [key]: _removed, ...rest } = state.tabOrderByWorkspace;
|
||||
nextTabOrderByWorkspace = rest;
|
||||
} else if (nextOrder.length === currentOrder.length) {
|
||||
nextTabOrderByWorkspace = state.tabOrderByWorkspace;
|
||||
} else {
|
||||
nextTabOrderByWorkspace = { ...state.tabOrderByWorkspace, [key]: nextOrder };
|
||||
}
|
||||
|
||||
const currentFocused = state.focusedTabIdByWorkspace[key] ?? null;
|
||||
const nextFocused =
|
||||
currentFocused !== normalizedTabId ? currentFocused : (nextOrder[nextOrder.length - 1] ?? null);
|
||||
const nextFocusedByWorkspace = (() => {
|
||||
if (!nextFocused) {
|
||||
const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace;
|
||||
return rest;
|
||||
}
|
||||
return { ...state.focusedTabIdByWorkspace, [key]: nextFocused };
|
||||
})();
|
||||
|
||||
const tabsChanged = nextTabs.length !== currentTabs.length;
|
||||
const orderChanged = nextOrder.length !== currentOrder.length;
|
||||
const focusChanged =
|
||||
(state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null);
|
||||
|
||||
if (!tabsChanged && !orderChanged && !focusChanged) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
uiTabsByWorkspace: nextUiTabsByWorkspace,
|
||||
tabOrderByWorkspace: nextTabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace: nextFocusedByWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RetargetTabInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}
|
||||
|
||||
export function applyRetargetTab(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: RetargetTabInput,
|
||||
): EnsureTabResult {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const normalizedTabId = trimNonEmpty(input.tabId);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
||||
if (!key || !normalizedTabId || !normalizedTarget) {
|
||||
return { state, tabId: null };
|
||||
}
|
||||
|
||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||
const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId);
|
||||
if (index < 0) {
|
||||
return { state, tabId: null };
|
||||
}
|
||||
|
||||
const currentTarget = currentTabs[index]?.target;
|
||||
if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) {
|
||||
return { state, tabId: null };
|
||||
}
|
||||
|
||||
const nextTabs = currentTabs.map((tab, tabIndex) =>
|
||||
tabIndex === index ? Object.assign({}, tab, { target: normalizedTarget }) : tab,
|
||||
);
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs },
|
||||
},
|
||||
tabId: normalizedTabId,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReorderTabsInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabIds: string[];
|
||||
}
|
||||
|
||||
export function applyReorderTabs(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: ReorderTabsInput,
|
||||
): WorkspaceTabsCoreState {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (!key) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const normalized = normalizeTabOrder(input.tabIds);
|
||||
const current = state.tabOrderByWorkspace[key] ?? [];
|
||||
if (current.length === normalized.length) {
|
||||
let same = true;
|
||||
for (let i = 0; i < current.length; i += 1) {
|
||||
if (current[i] !== normalized[i]) {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (same) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
tabOrderByWorkspace: {
|
||||
...state.tabOrderByWorkspace,
|
||||
[key]: normalized,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PurgeWorkspaceInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function applyPurgeWorkspace(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: PurgeWorkspaceInput,
|
||||
): WorkspaceTabsCoreState {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (!key) {
|
||||
return state;
|
||||
}
|
||||
if (
|
||||
!(key in state.uiTabsByWorkspace) &&
|
||||
!(key in state.tabOrderByWorkspace) &&
|
||||
!(key in state.focusedTabIdByWorkspace)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const { [key]: _tabs, ...remainingUiTabsByWorkspace } = state.uiTabsByWorkspace;
|
||||
const { [key]: _order, ...remainingTabOrderByWorkspace } = state.tabOrderByWorkspace;
|
||||
const { [key]: _focused, ...remainingFocusedTabIdByWorkspace } = state.focusedTabIdByWorkspace;
|
||||
return {
|
||||
...state,
|
||||
uiTabsByWorkspace: remainingUiTabsByWorkspace,
|
||||
tabOrderByWorkspace: remainingTabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace: remainingFocusedTabIdByWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectWorkspaceTabs(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: { serverId: string; workspaceId: string },
|
||||
): WorkspaceTab[] {
|
||||
const key = buildWorkspaceTabPersistenceKey(input);
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
return state.uiTabsByWorkspace[key] ?? [];
|
||||
}
|
||||
|
||||
interface MigrationRawSources {
|
||||
rawUiTabsByWorkspace: Record<string, unknown>;
|
||||
rawFocused: Record<string, unknown>;
|
||||
rawOrder: Record<string, unknown>;
|
||||
legacyOrder: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function extractMigrationRawSources(persistedState: unknown): MigrationRawSources {
|
||||
const top = toObjectRecord(persistedState) ?? {};
|
||||
const rawState = toObjectRecord(top.state) ?? top;
|
||||
|
||||
return {
|
||||
rawUiTabsByWorkspace:
|
||||
toObjectRecord(
|
||||
rawState.uiTabsByWorkspace ??
|
||||
rawState.openTabsByWorkspace ??
|
||||
top.uiTabsByWorkspace ??
|
||||
top.openTabsByWorkspace,
|
||||
) ?? {},
|
||||
rawFocused:
|
||||
toObjectRecord(
|
||||
rawState.focusedTabIdByWorkspace ??
|
||||
rawState.lastFocusedTabByWorkspace ??
|
||||
top.focusedTabIdByWorkspace,
|
||||
) ?? {},
|
||||
rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {},
|
||||
legacyOrder:
|
||||
toObjectRecord(
|
||||
rawState.tabOrderByWorkspace ??
|
||||
rawState.tabOrderLegacyByWorkspace ??
|
||||
top.tabOrderLegacyByWorkspace,
|
||||
) ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
|
||||
const kind = typeof raw.kind === "string" ? raw.kind : null;
|
||||
if (kind === "draft" && typeof raw.draftId === "string") {
|
||||
const setup = normalizeWorkspaceDraftTabSetup(raw.setup);
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "draft",
|
||||
draftId: raw.draftId,
|
||||
...(setup ? { setup } : {}),
|
||||
});
|
||||
}
|
||||
if (kind === "agent" && typeof raw.agentId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });
|
||||
}
|
||||
if (
|
||||
kind === "provider_subagent" &&
|
||||
typeof raw.parentAgentId === "string" &&
|
||||
typeof raw.subagentId === "string"
|
||||
) {
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: raw.parentAgentId,
|
||||
subagentId: raw.subagentId,
|
||||
});
|
||||
}
|
||||
if (kind === "terminal" && typeof raw.terminalId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
|
||||
}
|
||||
if (kind === "browser" && typeof raw.browserId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId });
|
||||
}
|
||||
if (kind === "file" && typeof raw.path === "string") {
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "file",
|
||||
path: raw.path,
|
||||
lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined,
|
||||
lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined,
|
||||
});
|
||||
}
|
||||
if (kind === "setup" && typeof raw.workspaceId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
|
||||
}
|
||||
return coercePersistedDiffTabTargetByKind(kind, raw);
|
||||
}
|
||||
|
||||
function coercePersistedDiffTabTargetByKind(
|
||||
kind: string | null,
|
||||
raw: Record<string, unknown>,
|
||||
): WorkspaceTabTarget | null {
|
||||
if (kind === "commit_diff" && typeof raw.sha === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "commit_diff", sha: raw.sha });
|
||||
}
|
||||
return kind === "diff" ? coercePersistedDiffTabTarget(raw) : null;
|
||||
}
|
||||
|
||||
// NOTE: This legacy store no longer persists diff tabs — live tab persistence is
|
||||
// owned by the workspace-layout store, which is where the "commit diff tabs are
|
||||
// ephemeral on reload" guarantee is enforced (see stripEphemeralTabsFromLayout).
|
||||
// This coercion exists only so this store's migration stays type-complete for any
|
||||
// old diff-shaped entries left in `workspace-tabs-state` blobs. Working-tree diff
|
||||
// tabs are dropped because they no longer exist as workspace targets; commit diff
|
||||
// tabs migrate to the dedicated `commit_diff` target shape.
|
||||
function coercePersistedDiffTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
|
||||
const diffTarget = toObjectRecord(raw.diffTarget);
|
||||
if (!diffTarget || diffTarget.kind !== "commit" || typeof diffTarget.sha !== "string") {
|
||||
return null;
|
||||
}
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "commit_diff",
|
||||
sha: diffTarget.sha,
|
||||
});
|
||||
}
|
||||
|
||||
function migrateSingleTab(rawTab: unknown, now: number): WorkspaceTab | null {
|
||||
const record = toObjectRecord(rawTab);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const rawTarget = toObjectRecord(record.target);
|
||||
const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null;
|
||||
if (!normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null);
|
||||
const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget);
|
||||
const rawCreatedAt = record.createdAt;
|
||||
return {
|
||||
tabId,
|
||||
target: normalizedTarget,
|
||||
createdAt: typeof rawCreatedAt === "number" ? rawCreatedAt : now,
|
||||
};
|
||||
}
|
||||
|
||||
interface MigratedTabsForKey {
|
||||
nextUiTabs: WorkspaceTab[];
|
||||
orderFromTabs: string[];
|
||||
}
|
||||
|
||||
function migrateUiTabsForKey(rawEntries: unknown, now: number): MigratedTabsForKey {
|
||||
const entries = Array.isArray(rawEntries) ? rawEntries : [];
|
||||
const nextUiTabs: WorkspaceTab[] = [];
|
||||
const orderFromTabs: string[] = [];
|
||||
const usedOrder = new Set<string>();
|
||||
|
||||
for (const rawTab of entries) {
|
||||
const migrated = migrateSingleTab(rawTab, now);
|
||||
if (!migrated) {
|
||||
continue;
|
||||
}
|
||||
if (!usedOrder.has(migrated.tabId)) {
|
||||
usedOrder.add(migrated.tabId);
|
||||
orderFromTabs.push(migrated.tabId);
|
||||
}
|
||||
nextUiTabs.push(migrated);
|
||||
}
|
||||
|
||||
return { nextUiTabs, orderFromTabs };
|
||||
}
|
||||
|
||||
function mergeExplicitTabOrder(
|
||||
tabOrderByWorkspace: Record<string, string[]>,
|
||||
rawOrder: Record<string, unknown>,
|
||||
): void {
|
||||
for (const key in rawOrder) {
|
||||
const normalizedOrder = normalizeTabOrder(rawOrder[key]);
|
||||
if (normalizedOrder.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const existing = tabOrderByWorkspace[key] ?? [];
|
||||
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]);
|
||||
}
|
||||
}
|
||||
|
||||
function convertLegacyOrderEntry(entry: unknown): string | null {
|
||||
const raw = typeof entry === "string" ? entry.trim() : "";
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (raw.startsWith("agent:")) {
|
||||
const agentId = raw.slice("agent:".length).trim();
|
||||
return agentId ? `agent_${agentId}` : null;
|
||||
}
|
||||
if (raw.startsWith("terminal:")) {
|
||||
const terminalId = raw.slice("terminal:".length).trim();
|
||||
return terminalId ? `terminal_${terminalId}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeLegacyOrderList(list: unknown[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (const entry of list) {
|
||||
const converted = convertLegacyOrderEntry(entry);
|
||||
if (converted) {
|
||||
result.push(converted);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeLegacyTabOrder(
|
||||
tabOrderByWorkspace: Record<string, string[]>,
|
||||
legacyOrder: Record<string, unknown>,
|
||||
): void {
|
||||
for (const key in legacyOrder) {
|
||||
const list = legacyOrder[key];
|
||||
if (!Array.isArray(list) || list.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const normalizedLegacyOrder = normalizeLegacyOrderList(list);
|
||||
if (normalizedLegacyOrder.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const existing = tabOrderByWorkspace[key] ?? [];
|
||||
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFocusedTabId(rawValue: unknown): string | null {
|
||||
if (typeof rawValue === "string") {
|
||||
return trimNonEmpty(rawValue);
|
||||
}
|
||||
if (!rawValue || typeof rawValue !== "object") {
|
||||
return null;
|
||||
}
|
||||
const value = rawValue as {
|
||||
kind?: string;
|
||||
agentId?: string;
|
||||
terminalId?: string;
|
||||
draftId?: string;
|
||||
};
|
||||
if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) {
|
||||
return `agent_${value.agentId.trim()}`;
|
||||
}
|
||||
if (
|
||||
value.kind === "terminal" &&
|
||||
typeof value.terminalId === "string" &&
|
||||
value.terminalId.trim()
|
||||
) {
|
||||
return `terminal_${value.terminalId.trim()}`;
|
||||
}
|
||||
if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) {
|
||||
return value.draftId.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function migrateFocusedTabIds(
|
||||
focusedTabIdByWorkspace: Record<string, string>,
|
||||
rawFocused: Record<string, unknown>,
|
||||
): void {
|
||||
for (const key in rawFocused) {
|
||||
const resolved = resolveFocusedTabId(rawFocused[key]);
|
||||
if (resolved) {
|
||||
focusedTabIdByWorkspace[key] = resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateWorkspaceTabsState(
|
||||
persistedState: unknown,
|
||||
options: { now: number },
|
||||
): WorkspaceTabsCoreState {
|
||||
const { rawUiTabsByWorkspace, rawFocused, rawOrder, legacyOrder } =
|
||||
extractMigrationRawSources(persistedState);
|
||||
|
||||
const uiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||
const tabOrderByWorkspace: Record<string, string[]> = {};
|
||||
const focusedTabIdByWorkspace: Record<string, string> = {};
|
||||
|
||||
for (const key in rawUiTabsByWorkspace) {
|
||||
const { nextUiTabs, orderFromTabs } = migrateUiTabsForKey(
|
||||
rawUiTabsByWorkspace[key],
|
||||
options.now,
|
||||
);
|
||||
if (nextUiTabs.length > 0) {
|
||||
uiTabsByWorkspace[key] = nextUiTabs;
|
||||
}
|
||||
if (orderFromTabs.length > 0) {
|
||||
tabOrderByWorkspace[key] = orderFromTabs;
|
||||
}
|
||||
}
|
||||
|
||||
mergeExplicitTabOrder(tabOrderByWorkspace, rawOrder);
|
||||
mergeLegacyTabOrder(tabOrderByWorkspace, legacyOrder);
|
||||
migrateFocusedTabIds(focusedTabIdByWorkspace, rawFocused);
|
||||
|
||||
return {
|
||||
uiTabsByWorkspace,
|
||||
tabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export function partializeWorkspaceTabsState(
|
||||
state: WorkspaceTabsCoreState,
|
||||
options: { now: number },
|
||||
): WorkspaceTabsCoreState {
|
||||
const nextUiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||
for (const key in state.uiTabsByWorkspace) {
|
||||
const tabs = (state.uiTabsByWorkspace[key] ?? [])
|
||||
.map((tab) => {
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(tab.target);
|
||||
const normalizedTabId = trimNonEmpty(tab.tabId);
|
||||
if (!normalizedTarget || !normalizedTabId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId: normalizedTabId,
|
||||
target: normalizedTarget,
|
||||
createdAt: typeof tab.createdAt === "number" ? tab.createdAt : options.now,
|
||||
} satisfies WorkspaceTab;
|
||||
})
|
||||
.filter((tab): tab is WorkspaceTab => tab !== null);
|
||||
if (tabs.length > 0) {
|
||||
nextUiTabsByWorkspace[key] = tabs;
|
||||
}
|
||||
}
|
||||
|
||||
const nextTabOrderByWorkspace: Record<string, string[]> = {};
|
||||
for (const key in state.tabOrderByWorkspace) {
|
||||
const order = normalizeTabOrder(state.tabOrderByWorkspace[key]);
|
||||
if (order.length > 0) {
|
||||
nextTabOrderByWorkspace[key] = order;
|
||||
}
|
||||
}
|
||||
|
||||
const nextFocusedTabIdByWorkspace: Record<string, string> = {};
|
||||
for (const key in state.focusedTabIdByWorkspace) {
|
||||
const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]);
|
||||
if (focusedTabId) {
|
||||
nextFocusedTabIdByWorkspace[key] = focusedTabId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uiTabsByWorkspace: nextUiTabsByWorkspace,
|
||||
tabOrderByWorkspace: nextTabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceTabTarget,
|
||||
} from "@/stores/workspace-tabs-store";
|
||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
export interface PrepareWorkspaceTabInput {
|
||||
serverId: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
|
||||
@@ -44,6 +44,42 @@ describe("provider subagent tab identity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("working diff tab identity", () => {
|
||||
const target = {
|
||||
kind: "working_diff" as const,
|
||||
focusPath: "src/example.ts",
|
||||
focusRequestId: 1,
|
||||
};
|
||||
|
||||
it("normalizes file focus navigation", () => {
|
||||
expect(
|
||||
normalizeWorkspaceTabTarget({
|
||||
...target,
|
||||
focusPath: " src\\example.ts ",
|
||||
}),
|
||||
).toEqual(target);
|
||||
});
|
||||
|
||||
it("treats focus as navigation state rather than tab identity", () => {
|
||||
expect(workspaceTabTargetsEqual(target, target)).toBe(true);
|
||||
expect(workspaceTabTargetsEqual(target, { ...target, focusPath: "src/other.ts" })).toBe(false);
|
||||
expect(workspaceTabTargetsEqual(target, { ...target, focusRequestId: 2 })).toBe(false);
|
||||
const workingDiffId = buildDeterministicWorkspaceTabId(target);
|
||||
const otherFocusId = buildDeterministicWorkspaceTabId({
|
||||
...target,
|
||||
focusPath: "src/other.ts",
|
||||
});
|
||||
const fileId = buildDeterministicWorkspaceTabId({
|
||||
kind: "file",
|
||||
path: target.focusPath,
|
||||
});
|
||||
|
||||
expect(workingDiffId).toBe("working_diff");
|
||||
expect(workingDiffId).toBe(otherFocusId);
|
||||
expect(workingDiffId).not.toBe(fileId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commit diff tab identity", () => {
|
||||
it("keys a commit diff tab by its sha", () => {
|
||||
expect(buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" })).toBe(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { normalizeWorkspaceFileLocation, workspaceFileLocationsEqual } from "@/workspace/file-open";
|
||||
|
||||
type WorkspaceDraftTabSetup = NonNullable<Extract<WorkspaceTabTarget, { kind: "draft" }>["setup"]>;
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
export function normalizeWorkspaceTabTarget(
|
||||
value: WorkspaceTabTarget | null | undefined,
|
||||
@@ -31,6 +29,9 @@ export function normalizeWorkspaceTabTarget(
|
||||
if (value.kind === "file") {
|
||||
return normalizeFileTabTarget(value);
|
||||
}
|
||||
if (value.kind === "working_diff") {
|
||||
return normalizeWorkingDiffTabTarget(value);
|
||||
}
|
||||
return normalizeSimpleWorkspaceTabTarget(value);
|
||||
}
|
||||
|
||||
@@ -104,12 +105,22 @@ export function workspaceTabTargetsEqual(
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
return secondaryWorkspaceTabTargetsEqual(left, right);
|
||||
}
|
||||
|
||||
function secondaryWorkspaceTabTargetsEqual(
|
||||
left: WorkspaceTabTarget,
|
||||
right: WorkspaceTabTarget,
|
||||
): boolean {
|
||||
if (left.kind === "browser" && right.kind === "browser") {
|
||||
return left.browserId === right.browserId;
|
||||
}
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return workspaceFileLocationsEqual(left, right);
|
||||
}
|
||||
if (left.kind === "working_diff" && right.kind === "working_diff") {
|
||||
return left.focusPath === right.focusPath && left.focusRequestId === right.focusRequestId;
|
||||
}
|
||||
if (left.kind === "setup" && right.kind === "setup") {
|
||||
return left.workspaceId === right.workspaceId;
|
||||
}
|
||||
@@ -174,6 +185,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
||||
if (target.kind === "commit_diff") {
|
||||
return `commit_diff_${target.sha}`;
|
||||
}
|
||||
if (target.kind === "working_diff") {
|
||||
return "working_diff";
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
@@ -192,6 +206,24 @@ function normalizeFileTabTarget(
|
||||
return location ? { kind: "file", ...location } : null;
|
||||
}
|
||||
|
||||
function normalizeWorkingDiffTabTarget(
|
||||
value: Extract<WorkspaceTabTarget, { kind: "working_diff" }>,
|
||||
): WorkspaceTabTarget | null {
|
||||
const focusPath = trimNonEmpty(value.focusPath)?.replace(/\\/g, "/") ?? null;
|
||||
const focusRequestId = normalizePositiveInteger(value.focusRequestId);
|
||||
return {
|
||||
kind: "working_diff" as const,
|
||||
...(focusPath ? { focusPath } : {}),
|
||||
...(focusRequestId ? { focusRequestId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: number | null | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
function trimOptionalString(value: string | null | undefined): string | null {
|
||||
return value == null ? null : trimNonEmpty(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWorkspaceTabPersistenceKey } from "./model";
|
||||
|
||||
describe("buildWorkspaceTabPersistenceKey", () => {
|
||||
it("trims and joins opaque server and workspace ids", () => {
|
||||
expect(
|
||||
buildWorkspaceTabPersistenceKey({
|
||||
serverId: " server-1 ",
|
||||
workspaceId: " setup\\workspace\\ ",
|
||||
}),
|
||||
).toBe("server-1:setup\\workspace\\");
|
||||
});
|
||||
|
||||
it("rejects incomplete identities", () => {
|
||||
expect(buildWorkspaceTabPersistenceKey({ serverId: "", workspaceId: "workspace" })).toBeNull();
|
||||
expect(buildWorkspaceTabPersistenceKey({ serverId: "server", workspaceId: " " })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { WorkspaceFileTabTarget } from "@/workspace/file-open";
|
||||
|
||||
export interface WorkspaceDraftTabSetup {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
modeId: string | null;
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WorkspaceWorkingDiffTabTarget {
|
||||
kind: "working_diff";
|
||||
focusPath?: string;
|
||||
focusRequestId?: number;
|
||||
}
|
||||
|
||||
export type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "browser"; browserId: string }
|
||||
| WorkspaceFileTabTarget
|
||||
| WorkspaceWorkingDiffTabTarget
|
||||
| { kind: "setup"; workspaceId: string }
|
||||
| { kind: "commit_diff"; sha: string };
|
||||
|
||||
export interface WorkspaceTab {
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabPersistenceKey(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): string | null {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return `${serverId}:${workspaceId}`;
|
||||
}
|
||||
@@ -8,11 +8,8 @@ import {
|
||||
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 { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
|
||||
|
||||
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
|
||||
@@ -20,7 +17,6 @@ function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: str
|
||||
if (workspaceKey) {
|
||||
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
|
||||
}
|
||||
useWorkspaceTabsStore.getState().purgeWorkspace(input);
|
||||
}
|
||||
|
||||
export interface ArchiveWorkspaceInput {
|
||||
|
||||
Reference in New Issue
Block a user