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:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileDiff, GitCommitHorizontal } from "lucide-react-native";
|
||||
@@ -7,25 +7,13 @@ 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 { DiffTooLargeState } from "@/git/diff-too-large-state";
|
||||
import { DiffLayoutToggle, GitDiffPane, 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 { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
@@ -56,6 +44,9 @@ function useDiffPanelPreferences() {
|
||||
const toggleHideWhitespace = useCallback(() => {
|
||||
void updatePreferences({ hideWhitespace: !preferences.hideWhitespace });
|
||||
}, [preferences.hideWhitespace, updatePreferences]);
|
||||
const toggleViewMode = useCallback(() => {
|
||||
void updatePreferences({ viewMode: preferences.viewMode === "flat" ? "tree" : "flat" });
|
||||
}, [preferences.viewMode, updatePreferences]);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
@@ -65,6 +56,7 @@ function useDiffPanelPreferences() {
|
||||
toggleLayout,
|
||||
toggleWrapLines,
|
||||
toggleHideWhitespace,
|
||||
toggleViewMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,186 +76,34 @@ function PanelState({
|
||||
);
|
||||
}
|
||||
|
||||
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" }>;
|
||||
}) {
|
||||
function WorkingDiffPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target");
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(path: string) => openFileInWorkspace({ location: { path }, disposition: "side" }),
|
||||
[openFileInWorkspace],
|
||||
);
|
||||
|
||||
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.diffTooLarge) {
|
||||
return <DiffTooLargeState />;
|
||||
}
|
||||
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>
|
||||
<GitDiffPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={cwd}
|
||||
enabled={isActive}
|
||||
host="panel"
|
||||
onOpenFile={handleOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FilePane } from "@/file-pane/pane";
|
||||
@@ -7,6 +7,9 @@ import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { createMaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { FileExplorerPane } from "@/components/file-explorer-pane";
|
||||
import { TreeRail } from "@/components/tree-rail";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
const CENTERED_PADDED_STYLE = {
|
||||
flex: 1,
|
||||
@@ -30,8 +33,14 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
||||
|
||||
function FilePanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target, fileNavigationRevision } = usePaneContext();
|
||||
const { serverId, workspaceId, target, fileNavigationRevision, retargetCurrentTab } =
|
||||
usePaneContext();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const handleOpenFile = useCallback(
|
||||
(path: string) => retargetCurrentTab({ kind: "file", path }),
|
||||
[retargetCurrentTab],
|
||||
);
|
||||
invariant(target.kind === "file", "FilePanel requires file target");
|
||||
if (!workspaceDirectory) {
|
||||
return (
|
||||
@@ -40,13 +49,31 @@ function FilePanel() {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (isCompact) {
|
||||
return (
|
||||
<FilePane
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
location={target}
|
||||
navigationRevision={fileNavigationRevision ?? 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FilePane
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
location={target}
|
||||
navigationRevision={fileNavigationRevision ?? 0}
|
||||
/>
|
||||
<TreeRail testID="file-tree-rail">
|
||||
<FilePane
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
location={target}
|
||||
navigationRevision={fileNavigationRevision ?? 0}
|
||||
/>
|
||||
<FileExplorerPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
onOpenFile={handleOpenFile}
|
||||
/>
|
||||
</TreeRail>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { FolderTree } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import invariant from "tiny-invariant";
|
||||
import { FileExplorerPane } from "@/components/file-explorer-pane";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
|
||||
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||
const CENTERED_PADDED_STYLE = {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 16,
|
||||
} as const;
|
||||
|
||||
function useFilesPanelDescriptor() {
|
||||
const { t } = useTranslation();
|
||||
return {
|
||||
label: t("panels.files.label"),
|
||||
subtitle: t("panels.files.subtitle"),
|
||||
tooltip: t("panels.files.tooltip"),
|
||||
titleState: "ready" as const,
|
||||
icon: ThemedFolderTree,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function FilesPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext();
|
||||
const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
invariant(target.kind === "files", "FilesPanel requires files target");
|
||||
const onOpenFile = useCallback(
|
||||
(path: string) => retargetCurrentTab({ kind: "file", path }),
|
||||
[retargetCurrentTab],
|
||||
);
|
||||
if (!workspaceRoot) {
|
||||
return (
|
||||
<View style={CENTERED_PADDED_STYLE}>
|
||||
<Text>{t("panels.file.directoryMissing")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FileExplorerPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const filesPanelRegistration: PanelRegistration<"files"> = {
|
||||
kind: "files",
|
||||
component: FilesPanel,
|
||||
useDescriptor: useFilesPanelDescriptor,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { usePullRequestData } from "@/panels/pull-request";
|
||||
|
||||
export function usePullRequestPanelAvailability(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
requested: boolean;
|
||||
enabled: boolean;
|
||||
timelineEnabled?: boolean;
|
||||
}) {
|
||||
const canQuery = input.isGit && Boolean(input.cwd);
|
||||
const prPane = usePullRequestData({
|
||||
serverId: input.serverId,
|
||||
cwd: input.cwd,
|
||||
enabled: canQuery && input.enabled,
|
||||
timelineEnabled: canQuery && input.enabled && Boolean(input.timelineEnabled),
|
||||
});
|
||||
return {
|
||||
prPane,
|
||||
showPullRequest: prPane.prNumber !== null || (input.requested && prPane.isLoading),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { GitPullRequest } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import invariant from "tiny-invariant";
|
||||
import { formatPrTabLabel } from "@/git/pull-request-panel";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { PullRequestContent, usePullRequestData } from "@/panels/pull-request";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const CENTERED_PADDED_STYLE = {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 16,
|
||||
} as const;
|
||||
|
||||
function usePullRequestPanelDescriptor(
|
||||
_target: { kind: "pull_request" },
|
||||
context: { serverId: string; workspaceId: string },
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const cwd = useWorkspaceDirectory(context.serverId, context.workspaceId) ?? "";
|
||||
const prPane = usePullRequestData({ serverId: context.serverId, cwd, timelineEnabled: false });
|
||||
const label =
|
||||
prPane.prNumber === null ? t("panels.pullRequest.label") : formatPrTabLabel(prPane.prNumber);
|
||||
return {
|
||||
label,
|
||||
subtitle: t("panels.pullRequest.subtitle"),
|
||||
tooltip: label,
|
||||
titleState: prPane.isLoading ? ("loading" as const) : ("ready" as const),
|
||||
icon: ThemedGitPullRequest,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function PullRequestPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId) ?? "";
|
||||
invariant(target.kind === "pull_request", "PullRequestPanel requires pull_request target");
|
||||
const prPane = usePullRequestData({ serverId, cwd });
|
||||
if (!cwd) {
|
||||
return (
|
||||
<View style={CENTERED_PADDED_STYLE}>
|
||||
<Text>{t("panels.file.directoryMissing")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PullRequestContent serverId={serverId} workspaceId={workspaceId} cwd={cwd} prPane={prPane} />
|
||||
);
|
||||
}
|
||||
|
||||
export const pullRequestPanelRegistration: PanelRegistration<"pull_request"> = {
|
||||
kind: "pull_request",
|
||||
component: PullRequestPanel,
|
||||
useDescriptor: usePullRequestPanelDescriptor,
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import { browserPanelRegistration } from "@/desktop/browser/panel";
|
||||
import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel";
|
||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||
import { filePanelRegistration } from "@/panels/file-panel";
|
||||
import { filesPanelRegistration } from "@/panels/files-panel";
|
||||
import { registerPanel } from "@/panels/panel-registry";
|
||||
import { setupPanelRegistration } from "@/panels/setup-panel";
|
||||
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
||||
import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel";
|
||||
import { pullRequestPanelRegistration } from "@/panels/pull-request-panel";
|
||||
|
||||
let panelsRegistered = false;
|
||||
|
||||
@@ -21,6 +23,8 @@ export function ensurePanelsRegistered(): void {
|
||||
registerPanel(terminalPanelRegistration);
|
||||
registerPanel(browserPanelRegistration);
|
||||
registerPanel(filePanelRegistration);
|
||||
registerPanel(filesPanelRegistration);
|
||||
registerPanel(pullRequestPanelRegistration);
|
||||
registerPanel(commitDiffPanelRegistration);
|
||||
registerPanel(workingDiffPanelRegistration);
|
||||
panelsRegistered = true;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
export function useAddFileToChat(input: { serverId: string; workspaceId?: string | null }) {
|
||||
const workspaceKey = input.workspaceId
|
||||
? buildWorkspaceTabPersistenceKey({ serverId: input.serverId, workspaceId: input.workspaceId })
|
||||
: null;
|
||||
const layout = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const focusedChat = useMemo(
|
||||
() => resolveFocusedChatTarget({ serverId: input.serverId, layout }),
|
||||
[input.serverId, layout],
|
||||
);
|
||||
const addFile = useCallback(
|
||||
async (filePath: string) => {
|
||||
if (!focusedChat || !workspaceKey) {
|
||||
return;
|
||||
}
|
||||
await useDraftStore.getState().attachWorkspaceFile({
|
||||
draftKey: focusedChat.draftKey,
|
||||
attachment: createWorkspaceFileAttachment({ path: filePath }),
|
||||
});
|
||||
focusTab(workspaceKey, focusedChat.tabId);
|
||||
},
|
||||
[focusTab, focusedChat, workspaceKey],
|
||||
);
|
||||
return { addFile, canAddToChat: focusedChat !== null };
|
||||
}
|
||||
Reference in New Issue
Block a user