mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 12:25:05 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40147c6805 | ||
|
|
974299450c |
@@ -54,8 +54,16 @@ type OlderHistoryInput = Pick<
|
||||
"onNearHistoryStart" | "hasOlderHistory" | "olderHistoryProgressKey"
|
||||
>;
|
||||
|
||||
function mountTimeline(historyMounted: StreamItem[], olderHistory?: OlderHistoryInput) {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
interface MountTimelineOptions {
|
||||
olderHistory?: OlderHistoryInput;
|
||||
isMobileBreakpoint?: boolean;
|
||||
}
|
||||
|
||||
function mountTimeline(
|
||||
historyMounted: StreamItem[],
|
||||
{ olderHistory, isMobileBreakpoint = true }: MountTimelineOptions = {},
|
||||
) {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const input: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
@@ -113,6 +121,13 @@ function mountTimeline(historyMounted: StreamItem[], olderHistory?: OlderHistory
|
||||
},
|
||||
distanceFromEnd: () =>
|
||||
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
|
||||
scrollTop: () => scrollContainer.scrollTop,
|
||||
scrollUp: (distance: number) =>
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -distance }));
|
||||
scrollContainer.scrollTop -= distance;
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
}),
|
||||
// Resize observers and the hold's settle run on animation frames.
|
||||
settle: () =>
|
||||
act(async () => {
|
||||
@@ -124,6 +139,53 @@ function mountTimeline(historyMounted: StreamItem[], olderHistory?: OlderHistory
|
||||
}
|
||||
|
||||
describe("web disclosure hold", () => {
|
||||
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
|
||||
"keeps the reading position when mounted history becomes virtualized (compact: $isMobileBreakpoint)",
|
||||
async (options) => {
|
||||
const history = Array.from({ length: 80 }, (_, index) => row(index));
|
||||
const timeline = mountTimeline(history, options);
|
||||
await timeline.settle();
|
||||
timeline.scrollUp(600);
|
||||
await timeline.settle();
|
||||
const offset = timeline.scrollTop();
|
||||
const readingTop = timeline.rowTop("message-64");
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
|
||||
timeline.render({
|
||||
historyVirtualized: history.slice(0, 60),
|
||||
historyMounted: history.slice(60),
|
||||
});
|
||||
await timeline.settle();
|
||||
|
||||
expect(timeline.scrollTop()).toBe(offset);
|
||||
expect(timeline.rowTop("message-64")).toBe(readingTop);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
|
||||
"keeps following output when mounted history becomes virtualized at the end (compact: $isMobileBreakpoint)",
|
||||
async (options) => {
|
||||
const history = Array.from({ length: 80 }, (_, index) => row(index));
|
||||
const timeline = mountTimeline(history, options);
|
||||
await timeline.settle();
|
||||
|
||||
timeline.render({
|
||||
historyVirtualized: history.slice(0, 60),
|
||||
historyMounted: history.slice(60),
|
||||
});
|
||||
await timeline.settle();
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
|
||||
timeline.render({
|
||||
historyVirtualized: history.slice(0, 60),
|
||||
historyMounted: history.slice(60),
|
||||
liveHead: [row(80)],
|
||||
});
|
||||
await timeline.settle();
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the trigger's top when an anchored toggle at the bottom opens rows below it, and stops following", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
@@ -225,11 +287,13 @@ describe("web history start", () => {
|
||||
let olderHistoryLoads = 0;
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history, {
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch:1",
|
||||
onNearHistoryStart: async () => {
|
||||
olderHistoryLoads += 1;
|
||||
return true;
|
||||
olderHistory: {
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch:1",
|
||||
onNearHistoryStart: async () => {
|
||||
olderHistoryLoads += 1;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
await timeline.settle();
|
||||
|
||||
@@ -457,6 +457,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
enabled: shouldUseVirtualizer,
|
||||
// History can become virtualized after the reader has already scrolled the mounted rows.
|
||||
initialOffset: () => scrollContainerRef.current?.scrollTop ?? 0,
|
||||
initialRect: {
|
||||
width: scrollContainerRef.current?.clientWidth ?? 0,
|
||||
height: scrollContainerRef.current?.clientHeight ?? 0,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
|
||||
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
|
||||
@@ -18,6 +18,12 @@ import { useCheckGroupState } from "./check-group-state";
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
|
||||
/**
|
||||
* Roughly eight rows. A repo with thirty checks would otherwise push the activity
|
||||
* timeline off the pane entirely, so past this height the list scrolls in place.
|
||||
*/
|
||||
const LIST_MAX_HEIGHT = 268;
|
||||
|
||||
/**
|
||||
* The three statuses the pane has always labelled for tests. Skipped has no id because
|
||||
* nothing asserts on it.
|
||||
@@ -106,7 +112,11 @@ export function ChecksSection({
|
||||
</Pressable>
|
||||
|
||||
{open && summary.groups.length > 0 ? (
|
||||
<View style={styles.list}>
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
nestedScrollEnabled
|
||||
>
|
||||
{summary.groups.map((group) => (
|
||||
<CheckGroup
|
||||
key={group.status}
|
||||
@@ -118,7 +128,7 @@ export function ChecksSection({
|
||||
onAddLogsToChat={onAddLogsToChat}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
@@ -253,6 +263,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
list: {
|
||||
maxHeight: LIST_MAX_HEIGHT,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
groupHeader: {
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { ToastApiProvider } from "@/contexts/toast-context";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { PrPaneCheck, PrPaneData } from "./data";
|
||||
import { PullRequestPane } from "./pane";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
const PANE_HEIGHT = 600;
|
||||
|
||||
const silentToasts: ToastApi = {
|
||||
show: () => () => {},
|
||||
copied: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
function successfulChecks(count: number): PrPaneCheck[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
provider: "github",
|
||||
name: `check-${index + 1}`,
|
||||
status: "success",
|
||||
url: `https://example.test/checks/${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function pullRequest(checks: PrPaneCheck[]): PrPaneData {
|
||||
return {
|
||||
provider: { id: "github", label: "GitHub" },
|
||||
forge: "github",
|
||||
number: 42,
|
||||
repoOwner: "acme",
|
||||
repoName: "widgets",
|
||||
title: "Ship the widget",
|
||||
state: "open",
|
||||
url: "https://example.test/pull/42",
|
||||
reviewDecision: "pending",
|
||||
awaitingReviewers: [],
|
||||
checks,
|
||||
activity: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A phone's home indicator: the pane clears it with bottom padding. */
|
||||
const HOME_INDICATOR_INSETS = { top: 0, right: 0, bottom: 34, left: 0 };
|
||||
|
||||
const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
function renderPane(data: PrPaneData, insets = NO_INSETS) {
|
||||
act(() =>
|
||||
root?.render(
|
||||
<SafeAreaInsetsContext.Provider value={insets}>
|
||||
<ToastApiProvider api={silentToasts}>
|
||||
<PullRequestPane serverId="server-1" cwd="/repo" data={data} activityLoading={false} />
|
||||
</ToastApiProvider>
|
||||
</SafeAreaInsetsContext.Provider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function mountPane(data: PrPaneData, insets = NO_INSETS) {
|
||||
container = document.createElement("div");
|
||||
container.style.height = `${PANE_HEIGHT}px`;
|
||||
container.style.width = "600px";
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
renderPane(data, insets);
|
||||
const pane = container.querySelector<HTMLElement>('[data-testid="pr-pane"]');
|
||||
if (!pane) throw new Error("The pull request pane did not render");
|
||||
return pane;
|
||||
}
|
||||
|
||||
/** The pressable row holding the Activity title. */
|
||||
function activityHeader(pane: HTMLElement): HTMLElement {
|
||||
const title = within(pane).getByText(i18n.t("workspace.git.pr.sections.activity"));
|
||||
const header = title.closest<HTMLElement>("[tabindex]");
|
||||
if (!header) throw new Error("The Activity title is not inside a pressable header");
|
||||
return header;
|
||||
}
|
||||
|
||||
function checksRegionBottom(pane: HTMLElement): number {
|
||||
return within(pane).getByRole("separator").getBoundingClientRect().top;
|
||||
}
|
||||
|
||||
/** Whether a user sees the element: no scroll container clips it out of view. */
|
||||
function isOnScreen(element: HTMLElement): boolean {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(rect.left + 8, rect.top + rect.height / 2);
|
||||
return hit !== null && element.contains(hit);
|
||||
}
|
||||
|
||||
/** React Native Web reports layout from a ResizeObserver, a frame or two after mounting. */
|
||||
async function settleLayout() {
|
||||
for (let frame = 0; frame < 2; frame++) {
|
||||
await act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
||||
}
|
||||
}
|
||||
|
||||
/** Drags the separator through each offset from where it starts, releasing at the last one. */
|
||||
function dragSeparator(pane: HTMLElement, ...deltasY: number[]) {
|
||||
const separator = within(pane).getByRole("separator");
|
||||
const rect = separator.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const startY = rect.top + rect.height / 2;
|
||||
const pointer = { pointerId: 1, bubbles: true, cancelable: true, clientX: x, button: 0 };
|
||||
act(() => {
|
||||
separator.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientY: startY }));
|
||||
});
|
||||
for (const deltaY of deltasY) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new PointerEvent("pointermove", { ...pointer, clientY: startY + deltaY }),
|
||||
);
|
||||
});
|
||||
}
|
||||
const endY = startY + (deltasY.at(-1) ?? 0);
|
||||
act(() => {
|
||||
window.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientY: endY }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("pull request pane layout", () => {
|
||||
it("docks a collapsed Activity section to the bottom and gives the checks the pane", async () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(10)));
|
||||
|
||||
act(() => activityHeader(pane).click());
|
||||
|
||||
await expect
|
||||
.poll(() => Math.round(activityHeader(pane).getBoundingClientRect().bottom))
|
||||
.toBe(Math.round(pane.getBoundingClientRect().bottom));
|
||||
const rows = within(pane).getAllByTestId("pr-pane-check-row");
|
||||
expect(rows).toHaveLength(10);
|
||||
expect(rows.every(isOnScreen)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps keyboard focus on the Activity header while it collapses and expands", () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(3)));
|
||||
const header = activityHeader(pane);
|
||||
act(() => header.focus());
|
||||
|
||||
act(() => header.click());
|
||||
expect(document.activeElement).toBe(header);
|
||||
act(() => header.click());
|
||||
expect(document.activeElement).toBe(header);
|
||||
});
|
||||
|
||||
it("exposes whether Activity is expanded to assistive technology", () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(3)));
|
||||
expect(activityHeader(pane).getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
act(() => activityHeader(pane).click());
|
||||
|
||||
expect(activityHeader(pane).getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps the Activity header inside the pane when a dragged pane gets short", async () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(30)));
|
||||
await settleLayout();
|
||||
dragSeparator(pane, PANE_HEIGHT);
|
||||
await settleLayout();
|
||||
|
||||
act(() => {
|
||||
if (container) container.style.height = "160px";
|
||||
});
|
||||
await settleLayout();
|
||||
|
||||
await expect
|
||||
.poll(() => activityHeader(pane).getBoundingClientRect().bottom)
|
||||
.toBeLessThanOrEqual(pane.getBoundingClientRect().bottom);
|
||||
expect(isOnScreen(activityHeader(pane))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["5px down", [5]],
|
||||
["400px down", [400]],
|
||||
["up past their end and back down", [-20, 100]],
|
||||
])("gives the checks their earlier share when they grow after a drag %s", async (_name, path) => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(2)));
|
||||
await settleLayout();
|
||||
const drawn = checksRegionBottom(pane);
|
||||
|
||||
dragSeparator(pane, ...path);
|
||||
await settleLayout();
|
||||
expect(checksRegionBottom(pane)).toBeCloseTo(drawn, 0);
|
||||
|
||||
renderPane(pullRequest(successfulChecks(30)));
|
||||
await settleLayout();
|
||||
const overview = within(pane).getByTestId("pr-pane-overview");
|
||||
expect(overview.getBoundingClientRect().height).toBeCloseTo(PANE_HEIGHT / 2, 0);
|
||||
});
|
||||
|
||||
it("resizes the checks against Activity by dragging the separator between them", async () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(30)));
|
||||
await settleLayout();
|
||||
const before = checksRegionBottom(pane);
|
||||
|
||||
dragSeparator(pane, 120);
|
||||
|
||||
await expect.poll(() => Math.round(checksRegionBottom(pane) - before)).toBe(120);
|
||||
expect(activityHeader(pane).getBoundingClientRect().top).toBeGreaterThan(before + 100);
|
||||
});
|
||||
|
||||
it("keeps the separator under the pointer when the pane clears a bottom inset", async () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(30)), HOME_INDICATOR_INSETS);
|
||||
await settleLayout();
|
||||
const before = checksRegionBottom(pane);
|
||||
|
||||
dragSeparator(pane, 120);
|
||||
|
||||
await expect.poll(() => Math.round(checksRegionBottom(pane) - before)).toBe(120);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Image, Pressable, Text, View, type ViewStyle } from "react-native";
|
||||
import { Image, Pressable, ScrollView, Text, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
CircleCheck,
|
||||
@@ -74,14 +74,13 @@ import {
|
||||
type PrTimelineEntry,
|
||||
} from "./timeline";
|
||||
import {
|
||||
SectionHeader,
|
||||
Section,
|
||||
SummaryPill,
|
||||
dangerColorMapping,
|
||||
foregroundMutedColorMapping,
|
||||
sectionKitStyles,
|
||||
successColorMapping,
|
||||
} from "./section-kit";
|
||||
import { PullRequestPaneSplit } from "./split";
|
||||
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircleX = withUnistyles(CircleX);
|
||||
@@ -479,8 +478,8 @@ export function PullRequestPane({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PullRequestPaneSplit activityOpen={activityOpen}>
|
||||
<>
|
||||
<View style={styles.root} testID="pr-pane">
|
||||
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||
<PaneContentToolbar style={styles.toolbar} testID="pr-pane-toolbar">
|
||||
<View style={styles.toolbarActions}>
|
||||
<Button
|
||||
@@ -500,9 +499,7 @@ export function PullRequestPane({
|
||||
accessibilityLabel={
|
||||
isRefreshing
|
||||
? t("workspace.git.diff.refreshing")
|
||||
: t("workspace.git.diff.refreshState", {
|
||||
brand: forgePresentation.brandLabel,
|
||||
})
|
||||
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
|
||||
}
|
||||
testID="pr-pane-refresh"
|
||||
style={toolbarRefreshButtonStyle}
|
||||
@@ -567,58 +564,60 @@ export function PullRequestPane({
|
||||
onAddLogsToChat={handleAddCheckLogsToChat}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<SectionHeader
|
||||
title={t("workspace.git.pr.sections.activity")}
|
||||
open={activityOpen}
|
||||
onToggle={handleToggleActivity}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
|
||||
<SummaryPill
|
||||
count={changesRequested}
|
||||
icon={SUMMARY_CHANGES_REQUESTED_ICON}
|
||||
variant="danger"
|
||||
/>
|
||||
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<View style={sectionKitStyles.sectionBody}>
|
||||
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
|
||||
<View style={styles.activityToolbar}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={MessageSquarePlus}
|
||||
onPress={handleAddAllToChat}
|
||||
disabled={activityLoading}
|
||||
>
|
||||
{t("workspace.git.pr.actions.addAllToChat")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{activityLoading ? <PrActivitySkeleton /> : null}
|
||||
{!activityLoading && visibleEntries.length === 0 ? (
|
||||
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
|
||||
) : null}
|
||||
{!activityLoading
|
||||
? visibleEntries.map(({ entry, collapsed }) => (
|
||||
<TimelineEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
collapsed={collapsed}
|
||||
collapsedEntryIds={collapsedEntryIds}
|
||||
attachEnabled={attachEnabled}
|
||||
brandLabel={forgePresentation.brandLabel}
|
||||
onAddToChat={handleAddActivityToChat}
|
||||
onAddThreadToChat={handleAddThreadToChat}
|
||||
onToggleCollapsed={handleToggleEntryCollapsed}
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title={t("workspace.git.pr.sections.activity")}
|
||||
open={activityOpen}
|
||||
onToggle={handleToggleActivity}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
|
||||
<SummaryPill
|
||||
count={changesRequested}
|
||||
icon={SUMMARY_CHANGES_REQUESTED_ICON}
|
||||
variant="danger"
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</View>
|
||||
</PullRequestPaneSplit>
|
||||
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
|
||||
<View style={styles.activityToolbar}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={MessageSquarePlus}
|
||||
onPress={handleAddAllToChat}
|
||||
disabled={activityLoading}
|
||||
>
|
||||
{t("workspace.git.pr.actions.addAllToChat")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{activityLoading ? <PrActivitySkeleton /> : null}
|
||||
{!activityLoading && visibleEntries.length === 0 ? (
|
||||
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
|
||||
) : null}
|
||||
{!activityLoading
|
||||
? visibleEntries.map(({ entry, collapsed }) => (
|
||||
<TimelineEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
collapsed={collapsed}
|
||||
collapsedEntryIds={collapsedEntryIds}
|
||||
attachEnabled={attachEnabled}
|
||||
brandLabel={forgePresentation.brandLabel}
|
||||
onAddToChat={handleAddActivityToChat}
|
||||
onAddThreadToChat={handleAddThreadToChat}
|
||||
onToggleCollapsed={handleToggleEntryCollapsed}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</Section>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1193,6 +1192,15 @@ function ThreadComment({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
invisible: {
|
||||
opacity: 0,
|
||||
},
|
||||
@@ -1253,6 +1261,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginLeft: theme.spacing[1],
|
||||
opacity: 0,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useMemo, type ReactNode } from "react";
|
||||
import React, { type ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { CountedCheckPresentation } from "@/git/check-presentation";
|
||||
import { CONTROL_HEIGHTS, mapLayout } from "@/components/ui/control-geometry";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
CheckPresentationIcon,
|
||||
getCheckPresentationTone,
|
||||
@@ -22,57 +21,39 @@ export const successColorMapping = (theme: Theme) => ({ color: theme.colors.stat
|
||||
export const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
export const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
|
||||
|
||||
interface SectionHeaderProps {
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
summary: ReactNode;
|
||||
children: ReactNode;
|
||||
accessibilityLabel?: string;
|
||||
}
|
||||
|
||||
interface SectionProps extends SectionHeaderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** The disclosure row of a section, for layouts that place the section's body themselves. */
|
||||
export function SectionHeader({
|
||||
export function Section({
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
summary,
|
||||
children,
|
||||
accessibilityLabel,
|
||||
}: SectionHeaderProps) {
|
||||
const accessibilityState = useMemo(() => ({ expanded: open }), [open]);
|
||||
// React Native Web does not map `accessibilityState.expanded` to `aria-expanded`.
|
||||
const webExpandedProps = useMemo(
|
||||
() => (isWeb ? ({ "aria-expanded": open } as const) : null),
|
||||
[open],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel ?? title}
|
||||
accessibilityState={accessibilityState}
|
||||
{...webExpandedProps}
|
||||
style={sectionKitStyles.sectionHeader}
|
||||
onPress={onToggle}
|
||||
>
|
||||
{open ? (
|
||||
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
|
||||
) : (
|
||||
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
|
||||
)}
|
||||
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
|
||||
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function Section({ children, ...header }: SectionProps) {
|
||||
}: SectionProps) {
|
||||
return (
|
||||
<View>
|
||||
<SectionHeader {...header} />
|
||||
{header.open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
|
||||
<Pressable
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
style={sectionKitStyles.sectionHeader}
|
||||
onPress={onToggle}
|
||||
>
|
||||
{open ? (
|
||||
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
|
||||
) : (
|
||||
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
|
||||
)}
|
||||
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
|
||||
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
|
||||
</Pressable>
|
||||
{open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { Children, useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { ScrollView, View, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
|
||||
/** Most of the pane the overview may take before it scrolls, until the user drags the separator. */
|
||||
const DEFAULT_OVERVIEW_SHARE = 0.5;
|
||||
const SPLIT_GROUP_ID = "pr-pane";
|
||||
|
||||
interface PullRequestPaneSplitProps {
|
||||
/**
|
||||
* Exactly three children, in order:
|
||||
* 1. the overview (the PR's header and checks), which scrolls in its own region;
|
||||
* 2. the Activity disclosure row, docked to the bottom of the pane while collapsed;
|
||||
* 3. the Activity timeline, filling the space below the separator while open.
|
||||
*
|
||||
* Slots are children because `react-perf/jsx-no-jsx-as-prop` rejects JSX in a prop. Pass all
|
||||
* three unconditionally, the way `TreeRail` takes its slots.
|
||||
*/
|
||||
children: ReactNode;
|
||||
activityOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the pane out the way stacked sidebar sections behave: the overview and Activity share the
|
||||
* height, a separator between them resizes the share, and a collapsed Activity gives its space
|
||||
* back by docking its header to the bottom.
|
||||
*/
|
||||
export function PullRequestPaneSplit({ children, activityOpen }: PullRequestPaneSplitProps) {
|
||||
const [overview, activityHeader, activityTimeline] = Children.toArray(children);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [overviewShare, setOverviewShare] = useState(DEFAULT_OVERVIEW_SHARE);
|
||||
// The pointer's share while a drag is in progress. The saved share is decided once, when the
|
||||
// drag ends, so moves along the way cannot overwrite the share the overview had before it.
|
||||
const [dragShare, setDragShare] = useState<number | null>(null);
|
||||
const [splitHeight, setSplitHeight] = useState(0);
|
||||
const [overviewHeight, setOverviewHeight] = useState(0);
|
||||
const [overviewContentHeight, setOverviewContentHeight] = useState(0);
|
||||
|
||||
const handleSplitLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setSplitHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const handleOverviewLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setOverviewHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const handleOverviewContentSizeChange = useCallback((_width: number, height: number) => {
|
||||
setOverviewContentHeight(height);
|
||||
}, []);
|
||||
const handlePreviewResize = useCallback((_groupId: string, sizes: number[]) => {
|
||||
const [share] = sizes;
|
||||
if (share !== undefined) setDragShare(share);
|
||||
}, []);
|
||||
const handleResize = useCallback(
|
||||
(_groupId: string, sizes: number[]) => {
|
||||
setDragShare(null);
|
||||
const [share] = sizes;
|
||||
if (share === undefined || splitHeight <= 0) return;
|
||||
const contentShare = overviewContentHeight / splitHeight;
|
||||
setOverviewShare((current) => resolveDraggedShare(share, current, contentShare));
|
||||
},
|
||||
[overviewContentHeight, splitHeight],
|
||||
);
|
||||
|
||||
// The overview stops at its content height, so a drag starts from where the separator is
|
||||
// drawn rather than from the share the overview may grow to.
|
||||
const separatorSizes = useMemo(() => {
|
||||
const share = splitHeight > 0 ? overviewHeight / splitHeight : DEFAULT_OVERVIEW_SHARE;
|
||||
return [share, 1 - share];
|
||||
}, [overviewHeight, splitHeight]);
|
||||
// The Activity header docks to the bottom edge, so it clears the home indicator the way the
|
||||
// Changes pane's docked Commits section does. The split is measured inside that padding: the
|
||||
// overview's percentage height resolves against this box, so the drag must use the same one.
|
||||
const rootStyle = useMemo(() => [styles.root, { paddingBottom: insets.bottom }], [insets.bottom]);
|
||||
const drawnShare = dragShare ?? overviewShare;
|
||||
const overviewStyle = useMemo(
|
||||
() =>
|
||||
activityOpen
|
||||
? [styles.overviewShared, { maxHeight: `${drawnShare * 100}%` as const }]
|
||||
: styles.overviewFill,
|
||||
[activityOpen, drawnShare],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={rootStyle} testID="pr-pane">
|
||||
<View style={styles.split} onLayout={handleSplitLayout}>
|
||||
<ScrollView
|
||||
style={overviewStyle}
|
||||
onLayout={handleOverviewLayout}
|
||||
onContentSizeChange={handleOverviewContentSizeChange}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="pr-pane-overview"
|
||||
>
|
||||
{overview}
|
||||
</ScrollView>
|
||||
{activityOpen ? (
|
||||
<ResizeHandle
|
||||
testID="pr-pane-split-handle"
|
||||
direction="vertical"
|
||||
groupId={SPLIT_GROUP_ID}
|
||||
index={0}
|
||||
sizes={separatorSizes}
|
||||
containerSize={splitHeight}
|
||||
onPreviewResizeSplit={handlePreviewResize}
|
||||
onResizeSplit={handleResize}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.divider} />
|
||||
)}
|
||||
{/* The header keeps one slot in both states, so toggling it does not remount the row
|
||||
and drop keyboard focus. Only the timeline mounts and unmounts. */}
|
||||
{activityHeader}
|
||||
{activityOpen ? (
|
||||
<ScrollView style={styles.activityScroll} showsVerticalScrollIndicator={false}>
|
||||
{activityTimeline}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The share a drag leaves the overview. Short content stops the separator at the content's
|
||||
* bottom, so a drag that ends there or below saves what was drawn and keeps any larger share
|
||||
* the overview already had. The separator then neither jumps to the pointer nor stays pinned
|
||||
* at the old content height when the content grows.
|
||||
*/
|
||||
function resolveDraggedShare(pointerShare: number, currentShare: number, contentShare: number) {
|
||||
if (pointerShare < contentShare) return pointerShare;
|
||||
return Math.max(currentShare, contentShare);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
split: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
overviewShared: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
overviewFill: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
// Same 1px line the separator draws, so collapsing Activity does not move the boundary's look.
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
activityScroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
}));
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createElement, type ElementType } from "react";
|
||||
import { lightHighlightColors } from "@getpaseo/highlight";
|
||||
import { lightTheme } from "../src/styles/theme";
|
||||
|
||||
const pinnedTheme = {
|
||||
const testTheme = {
|
||||
colorScheme: "light",
|
||||
colors: {
|
||||
syntax: lightHighlightColors,
|
||||
@@ -87,14 +86,6 @@ const pinnedTheme = {
|
||||
},
|
||||
};
|
||||
|
||||
// Tokens a test does not pin come from the real light theme, so a style or `uniProps` mapping
|
||||
// that reads one (the markdown renderer reads `colors.terminal`) gets a value instead of crashing.
|
||||
const testTheme = {
|
||||
...lightTheme,
|
||||
...pinnedTheme,
|
||||
colors: { ...lightTheme.colors, ...pinnedTheme.colors },
|
||||
};
|
||||
|
||||
type StyleFactory<T> = (theme: typeof testTheme) => T;
|
||||
|
||||
function isStyleFactory<T>(styles: T | StyleFactory<T>): styles is StyleFactory<T> {
|
||||
@@ -151,19 +142,7 @@ export const StyleSheet = {
|
||||
resolveStyles(isStyleFactory(styles) ? styles(testTheme) : styles),
|
||||
};
|
||||
|
||||
type UniPropsMapping = (theme: typeof testTheme, runtime: object) => Record<string, unknown>;
|
||||
|
||||
/** Applies `uniProps` with the test theme, the way Unistyles maps theme values onto props. */
|
||||
export const withUnistyles = <T>(Component: T): T => {
|
||||
function ThemedComponent({ uniProps, ...props }: { uniProps?: UniPropsMapping }) {
|
||||
// Explicit props win over mapped ones, as in Unistyles' `deepMergeObjects(mappings, uniProps, props)`.
|
||||
return createElement(Component as ElementType, {
|
||||
...(uniProps ? uniProps(testTheme, {}) : {}),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
return ThemedComponent as T;
|
||||
};
|
||||
export const withUnistyles = <T>(Component: T): T => Component;
|
||||
|
||||
export const useUnistyles = () => ({
|
||||
theme: testTheme,
|
||||
|
||||
@@ -113,111 +113,6 @@ test.each([
|
||||
}
|
||||
});
|
||||
|
||||
test("re-probes a v1 runtime on a forced refresh after OpenCode is updated to v2", async () => {
|
||||
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
|
||||
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-version-upgrade-"));
|
||||
const script = join(root, "version.cjs");
|
||||
const client = new OpenCodeRuntimeClient(createTestLogger(), {
|
||||
command: { mode: "replace", argv: [process.execPath, script] },
|
||||
env: { XDG_STATE_HOME: root },
|
||||
});
|
||||
const controlLabel = async () =>
|
||||
(await client.listControls({ provider: "opencode", cwd: root })).controls[0]?.label;
|
||||
try {
|
||||
await writeFile(script, 'console.log("1.14.46")');
|
||||
expect(await controlLabel()).toBe("Auto Accept");
|
||||
await writeFile(script, 'console.log("opencode v2.0.10")');
|
||||
expect(await controlLabel()).toBe("Auto Accept");
|
||||
|
||||
// The fake binary cannot serve, so the refresh fails on the v2 startup path it switched to.
|
||||
await expect(client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow(
|
||||
"OpenCode v2 server exited during startup",
|
||||
);
|
||||
|
||||
expect(await controlLabel()).toBe("Auto-accept");
|
||||
} finally {
|
||||
await client.shutdown();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createCountingVersionBinary(prefix: string) {
|
||||
const { mkdtemp, writeFile, readFile, rm } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
|
||||
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
|
||||
const root = await mkdtemp(join(tmpdir(), prefix));
|
||||
const script = join(root, "version.cjs");
|
||||
const probes = join(root, "probes");
|
||||
await writeFile(probes, "");
|
||||
const client = new OpenCodeRuntimeClient(createTestLogger(), {
|
||||
command: { mode: "replace", argv: [process.execPath, script] },
|
||||
env: { XDG_STATE_HOME: root },
|
||||
});
|
||||
return {
|
||||
client,
|
||||
root,
|
||||
report: (version: string) =>
|
||||
writeFile(
|
||||
script,
|
||||
`if (process.argv.includes("--version")) require("node:fs").appendFileSync(${JSON.stringify(probes)}, "x"); console.log(${JSON.stringify(version)})`,
|
||||
),
|
||||
probeCount: async () => (await readFile(probes, "utf8")).length,
|
||||
controlLabel: async () =>
|
||||
(await client.listControls({ provider: "opencode", cwd: root })).controls[0]?.label,
|
||||
dispose: async () => {
|
||||
await client.shutdown();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("keeps a v2 runtime on a forced refresh without probing the binary again", async () => {
|
||||
const binary = await createCountingVersionBinary("opencode-v2-refresh-");
|
||||
try {
|
||||
await binary.report("opencode v2.0.10");
|
||||
expect(await binary.controlLabel()).toBe("Auto-accept");
|
||||
expect(await binary.probeCount()).toBe(1);
|
||||
|
||||
await expect(binary.client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow(
|
||||
"OpenCode v2 server exited during startup",
|
||||
);
|
||||
|
||||
expect(await binary.probeCount()).toBe(1);
|
||||
expect(await binary.controlLabel()).toBe("Auto-accept");
|
||||
} finally {
|
||||
await binary.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([0, 1, 2, 3, 4, 5, 6, 8])(
|
||||
"probes once when a second forced refresh starts %i microtasks after the first",
|
||||
async (ticks) => {
|
||||
const binary = await createCountingVersionBinary("opencode-concurrent-refresh-");
|
||||
try {
|
||||
await binary.report("1.14.46");
|
||||
expect(await binary.controlLabel()).toBe("Auto Accept");
|
||||
await binary.report("opencode v2.0.10");
|
||||
|
||||
const first = binary.client.fetchCatalog({ scope: "global", force: true });
|
||||
for (let tick = 0; tick < ticks; tick++) await Promise.resolve();
|
||||
const second = binary.client.fetchCatalog({ scope: "global", force: true });
|
||||
const results = await Promise.allSettled([first, second]);
|
||||
|
||||
expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]);
|
||||
expect(await binary.probeCount()).toBe(2);
|
||||
expect(await binary.controlLabel()).toBe("Auto-accept");
|
||||
} finally {
|
||||
await binary.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("appends a first notice after old history and records a changed major version", async () => {
|
||||
const { withOpenCodeRuntimeNotice } = await import("./runtime-notice.js");
|
||||
const { V2Harness } = await import("./test-utils/v2-harness.js");
|
||||
|
||||
@@ -155,18 +155,8 @@ export class OpenCodeRuntimeClient implements AgentClient {
|
||||
return client === this.legacy ? session : withOpenCodeRuntimeNotice(session, 2, handle);
|
||||
}
|
||||
async fetchCatalog(options: FetchCatalogOptions, context?: ProviderRefreshContext) {
|
||||
if (options.force) await this.reprobeLegacySelection();
|
||||
return (await this.client()).fetchCatalog(options, context);
|
||||
}
|
||||
// Updating the binary does not change provider config, so the client survives it. A forced
|
||||
// refresh re-probes a v1 pick so a 1.x to 2.x update takes effect without a daemon restart.
|
||||
// A v2 pick owns live sessions and stays.
|
||||
private async reprobeLegacySelection() {
|
||||
const current = this.selected;
|
||||
if (!current) return;
|
||||
const client = await current.catch(() => null);
|
||||
if (client === this.legacy && this.selected === current) this.selected = null;
|
||||
}
|
||||
async listCommands(config: AgentSessionConfig) {
|
||||
return (await this.client()).listCommands(config);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs";
|
||||
|
||||
// A refused fetch proves the previous bridge stopped listening. Runtimes report
|
||||
// it differently: Node puts the code on `cause`, Bun puts it on the error.
|
||||
export function isRefused(error) {
|
||||
const code = error?.code ?? error?.cause?.code;
|
||||
return code === "ECONNREFUSED" || code === "ConnectionRefused";
|
||||
}
|
||||
|
||||
// OpenCode owns this plugin for the lifetime of a location. Paseo supplies only
|
||||
// live, per-session bindings over the authenticated OpenCode RPC channel.
|
||||
export async function registerSharedBridge(context) {
|
||||
@@ -137,7 +130,12 @@ export async function registerSharedBridge(context) {
|
||||
} catch (error) {
|
||||
// Refusal or rejected credentials mean this endpoint no longer
|
||||
// serves the previous binding. Other failures are inconclusive.
|
||||
if (error?.status !== 401 && !isRefused(error)) throw error;
|
||||
if (
|
||||
error?.status !== 401 &&
|
||||
error?.cause?.code !== "ECONNREFUSED" &&
|
||||
error?.code !== "ConnectionRefused"
|
||||
)
|
||||
throw error;
|
||||
}
|
||||
if (active)
|
||||
return { error: "OpenCode session is already bound to another Paseo bridge" };
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -169,88 +167,3 @@ test("a shared plugin binds Paseo sessions without involving unrelated sessions"
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a shared plugin rebinds a session after the previous bridge stops listening", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-shared-bridge-refused-"));
|
||||
const previous = createServer((_request, response) => {
|
||||
response.writeHead(200, { "Content-Type": "application/json", Connection: "close" });
|
||||
response.end(JSON.stringify({ tools: [] }));
|
||||
});
|
||||
await new Promise<void>((resolve) => previous.listen(0, "127.0.0.1", resolve));
|
||||
const previousPort = (previous.address() as AddressInfo).port;
|
||||
const current = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() });
|
||||
current.setManifestCatalog({ tools: new Map() });
|
||||
let rpc: Record<string, (input: Binding) => Promise<unknown>> = {};
|
||||
let dispose: (() => Promise<void>) | undefined;
|
||||
let releaseCurrent: (() => void) | undefined;
|
||||
try {
|
||||
const plugin = {
|
||||
package: await current.materializeV2Plugin(),
|
||||
options: { baseUrl: `http://127.0.0.1:${previousPort}`, token: "previous-bridge" },
|
||||
};
|
||||
const module = await import(
|
||||
pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href
|
||||
);
|
||||
dispose = await module.default.setup({
|
||||
options: {},
|
||||
location: { directory: home },
|
||||
provider: { reload: async () => {} },
|
||||
model: { reload: async () => {} },
|
||||
agent: { reload: async () => {} },
|
||||
command: { reload: async () => {} },
|
||||
skill: { reload: async () => {} },
|
||||
rpc: {
|
||||
register: async (_definition: unknown, handlers: typeof rpc) => {
|
||||
rpc = handlers;
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
},
|
||||
tool: { transform: async () => ({ dispose: async () => {} }) },
|
||||
session: {
|
||||
context: async () => [],
|
||||
get: async () => ({ parentID: "" }),
|
||||
hook: async () => ({ dispose: async () => {} }),
|
||||
},
|
||||
});
|
||||
await expect(rpc.bind({ sessionID: "paseo", ...plugin.options })).resolves.toEqual({});
|
||||
await current.start();
|
||||
// The restarted daemon owns the agent again, so its bridge serves this session.
|
||||
releaseCurrent = current.bindSession({ sessionId: "paseo", env: {} });
|
||||
await new Promise<void>((resolve) => {
|
||||
previous.close(() => resolve());
|
||||
previous.closeAllConnections();
|
||||
});
|
||||
await expect(
|
||||
rpc.bind({ sessionID: "paseo", ...current.sharedV2Connection() }),
|
||||
).resolves.toEqual({});
|
||||
} finally {
|
||||
await dispose?.();
|
||||
releaseCurrent?.();
|
||||
await current.close();
|
||||
await new Promise<void>((resolve) => previous.close(() => resolve()));
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a refused connection is the only probe failure that clears a binding", async () => {
|
||||
const source = new URL("./shared-bridge-plugin.mjs", import.meta.url).href;
|
||||
const { isRefused } = (await import(source)) as { isRefused: (error: unknown) => boolean };
|
||||
const nodeShape = Object.assign(new Error("fetch failed"), {
|
||||
cause: Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
|
||||
});
|
||||
const olderBunShape = Object.assign(new TypeError("Unable to connect"), {
|
||||
code: "ConnectionRefused",
|
||||
});
|
||||
// OpenCode 2.2.5 runs on Bun 1.4.4, which carries the code on the error and
|
||||
// leaves `cause` unset. Node puts the same failure on `cause` instead.
|
||||
const bunShape = Object.assign(
|
||||
new TypeError("ECONNREFUSED: Unable to connect. Is the computer able to access the url?"),
|
||||
{ code: "ECONNREFUSED" },
|
||||
);
|
||||
const inconclusive = Object.assign(new Error("request timed out"), { code: "ETIMEDOUT" });
|
||||
expect(isRefused(nodeShape)).toBe(true);
|
||||
expect(isRefused(olderBunShape)).toBe(true);
|
||||
expect(isRefused(bunShape)).toBe(true);
|
||||
expect(isRefused(inconclusive)).toBe(false);
|
||||
expect(isRefused(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1238,9 +1238,6 @@ test("a failed parent lookup is retried by the pull request status poll, keeping
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
now: () => new Date(nowMs),
|
||||
// Observing the workspace starts a background fetch of origin. A real one would run ssh
|
||||
// against the alias host in the checkout, and can outlive disposal and hold the folder.
|
||||
runGitFetch: async () => ({ changes: [], error: null }),
|
||||
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
|
||||
forgeOverrides: {
|
||||
// The adapter is real; only the poll is driven by the test instead of a timer.
|
||||
|
||||
Reference in New Issue
Block a user