mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 20:35:02 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d0f270ad | ||
|
|
a331a945ee | ||
|
|
88a2a84f65 | ||
|
|
83708e21de | ||
|
|
090a1282c0 | ||
|
|
c431dd49c6 | ||
|
|
42c7b7f886 | ||
|
|
85fb5b436d | ||
|
|
c81f38d7ca | ||
|
|
be487ce65f | ||
|
|
0e0ebc7c9e | ||
|
|
7f3967d288 | ||
|
|
ade902f195 | ||
|
|
1bd94ac2b0 | ||
|
|
7deb338d4c | ||
|
|
2fa0e4b169 | ||
|
|
d11239fd95 |
+1
-1
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which uses React Native’s press lifecycle on native with a short press delay so scrolling cancels pending feedback. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which owns the pressed feedback: pass the pressed fill as `highlightStyle` and never paint it in your own style. iOS and web paint that fill; Android draws the platform ripple over the content instead, clipped to the row's corners. On native, feedback waits for a short press delay so a scroll that claims the touch shows none. Content that knocks out of the row's surface, such as a badge ring, follows the fill through `onHighlightChange`. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ than downloading a published desktop release.
|
||||
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying agent and project JSON metadata and configuration. Both this setup and `PASEO_DEV_SEED_HOME` use the same seeder, which disables `agents.resumeOnStartup` in the copied config. Startup repairs copied running records without resuming agents or sending recovery prompts. Schedules and heartbeats (stored under `schedules/`), runtime files, identities, sockets, and logs are not copied. Existing nonempty homes are left unchanged unless explicitly reset. Copied agents still reference the original provider sessions and working directories: manually prompting one reaches that real session.
|
||||
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
|
||||
|
||||
Override knobs:
|
||||
|
||||
@@ -65,6 +65,10 @@ Two escape hatches in the codebase:
|
||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||
(the composer's input must stay focused so the user keeps typing).
|
||||
Use `@/components/ui/modal`, not React Native's `Modal`. On Android, React Native lays out a
|
||||
modal's first frames at the screen size minus the status and navigation bars, then grows it to
|
||||
the full edge-to-edge window, so a full-screen backdrop stops short of the bottom and snaps
|
||||
down. The wrapper sizes the content to the window from the first frame.
|
||||
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
|
||||
hoists the React subtree to a fixed mount point whose bounds cover the
|
||||
screen. Same window, same IME, hit-test works because the new parent is
|
||||
@@ -422,9 +426,11 @@ gets that 0 and draws over the navigation bar.
|
||||
|
||||
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
|
||||
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
|
||||
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
|
||||
inside it and read the inset below that. The modal is its own window, and the provider measures
|
||||
it. See `attachment-lightbox.tsx`.
|
||||
- An RN `Modal` passes the call site's context straight through, so wrap its content in
|
||||
`WindowSafeArea` from `@/window-layout` and read the inset below that. It provides the insets of
|
||||
the whole window, published by `WindowLayoutProvider`. Do not put a `SafeAreaProvider` inside the
|
||||
modal: on Android it measures the dialog window, whose bottom inset reads 24, then 0, then 24
|
||||
again over the first frames, and everything padded by it jumps. See `attachment-lightbox.tsx`.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
archivedToast,
|
||||
archiveFromRowButton,
|
||||
createGatedTeardown,
|
||||
expectDirectoryPresence,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
resizeSidebarToMinimum,
|
||||
rowArchiveButton,
|
||||
rowKebab,
|
||||
scrollSidebarListToEnd,
|
||||
undoButton,
|
||||
workspaceRow,
|
||||
workspaceTitleWidth,
|
||||
@@ -119,6 +121,41 @@ test.describe("Workspace archive undo", () => {
|
||||
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
|
||||
});
|
||||
|
||||
test("a long list ends just below its last row, and the toast over it moves no row", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
const fillers: Array<{ path: string; cleanup: () => Promise<void> }> = [];
|
||||
const fillerProjectIds: string[] = [];
|
||||
try {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const repo = await createTempGitRepo(`archive-toast-filler-${index}-`);
|
||||
fillers.push(repo);
|
||||
fillerProjectIds.push((await openProjectViaDaemon(client, repo.path)).projectId);
|
||||
}
|
||||
const worktree = await seedWorktree(page, "archive-toast-long-list");
|
||||
|
||||
const resting = await scrollSidebarListToEnd(page);
|
||||
expect(resting.listBottom - resting.rowBottoms.at(-1)!).toBeLessThan(resting.rowHeight);
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await page.mouse.move(0, 0);
|
||||
const withToast = await scrollSidebarListToEnd(page);
|
||||
const toastBox = await archivedToast(page).boundingBox();
|
||||
if (!toastBox) throw new Error("Toast is not laid out");
|
||||
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(withToast.listBottom);
|
||||
|
||||
await outlastUndoWindow(page);
|
||||
await expect(archivedToast(page)).toHaveCount(0);
|
||||
expect(await scrollSidebarListToEnd(page)).toEqual(withToast);
|
||||
} finally {
|
||||
for (const projectId of fillerProjectIds) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
for (const repo of fillers) await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("an archive that fails after the sidebar was replaced still shows its error", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface OlderTimelinePagesGate {
|
||||
getRepeatedEntryCount(): number;
|
||||
getOwnedEntryCountContaining(text: string): number;
|
||||
releasePage(pageNumber: number): void;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DaemonHydrationGate {
|
||||
@@ -371,15 +370,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
const ownedEntries = new Map<string, string>();
|
||||
const releasedPages = new Set<number>();
|
||||
const delayedForwards = new Map<number, Array<() => void>>();
|
||||
const requestWaiters = new Map<number, Array<() => void>>();
|
||||
|
||||
const resolveRequestWaiters = () => {
|
||||
for (const [count, resolvers] of requestWaiters) {
|
||||
if (requestCount < count) continue;
|
||||
requestWaiters.delete(count);
|
||||
for (const resolve of resolvers) resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const routePattern =
|
||||
daemonPort === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(String(daemonPort));
|
||||
@@ -393,7 +383,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
sessionMessage.direction === "before"
|
||||
) {
|
||||
requestCount += 1;
|
||||
resolveRequestWaiters();
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
@@ -439,14 +428,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
|
||||
delayedForwards.delete(pageNumber);
|
||||
},
|
||||
waitForRequestCount(count) {
|
||||
if (requestCount >= count) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
const resolvers = requestWaiters.get(count) ?? [];
|
||||
resolvers.push(resolve);
|
||||
requestWaiters.set(count, resolvers);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -427,7 +427,11 @@ export async function holdOlderHistoryPages(
|
||||
const loader = page.getByTestId("load-older-history-spinner");
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
await expect
|
||||
.poll(() => gate.getRequestCount(), {
|
||||
message: `older history requests ${count} page(s)`,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(count);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
|
||||
@@ -162,3 +162,38 @@ export function createGatedTeardown(): GatedTeardown {
|
||||
release: () => writeFileSync(releasePath, "release\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function archivedToast(page: Page) {
|
||||
return page.getByTestId("workspace-archived-toast");
|
||||
}
|
||||
|
||||
export interface SidebarListLayout {
|
||||
/** Bottom edge of the list's visible area. */
|
||||
listBottom: number;
|
||||
/** Bottom edges of the visible project and workspace rows, top to bottom. */
|
||||
rowBottoms: number[];
|
||||
rowHeight: number;
|
||||
}
|
||||
|
||||
/** Scrolls the sidebar's workspace list to its end and reads where its rows sit. */
|
||||
export async function scrollSidebarListToEnd(page: Page): Promise<SidebarListLayout> {
|
||||
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
await list.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
return list.evaluate((element) => {
|
||||
const visible = element.getBoundingClientRect();
|
||||
const rows = Array.from(
|
||||
element.querySelectorAll<HTMLElement>(
|
||||
'[data-testid^="sidebar-workspace-row-"],[data-testid^="sidebar-project-row-"]',
|
||||
),
|
||||
)
|
||||
.map((row) => row.getBoundingClientRect())
|
||||
.filter((row) => row.height > 0 && row.bottom > visible.top && row.top < visible.bottom);
|
||||
return {
|
||||
listBottom: visible.bottom,
|
||||
rowBottoms: rows.map((row) => row.bottom).sort((a, b) => a - b),
|
||||
rowHeight: Math.min(...rows.map((row) => row.height)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,8 +49,21 @@ afterEach(() => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
function mountTimeline(historyMounted: StreamItem[]) {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
type OlderHistoryInput = Pick<
|
||||
StreamRenderInput,
|
||||
"onNearHistoryStart" | "hasOlderHistory" | "olderHistoryProgressKey"
|
||||
>;
|
||||
|
||||
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",
|
||||
@@ -76,6 +89,7 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
...olderHistory,
|
||||
};
|
||||
const render = (segments: Partial<StreamRenderInput["segments"]>) =>
|
||||
root.render(strategy.render({ ...input, segments: { ...input.segments, ...segments } }));
|
||||
@@ -100,8 +114,20 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
scrollTo: (top: number) => {
|
||||
scrollContainer.scrollTop = top;
|
||||
},
|
||||
// A wheel listener runs before the browser applies the scroll the wheel causes.
|
||||
wheelUpTo: (top: number) => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
|
||||
scrollContainer.scrollTop = top;
|
||||
},
|
||||
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 () => {
|
||||
@@ -113,6 +139,53 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -208,3 +281,32 @@ describe("web disclosure hold", () => {
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web history start", () => {
|
||||
it("loads older history when an upward wheel lands at the start after the reader stopped following", async () => {
|
||||
let olderHistoryLoads = 0;
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history, {
|
||||
olderHistory: {
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch:1",
|
||||
onNearHistoryStart: async () => {
|
||||
olderHistoryLoads += 1;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
await timeline.settle();
|
||||
// Opening a row at the bottom stops following output without any scroll.
|
||||
timeline.hold("message-5", () =>
|
||||
timeline.render({ historyMounted: [...history.slice(0, 5), row(7, 200), history[5]!] }),
|
||||
);
|
||||
await timeline.settle();
|
||||
expect(olderHistoryLoads).toBe(0);
|
||||
|
||||
timeline.wheelUpTo(0);
|
||||
await timeline.settle();
|
||||
|
||||
expect(olderHistoryLoads).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -605,6 +607,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
|
||||
}, []);
|
||||
|
||||
const hasUpwardInputEvidence = useCallback(() => {
|
||||
const mouseGesture = mouseScrollGestureRef.current;
|
||||
return (
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current
|
||||
);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isActive) {
|
||||
return;
|
||||
@@ -791,16 +802,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
||||
const scrolledDown =
|
||||
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
|
||||
const mouseGesture = mouseScrollGestureRef.current;
|
||||
const hasUpwardInputEvidence =
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current;
|
||||
const userScrolledUp = scrolledUp && hasUpwardInputEvidence();
|
||||
|
||||
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
||||
setFollowOutput(true);
|
||||
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
|
||||
} else if (followOutputRef.current && userScrolledUp) {
|
||||
stopFollowingOutputFromUserIntent();
|
||||
} else if (userScrolledUp) {
|
||||
// Wheel listeners can run before or after the browser applies the scroll, so the
|
||||
// upward scroll itself re-arms loading when it reaches the start.
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
@@ -811,8 +822,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
evaluateHistoryStart();
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasUpwardInputEvidence,
|
||||
isJumpSettling,
|
||||
readingAnchor,
|
||||
rearmHistoryStartFromUserIntent,
|
||||
stopFollowingOutputFromUserIntent,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
|
||||
@@ -39,6 +39,21 @@ function createDeferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a pending lookup and waits until React Query has delivered the result. It delivers on a
|
||||
* timer, which would otherwise fire after the test, when jsdom is gone.
|
||||
*/
|
||||
async function resolveLookupAndDeliver(
|
||||
lookup: ReturnType<typeof createDeferred<DirectorySuggestionResult>>,
|
||||
result: DirectorySuggestionResult,
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
lookup.resolve(result);
|
||||
await lookup.promise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
interface OpenedFile {
|
||||
target: InlinePathTarget;
|
||||
disposition: OpenFileDisposition;
|
||||
@@ -322,11 +337,12 @@ describe("useFileLink", () => {
|
||||
act(() => {
|
||||
result.current.setWorkspaceRoot("/Users/test/other");
|
||||
});
|
||||
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
await resolveLookupAndDeliver(
|
||||
deferred,
|
||||
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
expect(openedFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { persistAttachmentFromBlob } from "@/attachments/service";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { type AttachmentPreviewUrl, useAttachmentPreviewUrl } from "./use-attachment-preview-url";
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let latest: AttachmentPreviewUrl | null = null;
|
||||
|
||||
function PreviewProbe({ attachment }: { attachment: AttachmentMetadata | null }) {
|
||||
latest = useAttachmentPreviewUrl(attachment);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function render(attachment: AttachmentMetadata | null) {
|
||||
await act(async () => root.render(<PreviewProbe attachment={attachment} />));
|
||||
}
|
||||
|
||||
async function waitForReady(): Promise<string> {
|
||||
await vi.waitFor(() => expect(latest?.status).toBe("ready"));
|
||||
if (latest?.status !== "ready") throw new Error("preview never resolved");
|
||||
return latest.url;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
latest = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("never hands back a URL it already released when the same attachment returns", async () => {
|
||||
const attachment = await persistAttachmentFromBlob({
|
||||
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
|
||||
mimeType: "image/png",
|
||||
fileName: "shot.png",
|
||||
});
|
||||
|
||||
await render(attachment);
|
||||
const released = await waitForReady();
|
||||
await render(null);
|
||||
await render(attachment);
|
||||
|
||||
expect(latest).not.toEqual({ status: "ready", url: released });
|
||||
expect(await waitForReady()).not.toBe(released);
|
||||
});
|
||||
|
||||
it("retries a failed attachment from loading when it returns", async () => {
|
||||
const stored = await persistAttachmentFromBlob({
|
||||
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
|
||||
mimeType: "image/png",
|
||||
fileName: "shot.png",
|
||||
});
|
||||
const missing = { ...stored, id: "missing-attachment", storageKey: "missing-attachment" };
|
||||
|
||||
await render(missing);
|
||||
await vi.waitFor(() => expect(latest?.status).toBe("failed"));
|
||||
await render(null);
|
||||
await render(missing);
|
||||
|
||||
expect(latest).toEqual({ status: "loading" });
|
||||
});
|
||||
@@ -2,62 +2,75 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
|
||||
|
||||
export type AttachmentPreviewUrl =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; url: string }
|
||||
| { status: "failed" };
|
||||
|
||||
const LOADING: AttachmentPreviewUrl = { status: "loading" };
|
||||
const FAILED: AttachmentPreviewUrl = { status: "failed" };
|
||||
|
||||
function attachmentKey(attachment: AttachmentMetadata | null | undefined): string | null {
|
||||
if (!attachment) return null;
|
||||
return [attachment.id, attachment.storageType, attachment.storageKey, attachment.mimeType].join(
|
||||
"\u0000",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a displayable URL for a stored attachment. Stays `loading` until the URL for the
|
||||
* current attachment resolves, so a caller never shows a failure, or the previous attachment's
|
||||
* URL, while the next one is still resolving. A missing attachment stays `loading`.
|
||||
*/
|
||||
export function useAttachmentPreviewUrl(
|
||||
attachment: AttachmentMetadata | null | undefined,
|
||||
): string | null {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
|
||||
): AttachmentPreviewUrl {
|
||||
const [resolved, setResolved] = useState<{
|
||||
key: string;
|
||||
preview: AttachmentPreviewUrl;
|
||||
} | null>(null);
|
||||
const attachmentRef = useRef(attachment);
|
||||
attachmentRef.current = attachment;
|
||||
|
||||
const id = attachment?.id;
|
||||
const storageType = attachment?.storageType;
|
||||
const storageKey = attachment?.storageKey;
|
||||
const mimeType = attachment?.mimeType;
|
||||
const key = attachmentKey(attachment);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let currentUrl: string | null = null;
|
||||
const current = attachmentRef.current;
|
||||
|
||||
activeAttachmentRef.current = current ?? null;
|
||||
if (!current) {
|
||||
setUrl(null);
|
||||
if (!current || key === null) {
|
||||
return;
|
||||
}
|
||||
let disposed = false;
|
||||
let currentUrl: string | null = null;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveAttachmentPreviewUrl(current);
|
||||
const url = await resolveAttachmentPreviewUrl(current);
|
||||
if (disposed) {
|
||||
await releaseAttachmentPreviewUrl({ attachment: current, url: resolved });
|
||||
await releaseAttachmentPreviewUrl({ attachment: current, url });
|
||||
return;
|
||||
}
|
||||
currentUrl = resolved;
|
||||
setUrl(resolved);
|
||||
currentUrl = url;
|
||||
setResolved({ key, preview: { status: "ready", url } });
|
||||
} catch (error) {
|
||||
console.error("[attachments] Failed to resolve preview URL", {
|
||||
attachmentId: current.id,
|
||||
error,
|
||||
});
|
||||
if (!disposed) {
|
||||
setUrl(null);
|
||||
setResolved({ key, preview: FAILED });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
const activeAttachment = activeAttachmentRef.current;
|
||||
if (!currentUrl || !activeAttachment) {
|
||||
return;
|
||||
if (currentUrl) {
|
||||
void releaseAttachmentPreviewUrl({ attachment: current, url: currentUrl });
|
||||
}
|
||||
void releaseAttachmentPreviewUrl({
|
||||
attachment: activeAttachment,
|
||||
url: currentUrl,
|
||||
});
|
||||
// A result lives only as long as its lookup. Kept, the same attachment coming back would
|
||||
// read a released URL, or an old failure, until the next lookup lands.
|
||||
setResolved((previous) => (previous?.key === key ? null : previous));
|
||||
};
|
||||
}, [id, storageType, storageKey, mimeType]);
|
||||
}, [key]);
|
||||
|
||||
return url;
|
||||
return resolved !== null && resolved.key === key ? resolved.preview : LOADING;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { persistAttachmentFromBlob } from "@/attachments/service";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { WindowLayoutProvider } from "@/window-layout";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
|
||||
|
||||
interface FrameSample {
|
||||
image: { x: number; y: number; width: number; height: number } | null;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
function nextFrame(): Promise<number> {
|
||||
return new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
|
||||
function createPng(width: number, height: number): Promise<Blob> {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas 2D context unavailable");
|
||||
context.fillStyle = "#3a7";
|
||||
context.fillRect(0, 0, width, height);
|
||||
return canvas.convertToBlob({ type: "image/png" });
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
|
||||
function isPainted(element: Element): boolean {
|
||||
for (let node: Element | null = element; node; node = node.parentElement) {
|
||||
if (Number(getComputedStyle(node).opacity) === 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** What the next paint will show: the visible lightbox image box, and whether the error is up. */
|
||||
function sampleFrame(): FrameSample {
|
||||
const image = document.querySelector('[data-testid="attachment-lightbox-image"]');
|
||||
const rect = image?.getBoundingClientRect();
|
||||
const visible = image && rect && rect.width > 0 && rect.height > 0 && isPainted(image);
|
||||
return {
|
||||
image: visible
|
||||
? {
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
}
|
||||
: null,
|
||||
error: (document.body.textContent ?? "").includes("Couldn't load image"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Samples every frame from the open until the image has held one box for 20 frames. */
|
||||
async function recordOpening(open: () => void): Promise<FrameSample[]> {
|
||||
const samples: FrameSample[] = [];
|
||||
open();
|
||||
let stableFrames = 0;
|
||||
for (let frame = 0; frame < 300 && stableFrames < 20; frame += 1) {
|
||||
await nextFrame();
|
||||
const sample = sampleFrame();
|
||||
const previous = samples.at(-1);
|
||||
stableFrames =
|
||||
sample.image && JSON.stringify(sample.image) === JSON.stringify(previous?.image)
|
||||
? stableFrames + 1
|
||||
: 0;
|
||||
samples.push(sample);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("React", React);
|
||||
await i18n.changeLanguage("en");
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderLightbox(source: ImageLightboxSource | null) {
|
||||
root.render(
|
||||
<SafeAreaProvider>
|
||||
<WindowLayoutProvider>
|
||||
<AttachmentLightbox source={source} onClose={noop} />
|
||||
</WindowLayoutProvider>
|
||||
</SafeAreaProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function expectOpeningPaintsOnlyTheFittedImage(source: ImageLightboxSource) {
|
||||
const samples = await recordOpening(() => renderLightbox(source));
|
||||
const fitted = samples.at(-1)?.image;
|
||||
|
||||
expect(fitted, "the image never settled").toBeTruthy();
|
||||
expect(samples.filter((sample) => sample.error)).toEqual([]);
|
||||
expect(
|
||||
samples.filter(
|
||||
(sample) => sample.image && JSON.stringify(sample.image) !== JSON.stringify(fitted),
|
||||
),
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
describe("AttachmentLightbox opening in the browser", () => {
|
||||
it("paints the fitted attachment image as its first image frame", async () => {
|
||||
const metadata = await persistAttachmentFromBlob({
|
||||
blob: await createPng(2400, 1600),
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
});
|
||||
|
||||
await expectOpeningPaintsOnlyTheFittedImage({ type: "attachment", metadata });
|
||||
});
|
||||
|
||||
it("paints only the fitted image when the same attachment opens again", async () => {
|
||||
const metadata = await persistAttachmentFromBlob({
|
||||
blob: await createPng(2400, 1600),
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
});
|
||||
const source: ImageLightboxSource = { type: "attachment", metadata };
|
||||
|
||||
await expectOpeningPaintsOnlyTheFittedImage(source);
|
||||
await act(async () => renderLightbox(null));
|
||||
await expectOpeningPaintsOnlyTheFittedImage(source);
|
||||
});
|
||||
|
||||
it("shows an image the browser reports no natural size for", async () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"></svg>';
|
||||
const uri = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
|
||||
|
||||
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
|
||||
});
|
||||
|
||||
it("paints the fitted timeline image as its first image frame", async () => {
|
||||
const uri = URL.createObjectURL(await createPng(2400, 1600));
|
||||
|
||||
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import type { AttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
|
||||
|
||||
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
|
||||
@@ -38,9 +39,9 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
|
||||
return {
|
||||
theme: hoistedTheme,
|
||||
imageMetadata: hoistedImageMetadata,
|
||||
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
|
||||
() => "blob:preview",
|
||||
),
|
||||
useAttachmentPreviewUrlMock: vi.fn<
|
||||
(metadata: AttachmentMetadata | null) => AttachmentPreviewUrl
|
||||
>(() => ({ status: "ready", url: "blob:preview" })),
|
||||
};
|
||||
});
|
||||
const attachmentSource: ImageLightboxSource = { type: "attachment", metadata: imageMetadata };
|
||||
@@ -73,10 +74,13 @@ vi.mock("react-i18next", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/window-layout", () => ({
|
||||
WindowSafeArea: ({ children }: { children?: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock("react-native-gesture-handler", () => ({
|
||||
GestureHandlerRootView: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-testid": "gesture-handler-root" }, children),
|
||||
@@ -151,7 +155,7 @@ beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
useAttachmentPreviewUrlMock.mockReturnValue("blob:preview");
|
||||
useAttachmentPreviewUrlMock.mockReturnValue({ status: "ready", url: "blob:preview" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -229,14 +233,22 @@ describe("AttachmentLightbox", () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows error text when the preview URL resolves to null", () => {
|
||||
useAttachmentPreviewUrlMock.mockReturnValue(null);
|
||||
it("shows error text when the preview URL fails to resolve", () => {
|
||||
useAttachmentPreviewUrlMock.mockReturnValue({ status: "failed" });
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
|
||||
|
||||
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||
expect(document.body.textContent ?? "").toContain("Couldn't load image");
|
||||
});
|
||||
|
||||
it("shows neither the image nor an error while the preview URL resolves", () => {
|
||||
useAttachmentPreviewUrlMock.mockReturnValue({ status: "loading" });
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
|
||||
|
||||
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||
expect(document.body.textContent ?? "").not.toContain("Couldn't load image");
|
||||
});
|
||||
|
||||
it("closes on Escape key on web", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
|
||||
|
||||
@@ -4,17 +4,21 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import {
|
||||
type AttachmentPreviewUrl,
|
||||
useAttachmentPreviewUrl,
|
||||
} from "@/attachments/use-attachment-preview-url";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||
import { useGlobalOverlayLayer } from "@/lib/overlay-root";
|
||||
import { WindowSafeArea } from "@/window-layout";
|
||||
|
||||
export type ImageLightboxSource =
|
||||
| { type: "attachment"; metadata: AttachmentMetadata }
|
||||
@@ -31,10 +35,13 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
|
||||
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const metadata = source?.type === "attachment" ? source.metadata : null;
|
||||
const attachmentUrl = useAttachmentPreviewUrl(metadata);
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
const attachmentPreview = useAttachmentPreviewUrl(metadata);
|
||||
const image: AttachmentPreviewUrl =
|
||||
source?.type === "uri" ? { status: "ready", url: source.uri } : attachmentPreview;
|
||||
const url = image.status === "ready" ? image.url : null;
|
||||
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
||||
const [errored, setErrored] = useState(false);
|
||||
const failed = errored || image.status === "failed";
|
||||
const modalLayer = useGlobalOverlayLayer("modal", source !== null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,14 +83,12 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasError = errored || !url;
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<ModalRoot style={styles.root}>
|
||||
{/* The modal is its own window: its insets are the window's, not the ones left where the
|
||||
image was opened (inside a sheet tab, the bottom one is already spent). */}
|
||||
<SafeAreaProvider>
|
||||
{/* The modal draws over the whole window: its insets are the window's, not the ones left
|
||||
where the image was opened (inside a sheet tab, the bottom one is already spent). */}
|
||||
<WindowSafeArea>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
@@ -94,9 +99,10 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<LightboxImageArea>
|
||||
{hasError ? (
|
||||
{failed ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
) : null}
|
||||
{!failed && url ? (
|
||||
<ZoomableImage
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
actions={actions}
|
||||
@@ -108,11 +114,11 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
testID="attachment-lightbox"
|
||||
uri={url}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</LightboxImageArea>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</SafeAreaProvider>
|
||||
</WindowSafeArea>
|
||||
</ModalRoot>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -133,9 +133,10 @@ export function AttachmentLabel({ icon, title, subtitle }: AttachmentLabelProps)
|
||||
|
||||
/** Square image preview pill body. */
|
||||
export function AttachmentThumbnail({ metadata }: { metadata: AttachmentMetadata }) {
|
||||
const uri = useAttachmentPreviewUrl(metadata);
|
||||
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
|
||||
if (!uri) {
|
||||
const preview = useAttachmentPreviewUrl(metadata);
|
||||
const uri = preview.status === "ready" ? preview.url : null;
|
||||
const source = useMemo(() => (uri ? { uri } : null), [uri]);
|
||||
if (!source) {
|
||||
return <View style={styles.thumbnailPlaceholder} />;
|
||||
}
|
||||
return <Image source={source} style={styles.thumbnail} />;
|
||||
|
||||
@@ -11,6 +11,10 @@ export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
const SCROLL_ENABLED_FLEX_STYLE = { flex: 1 };
|
||||
|
||||
// A drop remounts every row between the drag's start and end slots (see the draggable-flatlist
|
||||
// patch), so state held inside a rendered row resets when a drop moves it. Keep state that must
|
||||
// survive a reorder in the list's owner, keyed by row.
|
||||
|
||||
export function DraggableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
@@ -117,6 +121,10 @@ export function DraggableList<T>({
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
scrollEnabled={scrollEnabled}
|
||||
// A list that doesn't scroll shows every row, so it renders them all in its first commit.
|
||||
// FlatList's default first render is 10 rows; a nested list remounted by a drop would paint
|
||||
// 10 rows, then grow on a later commit.
|
||||
initialNumToRender={scrollEnabled ? undefined : data.length}
|
||||
extraData={extraData}
|
||||
simultaneousHandlers={simultaneousHandlers}
|
||||
dragGestureHostPresented={gestureHostPresented}
|
||||
|
||||
@@ -54,11 +54,7 @@ export function SettingsRowHeader({
|
||||
}: SettingsRowHeaderProps) {
|
||||
const layout = useSettingsRowLayout();
|
||||
const rowStyle = useCallback(
|
||||
({ hovered, pressed }: HoverState) => [
|
||||
layout.row,
|
||||
!disabled && hovered && styles.hovered,
|
||||
!disabled && pressed && styles.pressed,
|
||||
],
|
||||
({ hovered }: HoverState) => [layout.row, !disabled && hovered && styles.hovered],
|
||||
[disabled, layout],
|
||||
);
|
||||
const chevronStyle = useMemo(
|
||||
|
||||
@@ -462,7 +462,9 @@ describe("sidebar group visibility", () => {
|
||||
|
||||
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
|
||||
const items = entries(Array(count).fill("done"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
|
||||
);
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.current.canToggle).toBe(count > 7);
|
||||
});
|
||||
@@ -472,7 +474,7 @@ describe("sidebar group visibility", () => {
|
||||
(status) => {
|
||||
const items = entries([...Array(11).fill("done"), status]);
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, policy }),
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
|
||||
);
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-1",
|
||||
@@ -497,6 +499,7 @@ describe("sidebar group visibility", () => {
|
||||
useLimitedSidebarGroup({
|
||||
items,
|
||||
selection: null,
|
||||
expanded: false,
|
||||
policy: createWorkspaceVisibilityPolicy(entriesByKey),
|
||||
}),
|
||||
);
|
||||
@@ -518,7 +521,9 @@ describe("sidebar group visibility", () => {
|
||||
"needs_input",
|
||||
"failed",
|
||||
]);
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: false, policy }),
|
||||
);
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
});
|
||||
@@ -532,9 +537,10 @@ describe("sidebar group visibility", () => {
|
||||
"done",
|
||||
"attention",
|
||||
]);
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
|
||||
{ initialProps: { rows: items } },
|
||||
const { result, rerender } = renderHook(
|
||||
({ rows, expanded }) =>
|
||||
useLimitedSidebarGroup({ items: rows, selection: null, expanded, policy }),
|
||||
{ initialProps: { rows: items, expanded: false } },
|
||||
);
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-1",
|
||||
@@ -548,16 +554,15 @@ describe("sidebar group visibility", () => {
|
||||
"row-10",
|
||||
"row-12",
|
||||
]);
|
||||
act(() => result.current.toggleExpanded());
|
||||
rerender({ rows: items, expanded: true });
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) });
|
||||
rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })), expanded: true });
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
expect(result.current.expanded).toBe(true);
|
||||
rerender({ rows: items.slice(0, 3) });
|
||||
rerender({ rows: items.slice(0, 3), expanded: true });
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
rerender({ rows: items.toReversed() });
|
||||
rerender({ rows: items.toReversed(), expanded: true });
|
||||
expect(result.current.visibleItems).toEqual(items.toReversed());
|
||||
act(() => result.current.toggleExpanded());
|
||||
rerender({ rows: items.toReversed(), expanded: false });
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-12",
|
||||
"row-11",
|
||||
@@ -568,15 +573,25 @@ describe("sidebar group visibility", () => {
|
||||
"row-6",
|
||||
]);
|
||||
const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" };
|
||||
rerender({ rows: [newest, ...items] });
|
||||
rerender({ rows: [newest, ...items], expanded: false });
|
||||
expect(result.current.visibleItems[0]).toBe(newest);
|
||||
expect(result.current.visibleItems).toHaveLength(10);
|
||||
expect(result.current.expanded).toBe(false);
|
||||
act(() => result.current.toggleExpanded());
|
||||
unmount();
|
||||
const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
expect(remounted.result.current.expanded).toBe(false);
|
||||
expect(remounted.result.current.visibleItems).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("shows the reveal its owner passes in on every mount", () => {
|
||||
// A native drop remounts every project block it moves, so the group holds no reveal of its
|
||||
// own. Only the native list remounts rows; the browser list never does, so no test here can
|
||||
// drive that remount.
|
||||
const items = entries([...Array(7).fill("done"), "running", "done", "failed", "done"]);
|
||||
const first = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: true, policy }),
|
||||
);
|
||||
expect(first.result.current.visibleItems).toEqual(items);
|
||||
first.unmount();
|
||||
const remounted = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, expanded: true, policy }),
|
||||
);
|
||||
expect(remounted.result.current.visibleItems).toEqual(items);
|
||||
});
|
||||
|
||||
it("follows effective pending creation, recovery and agent activity without changing Created order", () => {
|
||||
@@ -602,6 +617,7 @@ describe("sidebar group visibility", () => {
|
||||
useLimitedSidebarGroup({
|
||||
items: [...items.slice(0, 11), last],
|
||||
selection: null,
|
||||
expanded: false,
|
||||
policy,
|
||||
}),
|
||||
{ initialProps: { last: items[11] } },
|
||||
@@ -641,12 +657,15 @@ describe("sidebar group visibility", () => {
|
||||
|
||||
it("preserves the twenty-row pinned policy regardless of status", () => {
|
||||
const items = entries(Array(21).fill("running"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null }));
|
||||
const { result, rerender } = renderHook(
|
||||
({ expanded }) => useLimitedSidebarGroup({ items, selection: null, expanded }),
|
||||
{ initialProps: { expanded: false } },
|
||||
);
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
act(() => result.current.toggleExpanded());
|
||||
rerender({ expanded: true });
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
act(() => result.current.toggleExpanded());
|
||||
rerender({ expanded: false });
|
||||
expect(result.current.visibleItems).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -368,12 +368,10 @@ function projectKebabStyle({
|
||||
|
||||
function getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
}: {
|
||||
isDragging: boolean;
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
@@ -382,7 +380,6 @@ function getProjectWorkspaceRowStyle({
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -820,7 +817,6 @@ function NewWorkspaceGhostRow({
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.newWorkspaceGhostRow,
|
||||
hovered && !pressed && styles.newWorkspaceGhostRowHovered,
|
||||
pressed && styles.newWorkspaceGhostRowPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
@@ -833,6 +829,7 @@ function NewWorkspaceGhostRow({
|
||||
})}
|
||||
onPress={handlePress}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.newWorkspaceGhostRowPressed}
|
||||
testID={`sidebar-project-new-workspace-row-${project.viewKey}`}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
@@ -883,7 +880,7 @@ function ProjectHeaderRow({
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||
const localDaemonServerId = useLocalDaemonServerId();
|
||||
@@ -930,25 +927,13 @@ function ProjectHeaderRow({
|
||||
setContextMenuOpen(open);
|
||||
if (open) setIsHovered(false);
|
||||
}, []);
|
||||
const handleProjectPressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
interaction.handlePressIn(event);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleProjectPressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
interaction.handlePressOut();
|
||||
}, [interaction]);
|
||||
|
||||
const projectRowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
const projectRowStyle = useMemo(
|
||||
() => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.projectRowHovered,
|
||||
pressed && styles.projectRowPressed,
|
||||
],
|
||||
[isDragging, selected, isHovered],
|
||||
);
|
||||
@@ -961,7 +946,7 @@ function ProjectHeaderRow({
|
||||
iconDataUri={iconDataUri}
|
||||
statusBucket={statusBucket}
|
||||
projectViewKey={project.viewKey}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered })}
|
||||
chevron={chevron}
|
||||
showChevron={isHovered && chevron !== null}
|
||||
isArchiving={isArchiving}
|
||||
@@ -1007,9 +992,10 @@ function ProjectHeaderRow({
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
highlightStyle={styles.projectRowPressed}
|
||||
onPressIn={handleProjectPressIn}
|
||||
onHighlightChange={setIsHighlighted}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={handleProjectPressOut}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
>
|
||||
@@ -1033,9 +1019,10 @@ function ProjectHeaderRow({
|
||||
accessibilityRole="button"
|
||||
style={projectRowStyle}
|
||||
highlightStyle={styles.projectRowPressed}
|
||||
onPressIn={handleProjectPressIn}
|
||||
onHighlightChange={setIsHighlighted}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={handleProjectPressOut}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.viewKey}`}
|
||||
>
|
||||
@@ -1091,7 +1078,7 @@ function WorkspaceRowInner({
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
@@ -1111,17 +1098,6 @@ function WorkspaceRowInner({
|
||||
}
|
||||
onPress();
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
const handleWorkspacePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
interaction.handlePressIn(event);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleWorkspacePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
interaction.handlePressOut();
|
||||
}, [interaction]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const isEditingTitle = titleEditor !== null;
|
||||
@@ -1135,13 +1111,8 @@ function WorkspaceRowInner({
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
|
||||
const workspaceRowStyle = getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
const workspaceRowStyle = getProjectWorkspaceRowStyle({ isDragging, selected, isHovered });
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
@@ -1176,10 +1147,11 @@ function WorkspaceRowInner({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
|
||||
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
|
||||
onHighlightChange={setIsHighlighted}
|
||||
onPressIn={isEditingTitle ? undefined : interaction.handlePressIn}
|
||||
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
|
||||
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
|
||||
onPressOut={isEditingTitle ? undefined : interaction.handlePressOut}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -1557,6 +1529,8 @@ function ProjectBlock({
|
||||
showShortcutBadges,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
onToggleCollapsed,
|
||||
workspacesExpanded,
|
||||
onToggleWorkspacesExpanded,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
onWorkspaceReorder,
|
||||
@@ -1583,6 +1557,9 @@ function ProjectBlock({
|
||||
showShortcutBadges: boolean;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onToggleCollapsed: (projectViewKey: string) => void;
|
||||
/** "Show more" on this project's workspaces, owned by the list so a drop's remount keeps it. */
|
||||
workspacesExpanded: boolean;
|
||||
onToggleWorkspacesExpanded: (projectViewKey: string) => void;
|
||||
onWorkspacePress?: () => void;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
@@ -1607,12 +1584,11 @@ function ProjectBlock({
|
||||
const {
|
||||
leadingItem: leadingWorkspace,
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup({
|
||||
items: project.workspaces,
|
||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
||||
expanded: workspacesExpanded,
|
||||
policy: visibilityPolicy,
|
||||
});
|
||||
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||
@@ -1770,6 +1746,10 @@ function ProjectBlock({
|
||||
onToggleCollapsed(project.viewKey);
|
||||
}, [onToggleCollapsed, project.viewKey]);
|
||||
|
||||
const handleToggleWorkspacesExpanded = useCallback(() => {
|
||||
onToggleWorkspacesExpanded(project.viewKey);
|
||||
}, [onToggleWorkspacesExpanded, project.viewKey]);
|
||||
|
||||
let projectChildren = null;
|
||||
if (!collapsed) {
|
||||
if (project.workspaces.length > 0) {
|
||||
@@ -1806,7 +1786,7 @@ function ProjectBlock({
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
onPress={toggleWorkspacesExpanded}
|
||||
onPress={handleToggleWorkspacesExpanded}
|
||||
testID={`sidebar-project-show-more-${project.viewKey}`}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1876,6 +1856,8 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.workspacesExpanded === next.workspacesExpanded &&
|
||||
previous.onToggleWorkspacesExpanded === next.onToggleWorkspacesExpanded &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
|
||||
@@ -2204,15 +2186,29 @@ function ProjectModeList({
|
||||
const selectionEnabled = isWorkspaceRoute;
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
||||
const [pinnedChatsExpanded, setPinnedChatsExpanded] = useState(false);
|
||||
const togglePinnedChatsExpanded = useCallback(
|
||||
() => setPinnedChatsExpanded((current) => !current),
|
||||
[],
|
||||
);
|
||||
const [expandedProjectKeys, setExpandedProjectKeys] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const toggleProjectWorkspacesExpanded = useCallback((projectViewKey: string) => {
|
||||
setExpandedProjectKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(projectViewKey)) next.add(projectViewKey);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const {
|
||||
leadingItem: leadingPinnedChat,
|
||||
visibleItems: visiblePinnedChats,
|
||||
expanded: pinnedChatsExpanded,
|
||||
canToggle: canTogglePinnedChats,
|
||||
toggleExpanded: togglePinnedChatsExpanded,
|
||||
} = useLimitedSidebarGroup({
|
||||
items: pinnedChats,
|
||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
||||
expanded: pinnedChatsExpanded,
|
||||
});
|
||||
const nativeScrollGestureProps = useMemo(
|
||||
() =>
|
||||
@@ -2368,6 +2364,8 @@ function ProjectModeList({
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onToggleCollapsed={onToggleProjectCollapsed}
|
||||
workspacesExpanded={expandedProjectKeys.has(item.viewKey)}
|
||||
onToggleWorkspacesExpanded={toggleProjectWorkspacesExpanded}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
manualSort={manualSort}
|
||||
parentGestureRef={parentGestureRef}
|
||||
@@ -2389,6 +2387,8 @@ function ProjectModeList({
|
||||
},
|
||||
[
|
||||
collapsedProjectKeys,
|
||||
expandedProjectKeys,
|
||||
toggleProjectWorkspacesExpanded,
|
||||
activeWorkspaceSelection,
|
||||
handleWorktreeCreated,
|
||||
manualSort,
|
||||
@@ -2592,9 +2592,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
|
||||
// Schedules icon across the divider; their layout boxes have different insets.
|
||||
paddingTop: 2,
|
||||
// Keep the last row scrollable above the sidebar toast, including a wrapped Undo row.
|
||||
// This space stays present across toast changes so the scroll range never jumps.
|
||||
paddingBottom: theme.spacing[24],
|
||||
// The sidebar toast floats over the list's end. Keep this padding the same whether or not
|
||||
// a toast shows: changing it moves the rows when the toast leaves.
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
projectListContainer: {
|
||||
width: "100%",
|
||||
|
||||
@@ -39,7 +39,6 @@ export function SidebarGroupToggleRow({
|
||||
styles.row,
|
||||
indented && sidebarWorkspaceRowStyles.rowIndented,
|
||||
hovered && !pressed && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
],
|
||||
[indented],
|
||||
);
|
||||
@@ -50,6 +49,7 @@ export function SidebarGroupToggleRow({
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.rowPressed}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
|
||||
@@ -12,16 +12,17 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
*/
|
||||
export function getSidebarRowBackdrop({
|
||||
isDragging = false,
|
||||
isPressed = false,
|
||||
isHighlighted = false,
|
||||
selected = false,
|
||||
isHovered = false,
|
||||
}: {
|
||||
isDragging?: boolean;
|
||||
isPressed?: boolean;
|
||||
/** The press highlight is painting the row's pressed fill. */
|
||||
isHighlighted?: boolean;
|
||||
selected?: boolean;
|
||||
isHovered?: boolean;
|
||||
}): SidebarSurfaceBackdrop {
|
||||
if (isDragging || isPressed) return "surface2";
|
||||
if (isDragging || isHighlighted) return "surface2";
|
||||
if (selected) return "surfaceSidebarSelected";
|
||||
if (isHovered) return "surfaceSidebarHover";
|
||||
return "surfaceSidebar";
|
||||
|
||||
@@ -9,13 +9,7 @@ import {
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import type { GestureType } from "react-native-gesture-handler";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -155,13 +149,20 @@ export function SidebarStatusWorkspaceList({
|
||||
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
const [pinnedWorkspacesExpanded, setPinnedWorkspacesExpanded] = useState(false);
|
||||
const togglePinnedWorkspacesExpanded = useCallback(
|
||||
() => setPinnedWorkspacesExpanded((current) => !current),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
leadingItem: leadingPinnedWorkspace,
|
||||
visibleItems: visiblePinnedWorkspaces,
|
||||
expanded: pinnedWorkspacesExpanded,
|
||||
canToggle: canTogglePinnedWorkspaces,
|
||||
toggleExpanded: togglePinnedWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
|
||||
} = useLimitedSidebarGroup({
|
||||
items: pinnedWorkspaces,
|
||||
selection: activeWorkspaceSelection,
|
||||
expanded: pinnedWorkspacesExpanded,
|
||||
});
|
||||
|
||||
const statusShortcutIndex = showShortcutBadges
|
||||
? shortcutIndexByWorkspaceKey
|
||||
@@ -366,15 +367,19 @@ function StatusGroupRows({
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const [workspacesExpanded, setWorkspacesExpanded] = useState(false);
|
||||
const toggleWorkspacesExpanded = useCallback(
|
||||
() => setWorkspacesExpanded((current) => !current),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
leadingItem: leadingWorkspace,
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup({
|
||||
items: group.rows,
|
||||
selection: activeWorkspaceSelection,
|
||||
expanded: workspacesExpanded,
|
||||
policy: STATUS_GROUP_VISIBILITY,
|
||||
});
|
||||
|
||||
@@ -460,12 +465,8 @@ function StatusGroupHeader({
|
||||
}, [group.key, toggleWorkspaceGroupCollapsed]);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
const rowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
styles.statusGroupRow,
|
||||
isHovered && styles.statusGroupRowHovered,
|
||||
pressed && styles.statusGroupRowPressed,
|
||||
],
|
||||
const rowStyle = useMemo(
|
||||
() => [styles.statusGroupRow, isHovered && styles.statusGroupRowHovered],
|
||||
[isHovered],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||
@@ -477,6 +478,7 @@ function StatusGroupHeader({
|
||||
accessibilityLabel={t("sidebar.statusGroupAccessibility", { label: group.label })}
|
||||
accessibilityState={accessibilityState}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.statusGroupRowPressed}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-status-group-${group.key}`}
|
||||
>
|
||||
@@ -826,7 +828,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const {
|
||||
role: _dragRole,
|
||||
@@ -851,17 +853,6 @@ function StatusWorkspaceRowInnerContent({
|
||||
}
|
||||
onPress();
|
||||
}, [didLongPressRef, onPress]);
|
||||
const handlePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
startDragPress?.(event);
|
||||
},
|
||||
[startDragPress],
|
||||
);
|
||||
const handlePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
endDragPress?.();
|
||||
}, [endDragPress]);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame
|
||||
@@ -887,13 +878,12 @@ function StatusWorkspaceRowInnerContent({
|
||||
showShortcut,
|
||||
});
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
isDragging,
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isHighlighted, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
{...dragAttributes}
|
||||
@@ -928,10 +918,11 @@ function StatusWorkspaceRowInnerContent({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
highlightStyle={styles.workspaceRowPressed}
|
||||
onPressIn={isEditingTitle ? undefined : handlePressIn}
|
||||
highlightStyle={isEditingTitle ? undefined : styles.workspaceRowPressed}
|
||||
onHighlightChange={setIsHighlighted}
|
||||
onPressIn={isEditingTitle ? undefined : startDragPress}
|
||||
onTouchMove={isEditingTitle ? undefined : moveDragPress}
|
||||
onPressOut={isEditingTitle ? undefined : handlePressOut}
|
||||
onPressOut={isEditingTitle ? undefined : endDragPress}
|
||||
onPress={isEditingTitle ? undefined : handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
@@ -1068,13 +1059,11 @@ function StatusWorkspaceActionSlot({
|
||||
}
|
||||
|
||||
function getStatusWorkspaceRowStyle({
|
||||
isPressed,
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
isDragging,
|
||||
}: {
|
||||
isPressed: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
inStatusGroup: boolean;
|
||||
@@ -1086,7 +1075,6 @@ function getStatusWorkspaceRowStyle({
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
isPressed && styles.workspaceRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1101,8 +1089,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
|
||||
paddingTop: 2,
|
||||
// Match project grouping: permanent room to scroll the final row above sidebar toasts.
|
||||
paddingBottom: theme.spacing[24],
|
||||
// The sidebar toast floats over the list's end. Keep this padding the same whether or not
|
||||
// a toast shows: changing it moves the rows when the toast leaves.
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
pinnedSection: {
|
||||
marginBottom: theme.spacing[1],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
selectSidebarGroupVisibility,
|
||||
type SidebarGroupItem,
|
||||
@@ -12,23 +12,22 @@ const PINNED_POLICY = { kind: "pinned" } as const;
|
||||
interface LimitedSidebarGroupInput<T> {
|
||||
items: readonly T[];
|
||||
selection: ActiveWorkspaceSelection | null;
|
||||
/**
|
||||
* Whether the user revealed the whole group. The caller owns it: a native drop remounts the
|
||||
* rows it moved, and a reveal held inside a row would reset with them.
|
||||
*/
|
||||
expanded: boolean;
|
||||
policy?: SidebarGroupVisibilityPolicy<T>;
|
||||
}
|
||||
|
||||
export function useLimitedSidebarGroup<T extends SidebarGroupItem>({
|
||||
items,
|
||||
selection,
|
||||
expanded,
|
||||
policy = PINNED_POLICY,
|
||||
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
|
||||
expanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
} {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibility = useMemo(
|
||||
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> {
|
||||
return useMemo(
|
||||
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
|
||||
[expanded, items, policy, selection],
|
||||
);
|
||||
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
||||
|
||||
return { ...visibility, expanded, toggleExpanded };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import {
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableProps,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewProps,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
type MenuTriggerState,
|
||||
} from "@/components/ui/menu";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import type { PressHighlightProps } from "@/components/ui/press-highlight.types";
|
||||
|
||||
/**
|
||||
* A menu opened by a long press or a right click, anchored to the point of the gesture rather
|
||||
@@ -109,8 +109,7 @@ export function ContextMenuTrigger({
|
||||
triggerRef,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<PressableProps, "style"> & {
|
||||
highlightStyle?: StyleProp<ViewStyle>;
|
||||
Omit<PressHighlightProps, "style"> & {
|
||||
style?: TriggerStyleProp;
|
||||
enabled?: boolean;
|
||||
enabledOnMobile?: boolean;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Modal as NativeModal, type ModalProps as NativeModalProps } from "react-native";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Modal as NativeModal,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
type ModalProps as NativeModalProps,
|
||||
} from "react-native";
|
||||
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
|
||||
|
||||
// React Native defaults iPhone modals to portrait even when the app supports rotation.
|
||||
@@ -17,7 +23,23 @@ type ModalProps = Omit<NativeModalProps, "onRequestClose"> & {
|
||||
function noClose() {}
|
||||
|
||||
export function Modal(props: ModalProps) {
|
||||
const { visible = true, onRequestClose } = props;
|
||||
const { visible = true, onRequestClose, style } = props;
|
||||
useDismissibleOverlay(visible && onRequestClose !== undefined, onRequestClose ?? noClose);
|
||||
return <NativeModal supportedOrientations={orientations} {...props} />;
|
||||
const windowFrame = useAndroidWindowFrame();
|
||||
const containerStyle = useMemo(() => [windowFrame, style], [windowFrame, style]);
|
||||
return <NativeModal supportedOrientations={orientations} {...props} style={containerStyle} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* On Android, React Native lays out a modal's first frames at the screen size minus the status and
|
||||
* navigation bars, then grows it to the full window once the dialog reports its size. The app draws
|
||||
* edge-to-edge, so the dialog always covers the whole window: size the content to the window from
|
||||
* the first frame, or a full-screen backdrop stops short of the bottom and then snaps down.
|
||||
*/
|
||||
function useAndroidWindowFrame() {
|
||||
const { width, height } = useWindowDimensions();
|
||||
return useMemo(
|
||||
() => (Platform.OS === "android" ? { flex: 0, width, height } : null),
|
||||
[width, height],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { forwardRef, useCallback, useMemo } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
type PressableProps,
|
||||
type PressableStateCallbackType,
|
||||
type View,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PressHighlightProps } from "./press-highlight.types";
|
||||
|
||||
interface RipplePressableProps extends Omit<PressableProps, "style"> {
|
||||
rippleColor?: string;
|
||||
// `withUnistyles` flattens a `style` prop, which would drop the caller's state callback.
|
||||
pressableStyle: PressableProps["style"];
|
||||
}
|
||||
|
||||
// A foreground ripple tints the content with the surface, so nothing inside needs to know the
|
||||
// row is pressed.
|
||||
const RipplePressable = withUnistyles(
|
||||
forwardRef<View, RipplePressableProps>(function RipplePressable(
|
||||
{ rippleColor, pressableStyle, ...props },
|
||||
ref,
|
||||
) {
|
||||
const ripple = useMemo(
|
||||
() => (rippleColor ? { color: rippleColor, foreground: true } : undefined),
|
||||
[rippleColor],
|
||||
);
|
||||
return <Pressable {...props} ref={ref} style={pressableStyle} android_ripple={ripple} />;
|
||||
}),
|
||||
);
|
||||
|
||||
// Android's own `ViewConfiguration.getTapTimeout()`: a native list in a scroll view waits this long
|
||||
// before showing pressed, so a scroll that claims the touch first cancels it.
|
||||
const TAP_TIMEOUT_MS = 100;
|
||||
|
||||
const rippleColorMapping = (theme: Theme) => ({ rippleColor: theme.colors.interactionHighlight });
|
||||
|
||||
/**
|
||||
* The platform ripple replaces `highlightStyle`. Pressable starts it from JS after the press
|
||||
* delay, then the animation runs on the UI thread without a React commit.
|
||||
*/
|
||||
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
|
||||
{
|
||||
highlightStyle,
|
||||
onHighlightChange: _onHighlightChange,
|
||||
style,
|
||||
unstable_pressDelay = TAP_TIMEOUT_MS,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const hasRipple = highlightStyle != null;
|
||||
const rippleStyle = useCallback(
|
||||
(state: PressableStateCallbackType) => [
|
||||
hasRipple && styles.rippleBorderReset,
|
||||
typeof style === "function" ? style(state) : style,
|
||||
hasRipple && styles.rippleClip,
|
||||
],
|
||||
[style, hasRipple],
|
||||
);
|
||||
return (
|
||||
<RipplePressable
|
||||
{...props}
|
||||
ref={ref}
|
||||
pressableStyle={rippleStyle}
|
||||
uniProps={hasRipple ? rippleColorMapping : undefined}
|
||||
unstable_pressDelay={unstable_pressDelay}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// React Native 0.86 sends NaN when a style drops `borderWidth`, and an overflow-hidden view then
|
||||
// clips to a NaN padding box and stops painting. A sidebar row loses its drag border on drop.
|
||||
// An explicit 0 under the caller's style sends 0 instead. Fixed upstream in
|
||||
// facebook/react-native#57789; remove once the app ships a React Native release with it.
|
||||
rippleBorderReset: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
// React Native draws ripples in the view's rectangle. With overflow hidden, the view clips its
|
||||
// children to its rounded padding box, and that clip is still on the canvas when Android draws
|
||||
// the foreground, so the ripple follows the corners.
|
||||
rippleClip: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { ArrowUp } from "lucide-react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { PressHighlight } from "./press-highlight.native";
|
||||
import { PressHighlight as AndroidPressHighlight } from "./press-highlight.android";
|
||||
import { PressHighlight as WebPressHighlight } from "./press-highlight";
|
||||
import { FloatingActionButton } from "./floating-action-button";
|
||||
|
||||
const rowStyle = { backgroundColor: "rgb(255, 255, 255)", borderRadius: 8 };
|
||||
@@ -13,6 +15,22 @@ afterEach(cleanup);
|
||||
|
||||
function onPress() {}
|
||||
|
||||
function recordPresses() {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
onPressIn: () => calls.push("in"),
|
||||
onPressOut: () => calls.push("out"),
|
||||
};
|
||||
}
|
||||
|
||||
const borderedRowStyle = { ...rowStyle, borderWidth: 1, borderColor: "rgb(0, 0, 0)" };
|
||||
|
||||
function recordHighlights() {
|
||||
const calls: boolean[] = [];
|
||||
return { calls, record: (highlighted: boolean) => calls.push(highlighted) };
|
||||
}
|
||||
|
||||
it("keeps a floating action opaque while showing its held-press feedback", async () => {
|
||||
const { getByRole } = render(
|
||||
<FloatingActionButton icon={ArrowUp} accessibilityLabel="Jump to file" onPress={onPress} />,
|
||||
@@ -31,30 +49,147 @@ it("keeps a floating action opaque while showing its held-press feedback", async
|
||||
await waitFor(() => expect(getComputedStyle(button).backgroundColor).toBe(restingFill));
|
||||
});
|
||||
|
||||
it("uses the pressable's pressed state for the native row fill and its existing corners", () => {
|
||||
const { getByTestId, rerender } = render(
|
||||
it("paints the native row fill while pressed and keeps its existing corners", async () => {
|
||||
const { getByTestId } = render(
|
||||
<GestureHandlerRootView>
|
||||
<PressHighlight
|
||||
testID="row"
|
||||
testOnly_pressed
|
||||
style={rowStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
/>
|
||||
<PressHighlight testID="row" style={rowStyle} highlightStyle={highlightStyle} />
|
||||
</GestureHandlerRootView>,
|
||||
);
|
||||
const row = getByTestId("row");
|
||||
|
||||
expect(getComputedStyle(getByTestId("row")).backgroundColor).toBe("rgb(20, 30, 40)");
|
||||
expect(getComputedStyle(getByTestId("row")).borderRadius).toBe("8px");
|
||||
fireEvent.mouseDown(row, { button: 0 });
|
||||
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)"));
|
||||
expect(getComputedStyle(row).borderRadius).toBe("8px");
|
||||
|
||||
rerender(
|
||||
<GestureHandlerRootView>
|
||||
<PressHighlight
|
||||
testID="row"
|
||||
testOnly_pressed={false}
|
||||
style={rowStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
/>
|
||||
</GestureHandlerRootView>,
|
||||
);
|
||||
expect(getComputedStyle(getByTestId("row")).backgroundColor).toBe("rgb(255, 255, 255)");
|
||||
fireEvent.mouseUp(row, { button: 0 });
|
||||
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)"));
|
||||
});
|
||||
|
||||
it("paints the pressed fill on web and reports it, so a caller does not paint its own", async () => {
|
||||
const highlights = recordHighlights();
|
||||
const { getByTestId } = render(
|
||||
<WebPressHighlight
|
||||
testID="row"
|
||||
style={rowStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
onHighlightChange={highlights.record}
|
||||
onPress={onPress}
|
||||
/>,
|
||||
);
|
||||
const row = getByTestId("row");
|
||||
|
||||
fireEvent.mouseDown(row, { button: 0 });
|
||||
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)"));
|
||||
expect(highlights.calls).toEqual([true]);
|
||||
|
||||
fireEvent.mouseUp(row, { button: 0 });
|
||||
await waitFor(() => expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)"));
|
||||
expect(highlights.calls).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("leaves the Android row surface alone while pressed and clips the ripple to its corners", () => {
|
||||
const highlights = recordHighlights();
|
||||
const { getByTestId } = render(
|
||||
<AndroidPressHighlight
|
||||
testID="row"
|
||||
testOnly_pressed
|
||||
style={rowStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
onHighlightChange={highlights.record}
|
||||
/>,
|
||||
);
|
||||
const row = getComputedStyle(getByTestId("row"));
|
||||
|
||||
expect(row.backgroundColor).toBe("rgb(255, 255, 255)");
|
||||
expect(row.borderRadius).toBe("8px");
|
||||
expect(row.overflow).toBe("hidden");
|
||||
expect(highlights.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the caller's border and press handlers on the Android ripple row", async () => {
|
||||
const presses = recordPresses();
|
||||
const { getByTestId } = render(
|
||||
<AndroidPressHighlight
|
||||
testID="row"
|
||||
style={borderedRowStyle}
|
||||
highlightStyle={highlightStyle}
|
||||
onPressIn={presses.onPressIn}
|
||||
onPressOut={presses.onPressOut}
|
||||
onPress={onPress}
|
||||
/>,
|
||||
);
|
||||
const row = getByTestId("row");
|
||||
|
||||
expect(getComputedStyle(row).borderTopWidth).toBe("1px");
|
||||
fireEvent.mouseDown(row, { button: 0 });
|
||||
fireEvent.mouseUp(row, { button: 0 });
|
||||
await waitFor(() => expect(presses.calls).toEqual(["in", "out"]));
|
||||
});
|
||||
|
||||
it("paints and reports nothing on web without a pressed fill, such as a row being renamed", () => {
|
||||
const highlights = recordHighlights();
|
||||
const { getByTestId } = render(
|
||||
<WebPressHighlight
|
||||
testID="row"
|
||||
style={rowStyle}
|
||||
onHighlightChange={highlights.record}
|
||||
onPress={onPress}
|
||||
/>,
|
||||
);
|
||||
const row = getByTestId("row");
|
||||
|
||||
fireEvent.mouseDown(row, { button: 0 });
|
||||
expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)");
|
||||
fireEvent.mouseUp(row, { button: 0 });
|
||||
expect(highlights.calls).toEqual([]);
|
||||
});
|
||||
|
||||
const midPress = { ...recordPresses(), highlights: recordHighlights() };
|
||||
|
||||
function PressedRow({ filled }: { filled: boolean }) {
|
||||
return (
|
||||
<WebPressHighlight
|
||||
testID="row"
|
||||
style={rowStyle}
|
||||
highlightStyle={filled ? highlightStyle : undefined}
|
||||
onHighlightChange={midPress.highlights.record}
|
||||
onPressIn={midPress.onPressIn}
|
||||
onPressOut={midPress.onPressOut}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function resetMidPress() {
|
||||
midPress.calls.length = 0;
|
||||
midPress.highlights.calls.length = 0;
|
||||
}
|
||||
|
||||
it("keeps a press's fill and report together when the fill appears mid-press", async () => {
|
||||
resetMidPress();
|
||||
const { getByTestId, rerender } = render(<PressedRow filled={false} />);
|
||||
const row = getByTestId("row");
|
||||
|
||||
fireEvent.mouseDown(row, { button: 0 });
|
||||
await waitFor(() => expect(midPress.calls).toEqual(["in"]));
|
||||
rerender(<PressedRow filled />);
|
||||
expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)");
|
||||
fireEvent.mouseUp(row, { button: 0 });
|
||||
await waitFor(() => expect(midPress.calls).toEqual(["in", "out"]));
|
||||
expect(midPress.highlights.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a press's fill and report together when the fill goes away mid-press", async () => {
|
||||
resetMidPress();
|
||||
const { getByTestId, rerender } = render(<PressedRow filled />);
|
||||
const row = getByTestId("row");
|
||||
|
||||
fireEvent.mouseDown(row, { button: 0 });
|
||||
await waitFor(() => expect(midPress.calls).toEqual(["in"]));
|
||||
rerender(<PressedRow filled={false} />);
|
||||
expect(getComputedStyle(row).backgroundColor).toBe("rgb(20, 30, 40)");
|
||||
fireEvent.mouseUp(row, { button: 0 });
|
||||
await waitFor(() => expect(midPress.calls).toEqual(["in", "out"]));
|
||||
expect(getComputedStyle(row).backgroundColor).toBe("rgb(255, 255, 255)");
|
||||
expect(midPress.highlights.calls).toEqual([true, false]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type {
|
||||
GestureResponderEvent,
|
||||
PressableStateCallbackType,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
} from "react-native";
|
||||
import type { PressHighlightProps } from "./press-highlight.types";
|
||||
|
||||
type PressFillProps = Pick<
|
||||
PressHighlightProps,
|
||||
"style" | "highlightStyle" | "onPressIn" | "onPressOut" | "onHighlightChange"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Paints `highlightStyle` while pressed and reports it, for the platforms that show a fill. A press
|
||||
* keeps the fill it started with until it ends, so the paint and the report always agree, even when
|
||||
* the caller adds or removes `highlightStyle` mid-press.
|
||||
*/
|
||||
export function usePressFill({
|
||||
style,
|
||||
highlightStyle,
|
||||
onPressIn,
|
||||
onPressOut,
|
||||
onHighlightChange,
|
||||
}: PressFillProps) {
|
||||
const [pressFill, setPressFill] = useState<StyleProp<ViewStyle> | null>(null);
|
||||
// Pressable can call the press-out handler from the render that started the press.
|
||||
const reportedRef = useRef(false);
|
||||
const fillStyle = useCallback(
|
||||
(state: PressableStateCallbackType) => [
|
||||
typeof style === "function" ? style(state) : style,
|
||||
pressFill,
|
||||
],
|
||||
[style, pressFill],
|
||||
);
|
||||
const handlePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
if (highlightStyle != null) {
|
||||
setPressFill(highlightStyle);
|
||||
reportedRef.current = true;
|
||||
onHighlightChange?.(true);
|
||||
}
|
||||
onPressIn?.(event);
|
||||
},
|
||||
[highlightStyle, onHighlightChange, onPressIn],
|
||||
);
|
||||
const handlePressOut = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setPressFill(null);
|
||||
if (reportedRef.current) {
|
||||
reportedRef.current = false;
|
||||
onHighlightChange?.(false);
|
||||
}
|
||||
onPressOut?.(event);
|
||||
},
|
||||
[onHighlightChange, onPressOut],
|
||||
);
|
||||
return { style: fillStyle, onPressIn: handlePressIn, onPressOut: handlePressOut };
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
import { forwardRef, useCallback } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, type View } from "react-native";
|
||||
import { forwardRef } from "react";
|
||||
import { Pressable, type View } from "react-native";
|
||||
import { usePressFill } from "./press-highlight.fill";
|
||||
import type { PressHighlightProps } from "./press-highlight.types";
|
||||
|
||||
// iOS. Android resolves `press-highlight.android.tsx`.
|
||||
|
||||
// Feedback waits this long so a scroll that claims the touch first cancels it.
|
||||
const PRESS_DELAY_MS = 130;
|
||||
|
||||
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
|
||||
{ highlightStyle, style, unstable_pressDelay = 130, ...props },
|
||||
{
|
||||
highlightStyle,
|
||||
onHighlightChange,
|
||||
style,
|
||||
onPressIn,
|
||||
onPressOut,
|
||||
unstable_pressDelay = PRESS_DELAY_MS,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
// Give the scroll view time to claim a pan before Pressable activates its feedback.
|
||||
const pressStyle = useCallback(
|
||||
(state: PressableStateCallbackType) => [
|
||||
typeof style === "function" ? style(state) : style,
|
||||
state.pressed && highlightStyle,
|
||||
],
|
||||
[style, highlightStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable {...props} ref={ref} unstable_pressDelay={unstable_pressDelay} style={pressStyle} />
|
||||
);
|
||||
const fill = usePressFill({ style, highlightStyle, onPressIn, onPressOut, onHighlightChange });
|
||||
return <Pressable {...props} {...fill} ref={ref} unstable_pressDelay={unstable_pressDelay} />;
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { forwardRef } from "react";
|
||||
import { Pressable } from "react-native";
|
||||
import type { View } from "react-native";
|
||||
import { usePressFill } from "./press-highlight.fill";
|
||||
import type { PressHighlightProps } from "./press-highlight.types";
|
||||
|
||||
export const PressHighlight = forwardRef<View, PressHighlightProps>(function PressHighlight(
|
||||
{ highlightStyle: _highlightStyle, ...props },
|
||||
{ highlightStyle, onHighlightChange, style, onPressIn, onPressOut, ...props },
|
||||
ref,
|
||||
) {
|
||||
return <Pressable ref={ref} {...props} />;
|
||||
const fill = usePressFill({ style, highlightStyle, onPressIn, onPressOut, onHighlightChange });
|
||||
return <Pressable ref={ref} {...props} {...fill} />;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { PressableProps, StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
export interface PressHighlightProps extends PressableProps {
|
||||
/** Native pressed style. Feedback and cancellation follow Pressable's press lifecycle. */
|
||||
/**
|
||||
* The pressed fill. iOS and web paint it while the pressable is pressed; Android draws the
|
||||
* platform ripple over the content instead. Callers do not paint their own pressed fill.
|
||||
*/
|
||||
highlightStyle?: StyleProp<ViewStyle>;
|
||||
/**
|
||||
* Reports when `highlightStyle` starts and stops painting, so content that knocks out of the
|
||||
* surface (a badge ring, a text scrim) can match it. The Android ripple tints over the content,
|
||||
* so it never reports a highlight.
|
||||
*/
|
||||
onHighlightChange?: (highlighted: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Image } from "react-native";
|
||||
import type { ViewportSize } from "./geometry";
|
||||
|
||||
/** Resolves the image's natural size; rejects when the image cannot load or has no size. */
|
||||
export function measureImage(uri: string): Promise<ViewportSize> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => {
|
||||
if (width > 0 && height > 0) resolve({ width, height });
|
||||
else reject(new Error("Image has no size"));
|
||||
},
|
||||
reject,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ViewportSize } from "./geometry";
|
||||
|
||||
// The size a browser gives a replaced element that has no intrinsic size of its own.
|
||||
const NO_INTRINSIC_SIZE: ViewportSize = { width: 300, height: 150 };
|
||||
|
||||
/**
|
||||
* Resolves the image's natural size; rejects when the image cannot load. React Native Web's
|
||||
* `Image.getSize` never answers for an image that loads with no natural size (an SVG without
|
||||
* width and height, in some browsers), so the image would never show and never fail.
|
||||
*/
|
||||
export function measureImage(uri: string): Promise<ViewportSize> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new window.Image();
|
||||
image.addEventListener("load", () =>
|
||||
resolve(
|
||||
image.naturalWidth > 0 && image.naturalHeight > 0
|
||||
? { width: image.naturalWidth, height: image.naturalHeight }
|
||||
: NO_INTRINSIC_SIZE,
|
||||
),
|
||||
);
|
||||
image.addEventListener("error", () => reject(new Error("Image failed to load")));
|
||||
image.src = uri;
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { ViewportFitOptions, ViewportSize } from "./geometry";
|
||||
import { measureImage } from "./image-size";
|
||||
import { ZoomableViewport } from "./index";
|
||||
import type { ZoomableViewportAction } from "./types";
|
||||
|
||||
@@ -34,42 +35,33 @@ export function ZoomableImage({
|
||||
testID = "zoomable-image",
|
||||
wheelActivation = "always",
|
||||
}: ZoomableImageProps) {
|
||||
const [loadedSize, setLoadedSize] = useState<ViewportSize | null>(null);
|
||||
const resolvedSize = contentSize ?? loadedSize;
|
||||
const [loaded, setLoaded] = useState<{ uri: string; size: ViewportSize } | null>(null);
|
||||
const resolvedSize = contentSize ?? (loaded?.uri === uri ? loaded.size : null);
|
||||
const source = useMemo(() => ({ uri }), [uri]);
|
||||
const [paintedUri, setPaintedUri] = useState<string | null>(null);
|
||||
const handleLoad = useCallback(() => setPaintedUri(uri), [uri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentSize) return;
|
||||
let active = true;
|
||||
setLoadedSize(null);
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => {
|
||||
if (active && width > 0 && height > 0) setLoadedSize({ width, height });
|
||||
},
|
||||
() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const size = await measureImage(uri);
|
||||
if (active) setLoaded({ uri, size });
|
||||
} catch {
|
||||
if (active) onError?.();
|
||||
},
|
||||
);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [contentSize, onError, uri]);
|
||||
|
||||
// Paint nothing until the image can be fitted: an unfitted image fills the whole area for the
|
||||
// frames before the size arrives, then jumps to its fitted box. Then keep the viewport, toolbar
|
||||
// included, hidden until the image itself has loaded, so they appear together.
|
||||
if (!resolvedSize) {
|
||||
return (
|
||||
<View style={[styles.root, style]} testID={testID}>
|
||||
<Image
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole="image"
|
||||
onError={onError}
|
||||
resizeMode="contain"
|
||||
source={source}
|
||||
style={styles.image}
|
||||
testID={`${testID}-image`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
return <View style={[styles.root, style]} testID={testID} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -81,12 +73,15 @@ export function ZoomableImage({
|
||||
maxScale={maxScale}
|
||||
minScale={minScale}
|
||||
onPressOutsideContent={onPressOutsideContent}
|
||||
style={style}
|
||||
style={[style, paintedUri !== uri && styles.loading]}
|
||||
testID={testID}
|
||||
wheelActivation={wheelActivation}
|
||||
>
|
||||
<Image
|
||||
// Android fades a freshly decoded image in over 300ms; it appears with its toolbar instead.
|
||||
fadeDuration={0}
|
||||
onError={onError}
|
||||
onLoad={handleLoad}
|
||||
resizeMode="contain"
|
||||
source={source}
|
||||
style={styles.image}
|
||||
@@ -99,4 +94,5 @@ export function ZoomableImage({
|
||||
const styles = StyleSheet.create(() => ({
|
||||
root: { flex: 1, minHeight: 0 },
|
||||
image: { width: "100%", height: "100%" },
|
||||
loading: { opacity: 0 },
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { memo, type ReactElement, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Text, View, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
@@ -92,18 +87,17 @@ function useFileHeaderHover(enabled: boolean, onActiveChange?: (active: boolean)
|
||||
return { isHovered, handlePointerEnter, handlePointerLeave };
|
||||
}
|
||||
|
||||
// Press feedback is the pressable's `highlightStyle`, so it is not part of the active fill.
|
||||
function fileHeaderIsActive(input: {
|
||||
isHovered: boolean;
|
||||
pressed: boolean;
|
||||
showsBodyState: boolean;
|
||||
isSelected: boolean;
|
||||
}): boolean {
|
||||
return input.isHovered || input.pressed || (!input.showsBodyState && input.isSelected);
|
||||
return input.isHovered || (!input.showsBodyState && input.isSelected);
|
||||
}
|
||||
|
||||
function fileHeaderInteractionStyle(input: {
|
||||
isHovered: boolean;
|
||||
pressed: boolean;
|
||||
showsBodyState: boolean;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
@@ -217,8 +211,8 @@ export const FileHeader = memo(function FileHeader({
|
||||
[file.path, onHeaderHeightChange],
|
||||
),
|
||||
});
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
const pressableStyle = useMemo(
|
||||
() => [
|
||||
styles.header,
|
||||
!showsBodyState && workspaceTreeRowStyles.row,
|
||||
showsBodyState && styles.documentHeader,
|
||||
@@ -229,18 +223,14 @@ export const FileHeader = memo(function FileHeader({
|
||||
: null,
|
||||
fileHeaderInteractionStyle({
|
||||
isHovered: hover.isHovered,
|
||||
pressed,
|
||||
showsBodyState,
|
||||
isSelected,
|
||||
}),
|
||||
],
|
||||
[depth, hover.isHovered, isSelected, showsBodyState],
|
||||
);
|
||||
const canvasPressableStyle = useCallback(
|
||||
(state: PressableStateCallbackType) => [
|
||||
pressableStyle(state),
|
||||
canvasRendered && styles.canvasInteraction,
|
||||
],
|
||||
const canvasPressableStyle = useMemo(
|
||||
() => [pressableStyle, canvasRendered && styles.canvasInteraction],
|
||||
[canvasRendered, pressableStyle],
|
||||
);
|
||||
const accessibilityState = useMemo(
|
||||
@@ -324,7 +314,7 @@ export const FileHeader = memo(function FileHeader({
|
||||
} else {
|
||||
trigger = (
|
||||
<View
|
||||
style={[pressableStyle({ pressed: false }), canvasRendered && styles.canvasInteraction]}
|
||||
style={[pressableStyle, canvasRendered && styles.canvasInteraction]}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
{renderedContent}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
|
||||
import { Pressable, 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,12 +18,6 @@ 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.
|
||||
@@ -112,11 +106,7 @@ export function ChecksSection({
|
||||
</Pressable>
|
||||
|
||||
{open && summary.groups.length > 0 ? (
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
nestedScrollEnabled
|
||||
>
|
||||
<View style={styles.list}>
|
||||
{summary.groups.map((group) => (
|
||||
<CheckGroup
|
||||
key={group.status}
|
||||
@@ -128,7 +118,7 @@ export function ChecksSection({
|
||||
onAddLogsToChat={onAddLogsToChat}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
@@ -263,9 +253,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
list: {
|
||||
maxHeight: LIST_MAX_HEIGHT,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
groupHeader: {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
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, ScrollView, Text, View, type ViewStyle } from "react-native";
|
||||
import { Image, Pressable, Text, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
CircleCheck,
|
||||
@@ -74,13 +74,14 @@ import {
|
||||
type PrTimelineEntry,
|
||||
} from "./timeline";
|
||||
import {
|
||||
Section,
|
||||
SectionHeader,
|
||||
SummaryPill,
|
||||
dangerColorMapping,
|
||||
foregroundMutedColorMapping,
|
||||
sectionKitStyles,
|
||||
successColorMapping,
|
||||
} from "./section-kit";
|
||||
import { PullRequestPaneSplit } from "./split";
|
||||
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircleX = withUnistyles(CircleX);
|
||||
@@ -478,8 +479,8 @@ export function PullRequestPane({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane">
|
||||
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||
<PullRequestPaneSplit activityOpen={activityOpen}>
|
||||
<>
|
||||
<PaneContentToolbar style={styles.toolbar} testID="pr-pane-toolbar">
|
||||
<View style={styles.toolbarActions}>
|
||||
<Button
|
||||
@@ -499,7 +500,9 @@ 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}
|
||||
@@ -564,60 +567,58 @@ export function PullRequestPane({
|
||||
onAddLogsToChat={handleAddCheckLogsToChat}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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"
|
||||
</>
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
))
|
||||
: null}
|
||||
</View>
|
||||
</PullRequestPaneSplit>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1192,15 +1193,6 @@ function ThreadComment({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
invisible: {
|
||||
opacity: 0,
|
||||
},
|
||||
@@ -1261,10 +1253,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginLeft: theme.spacing[1],
|
||||
opacity: 0,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
import React, { useMemo, 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,
|
||||
@@ -21,39 +22,57 @@ 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 SectionProps {
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
summary: ReactNode;
|
||||
children: ReactNode;
|
||||
accessibilityLabel?: string;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
interface SectionProps extends SectionHeaderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** The disclosure row of a section, for layouts that place the section's body themselves. */
|
||||
export function SectionHeader({
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
summary,
|
||||
children,
|
||||
accessibilityLabel,
|
||||
}: SectionProps) {
|
||||
}: 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) {
|
||||
return (
|
||||
<View>
|
||||
<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}
|
||||
<SectionHeader {...header} />
|
||||
{header.open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -487,10 +487,7 @@ function LocalModelRow({
|
||||
}
|
||||
|
||||
const rowStyle = useCallback(
|
||||
({ hovered, pressed }: HoverState) => [
|
||||
!selectionLocked && hovered && styles.rowHovered,
|
||||
!selectionLocked && pressed && styles.rowPressed,
|
||||
],
|
||||
({ hovered }: HoverState) => [!selectionLocked && hovered && styles.rowHovered],
|
||||
[selectionLocked],
|
||||
);
|
||||
const accessibilityState = useMemo(
|
||||
|
||||
@@ -113,8 +113,8 @@ function pinnedUsageLabel(
|
||||
return `${label}: ${summary}${error}`;
|
||||
}
|
||||
|
||||
function triggerStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.trigger, hovered && styles.triggerHovered, pressed && styles.triggerPressed];
|
||||
function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.trigger, hovered && styles.triggerHovered];
|
||||
}
|
||||
|
||||
function layoutWidth(event: LayoutChangeEvent): number {
|
||||
@@ -161,6 +161,7 @@ function PinnedUsageTrigger({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={pinnedUsageLabel(label, sources, errorLabel)}
|
||||
style={triggerStyle}
|
||||
highlightStyle={styles.triggerPressed}
|
||||
testID="sidebar-usage"
|
||||
>
|
||||
<View style={styles.line} onLayout={handleAvailableLayout}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import type { EdgeInsets } from "react-native-safe-area-context";
|
||||
import type { TopBand, WindowLayout } from "./model";
|
||||
|
||||
export const WindowLayoutContext = createContext<WindowLayout | null>(null);
|
||||
@@ -7,6 +8,12 @@ export const WindowLayoutContext = createContext<WindowLayout | null>(null);
|
||||
/** Published by `WindowLayoutProvider` beside the safe-area insets it derives from it. */
|
||||
export const TopBandContext = createContext<TopBand | null>(null);
|
||||
|
||||
/**
|
||||
* The insets of the whole window, published by `WindowLayoutProvider` before any surface (a
|
||||
* sheet tab, for one) narrows the insets its content reads.
|
||||
*/
|
||||
export const WindowInsetsContext = createContext<EdgeInsets | null>(null);
|
||||
|
||||
export function useWindowLayout(): WindowLayout | null {
|
||||
const layout = useContext(WindowLayoutContext);
|
||||
const { width, height } = useWindowDimensions();
|
||||
|
||||
@@ -2,13 +2,19 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
|
||||
import { requireNativeViewManager } from "expo-modules-core";
|
||||
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { TopBandContext, WindowLayoutContext, useWindowLayout } from "./context";
|
||||
import {
|
||||
TopBandContext,
|
||||
WindowInsetsContext,
|
||||
WindowLayoutContext,
|
||||
useWindowLayout,
|
||||
} from "./context";
|
||||
import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./model";
|
||||
|
||||
export { useWindowLayout } from "./context";
|
||||
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
|
||||
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
|
||||
export { useTopBand } from "./context";
|
||||
export { WindowSafeArea } from "./window-safe-area";
|
||||
export {
|
||||
resolveDesktopDivisionLayout,
|
||||
resolveWorkspaceActionPlacement,
|
||||
@@ -56,7 +62,9 @@ function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
|
||||
);
|
||||
return (
|
||||
<TopBandContext.Provider value={topBand}>
|
||||
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
|
||||
<WindowInsetsContext.Provider value={insets}>
|
||||
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
|
||||
</WindowInsetsContext.Provider>
|
||||
</TopBandContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { WindowInsetsContext } from "./context";
|
||||
|
||||
export { useWindowLayout } from "./context";
|
||||
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
|
||||
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
|
||||
export { useTopBand } from "./context";
|
||||
export { WindowSafeArea } from "./window-safe-area";
|
||||
export {
|
||||
resolveDesktopDivisionLayout,
|
||||
resolveWorkspaceActionPlacement,
|
||||
@@ -12,5 +15,6 @@ export {
|
||||
export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model";
|
||||
|
||||
export function WindowLayoutProvider({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
const insets = useSafeAreaInsets();
|
||||
return <WindowInsetsContext.Provider value={insets}>{children}</WindowInsetsContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Text } from "react-native";
|
||||
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { WindowLayoutProvider, WindowSafeArea } from "@/window-layout";
|
||||
|
||||
vi.stubGlobal("React", React);
|
||||
const windowInsets = { top: 52, right: 0, bottom: 24, left: 0 };
|
||||
const sheetTabInsets = { ...windowInsets, bottom: 0 };
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
afterEach(() => {
|
||||
act(() => root.render(null));
|
||||
});
|
||||
|
||||
function InsetsProbe({ testID }: { testID: string }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
return <Text testID={testID}>{JSON.stringify(insets)}</Text>;
|
||||
}
|
||||
|
||||
it("gives an overlay opened inside a sheet tab the window's insets", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<SafeAreaInsetsContext.Provider value={windowInsets}>
|
||||
<WindowLayoutProvider>
|
||||
<SafeAreaInsetsContext.Provider value={sheetTabInsets}>
|
||||
<InsetsProbe testID="sheet-tab" />
|
||||
<WindowSafeArea>
|
||||
<InsetsProbe testID="overlay" />
|
||||
</WindowSafeArea>
|
||||
</SafeAreaInsetsContext.Provider>
|
||||
</WindowLayoutProvider>
|
||||
</SafeAreaInsetsContext.Provider>,
|
||||
),
|
||||
);
|
||||
|
||||
const insetsOf = (testID: string) =>
|
||||
JSON.parse(container.querySelector(`[data-testid="${testID}"]`)?.textContent ?? "null");
|
||||
expect(insetsOf("sheet-tab")).toEqual(sheetTabInsets);
|
||||
expect(insetsOf("overlay")).toEqual(windowInsets);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useContext, type ReactNode } from "react";
|
||||
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
|
||||
import { WindowInsetsContext } from "./context";
|
||||
|
||||
/**
|
||||
* Gives an overlay that draws over the whole window, such as an RN `Modal`, the window's insets
|
||||
* instead of the ones its call site was given. Read them below it with `useSafeAreaInsets()`.
|
||||
*/
|
||||
export function WindowSafeArea({ children }: { children: ReactNode }) {
|
||||
const insets = useContext(WindowInsetsContext);
|
||||
if (!insets) {
|
||||
throw new Error("WindowSafeArea must render inside WindowLayoutProvider");
|
||||
}
|
||||
return <SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createElement, type ElementType } from "react";
|
||||
import { lightHighlightColors } from "@getpaseo/highlight";
|
||||
import { lightTheme } from "../src/styles/theme";
|
||||
|
||||
const testTheme = {
|
||||
const pinnedTheme = {
|
||||
colorScheme: "light",
|
||||
colors: {
|
||||
syntax: lightHighlightColors,
|
||||
@@ -86,6 +87,14 @@ const testTheme = {
|
||||
},
|
||||
};
|
||||
|
||||
// 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> {
|
||||
@@ -142,7 +151,19 @@ export const StyleSheet = {
|
||||
resolveStyles(isStyleFactory(styles) ? styles(testTheme) : styles),
|
||||
};
|
||||
|
||||
export const withUnistyles = <T>(Component: T): T => Component;
|
||||
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 useUnistyles = () => ({
|
||||
theme: testTheme,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ProviderInputSchema, type ProviderInput } from "./provider.js";
|
||||
|
||||
type ReviewMode = "uncommitted" | "base" | "all";
|
||||
type Delivery = "auto" | "steer";
|
||||
|
||||
const contextLine = {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 12,
|
||||
type: "add",
|
||||
content: "const value = 1;",
|
||||
} as const;
|
||||
|
||||
function reviewPrompt(mode: ReviewMode, delivery: Delivery): ProviderInput {
|
||||
return {
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "message-1",
|
||||
delivery,
|
||||
input: {
|
||||
type: "message",
|
||||
content: [
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/workspace",
|
||||
mode,
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: 12,
|
||||
body: "Please review this change.",
|
||||
context: {
|
||||
hunkHeader: "@@ -10,2 +10,3 @@",
|
||||
targetLine: contextLine,
|
||||
lines: [{ ...contextLine, type: "context", oldLineNumber: 11 }, contextLine],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Provider review attachments", () => {
|
||||
it.each(["uncommitted", "base", "all"] as const)(
|
||||
"preserves mode %s on an ordinary prompt",
|
||||
(mode) => {
|
||||
const input = reviewPrompt(mode, "auto");
|
||||
expect(ProviderInputSchema.parse(input)).toEqual(input);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["uncommitted", "base", "all"] as const)(
|
||||
"preserves mode %s on a steering prompt",
|
||||
(mode) => {
|
||||
const input = reviewPrompt(mode, "steer");
|
||||
expect(ProviderInputSchema.parse(input)).toEqual(input);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -213,7 +213,7 @@ interface ProviderReviewAttachment {
|
||||
type: "review";
|
||||
mimeType: "application/paseo-review";
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
mode: "uncommitted" | "base" | "all";
|
||||
baseRef?: string | null;
|
||||
comments: Array<{
|
||||
filePath: string;
|
||||
@@ -881,7 +881,7 @@ const providerContentSchema: z.ZodType<ProviderContent> = z.union([
|
||||
type: z.literal("review"),
|
||||
mimeType: z.literal("application/paseo-review"),
|
||||
cwd: z.string(),
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
mode: z.enum(["uncommitted", "base", "all"]),
|
||||
baseRef: z.string().nullable().optional(),
|
||||
comments: z.array(
|
||||
z
|
||||
|
||||
@@ -113,6 +113,111 @@ 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,8 +155,18 @@ 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,5 +1,12 @@
|
||||
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) {
|
||||
@@ -130,12 +137,7 @@ 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 &&
|
||||
error?.cause?.code !== "ECONNREFUSED" &&
|
||||
error?.code !== "ConnectionRefused"
|
||||
)
|
||||
throw error;
|
||||
if (error?.status !== 401 && !isRefused(error)) throw error;
|
||||
}
|
||||
if (active)
|
||||
return { error: "OpenCode session is already bound to another Paseo bridge" };
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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";
|
||||
@@ -167,3 +169,88 @@ 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);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm, mkdir, writeFile, readdir, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join } from "node:path";
|
||||
import { AgentStorage, parseStoredAgentRecord } from "../agent-storage.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { readPersistedConfig } from "../../persisted-config.js";
|
||||
import { createRestartRecovery } from "./index.js";
|
||||
|
||||
test("recovery saves intent before normalization, clears before dispatch, and resumes each agent independently", async () => {
|
||||
@@ -103,3 +106,130 @@ test("opt-out consumes pending recovery and excludes archived, private, owned an
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const bashAvailable =
|
||||
process.platform !== "win32" &&
|
||||
spawnSync("bash", ["-c", "true"], { stdio: "ignore" }).status === 0;
|
||||
for (const seeder of ["setup", "shell", "setup-bom"] as const) {
|
||||
test.skipIf(seeder === "shell" && !bashAvailable)(
|
||||
`${seeder} seed never dispatches copied running or pending agents and excludes automation`,
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "inert-seed-"));
|
||||
try {
|
||||
const source = join(root, "source");
|
||||
const targetRoot = join(root, "target");
|
||||
const target = join(targetRoot, ".dev/paseo-home");
|
||||
const storage = new AgentStorage(join(root, "source-records"), createTestLogger());
|
||||
for (const [id, lastStatus, resumeOnStartup] of [
|
||||
["live", "running", false],
|
||||
["crashed", "running", true],
|
||||
["stopped", "closed", true],
|
||||
] as const) {
|
||||
await storage.upsert(
|
||||
parseStoredAgentRecord({
|
||||
id,
|
||||
lastStatus,
|
||||
resumeOnStartup,
|
||||
provider: "codex",
|
||||
cwd: root,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
await storage.flush();
|
||||
await mkdir(source, { recursive: true });
|
||||
await symlink(
|
||||
join(root, "source-records"),
|
||||
join(source, "agents"),
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
await mkdir(join(source, "schedules"), { recursive: true });
|
||||
await writeFile(join(source, "schedules", "schedule.json"), '{"kind":"scheduled"}');
|
||||
await writeFile(join(source, "schedules", "heartbeat.json"), '{"kind":"heartbeat"}');
|
||||
await writeFile(
|
||||
join(source, "config.json"),
|
||||
(seeder === "setup-bom" ? "\uFEFF" : "") +
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
agents: { resumeOnStartup: true, resumePrompt: "Continue" },
|
||||
}),
|
||||
);
|
||||
const repo = fileURLToPath(new URL("../../../../../../", import.meta.url));
|
||||
const env = {
|
||||
...process.env,
|
||||
PASEO_SOURCE_CHECKOUT_PATH: join(root, "checkout"),
|
||||
PASEO_WORKTREE_PATH: targetRoot,
|
||||
PASEO_DEV_SEED_HOME: source,
|
||||
PASEO_HOME: target,
|
||||
};
|
||||
if (seeder !== "shell")
|
||||
execFileSync(process.execPath, [join(repo, "scripts/seed-worktree-dev-state.mjs")], {
|
||||
env,
|
||||
});
|
||||
else {
|
||||
const linkedRepo = join(root, "linked-checkout");
|
||||
await symlink(repo, linkedRepo, "dir");
|
||||
execFileSync("bash", [join(linkedRepo, "scripts/dev-home.sh")], { env });
|
||||
}
|
||||
expect((await readdir(target)).sort()).toEqual(["agents", "config.json"]);
|
||||
const config = readPersistedConfig(target);
|
||||
const copied = new AgentStorage(join(target, "agents"), createTestLogger());
|
||||
const dispatched: string[] = [];
|
||||
const recovery = createRestartRecovery({
|
||||
storage: copied,
|
||||
settings: () => ({
|
||||
enabled: config.agents?.resumeOnStartup === true,
|
||||
prompt: "Continue",
|
||||
}),
|
||||
eligible: async () => true,
|
||||
dispatch: async (id) => {
|
||||
dispatched.push(id);
|
||||
},
|
||||
report: () => {},
|
||||
});
|
||||
await recovery.prepareStartup();
|
||||
recovery.resumePending();
|
||||
for (const record of await copied.listAll())
|
||||
await recovery.waitForResume(record.id, new AbortController().signal);
|
||||
expect(dispatched).toEqual([]);
|
||||
expect((await copied.listAll()).map((r) => [r.lastStatus, r.resumeOnStartup])).toEqual([
|
||||
["closed", false],
|
||||
["closed", false],
|
||||
["closed", false],
|
||||
]);
|
||||
expect(readPersistedConfig(source).agents?.resumeOnStartup).toBe(true);
|
||||
const unchangedSource = new AgentStorage(join(root, "source-records"), createTestLogger());
|
||||
expect((await unchangedSource.get("live"))?.lastStatus).toBe("running");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("seeding skips an existing home without reading invalid source config", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "seed-skip-"));
|
||||
try {
|
||||
const source = join(root, "source");
|
||||
const targetRoot = join(root, "target");
|
||||
const target = join(targetRoot, ".dev/paseo-home");
|
||||
await mkdir(source, { recursive: true });
|
||||
await mkdir(target, { recursive: true });
|
||||
await writeFile(join(source, "config.json"), "invalid JSON");
|
||||
await writeFile(join(target, "keep.json"), "{}");
|
||||
const repo = fileURLToPath(new URL("../../../../../../", import.meta.url));
|
||||
execFileSync(process.execPath, [join(repo, "scripts/seed-worktree-dev-state.mjs")], {
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_SOURCE_CHECKOUT_PATH: join(root, "checkout"),
|
||||
PASEO_WORKTREE_PATH: targetRoot,
|
||||
PASEO_DEV_SEED_HOME: source,
|
||||
PASEO_DEV_RESET_HOME: "0",
|
||||
},
|
||||
});
|
||||
expect(await readdir(target)).toEqual(["keep.json"]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1238,6 +1238,9 @@ 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.
|
||||
|
||||
@@ -57,8 +57,124 @@ index 2abb4a2..8ca967f 100644
|
||||
outerScrollOffset?: Animated.SharedValue<number>;
|
||||
onAnimValInit?: (animVals: ReturnType<typeof useAnimatedValues>) => void;
|
||||
itemEnteringAnimation?: AnimateProps<Animated.View>["entering"];
|
||||
diff --git a/node_modules/react-native-draggable-flatlist/src/components/CellRendererComponent.tsx b/node_modules/react-native-draggable-flatlist/src/components/CellRendererComponent.tsx
|
||||
index e4e861a..f485139 100644
|
||||
--- a/node_modules/react-native-draggable-flatlist/src/components/CellRendererComponent.tsx
|
||||
+++ b/node_modules/react-native-draggable-flatlist/src/components/CellRendererComponent.tsx
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
runOnUI,
|
||||
+ SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
@@ -40,12 +41,12 @@ function CellRendererComponent<T>(props: Props<T>) {
|
||||
keyExtractor,
|
||||
horizontal,
|
||||
layoutAnimationDisabled,
|
||||
+ transformHostKeys,
|
||||
} = useDraggableFlatListContext<T>();
|
||||
|
||||
const key = keyExtractor(item, index);
|
||||
const offset = useSharedValue(-1);
|
||||
const size = useSharedValue(-1);
|
||||
- const heldTanslate = useSharedValue(0);
|
||||
|
||||
const translate = useCellTranslate({
|
||||
cellOffset: offset,
|
||||
@@ -55,20 +56,6 @@ function CellRendererComponent<T>(props: Props<T>) {
|
||||
|
||||
const isActive = activeKey === key;
|
||||
|
||||
- const animStyle = useAnimatedStyle(() => {
|
||||
- // When activeKey becomes null at the end of a drag and the list reorders,
|
||||
- // the animated style may apply before the next paint, causing a flicker.
|
||||
- // Solution is to hold over the last animated value until the next onLayout.
|
||||
- // (Not required in web)
|
||||
- if (translate.value && !isWeb) {
|
||||
- heldTanslate.value = translate.value;
|
||||
- }
|
||||
- const t = activeKey ? translate.value : heldTanslate.value;
|
||||
- return {
|
||||
- transform: [horizontalAnim.value ? { translateX: t } : { translateY: t }],
|
||||
- };
|
||||
- }, [translate, activeKey]);
|
||||
-
|
||||
const updateCellMeasurements = useStableCallback(() => {
|
||||
const onSuccess: MeasureLayoutOnSuccessCallback = (x, y, w, h) => {
|
||||
if (isWeb && horizontal) x += scrollOffset.value;
|
||||
@@ -99,7 +86,6 @@ function CellRendererComponent<T>(props: Props<T>) {
|
||||
});
|
||||
|
||||
const onCellLayout = useStableCallback((e?: LayoutChangeEvent) => {
|
||||
- heldTanslate.value = 0;
|
||||
updateCellMeasurements();
|
||||
if (onLayout && e) onLayout(e);
|
||||
});
|
||||
@@ -162,14 +148,58 @@ function CellRendererComponent<T>(props: Props<T>) {
|
||||
? itemLayoutAnimation
|
||||
: undefined
|
||||
}
|
||||
- style={[props.style, baseStyle, animStyle]}
|
||||
+ style={[props.style, baseStyle]}
|
||||
pointerEvents={activeKey ? "none" : "auto"}
|
||||
>
|
||||
- <CellProvider isActive={isActive}>{children}</CellProvider>
|
||||
+ <CellTransformHost
|
||||
+ key={transformHostKeys.get(key) ?? 0}
|
||||
+ translate={translate}
|
||||
+ dragging={activeKey !== null}
|
||||
+ horizontal={horizontal}
|
||||
+ >
|
||||
+ <CellProvider isActive={isActive}>{children}</CellProvider>
|
||||
+ </CellTransformHost>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
+// Owns the cell's drag translate. The list remounts it, with the cell's whole subtree, when a
|
||||
+// drop moves the cell: the reorder commit then paints the new slot with no translate, because a
|
||||
+// new view has no last animated transform for Reanimated to re-apply.
|
||||
+function CellTransformHost({
|
||||
+ translate,
|
||||
+ dragging,
|
||||
+ horizontal,
|
||||
+ children,
|
||||
+}: {
|
||||
+ translate: SharedValue<number>;
|
||||
+ dragging: boolean;
|
||||
+ horizontal: boolean;
|
||||
+ children: React.ReactNode;
|
||||
+}) {
|
||||
+ const { horizontalAnim } = useAnimatedValues();
|
||||
+ const animStyle = useAnimatedStyle(() => {
|
||||
+ const t = dragging ? translate.value : 0;
|
||||
+ return {
|
||||
+ transform: [horizontalAnim.value ? { translateX: t } : { translateY: t }],
|
||||
+ };
|
||||
+ }, [translate, dragging]);
|
||||
+
|
||||
+ return (
|
||||
+ <Animated.View
|
||||
+ collapsable={false}
|
||||
+ style={[horizontal ? styles.row : styles.column, animStyle]}
|
||||
+ >
|
||||
+ {children}
|
||||
+ </Animated.View>
|
||||
+ );
|
||||
+}
|
||||
+
|
||||
+const styles = {
|
||||
+ row: { flexDirection: "row" as const },
|
||||
+ column: { flexDirection: "column" as const },
|
||||
+};
|
||||
+
|
||||
export default typedMemo(CellRendererComponent);
|
||||
|
||||
declare global {
|
||||
diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
|
||||
index 7c88afc..a74140f 100644
|
||||
index 7c88afc..ad75714 100644
|
||||
--- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
|
||||
+++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
|
||||
@@ -16,9 +16,11 @@ import {
|
||||
@@ -88,13 +204,26 @@ index 7c88afc..a74140f 100644
|
||||
+ sessionId: number;
|
||||
+ index: number;
|
||||
+ } | null>(null);
|
||||
|
||||
|
||||
const {
|
||||
dragHitSlop = DEFAULT_PROPS.dragHitSlop,
|
||||
@@ -144,19 +157,88 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -100,6 +113,12 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
} = props;
|
||||
|
||||
let [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
+ // Reanimated re-applies a view's last animated transform to every React commit, so a moved
|
||||
+ // cell would paint once at its new slot plus its old drag translate. The drop gives each
|
||||
+ // moved cell a new transform host instead: a fresh view has no animated props to re-apply.
|
||||
+ const [transformHostKeys, setTransformHostKeys] = useState<
|
||||
+ ReadonlyMap<string, number>
|
||||
+ >(() => new Map());
|
||||
const [layoutAnimationDisabled, setLayoutAnimationDisabled] = useState(
|
||||
!propsRef.current.enableLayoutAnimationExperimental
|
||||
);
|
||||
@@ -144,19 +163,88 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
});
|
||||
}, [props.data, keyExtractor, keyToIndexRef]);
|
||||
|
||||
|
||||
+ const onDragCancel = useStableCallback((sessionId: number, index: number) => {
|
||||
+ if (activeDragRef.current?.sessionId !== sessionId) return;
|
||||
+ activeDragRef.current = null;
|
||||
@@ -168,7 +297,7 @@ index 7c88afc..a74140f 100644
|
||||
- activeCellOffset.value = cellData.measurements.offset;
|
||||
- activeCellSize.value = cellData.measurements.size;
|
||||
- }
|
||||
|
||||
|
||||
const { onDragBegin } = propsRef.current;
|
||||
if (index !== undefined) {
|
||||
- spacerIndexAnim.value = index;
|
||||
@@ -187,10 +316,10 @@ index 7c88afc..a74140f 100644
|
||||
setActiveKey(activeKey);
|
||||
onDragBegin?.(index);
|
||||
}
|
||||
@@ -218,8 +300,25 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -218,14 +306,46 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
props.onRelease?.(index);
|
||||
});
|
||||
|
||||
|
||||
+ const finalizeCancelledDrag = () => {
|
||||
+ "worklet";
|
||||
+ const activeIndex = activeIndexAnim.value;
|
||||
@@ -212,12 +341,33 @@ index 7c88afc..a74140f 100644
|
||||
+ if (activeDragRef.current?.sessionId !== sessionId) return;
|
||||
+ activeDragRef.current = null;
|
||||
const { onDragEnd, data } = props;
|
||||
|
||||
|
||||
const newData = [...data];
|
||||
@@ -238,26 +337,6 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
if (from !== to) {
|
||||
newData.splice(from, 1);
|
||||
newData.splice(to, 0, data[from]);
|
||||
+ const movedKeys = data
|
||||
+ .slice(Math.min(from, to), Math.max(from, to) + 1)
|
||||
+ .map((item, offset) => keyExtractor(item, Math.min(from, to) + offset));
|
||||
+ setTransformHostKeys((previous) => {
|
||||
+ const next = new Map<string, number>();
|
||||
+ data.forEach((item, index) => {
|
||||
+ const itemKey = keyExtractor(item, index);
|
||||
+ const hostKey = previous.get(itemKey);
|
||||
+ if (hostKey !== undefined) next.set(itemKey, hostKey);
|
||||
+ });
|
||||
+ for (const movedKey of movedKeys) {
|
||||
+ next.set(movedKey, (previous.get(movedKey) ?? 0) + 1);
|
||||
+ }
|
||||
+ return next;
|
||||
+ });
|
||||
}
|
||||
|
||||
onDragEnd?.({ from, to, data: newData });
|
||||
@@ -238,26 +358,6 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
props.onPlaceholderIndexChange?.(index);
|
||||
});
|
||||
|
||||
|
||||
- // Handle case where user ends drag without moving their finger.
|
||||
- useAnimatedReaction(
|
||||
- () => {
|
||||
@@ -241,15 +391,15 @@ index 7c88afc..a74140f 100644
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
return spacerIndexAnim.value;
|
||||
@@ -271,11 +350,24 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -271,11 +371,24 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
);
|
||||
|
||||
|
||||
const gestureDisabled = useSharedValue(false);
|
||||
+ const touchStartX = useSharedValue(0);
|
||||
+ const touchStartY = useSharedValue(0);
|
||||
+ const manualPanActivated = useSharedValue(false);
|
||||
+ const coordinatesDragWithExternalGesture = Boolean(props.simultaneousHandlers);
|
||||
|
||||
|
||||
- const panGesture = Gesture.Pan()
|
||||
+ let panGesture = Gesture.Pan()
|
||||
.onBegin((evt) => {
|
||||
@@ -267,7 +417,7 @@ index 7c88afc..a74140f 100644
|
||||
panGestureState.value = evt.state;
|
||||
})
|
||||
.onUpdate((evt) => {
|
||||
@@ -286,8 +378,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -286,8 +399,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
: evt.translationY;
|
||||
touchTranslate.value = translation;
|
||||
})
|
||||
@@ -284,15 +434,15 @@ index 7c88afc..a74140f 100644
|
||||
// Set touch val to current translate val
|
||||
isTouchActiveNative.value = false;
|
||||
const translation = horizontalAnim.value
|
||||
@@ -299,6 +398,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
|
||||
@@ -299,6 +419,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
|
||||
// Only call onDragEnd if actually dragging a cell
|
||||
if (activeIndexAnim.value === -1 || disabled.value) return;
|
||||
+ terminalHandled.value = true;
|
||||
disabled.value = true;
|
||||
runOnJS(onRelease)(activeIndexAnim.value);
|
||||
const springTo = placeholderOffset.value - activeCellOffset.value;
|
||||
@@ -309,22 +409,84 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -309,22 +430,84 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
runOnJS(onDragEnd)({
|
||||
from: activeIndexAnim.value,
|
||||
to: spacerIndexAnim.value,
|
||||
@@ -371,7 +521,7 @@ index 7c88afc..a74140f 100644
|
||||
+ stateManager.fail();
|
||||
+ }
|
||||
});
|
||||
|
||||
|
||||
if (dragHitSlop) panGesture.hitSlop(dragHitSlop);
|
||||
- if (activationDistanceProp) {
|
||||
+ if (coordinatesDragWithExternalGesture) {
|
||||
@@ -380,7 +530,7 @@ index 7c88afc..a74140f 100644
|
||||
const activeOffset = [-activationDistanceProp, activationDistanceProp];
|
||||
if (props.horizontal) {
|
||||
panGesture.activeOffsetX(activeOffset);
|
||||
@@ -332,6 +494,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -332,6 +515,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
panGesture.activeOffsetY(activeOffset);
|
||||
}
|
||||
}
|
||||
@@ -399,12 +549,14 @@ index 7c88afc..a74140f 100644
|
||||
+ props.dragGestureHostPresented === false
|
||||
+ ? Gesture.Simultaneous(panGesture, Gesture.Manual().enabled(false))
|
||||
+ : panGesture;
|
||||
|
||||
|
||||
const onScroll = useStableCallback((scrollOffset: number) => {
|
||||
props.onScrollOffsetChange?.(scrollOffset);
|
||||
@@ -373,8 +550,9 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
@@ -372,9 +570,11 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
|
||||
keyExtractor={keyExtractor}
|
||||
horizontal={!!props.horizontal}
|
||||
layoutAnimationDisabled={layoutAnimationDisabled}
|
||||
+ transformHostKeys={transformHostKeys}
|
||||
>
|
||||
- <GestureDetector gesture={panGesture}>
|
||||
+ <GestureDetector gesture={detectorGesture}>
|
||||
@@ -426,17 +578,17 @@ index 1559352..33eb5de 100644
|
||||
useSharedValue,
|
||||
@@ -48,7 +48,7 @@ function NestableDraggableFlatListInner<T>(
|
||||
});
|
||||
|
||||
|
||||
const onListContainerLayout = useStableCallback(async ({ containerRef }) => {
|
||||
- const nodeHandle = findNodeHandle(scrollableRef.current);
|
||||
+ const nodeHandle = scrollableRef.current;
|
||||
|
||||
|
||||
const onSuccess = (_x: number, y: number) => {
|
||||
listVerticalOffset.value = y;
|
||||
@@ -74,6 +74,10 @@ function NestableDraggableFlatListInner<T>(
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+ const onDragTerminate = useStableCallback(() => {
|
||||
+ setOuterScrollEnabled(true);
|
||||
+ });
|
||||
@@ -452,6 +604,37 @@ index 1559352..33eb5de 100644
|
||||
onAnimValInit={onAnimValInit}
|
||||
/>
|
||||
);
|
||||
diff --git a/node_modules/react-native-draggable-flatlist/src/context/draggableFlatListContext.tsx b/node_modules/react-native-draggable-flatlist/src/context/draggableFlatListContext.tsx
|
||||
index 75f7db7..e99faf3 100644
|
||||
--- a/node_modules/react-native-draggable-flatlist/src/context/draggableFlatListContext.tsx
|
||||
+++ b/node_modules/react-native-draggable-flatlist/src/context/draggableFlatListContext.tsx
|
||||
@@ -5,6 +5,7 @@ type Props<T> = {
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
horizontal: boolean;
|
||||
layoutAnimationDisabled: boolean;
|
||||
+ transformHostKeys: ReadonlyMap<string, number>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -19,6 +20,7 @@ export default function DraggableFlatListProvider<T>({
|
||||
keyExtractor,
|
||||
horizontal,
|
||||
layoutAnimationDisabled,
|
||||
+ transformHostKeys,
|
||||
children,
|
||||
}: Props<T>) {
|
||||
const value = useMemo(
|
||||
@@ -27,8 +29,9 @@ export default function DraggableFlatListProvider<T>({
|
||||
keyExtractor,
|
||||
horizontal,
|
||||
layoutAnimationDisabled,
|
||||
+ transformHostKeys,
|
||||
}),
|
||||
- [activeKey, keyExtractor, horizontal, layoutAnimationDisabled]
|
||||
+ [activeKey, keyExtractor, horizontal, layoutAnimationDisabled, transformHostKeys]
|
||||
);
|
||||
|
||||
return (
|
||||
diff --git a/node_modules/react-native-draggable-flatlist/src/types.ts b/node_modules/react-native-draggable-flatlist/src/types.ts
|
||||
index d6755c8..1b244a6 100644
|
||||
--- a/node_modules/react-native-draggable-flatlist/src/types.ts
|
||||
|
||||
+3
-55
@@ -4,62 +4,10 @@ default_dev_paseo_root() {
|
||||
git rev-parse --show-toplevel 2>/dev/null || pwd
|
||||
}
|
||||
|
||||
copy_json_tree() {
|
||||
local source_dir="$1"
|
||||
local target_dir="$2"
|
||||
|
||||
if [ ! -d "$source_dir" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$target_dir"
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --include='*/' --include='*.json' --exclude='*' "$source_dir/" "$target_dir/"
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS= read -r -d '' source_file; do
|
||||
local relative_path="${source_file#"$source_dir"/}"
|
||||
local target_file="$target_dir/$relative_path"
|
||||
mkdir -p "$(dirname "$target_file")"
|
||||
cp "$source_file" "$target_file"
|
||||
done < <(find "$source_dir" -type f -name '*.json' -print0)
|
||||
}
|
||||
|
||||
has_files() {
|
||||
[ -d "$1" ] && [ -n "$(find "$1" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]
|
||||
}
|
||||
|
||||
seed_worktree_paseo_home() {
|
||||
local source_home="${PASEO_DEV_SEED_HOME:-$HOME/.paseo}"
|
||||
local target_home="$1"
|
||||
|
||||
if [ ! -d "$source_home" ]; then
|
||||
echo " Seed: skipped (${source_home} missing)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$source_home" = "$target_home" ]; then
|
||||
echo " Seed: skipped (source is target)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "${PASEO_DEV_RESET_HOME:-0}" = "1" ]; then
|
||||
rm -rf "$target_home"
|
||||
elif has_files "$target_home"; then
|
||||
echo " Seed: skipped (${target_home} already has data)"
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$target_home"
|
||||
echo " Seed: copying metadata from ${source_home}"
|
||||
copy_json_tree "$source_home/agents" "$target_home/agents"
|
||||
copy_json_tree "$source_home/projects" "$target_home/projects"
|
||||
if [ -f "$source_home/config.json" ]; then
|
||||
cp "$source_home/config.json" "$target_home/config.json"
|
||||
fi
|
||||
|
||||
echo " Seed: copied metadata from ${source_home}"
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
node "$script_dir/seed-dev-home.mjs" "${PASEO_DEV_SEED_HOME:-$HOME/.paseo}" "$1"
|
||||
}
|
||||
|
||||
configure_dev_daemon_config() {
|
||||
|
||||
@@ -48,6 +48,10 @@ const patchedPackages = [
|
||||
nodeModulesPath: "node_modules/@legendapp/list",
|
||||
patchPrefix: "@legendapp+list+",
|
||||
},
|
||||
// Native sidebar lists. Besides gesture sessions and panel-gesture arbitration, the drop gives
|
||||
// each moved cell a fresh transform host. Reanimated re-applies a view's last animated
|
||||
// transform to every React commit, so a reused host painted one frame of new order plus old
|
||||
// drag translate (upstream computerjazz/react-native-draggable-flatlist#621, open).
|
||||
{
|
||||
nodeModulesPath: "node_modules/react-native-draggable-flatlist",
|
||||
patchPrefix: "react-native-draggable-flatlist+",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
cpSync,
|
||||
realpathSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export function seedPaseoHome(source, target) {
|
||||
if (!existsSync(source)) {
|
||||
console.log(` Seed: skipped (${source} missing)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === target) {
|
||||
console.log(" Seed: skipped (source is target)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.PASEO_DEV_RESET_HOME !== "1" && hasEntries(target)) {
|
||||
console.log(` Seed: skipped (${target} already has data)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = join(source, "config.json");
|
||||
const config = existsSync(configPath)
|
||||
? JSON.parse(readFileSync(configPath, "utf8").replace(/^\uFEFF/, ""))
|
||||
: { version: 1 };
|
||||
|
||||
if (process.env.PASEO_DEV_RESET_HOME === "1") {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
mkdirSync(target, { recursive: true });
|
||||
console.log(` Seed: copying metadata from ${source}`);
|
||||
copyJsonTree(join(source, "agents"), join(target, "agents"));
|
||||
copyJsonTree(join(source, "projects"), join(target, "projects"));
|
||||
|
||||
// A snapshot does not own the source daemon's pending work. Recovery normalizes
|
||||
// copied running records on startup without sending a prompt when disabled.
|
||||
config.agents = { ...config.agents, resumeOnStartup: false };
|
||||
writeFileSync(join(target, "config.json"), JSON.stringify(config, null, 2));
|
||||
|
||||
console.log(` Seed: copied metadata from ${source}`);
|
||||
}
|
||||
|
||||
// Durable JSON metadata only. Runtime files (pid files, sockets, logs) must not be copied.
|
||||
function copyJsonTree(source, target) {
|
||||
if (!existsSync(source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
cpSync(source, target, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: (path) => isDirectory(path) || path.endsWith(".json"),
|
||||
});
|
||||
}
|
||||
|
||||
function hasEntries(path) {
|
||||
try {
|
||||
return readdirSync(path).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(path) {
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
||||
seedPaseoHome(process.argv[2], process.argv[3]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
// Worktree setup runs through a stable script shell: bash on macOS/Linux, but PowerShell
|
||||
@@ -7,6 +7,8 @@ import { dirname, join } from "node:path";
|
||||
// `./scripts/*.sh`) cannot express this step portably, so the seeding lives in Node — the
|
||||
// one interpreter every Paseo checkout already depends on.
|
||||
|
||||
import { seedPaseoHome } from "./seed-dev-home.mjs";
|
||||
|
||||
const sourceRoot = process.env.PASEO_SOURCE_CHECKOUT_PATH;
|
||||
const targetRoot = process.env.PASEO_WORKTREE_PATH || process.cwd();
|
||||
|
||||
@@ -14,55 +16,12 @@ if (!sourceRoot || sourceRoot === targetRoot) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
seedPaseoHome();
|
||||
seedPaseoHome(
|
||||
process.env.PASEO_DEV_SEED_HOME || join(sourceRoot, ".dev/paseo-home"),
|
||||
join(targetRoot, ".dev/paseo-home"),
|
||||
);
|
||||
copyServerEnv();
|
||||
|
||||
function seedPaseoHome() {
|
||||
const source = process.env.PASEO_DEV_SEED_HOME || join(sourceRoot, ".dev/paseo-home");
|
||||
const target = join(targetRoot, ".dev/paseo-home");
|
||||
|
||||
if (!existsSync(source)) {
|
||||
console.log(` Seed: skipped (${source} missing)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === target) {
|
||||
console.log(" Seed: skipped (source is target)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.PASEO_DEV_RESET_HOME === "1") {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
} else if (hasEntries(target)) {
|
||||
console.log(` Seed: skipped (${target} already has data)`);
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(target, { recursive: true });
|
||||
console.log(` Seed: copying metadata from ${source}`);
|
||||
copyJsonTree(join(source, "agents"), join(target, "agents"));
|
||||
copyJsonTree(join(source, "projects"), join(target, "projects"));
|
||||
|
||||
const config = join(source, "config.json");
|
||||
if (existsSync(config)) {
|
||||
cpSync(config, join(target, "config.json"));
|
||||
}
|
||||
|
||||
console.log(` Seed: copied metadata from ${source}`);
|
||||
}
|
||||
|
||||
// Durable JSON metadata only. Runtime files (pid files, sockets, logs) must not be copied.
|
||||
function copyJsonTree(source, target) {
|
||||
if (!existsSync(source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
cpSync(source, target, {
|
||||
recursive: true,
|
||||
filter: (path) => isDirectory(path) || path.endsWith(".json"),
|
||||
});
|
||||
}
|
||||
|
||||
function copyServerEnv() {
|
||||
const source = join(sourceRoot, "packages/server/.env");
|
||||
const target = join(targetRoot, "packages/server/.env");
|
||||
@@ -77,19 +36,3 @@ function copyServerEnv() {
|
||||
cpSync(source, target);
|
||||
console.log(` Env: copied ${source}`);
|
||||
}
|
||||
|
||||
function hasEntries(path) {
|
||||
try {
|
||||
return readdirSync(path).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(path) {
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user