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:
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user