Make the workspace right pane a first-class tab host (#3287)
* feat(app): add persistent workspace side panes Keep the common review and file-browsing workflow beside the active agent without tearing down pane contents. Workspace tabs now share one compact chip language, assistant file links route into the retained right pane, and diff/file trees share stable geometry. * fix(app): localize pull request empty state * test(app): update explorer pane coverage * fix(app): preserve explorer pane workflows * test(app): make explorer tab selection idempotent * fix(app): persist workspace review pins * fix(app): preserve sidebar scrim drag hook * fix(app): restore companion pane interactions * test(app): follow selected sidebar surface
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getPullRequestIdentity, resolvePullRequestContentState } from "./state";
|
||||
|
||||
describe("pull request panel", () => {
|
||||
it("resolves a manually opened panel without a pull request to the empty state", () => {
|
||||
expect(resolvePullRequestContentState({ data: null, error: null, isLoading: false })).toBe(
|
||||
"empty",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the canonical URL as the stable detected pull request identity", () => {
|
||||
expect(
|
||||
getPullRequestIdentity({
|
||||
forge: "github",
|
||||
number: 42,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
title: "PR panel",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
isMerged: false,
|
||||
isDraft: false,
|
||||
mergeable: "MERGEABLE",
|
||||
checks: [],
|
||||
}),
|
||||
).toBe("url:https://github.com/getpaseo/paseo/pull/42");
|
||||
});
|
||||
|
||||
it("does not manufacture an identity while detection has no result", () => {
|
||||
expect(getPullRequestIdentity(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import {
|
||||
PullRequestPane,
|
||||
PullRequestPaneError,
|
||||
PullRequestPaneSkeleton,
|
||||
usePrPaneData,
|
||||
} from "@/git/pull-request-panel";
|
||||
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
|
||||
import { getPullRequestIdentity, resolvePullRequestContentState } from "./state";
|
||||
|
||||
export function usePullRequestAutoAdd(input: {
|
||||
workspaceKey: string | null;
|
||||
serverId: string;
|
||||
cwd: string | null;
|
||||
enabled: boolean;
|
||||
}): void {
|
||||
const status = useCheckoutPrStatusQuery({
|
||||
serverId: input.serverId,
|
||||
cwd: input.cwd || "",
|
||||
enabled: input.enabled,
|
||||
}).status;
|
||||
const observePullRequest = useWorkspaceLayoutStore((state) => state.observePullRequest);
|
||||
const identity = getPullRequestIdentity(status);
|
||||
|
||||
useEffect(() => {
|
||||
if (input.workspaceKey) {
|
||||
observePullRequest(input.workspaceKey, identity);
|
||||
}
|
||||
}, [identity, input.workspaceKey, observePullRequest]);
|
||||
}
|
||||
|
||||
export function PullRequestContent(input: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
prPane: UsePrPaneDataResult;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const refresh = useCheckoutGitActionsStore((state) => state.refresh);
|
||||
const onRetry = useCallback(() => {
|
||||
void refresh({ serverId: input.serverId, cwd: input.cwd }).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
||||
});
|
||||
}, [input.cwd, input.serverId, refresh, t, toast]);
|
||||
|
||||
if (input.prPane.data) {
|
||||
return (
|
||||
<PullRequestPane
|
||||
serverId={input.serverId}
|
||||
cwd={input.cwd}
|
||||
data={input.prPane.data}
|
||||
activityLoading={input.prPane.activityLoading}
|
||||
workspaceAttachmentScopeKey={buildWorkspaceAttachmentScopeKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
cwd: input.cwd,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const contentState = resolvePullRequestContentState(input.prPane);
|
||||
if (contentState === "error") {
|
||||
return <PullRequestPaneError onRetry={onRetry} />;
|
||||
}
|
||||
if (contentState === "loading") {
|
||||
return <PullRequestPaneSkeleton />;
|
||||
}
|
||||
return (
|
||||
<View style={EMPTY_STYLE} testID="pull-request-empty-state">
|
||||
<Text style={EMPTY_TITLE_STYLE}>{t("panels.pullRequest.emptyTitle")}</Text>
|
||||
<Text>{t("panels.pullRequest.emptyDescription")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePullRequestData(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
enabled?: boolean;
|
||||
timelineEnabled?: boolean;
|
||||
}) {
|
||||
return usePrPaneData(input);
|
||||
}
|
||||
|
||||
const EMPTY_STYLE = {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
padding: 24,
|
||||
} as const;
|
||||
|
||||
const EMPTY_TITLE_STYLE = { fontWeight: "600" } as const;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
|
||||
|
||||
type PullRequestStatus = CheckoutPrStatusResponse["payload"]["status"];
|
||||
|
||||
export function getPullRequestIdentity(status: PullRequestStatus): string | null {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
const url = status.url.trim();
|
||||
if (url) {
|
||||
return `url:${url}`;
|
||||
}
|
||||
if (status.number === undefined) {
|
||||
return null;
|
||||
}
|
||||
return `repo:${status.forge}:${status.repoOwner ?? ""}/${status.repoName ?? ""}#${status.number}`;
|
||||
}
|
||||
|
||||
export function resolvePullRequestContentState(input: {
|
||||
data: unknown;
|
||||
error: Error | null;
|
||||
isLoading: boolean;
|
||||
}): "ready" | "error" | "loading" | "empty" {
|
||||
if (input.data) return "ready";
|
||||
if (input.error) return "error";
|
||||
if (input.isLoading) return "loading";
|
||||
return "empty";
|
||||
}
|
||||
Reference in New Issue
Block a user