Compare commits

..
Author SHA1 Message Date
Mohamed Boudra d61a6d2880 Bring a revealed sheet to the top instead of duplicating it as a tab 2026-10-14 17:58:43 +02:00
Mohamed Boudra 6abf6e4690 Let a stacked sheet's backdrop catch taps on iOS 2026-10-14 17:45:22 +02:00
Mohamed Boudra b041a83fc6 Export BottomSheetHandle from the browser test Gorhom stub 2026-10-14 17:38:50 +02:00
Mohamed Boudra 0a10ebed2f Pause covered workspace sheets and mock the shared sheet handle in tests 2026-10-14 17:38:50 +02:00
Mohamed Boudra 88142d8655 Stack workspace sheets and tighten sidebar density
- Workspace sheets stack: opening a target from a sheet covers it, Back and
  drag close only the top sheet, and the header is the sheet's drag handle.
- The workspace sheet reuses the shared sheet handle, background and header.
- Fix the shared sheet handle indicator rendering black.
- Sidebar text returns to semantic sizes; Pinned and Workspaces share one
  section header.
2026-10-14 17:38:50 +02:00
46 changed files with 847 additions and 1873 deletions
+11 -6
View File
@@ -7,7 +7,7 @@ contracts.
| ---------------- | ---------------------------- | ------------------------------------------ |
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
| Workspace sheet | Compact look-ups | Holds one tab; closes or becomes a tab |
| Workspace sheet | Compact look-ups | Stacks tabs; each closes or becomes a tab |
## Panel host contract
@@ -28,12 +28,17 @@ retention, while each shell owns its tabs, focus, dragging, resizing, and shortc
On compact layouts, files and subagents open in a closable sheet over the workspace, so the tab
switcher keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
sheet and tab, so callers open targets the same way on every layout. The sheet holds one ordinary
`WorkspaceTab`, which is not persisted.
sheet and tab, so callers open targets the same way on every layout. Each sheet holds one ordinary
`WorkspaceTab`, and sheets stack: a target opened from a sheet covers it, and closing the top sheet
(drag, Close, backdrop, or Android Back) uncovers the one below. Revealing a target that already has
a sheet brings that sheet back to the top and closes the sheets above it, except ones holding unsaved
work. Opening a sheet as a tab dismisses the sheets above it, since the
tab would sit under them. The stack is not persisted.
The sheet is modal and covers the whole compact column, header included. It renders in the
workspace tree rather than through `IsolatedBottomSheetModal`: its panel needs the navigator and
every workspace provider an ordinary pane has, and a portal drops them
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
handle, because the panel scrolls on its own and content panning would fight it. Sheets render in
the workspace tree rather than through `IsolatedBottomSheetModal`: their panels need the navigator
and every workspace provider an ordinary pane has, and a portal drops them
([floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site)).
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
+1 -1
View File
@@ -21,7 +21,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Workspace sheet** — The compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `sheetByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet).
- **Workspace sheet** — A compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher; opening another target from a sheet stacks a new one on top. UI action: "Open as tab". Code: `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
-6
View File
@@ -86,12 +86,6 @@ buildNpmPackage {
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
# to phone home during the build.
CI = "1";
# Metro's worker runs out of heap transforming the generated
# ws-outbound.aot.js validator (about 12 MB). Node's default heap limit is
# 2 GB on machines with less than 15 GB of memory, such as GitHub's 7 GB
# macOS runners, and 4 GB above that. 4096 MB is that 4 GB ceiling, so this
# only raises the limit on smaller builders, like desktop-release.yml does.
NODE_OPTIONS = "--max-old-space-size=4096";
};
buildPhase = ''
@@ -1,48 +0,0 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openSettingsSection } from "../support/helpers/settings";
async function openAppearance(page: Page) {
await page.goto("/settings");
await openSettingsSection(page, "appearance");
}
async function setFontFamily(page: Page, name: string, family: string) {
const input = page.getByRole("textbox", { name });
await input.fill(family);
await input.press("Tab");
}
for (const name of ["Code font family", "Interface font family"]) {
test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => {
await openAppearance(page);
await setFontFamily(page, name, "'Fira Code', monospace");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await setFontFamily(page, name, "'foo");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
await page.screenshot({ path: testInfo.outputPath("rejected-font.png") });
await page.reload();
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await setFontFamily(page, name, "");
await expect(page.getByRole("textbox", { name })).toHaveValue("");
});
}
test("opens settings with defaults when a malformed font was already saved", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ monoFontFamily: "'foo", uiFontFamily: '"foo' }),
);
});
await openAppearance(page);
await expect(page.getByRole("textbox", { name: "Code font family" })).toHaveValue("");
await expect(page.getByRole("textbox", { name: "Interface font family" })).toHaveValue("");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
});
@@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { gotoWorkspace } from "../support/helpers/launcher";
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
@@ -444,25 +444,3 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
});
test("downloads a file over direct TCP with its original filename and bytes", async ({
page,
}, testInfo) => {
const fileName = "rapport données.bin";
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const downloaded = await downloadFileFromExplorer(page, fileName);
const url = new URL(downloaded.url());
expect(url.protocol).toBe("http:");
expect(url.port).toBe(getE2EDaemonPort());
expect(url.pathname).toBe("/api/files/download");
expect(url.searchParams.get("token")).toBeTruthy();
expect(downloaded.suggestedFilename()).toBe(fileName);
const savedFile = testInfo.outputPath(fileName);
await downloaded.saveAs(savedFile);
expect(await downloaded.failure()).toBeNull();
expect(await readFile(savedFile)).toEqual(source);
});
@@ -1,4 +1,4 @@
import { expect, type Download, type Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import { openFilesPanel } from "./workspace-tabs";
function fileExplorerTree(page: Page) {
@@ -39,10 +39,3 @@ export async function expectFileTabOpen(page: Page, filePath: string): Promise<v
timeout: 30_000,
});
}
export async function downloadFileFromExplorer(page: Page, fileName: string): Promise<Download> {
await fileExplorerEntry(page, fileName).click({ button: "right" });
const downloaded = page.waitForEvent("download");
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
return downloaded;
}
@@ -7,8 +7,8 @@ export function workspaceSheet(page: Page, label: string): Locator {
/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */
const SHEET_SNAP_FRACTION = 0.92;
/** The dialog starts below the sheet's drag handle. */
const SHEET_HANDLE_MAX_HEIGHT = 32;
/** The header starts below the sheet's drag indicator, inside the handle. */
const SHEET_INDICATOR_MAX_HEIGHT = 32;
/**
* The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it
@@ -18,14 +18,16 @@ export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Pro
const viewportHeight = page.viewportSize()?.height ?? 0;
const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
const sheet = workspaceSheet(page, label);
const header = sheet.getByTestId("workspace-sheet-header");
const body = sheet.getByTestId("workspace-sheet-body");
await expect
.poll(async () => {
const box = await sheet.boundingBox();
if (!box) return null;
const top = Math.round(box.y);
const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]);
if (!headerBox || !bodyBox) return null;
const top = Math.round(headerBox.y);
return {
dockedToBottom: Math.round(box.y + box.height) === viewportHeight,
topAtSnap: top >= snapTop && top <= snapTop + SHEET_HANDLE_MAX_HEIGHT,
dockedToBottom: Math.round(bodyBox.y + bodyBox.height) === viewportHeight,
topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_MAX_HEIGHT,
};
})
.toEqual({ dockedToBottom: true, topAtSnap: true });
@@ -73,9 +75,11 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
export async function tapWorkspaceSheetBackdrop(page: Page): Promise<void> {
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.last()
.click({ position: { x: 10, y: 10 } });
}
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Keyboard, Pressable, Text, View } from "react-native";
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
getOverlayRoot,
@@ -12,7 +12,7 @@ import {
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "../lib/overlay-root";
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
@@ -223,7 +223,8 @@ const styles = StyleSheet.create((theme) => ({
const WEB_EXIT_DURATION_MS = 160;
function SheetBackground({ style }: BottomSheetBackgroundProps) {
/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */
export function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
const combinedStyle = useMemo(
() => [
@@ -239,6 +240,39 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={combinedStyle} />;
}
const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({
indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] },
}));
const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 };
/**
* The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children
* join the handle, so a header passed here drags the sheet like the indicator does. That is how a
* sheet whose content scrolls on its own keeps its header draggable without content panning.
*/
export function SheetHandle({
children,
// Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator.
indicatorStyle: _indicatorStyle,
...props
}: ComponentProps<typeof BottomSheetHandle>) {
if (!children) return <ThemedBottomSheetHandle {...props} />;
// The children carry their own semantics; the handle's slider role would swallow them on web.
return (
<ThemedBottomSheetHandle
{...props}
style={HANDLE_WITH_CONTENT_STYLE}
accessible={false}
accessibilityRole={null}
accessibilityLabel={null}
accessibilityHint={null}
>
{children}
</ThemedBottomSheetHandle>
);
}
/**
* The sheet body, indented to the sheet's content inset.
*
@@ -449,7 +483,6 @@ export function AdaptiveModalSheet({
bodyStyle,
contextBridge = null,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
@@ -481,10 +514,6 @@ export function AdaptiveModalSheet({
) : null,
[footer, footerClearanceStyle, footerContainerStyle],
);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
);
useEffect(() => {
if (!isWeb && visible) {
// A newly opened sheet owns input. A keyboard belonging to the sheet below
@@ -600,7 +629,7 @@ export function AdaptiveModalSheet({
backdropOpacity={0.45}
enablePanDownToClose
backgroundComponent={SheetBackground}
handleIndicatorStyle={handleIndicatorStyle}
handleComponent={SheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
+13 -38
View File
@@ -35,6 +35,7 @@ import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
@@ -805,21 +806,18 @@ function DesktopSidebar({
function WorkspacesSectionHeader() {
return (
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</View>
</View>
<SidebarSectionHeader title="Workspaces">
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</SidebarSectionHeader>
);
}
@@ -850,29 +848,6 @@ const styles = StyleSheet.create((theme) => ({
sidebarHeaderGroupBelowChrome: {
paddingTop: 0,
},
workspacesSectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title
// lands at spacing[2] left to align with project icons. Settings2's painted path stops
// inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail.
paddingLeft: theme.spacing[2],
paddingRight: 4,
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[1],
},
workspacesSectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
workspacesSectionActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
sidebarContent: {
flex: 1,
minHeight: 0,
@@ -3,71 +3,6 @@ import { describe, expect, it } from "vitest";
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
describe("splitHtmlishMarkdown", () => {
it("renders heading text beside a linked picture without literal heading tags", () => {
const source =
'<h2><a href="https://example.com/r"><picture><source srcset="https://example.com/d.svg"><img alt="Retrigger" src="https://example.com/l.svg"></picture></a>Confidence Score: 5/5</h2>';
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "inlineImage",
alt: "Retrigger",
src: "https://example.com/l.svg",
href: "https://example.com/r",
flowsWithText: true,
},
{ kind: "markdown", text: "Confidence Score: 5/5" },
]);
});
it.each(["h1", "h2", "h3", "h4", "h5", "h6"])(
"keeps a mixed %s heading together after preceding markdown",
(heading) => {
const source = `Intro\n\n<${heading}><img alt="Icon" src="https://example.com/icon.svg">Caption</${heading}>\n\nOutro`;
expect(splitHtmlishMarkdown(source)).toEqual([
{ kind: "markdown", text: "Intro\n\n" },
{
kind: "inlineImage",
alt: "Icon",
src: "https://example.com/icon.svg",
flowsWithText: true,
},
{ kind: "markdown", text: "Caption\n\nOutro" },
]);
},
);
it("preserves formatted text before and after images in a details heading", () => {
const source =
'<details><summary>More</summary><h2><strong>Before</strong><img alt="Icon" src="https://example.com/icon.svg"><em>After</em></h2></details>';
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "details",
summary: "More",
body: "**Before***After*",
bodyParts: [
{ kind: "markdown", text: "**Before**" },
{ kind: "inlineImage", alt: "Icon", src: "https://example.com/icon.svg" },
{ kind: "markdown", text: "*After*" },
],
},
]);
});
it("keeps mixed HTML headings literal in code examples", () => {
const heading = '<h2><img src="https://example.com/icon.svg">Caption</h2>';
const source = `Use \`${heading}\`\n\n\`\`\`html\n${heading}\n\`\`\``;
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("preserves unknown wrappers around text-only headings", () => {
expect(splitHtmlishMarkdown("<section><h2>Heading</h2></section>")).toEqual([
{ kind: "markdown", text: "<section>Heading</section>" },
]);
});
const inlineImageBody = [
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
"",
@@ -125,20 +125,9 @@ function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
continue;
}
if (isHeadingTag(token)) {
const closeIndex = findMixedHeadingClose(tokens, cursor);
if (closeIndex !== null) {
// Headings render as their contents. Split those contents together so an
// embedded image cannot strand the opening heading tag in a text slice.
for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) {
appendDisplayPart(parts, part);
}
cursor = closeIndex + 1;
continue;
}
}
const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length;
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
cursor = end;
}
@@ -270,44 +259,15 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
return false;
}
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
for (let index = start; index < tokens.length; index += 1) {
if (
isOpenTag(tokens[index], "details") ||
findMixedHeadingClose(tokens, index) !== null ||
parseInlineImageAt(tokens, index)
) {
if (parseInlineImageAt(tokens, index)) {
return index;
}
}
return null;
}
function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null {
const token = tokens[start];
if (!isHeadingTag(token)) {
return null;
}
const closeIndex = findMatchingClose(tokens, start, token.name);
if (closeIndex === null) {
return null;
}
for (let index = start + 1; index < closeIndex; index += 1) {
if (parseInlineImageAt(tokens, index)) {
return closeIndex;
}
}
return null;
}
function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void {
if (part.kind === "markdown") {
appendMarkdownPart(parts, part.text);
} else {
parts.push(part);
}
}
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
if (!text) {
return;
@@ -30,7 +30,7 @@ import {
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import { density, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -93,7 +93,7 @@ import {
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import {
@@ -2114,6 +2114,7 @@ function ProjectModeList({
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
}) {
const { t } = useTranslation();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
@@ -2413,7 +2414,12 @@ function ProjectModeList({
<>
{pinnedChats.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
<SidebarSectionHeader
title={t("sidebar.pinned.title")}
collapsed={pinnedCollapsed}
onToggle={togglePinnedCollapsed}
testID="sidebar-pinned-section-header"
/>
{pinnedCollapsed ? null : (
<>
<DraggableList
@@ -2550,12 +2556,12 @@ const styles = StyleSheet.create((theme) => ({
},
newWorkspaceGhostText: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
newWorkspaceGhostTextHovered: {
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
@@ -2603,7 +2609,7 @@ const styles = StyleSheet.create((theme) => ({
},
projectTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -1,62 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Pressable, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DensityIcon } from "@/components/ui/density-icon";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { density, densityFontSize } from "@/styles/density";
export function PinnedSectionHeader({
collapsed,
onToggle,
}: {
collapsed: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onToggle}
style={styles.header}
testID="sidebar-pinned-section-header"
>
{({ hovered }) => (
<>
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
{hovered || isNative || isCompact ? (
<DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
) : null}
</>
)}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: density.rowHeight,
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
userSelect: "none",
},
title: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "sm"),
fontWeight: theme.fontWeight.normal,
},
}));
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
import { density, densityIconBox, densityIconProps } from "@/styles/density";
import { densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
@@ -24,6 +24,9 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
// Every surfaced status shares one badge shell, so the badge never changes size or position
// between states. Only the thing inside it changes.
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
// workspaceBranchText) so the icon centers on the title rather than floating above it.
const LEADING_SLOT_HEIGHT = 20;
const STATUS_BADGE_SIZE = 12;
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
@@ -273,10 +276,10 @@ const styles = StyleSheet.create((theme) => {
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot
// only as tall as the icon puts it above the title and the kebab — which is why the
// workspace status indicator shares the same `rowLineHeight`.
// workspace status indicator is as tall as `LEADING_SLOT_HEIGHT`.
projectLeadingVisualSlot: {
width: densityIconBox("md"),
height: density.rowLineHeight,
height: LEADING_SLOT_HEIGHT,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -6,7 +6,7 @@ import { StyleSheet } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
import { DensityIcon } from "@/components/ui/density-icon";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import { density, densityIconBox } from "@/styles/density";
/**
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so
@@ -100,13 +100,13 @@ const styles = StyleSheet.create((theme) => ({
},
text: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
textHovered: {
color: theme.colors.foreground,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
@@ -4,7 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { DensityIcon } from "@/components/ui/density-icon";
import { Shortcut } from "@/components/ui/shortcut";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import { density, densityIconBox } from "@/styles/density";
import type { ShortcutKey } from "@/utils/format-shortcut";
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
@@ -152,7 +152,7 @@ const styles = StyleSheet.create((theme) => ({
iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
label: {
flexShrink: 1,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
@@ -0,0 +1,115 @@
import { useMemo, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DensityIcon } from "@/components/ui/density-icon";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { density } from "@/styles/density";
/** A sidebar section label such as "Pinned" or "Workspaces". A section with `onToggle`
* collapses from its title; children sit at the trailing edge as its actions. */
export function SidebarSectionHeader({
title,
collapsed,
onToggle,
testID,
children,
}: {
title: string;
collapsed?: boolean;
onToggle?: () => void;
testID?: string;
children?: ReactNode;
}) {
return (
<View style={styles.header}>
{onToggle ? (
<CollapsibleTitle
title={title}
collapsed={collapsed ?? false}
onToggle={onToggle}
testID={testID}
/>
) : (
<Text style={styles.title} testID={testID}>
{title}
</Text>
)}
{children ? <View style={styles.actions}>{children}</View> : null}
</View>
);
}
function CollapsibleTitle({
title,
collapsed,
onToggle,
testID,
}: {
title: string;
collapsed: boolean;
onToggle: () => void;
testID?: string;
}) {
const isCompact = useIsCompactFormFactor();
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onToggle}
style={styles.toggle}
testID={testID}
>
{({ hovered }) => (
<>
<Text style={styles.title}>{title}</Text>
{hovered || isNative || isCompact ? (
<DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
) : null}
</>
)}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: density.rowHeight,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
// The title lands at spacing[2] to align with the rows' leading icons. Trailing actions
// are icon buttons whose painted glyph stops inside the SVG box, so 4px aligns the ink
// rather than the box to the row rail.
paddingLeft: theme.spacing[2],
paddingRight: 4,
userSelect: "none",
},
// The toggle reaches back over the header's leading padding so the whole label edge is pressable.
toggle: {
alignSelf: "stretch",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginLeft: -theme.spacing[2],
paddingHorizontal: theme.spacing[2],
},
title: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
actions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
}));
@@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet } from "react-native-unistyles";
import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
import { density, densityIconBox, densityIconProps } from "@/styles/density";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { withUnistyles } from "react-native-unistyles";
import {
@@ -73,7 +73,7 @@ import {
SidebarWorkspaceContextMenu,
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
@@ -146,6 +146,7 @@ export function SidebarStatusWorkspaceList({
parentGestureRef,
dragGestureHostActive,
}: StatusWorkspaceListProps) {
const { t } = useTranslation();
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedWorkspaceGroupKeys,
);
@@ -202,7 +203,12 @@ export function SidebarStatusWorkspaceList({
<>
{pinnedWorkspaces.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
<SidebarSectionHeader
title={t("sidebar.pinned.title")}
collapsed={pinnedCollapsed}
onToggle={togglePinnedCollapsed}
testID="sidebar-pinned-section-header"
/>
{pinnedCollapsed ? null : (
<>
<DraggableList
@@ -1099,7 +1105,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -16,7 +16,7 @@ import {
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { useAppSettings } from "@/hooks/use-settings";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import { densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
@@ -453,6 +453,9 @@ export function SidebarWorkspaceTrailingActionOverlay({
);
}
// The status dot centres on the title's first line, so both share its line height.
const TITLE_LINE_HEIGHT = 20;
const styles = StyleSheet.create((theme) => ({
workspaceRowContent: {
position: "relative",
@@ -481,7 +484,7 @@ const styles = StyleSheet.create((theme) => ({
workspaceStatusDot: {
position: "relative",
width: densityIconBox("md"),
height: density.rowLineHeight,
height: TITLE_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
@@ -513,9 +516,9 @@ const styles = StyleSheet.create((theme) => ({
// to the meta row, so it takes the full width the trailing slot leaves behind.
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
lineHeight: density.rowLineHeight,
lineHeight: TITLE_LINE_HEIGHT,
opacity: 0.76,
flex: 1,
minWidth: 0,
@@ -154,3 +154,4 @@ export function useIsolatedBottomSheetVisibility({
}
export { SheetVisibleFrame } from "./visible-frame";
export { systemBackPress } from "./back-press";
+5 -15
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useHosts } from "@/runtime/host-runtime";
import { useDownloadStore } from "@/stores/download-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
@@ -12,9 +12,8 @@ interface UseFileDownloadParams {
/**
* Returns a stable callback that downloads a single workspace file by its
* workspace-relative path. Shared by the file explorer tree and the git diff
* pane so both surfaces download through the same download-store pipeline instead
* of duplicating the plumbing: the HTTP token download over a direct TCP connection,
* the session socket over anything else.
* pane so both surfaces download through the same host token + download-store
* pipeline instead of duplicating the plumbing.
*/
export function useFileDownload({
serverId,
@@ -31,7 +30,7 @@ export function useFileDownload({
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const { requestFileDownloadToken, requestFileBytes } = useFileExplorerActions({
const { requestFileDownloadToken } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
@@ -49,18 +48,9 @@ export function useFileDownload({
fileName,
path,
daemonProfile,
activeConnectionId: getHostRuntimeStore().getSnapshot(serverId)?.activeConnectionId ?? null,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
readFile: requestFileBytes,
});
},
[
daemonProfile,
requestFileBytes,
requestFileDownloadToken,
serverId,
startDownload,
workspaceScopeId,
],
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
}
@@ -253,21 +253,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
[client, normalizedWorkspaceRoot, t],
);
const requestFileBytes = useCallback(
async (path: string, maxBytes: number) => {
if (!normalizedWorkspaceRoot) {
throw new Error(t("workspace.fileExplorer.states.unavailable"));
}
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
// No request timeout: a large file over a slow relay legitimately takes longer than the
// 60 s default, and a dead connection still rejects on disconnect.
return client.readFile(normalizedWorkspaceRoot, path, undefined, maxBytes, 0);
},
[client, normalizedWorkspaceRoot, t],
);
const createEntry = useCallback(
async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => {
if (!client || !normalizedWorkspaceRoot) {
@@ -357,7 +342,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
requestFileBytes,
createEntry,
renameEntry,
duplicateEntry,
@@ -15,7 +15,6 @@ import {
parseClampedFontSize,
parseTerminalScrollbackLines,
resolveContentMaxWidth,
sanitizeFontFamily,
saveAppSettings,
type SettingsDeps,
} from "./storage";
@@ -1095,34 +1094,3 @@ describe("content max width", () => {
expect((await load("wide")).contentMaxWidth).toBeNull();
});
});
describe("font family CSS safety", () => {
it.each(["'foo", '"foo', "Menlo, 'foo", "foo\\", "foo/*", "foo(", "foo["])(
"rejects unfinished CSS syntax: %s",
(value) => expect(sanitizeFontFamily(value)).toBeNull(),
);
it.each([
"Menlo",
"'Fira Code', monospace",
'"Fira Code", monospace',
"'Font\\'s name'",
"Font\\ Name",
"'Font (Mono)'",
"'Font [Mono]'",
])("preserves complete font families: %s", (value) =>
expect(sanitizeFontFamily(value)).toBe(value),
);
it.each(["uiFontFamily", "monoFontFamily"])(
"recovers a persisted unmatched quote in %s",
async (field) => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ [field]: "'foo" }),
}),
});
expect(await loadAppSettingsFromStorage(deps)).toMatchObject({ [field]: "" });
},
);
});
+1 -18
View File
@@ -522,24 +522,7 @@ export function sanitizeFontFamily(value: unknown): string | null {
if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) {
return null; // control chars would corrupt the font-family string
}
// Unistyles serializes theme values into one shared stylesheet. An unfinished
// string, escape, comment, or block consumes the declarations that follow.
let quote: string | null = null;
let escaped = false;
for (const char of trimmed) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (quote !== null) {
if (char === quote) quote = null;
} else if (char === "'" || char === '"') {
quote = char;
} else if (/[()[\]/*]/.test(char)) {
return null;
}
}
return quote === null && !escaped ? trimmed : null;
return trimmed; // quotes/commas are legit in stacks
}
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
@@ -241,7 +241,7 @@ interface FontFamilyRowProps {
draft: string;
withBorder: boolean;
onChangeDraft: (value: string) => void;
onCommit: (value: string) => string;
onCommit: (value: string) => void;
}
function FontFamilyRow({
@@ -255,16 +255,13 @@ function FontFamilyRow({
onChangeDraft,
onCommit,
}: FontFamilyRowProps) {
const inputRef = useRef<EditingTextInputHandle>(null);
const handleCommit = useCallback(() => {
const committed = onCommit(draft);
inputRef.current?.replaceText(committed);
onCommit(draft);
}, [draft, onCommit]);
// Resync from the committed value when it changes elsewhere.
useEffect(() => {
onChangeDraft(value);
inputRef.current?.replaceText(value);
// Only resync on external value changes, not on local keystrokes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
@@ -276,7 +273,6 @@ function FontFamilyRow({
<Text style={settingsStyles.rowHint}>{hint}</Text>
</View>
<TextInput
ref={inputRef}
initialValue={draft}
onChangeText={onChangeDraft}
onBlur={handleCommit}
@@ -537,13 +533,12 @@ export function AppearanceSection() {
const sanitized = sanitizeFontFamily(value);
if (sanitized === null) {
setUiFontDraft(settings.uiFontFamily);
return settings.uiFontFamily;
return;
}
setUiFontDraft(sanitized);
if (sanitized !== settings.uiFontFamily) {
void updateSettings({ uiFontFamily: sanitized });
}
return sanitized;
},
[settings.uiFontFamily, updateSettings],
);
@@ -553,13 +548,12 @@ export function AppearanceSection() {
const sanitized = sanitizeFontFamily(value);
if (sanitized === null) {
setMonoFontDraft(settings.monoFontFamily);
return settings.monoFontFamily;
return;
}
setMonoFontDraft(sanitized);
if (sanitized !== settings.monoFontFamily) {
void updateSettings({ monoFontFamily: sanitized });
}
return sanitized;
},
[settings.monoFontFamily, updateSettings],
);
@@ -80,6 +80,7 @@ vi.mock("@gorhom/bottom-sheet", async () => {
await vi.importActual<typeof import("react-native")>("react-native");
return {
BottomSheetBackdrop: () => null,
BottomSheetHandle: () => null,
BottomSheetModal: ReactModule.forwardRef(() => null),
BottomSheetScrollView: ScrollView,
BottomSheetTextInput: TextInput,
@@ -75,6 +75,7 @@ import {
selectExplorerSidebarPaneId,
selectPaneFocusSuspended,
type WorkspaceLayout,
type WorkspaceSheet,
type WorkspaceTabPlacement,
useWorkspaceLayoutStore,
useWorkspaceLayoutStoreHydrated,
@@ -204,6 +205,7 @@ import { RenderProfile } from "@/utils/render-profiler";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request";
const NO_SHEETS: readonly WorkspaceSheet[] = [];
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
@@ -1309,8 +1311,12 @@ function WorkspaceScreenContent({
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const sheets = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS,
);
// Only the top sheet is on screen.
const sheetTab = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.sheetByWorkspace[persistenceKey]?.tab ?? null) : null,
persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null,
);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
@@ -2069,7 +2075,7 @@ function WorkspaceScreenContent({
const handleCloseTabById = useCallback(
async (tabId: string) => {
if (persistenceKey && sheetTab?.tabId === tabId) {
if (persistenceKey && sheets.some((sheet) => sheet.tab.tabId === tabId)) {
if (await confirmDiscardModifiedTab(tabId)) {
closeWorkspaceTab(persistenceKey, tabId);
}
@@ -2100,7 +2106,7 @@ function WorkspaceScreenContent({
handleClosePassiveTab,
handleCloseTerminalTab,
persistenceKey,
sheetTab,
sheets,
],
);
@@ -3524,8 +3530,8 @@ function WorkspaceScreenContent({
const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null;
const workspacePanelTabIds = useMemo(
() => [...uiTabs.map((tab) => tab.tabId), ...(sheetTab ? [sheetTab.tabId] : [])],
[uiTabs, sheetTab],
() => [...uiTabs.map((tab) => tab.tabId), ...sheets.map((sheet) => sheet.tab.tabId)],
[uiTabs, sheets],
);
const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`;
@@ -1,16 +1,31 @@
import BottomSheet, {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
type BottomSheetHandleProps,
} from "@gorhom/bottom-sheet";
import { useIsFocused } from "@react-navigation/native";
import { type ComponentType, useCallback, useEffect, useMemo, useRef } from "react";
import { BackHandler, View } from "react-native";
import {
type ComponentType,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { SheetHeaderView } from "@/components/adaptive-modal-sheet";
import {
SheetBackground,
SheetHandle,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { systemBackPress } from "@/components/ui/isolated-bottom-sheet-modal";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { Button } from "@/components/ui/button";
import { isNative } from "@/constants/platform";
import type { PanelIconProps } from "@/panels/panel-registry";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
@@ -22,17 +37,7 @@ import {
import { useWorkspaceLayoutStore, type WorkspaceSheet } from "@/stores/workspace-layout-store";
const SNAP_POINTS = ["92%"];
const ThemedBottomSheet = withUnistyles(BottomSheet, (theme) => ({
backgroundStyle: {
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
handleIndicatorStyle: {
backgroundColor: theme.colors.palette.zinc[600],
},
}));
const NO_SHEETS: readonly WorkspaceSheet[] = [];
function PanelIcon({
icon: Icon,
@@ -46,6 +51,8 @@ const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
color: theme.colors.foregroundMuted,
}));
// The workspace sheet is a Gorhom sheet rendered in place rather than a portaled modal, so the
// backdrop closes the sheet itself instead of dismissing the top of the modal stack.
function renderBackdrop(props: BottomSheetBackdropProps) {
return (
<BottomSheetBackdrop
@@ -58,6 +65,45 @@ function renderBackdrop(props: BottomSheetBackdropProps) {
);
}
// Stacked sheets share the bottom sheet's dim rather than darkening it again, but still catch the
// tap so it closes the top sheet. The backdrop is clear rather than transparent: iOS skips views
// under 0.01 opacity when hit testing, which would hand the tap to the sheet below.
function renderStackedBackdrop(props: BottomSheetBackdropProps) {
return (
<BottomSheetBackdrop
{...props}
style={styles.clearBackdrop}
appearsOnIndex={0}
disappearsOnIndex={-1}
opacity={1}
pressBehavior="close"
/>
);
}
interface WorkspaceSheetHandleValue {
header: SheetHeader;
onClose: () => void;
}
const WorkspaceSheetHandleContext = createContext<WorkspaceSheetHandleValue | null>(null);
// Gorhom renders `handleComponent` as a component type, so it stays one module-level component and
// reads the header from context; a per-render function would remount the header on every change.
function WorkspaceSheetHandle(props: BottomSheetHandleProps) {
const value = useContext(WorkspaceSheetHandleContext);
if (!value) throw new Error("WorkspaceSheetHandle must render inside a workspace sheet");
return (
<SheetHandle {...props}>
<SheetHeaderView
header={value.header}
onClose={value.onClose}
testID="workspace-sheet-header"
/>
</SheetHandle>
);
}
export interface WorkspaceSheetHostProps {
workspaceKey: string | null;
serverId: string;
@@ -73,96 +119,54 @@ interface WorkspaceSheetPresentationProps extends Omit<
"workspaceKey" | "onCloseTab"
> {
sheet: WorkspaceSheet;
onClose: () => Promise<void>;
onOpenAsTab: () => void;
/** Only the bottom sheet dims the workspace; the ones above share its dim. */
isBottom: boolean;
/** Only the top sheet answers Android Back; the ones below wait their turn. */
isTop: boolean;
onClose: (tabId: string) => Promise<void>;
onOpenAsTab: (tabId: string) => void;
}
/**
* Hosts the workspace's sheet tab over the compact tab strip. It renders in the workspace tree,
* not through a portal, so the panel keeps every context an ordinary pane has.
* Hosts the workspace's sheets over the compact tab strip, one Gorhom sheet per entry, top last.
* They render in the workspace tree, not through a portal, so each panel keeps every context an
* ordinary pane has. The shared sheet parts (`SheetBackground`, `SheetHandle`, `SheetHeaderView`)
* keep them looking and dragging like every other sheet.
*/
export function WorkspaceSheetHost({ workspaceKey, onCloseTab, ...rest }: WorkspaceSheetHostProps) {
const sheet = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.sheetByWorkspace[workspaceKey] ?? null) : null,
const sheets = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
);
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
const sheetTabId = sheet?.tab.tabId ?? null;
const handleClose = useCallback(async () => {
if (sheetTabId) await onCloseTab(sheetTabId);
}, [onCloseTab, sheetTabId]);
const handleOpenAsTab = useCallback(() => {
if (workspaceKey) openSheetAsTab(workspaceKey);
}, [openSheetAsTab, workspaceKey]);
if (!sheet) return null;
const handleOpenAsTab = useCallback(
(tabId: string) => {
if (workspaceKey) openSheetAsTab(workspaceKey, tabId);
},
[openSheetAsTab, workspaceKey],
);
if (sheets.length === 0) return null;
return (
<WorkspaceSheetPresentation
{...rest}
sheet={sheet}
onClose={handleClose}
onOpenAsTab={handleOpenAsTab}
/>
<View style={styles.host} pointerEvents="box-none">
{sheets.map((sheet, index) => (
<WorkspaceSheetPresentation
// Keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
{...rest}
sheet={sheet}
isBottom={index === 0}
isTop={index === sheets.length - 1}
onClose={onCloseTab}
onOpenAsTab={handleOpenAsTab}
/>
))}
</View>
);
}
function WorkspaceSheetPresentation({
sheet,
serverId,
workspaceId,
isWorkspaceFocused,
buildContent,
onClose,
onOpenAsTab,
}: WorkspaceSheetPresentationProps) {
const sheetRef = useRef<BottomSheet>(null);
const isScreenFocused = useIsFocused();
const ownsBackPress = isNative && isWorkspaceFocused && isScreenFocused;
const handleChange = useCallback(
(index: number) => {
if (index !== -1) return;
// A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up.
void onClose().then(() => sheetRef.current?.snapToIndex(0));
},
[onClose],
);
useEffect(() => {
// Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back.
if (!ownsBackPress) return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
void onClose();
return true;
});
return () => subscription.remove();
}, [onClose, ownsBackPress]);
return (
<View style={styles.host} pointerEvents="box-none">
<ThemedBottomSheet
ref={sheetRef}
index={0}
snapPoints={SNAP_POINTS}
enableDynamicSizing={false}
enablePanDownToClose
enableContentPanningGesture={false}
backdropComponent={renderBackdrop}
onChange={handleChange}
>
<WorkspaceSheetBody
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
sheet={sheet}
serverId={serverId}
workspaceId={workspaceId}
isWorkspaceFocused={isWorkspaceFocused}
buildContent={buildContent}
onClose={onClose}
onOpenAsTab={onOpenAsTab}
/>
</ThemedBottomSheet>
</View>
);
}
function WorkspaceSheetBody({
sheet,
isBottom,
isTop,
serverId,
workspaceId,
isWorkspaceFocused,
@@ -172,17 +176,110 @@ function WorkspaceSheetBody({
}: WorkspaceSheetPresentationProps) {
const { t } = useTranslation();
const { tab } = sheet;
const sheetRef = useRef<BottomSheet>(null);
const isScreenFocused = useIsFocused();
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.target.kind);
if (!registration) {
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
}
// The body is keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
const descriptor = registration.useDescriptor(tab.target as never, {
serverId,
workspaceId,
tabId: tab.tabId,
});
const handleClose = useCallback(() => onClose(tab.tabId), [onClose, tab.tabId]);
const handleChange = useCallback(
(index: number) => {
if (index !== -1) return;
// A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up.
void handleClose().then(() => sheetRef.current?.snapToIndex(0));
},
[handleClose],
);
// Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back.
const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused;
useEffect(() => {
if (!ownsBackPress) return;
return systemBackPress.subscribe(() => {
void handleClose();
return true;
});
}, [handleClose, ownsBackPress]);
const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]);
const header = useMemo<SheetHeader>(
() => ({
title: descriptor.label,
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
actions: sheet.policy.openAsTab ? (
<Button size="sm" variant="secondary" onPress={handleOpenAsTab}>
{t("workspace.sheet.openAsTab")}
</Button>
) : undefined,
}),
[descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t],
);
const handleHeaderClose = useCallback(() => {
void handleClose();
}, [handleClose]);
const handleValue = useMemo<WorkspaceSheetHandleValue>(
() => ({ header, onClose: handleHeaderClose }),
[handleHeaderClose, header],
);
return (
// Gorhom renders the handle beside the content, so the dialog wraps the whole sheet.
<View
style={styles.sheetLayer}
pointerEvents="box-none"
role="dialog"
aria-label={descriptor.label}
>
{/* The header is the sheet's handle: the panel scrolls on its own, so dragging the content
would fight it, and the header is where people reach to pull the sheet down. */}
<WorkspaceSheetHandleContext.Provider value={handleValue}>
<BottomSheet
ref={sheetRef}
index={0}
snapPoints={SNAP_POINTS}
enableDynamicSizing={false}
enablePanDownToClose
enableContentPanningGesture={false}
backgroundComponent={SheetBackground}
handleComponent={WorkspaceSheetHandle}
backdropComponent={isBottom ? renderBackdrop : renderStackedBackdrop}
onChange={handleChange}
accessible={false}
>
{/* A covered sheet keeps its state but stops its live work until it is on top again. */}
<RetainedPanelActivity active={isTop}>
<WorkspaceSheetBody
tab={tab}
isPaneFocused={isTop && isWorkspaceFocused}
isWorkspaceFocused={isWorkspaceFocused}
buildContent={buildContent}
/>
</RetainedPanelActivity>
</BottomSheet>
</WorkspaceSheetHandleContext.Provider>
</View>
);
}
function WorkspaceSheetBody({
tab,
isPaneFocused,
isWorkspaceFocused,
buildContent,
}: {
tab: WorkspaceSheet["tab"];
/** Only the top sheet's pane is focused; the ones below are covered. */
isPaneFocused: boolean;
isWorkspaceFocused: boolean;
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
}) {
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
() => ({
key: tab.tabId,
@@ -194,38 +291,20 @@ function WorkspaceSheetBody({
[tab],
);
const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]);
const handleClose = useCallback(() => {
void onClose();
}, [onClose]);
// The sheet reaches the bottom of the screen, so it owns the system navigation inset.
const { bottom: bottomInset } = useSafeAreaInsets();
const contentStyle = useMemo(
() => [styles.content, { paddingBottom: bottomInset }],
[bottomInset],
);
const header = useMemo(
() => ({
title: descriptor.label,
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
actions: sheet.policy.openAsTab ? (
<Button size="sm" variant="secondary" onPress={onOpenAsTab}>
{t("workspace.sheet.openAsTab")}
</Button>
) : undefined,
}),
[descriptor.icon, descriptor.label, onOpenAsTab, sheet.policy.openAsTab, t],
);
return (
<View style={styles.body} role="dialog" aria-label={descriptor.label}>
<SheetHeaderView header={header} onClose={handleClose} />
<View style={contentStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isWorkspaceFocused}
/>
</View>
<View style={contentStyle} testID="workspace-sheet-body">
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
);
}
@@ -234,9 +313,11 @@ const styles = StyleSheet.create({
host: {
...StyleSheet.absoluteFillObject,
},
body: {
flex: 1,
minHeight: 0,
clearBackdrop: {
backgroundColor: "transparent",
},
sheetLayer: {
...StyleSheet.absoluteFillObject,
},
content: {
flex: 1,
@@ -1,265 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import { defaultHostAppearance } from "@/hosts/appearance";
import { i18n } from "@/i18n/i18next";
import {
MAX_SESSION_DOWNLOAD_BYTES,
useDownloadStore,
type Download,
type DownloadedBytes,
} from "@/stores/download-store";
import type {
DirectTcpHostConnection,
HostProfile,
RelayHostConnection,
} from "@/types/host-connection";
const FILE_BYTES = new TextEncoder().encode("relay download payload");
const FILE_PATH = "reports/données été.bin";
const FILE_NAME = "données été.bin";
const MIME_TYPE = "application/octet-stream";
const relayConnection: RelayHostConnection = {
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "cHVibGljLWtleQ==",
};
// The #3753 shape: the relay hostname stored as a directTcp entry that nothing can reach.
const staleTcpConnection: DirectTcpHostConnection = {
id: "direct:relay.paseo.sh:443",
type: "directTcp",
endpoint: "relay.paseo.sh:443",
};
const localTcpConnection: DirectTcpHostConnection = {
id: "direct:localhost:6767",
type: "directTcp",
endpoint: "localhost:6767",
};
function profileWith(connections: HostProfile["connections"]): HostProfile {
return {
serverId: "srv_remote",
label: "Remote",
appearance: defaultHostAppearance(),
lifecycle: {},
connections,
preferredConnectionId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
function createTokenRequest(size: number | null = FILE_BYTES.byteLength) {
const requested: string[] = [];
return {
requested,
request: async (path: string) => {
requested.push(path);
return { token: "tok", fileName: FILE_NAME, mimeType: MIME_TYPE, size, error: null };
},
};
}
function createFileReader(read: () => Promise<{ bytes: Uint8Array; size: number }>) {
const requested: string[] = [];
return {
requested,
read: async (path: string) => {
requested.push(path);
return read();
},
};
}
function createFileSaver() {
const savedFiles: DownloadedBytes[] = [];
return {
savedFiles,
save: async (file: DownloadedBytes) => {
savedFiles.push(file);
return null;
},
};
}
interface StartInput {
profile: HostProfile;
activeConnectionId: string | null;
token?: ReturnType<typeof createTokenRequest>;
reader?: ReturnType<typeof createFileReader>;
saver?: ReturnType<typeof createFileSaver>;
}
async function startDownload(input: StartInput): Promise<Download | undefined> {
const token = input.token ?? createTokenRequest();
const reader =
input.reader ??
createFileReader(async () => ({ bytes: FILE_BYTES, size: FILE_BYTES.byteLength }));
const saver = input.saver ?? createFileSaver();
await useDownloadStore.getState().startDownload({
serverId: "srv_remote",
scopeId: "workspace-1",
fileName: FILE_NAME,
path: FILE_PATH,
daemonProfile: input.profile,
activeConnectionId: input.activeConnectionId,
requestFileDownloadToken: token.request,
readFile: reader.read,
saveFile: saver.save,
});
const { activeDownloadId, downloads } = useDownloadStore.getState();
return activeDownloadId ? downloads.get(activeDownloadId) : undefined;
}
describe("download store transport selection", () => {
beforeEach(() => {
useDownloadStore.setState({ downloads: new Map(), activeDownloadId: null });
});
it("downloads over the session when the active connection is the relay", async () => {
const token = createTokenRequest();
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
size: FILE_BYTES.byteLength,
}));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
token,
reader,
saver,
});
expect(download).toMatchObject({ fileName: FILE_NAME, status: "complete" });
expect(token.requested).toEqual([FILE_PATH]);
expect(reader.requested).toEqual([FILE_PATH]);
expect(saver.savedFiles).toEqual([
{ bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE },
]);
});
it("bounds the session read even when the token reports a smaller file", async () => {
const readRequests: Array<{ path: string; maxBytes: number }> = [];
const saver = createFileSaver();
await useDownloadStore.getState().startDownload({
serverId: "srv_remote",
scopeId: "workspace-1",
fileName: FILE_NAME,
path: FILE_PATH,
daemonProfile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
requestFileDownloadToken: createTokenRequest(1).request,
readFile: async (path: string, maxBytes: number) => {
readRequests.push({ path, maxBytes });
return { bytes: FILE_BYTES, size: FILE_BYTES.byteLength };
},
saveFile: saver.save,
});
expect(readRequests).toEqual([{ path: FILE_PATH, maxBytes: MAX_SESSION_DOWNLOAD_BYTES }]);
expect(saver.savedFiles).toEqual([
{ bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE },
]);
});
it("ignores a directTcp entry that is not the active connection", async () => {
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
size: FILE_BYTES.byteLength,
}));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([staleTcpConnection, relayConnection]),
activeConnectionId: relayConnection.id,
reader,
saver,
});
expect(download).toMatchObject({ status: "complete" });
expect(reader.requested).toEqual([FILE_PATH]);
expect(saver.savedFiles).toHaveLength(1);
});
it("keeps the HTTP token download when the active connection is directTcp", async () => {
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
size: FILE_BYTES.byteLength,
}));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([localTcpConnection, relayConnection]),
activeConnectionId: localTcpConnection.id,
reader,
saver,
});
expect(download).toMatchObject({ status: "complete" });
expect(reader.requested).toEqual([]);
expect(saver.savedFiles).toEqual([]);
});
it("fails the download and saves nothing when the session read fails", async () => {
const reader = createFileReader(async () => {
throw new Error("Connection lost");
});
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
reader,
saver,
});
expect(download).toMatchObject({ status: "error", message: "Connection lost" });
expect(saver.savedFiles).toEqual([]);
});
it("refuses a file over the session size limit before reading it", async () => {
const token = createTokenRequest(MAX_SESSION_DOWNLOAD_BYTES + 1);
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
size: FILE_BYTES.byteLength,
}));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
token,
reader,
saver,
});
expect(download).toMatchObject({
status: "error",
message: i18n.t("composer.errors.fileTooLarge", { fileName: FILE_NAME, size: "64 MB" }),
});
expect(download?.message).toContain("64 MB");
expect(reader.requested).toEqual([]);
expect(saver.savedFiles).toEqual([]);
});
it("fails instead of saving an empty file when the daemon returns no content", async () => {
const reader = createFileReader(async () => ({ bytes: new Uint8Array(), size: 6 }));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
reader,
saver,
});
expect(download).toMatchObject({
status: "error",
message: "File transfer incomplete: expected 6 bytes, received 0.",
});
expect(saver.savedFiles).toEqual([]);
});
});
+14 -105
View File
@@ -16,16 +16,6 @@ interface DownloadProgress {
eta: number;
}
// The session path holds the file in memory two to three times over (client chunks, the
// concatenated result, then the Blob or native write). Lift once the client streams chunks to disk.
export const MAX_SESSION_DOWNLOAD_BYTES = 64 * 1024 * 1024;
export interface DownloadedBytes {
bytes: Uint8Array;
fileName: string;
mimeType: string | null;
}
export interface Download {
id: string;
serverId: string;
@@ -47,18 +37,12 @@ interface DownloadState {
fileName: string;
path: string;
daemonProfile: HostProfile | undefined;
/** Connection the host runtime is using right now; `null` while disconnected. */
activeConnectionId: string | null;
requestFileDownloadToken: (path: string) => Promise<{
token: string | null;
fileName: string | null;
mimeType: string | null;
size: number | null;
error: string | null;
}>;
/** Reads the whole file over the session socket; used when no direct HTTP endpoint is active. */
readFile: (path: string, maxBytes: number) => Promise<{ bytes: Uint8Array; size: number }>;
saveFile?: typeof saveDownloadedBytes;
}) => Promise<void>;
updateProgress: (id: string, progress: DownloadProgress) => void;
@@ -82,10 +66,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
fileName,
path,
daemonProfile,
activeConnectionId,
requestFileDownloadToken,
readFile,
saveFile = saveDownloadedBytes,
}) => {
const id = generateDownloadId();
const download: Download = {
@@ -108,42 +89,12 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
throw new Error(tokenResponse.error ?? i18n.t("downloads.requestTokenFailed"));
}
const resolvedFileName = tokenResponse.fileName ?? fileName;
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile, activeConnectionId);
if (!downloadTarget) {
// Relay, SSH, socket and pipe hosts have no HTTP endpoint: read the file over the
// session socket that is already connected (#543).
if (
typeof tokenResponse.size === "number" &&
tokenResponse.size > MAX_SESSION_DOWNLOAD_BYTES
) {
throw new Error(
i18n.t("composer.errors.fileTooLarge", { fileName: resolvedFileName, size: "64 MB" }),
);
}
const { bytes, size } = await readFile(path, MAX_SESSION_DOWNLOAD_BYTES);
// A daemon without a binary channel answers `encoding: "none"` for a binary file: no
// content, real size. Never save that as a complete download.
if (bytes.byteLength !== size) {
throw new Error(
`File transfer incomplete: expected ${size} bytes, received ${bytes.byteLength}.`,
);
}
const savedUri = await saveFile({
bytes,
fileName: resolvedFileName,
mimeType: tokenResponse.mimeType,
});
get().completeDownload(id);
if (savedUri !== null) {
await shareSavedFile(savedUri, tokenResponse.mimeType, resolvedFileName);
}
return;
}
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile);
if (!downloadTarget.baseUrl) {
throw new Error(i18n.t("downloads.hostUnavailable"));
}
const resolvedFileName = tokenResponse.fileName ?? fileName;
const downloadUrl = buildDownloadUrl(
downloadTarget.baseUrl,
tokenResponse.token,
@@ -195,7 +146,14 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
get().completeDownload(id);
await shareSavedFile(result.uri, tokenResponse.mimeType, resolvedFileName);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(result.uri, {
mimeType: tokenResponse.mimeType ?? undefined,
dialogTitle: resolvedFileName
? i18n.t("downloads.shareFileNamed", { fileName: resolvedFileName })
: i18n.t("downloads.shareFile"),
});
}
} catch (error) {
const message = error instanceof Error ? error.message : i18n.t("downloads.failed");
if (isWeb) {
@@ -286,18 +244,10 @@ interface DownloadTarget {
authCredentials: { username: string; password: string } | null;
}
/**
* The HTTP download route is only reachable over a direct TCP connection. Returns `null` when
* the connection in use is anything else (relay, SSH, socket, pipe, or none yet), so the caller
* reads the file over the session socket instead.
*/
function resolveDaemonDownloadTarget(
daemon: HostProfile | undefined,
activeConnectionId: string | null,
): DownloadTarget | null {
const connection = daemon?.connections.find((conn) => conn.id === activeConnectionId);
if (connection?.type !== "directTcp") {
return null;
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
const connection = daemon?.connections.find((conn) => conn.type === "directTcp") ?? null;
if (!connection) {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
let parsed: URL;
@@ -349,47 +299,6 @@ function buildDownloadUrl(
return url.toString();
}
/**
* Saves bytes read over the session. Web and Electron hand them to the browser's download
* manager and return `null`; native writes the file and returns its URI for the share sheet.
*/
async function saveDownloadedBytes({
bytes,
fileName,
mimeType,
}: DownloadedBytes): Promise<string | null> {
if (isWeb) {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
const url = URL.createObjectURL(
new Blob([buffer], { type: mimeType ?? "application/octet-stream" }),
);
triggerBrowserDownload(url, fileName);
// The browser may still be reading the blob while its save prompt is open.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
return null;
}
// Resolve and write in one tick so two downloads of the same name cannot pick the same path.
const targetFile = resolveDownloadTargetFile(fileName);
targetFile.write(bytes);
return targetFile.uri;
}
async function shareSavedFile(
uri: string,
mimeType: string | null,
fileName: string,
): Promise<void> {
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, {
mimeType: mimeType ?? undefined,
dialogTitle: fileName
? i18n.t("downloads.shareFileNamed", { fileName })
: i18n.t("downloads.shareFile"),
});
}
}
function triggerBrowserDownload(url: string, fileName: string) {
if (typeof document === "undefined") {
if (typeof window !== "undefined") {
@@ -4449,7 +4449,7 @@ describe("compact workspace sheet", () => {
function createCompactStore() {
const store = createWorkspaceLayoutStore(workspaceLayoutIds);
store.setState({ layoutByWorkspace: {}, sheetByWorkspace: {} });
store.setState({ layoutByWorkspace: {}, sheetsByWorkspace: {} });
store.getState().setLayoutMode("compact");
store.getState().openTab({
workspaceKey,
@@ -4459,6 +4459,16 @@ describe("compact workspace sheet", () => {
return store;
}
function topSheet(store: ReturnType<typeof createCompactStore>) {
return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1);
}
function sheetTargets(store: ReturnType<typeof createCompactStore>) {
return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map(
(sheet) => sheet.tab.target,
);
}
function tabKinds(store: ReturnType<typeof createCompactStore>): string[] {
const layout = store.getState().layoutByWorkspace[workspaceKey];
const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab]));
@@ -4523,7 +4533,7 @@ describe("compact workspace sheet", () => {
const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab).toEqual({
expect(topSheet(store)?.tab).toEqual({
tabId,
target: fileTarget,
createdAt: expect.any(Number),
@@ -4547,22 +4557,71 @@ describe("compact workspace sheet", () => {
const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
expect(tabId).toBe(fileTabId);
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneContainingTab(layout.root, fileTabId ?? "")?.focusedTabId).toBe(fileTabId);
});
it("replaces the sheet content with the next sheet target", () => {
it("stacks the next sheet target over the open sheet", () => {
const store = createCompactStore();
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
const nextTarget = { kind: "file" as const, path: "src/other.ts" };
const nextTabId = store
.getState()
.openTab({ workspaceKey, target: nextTarget, intent: "reveal" });
expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]);
expect(tabKinds(store)).toEqual(["terminal"]);
store.getState().closeTab(workspaceKey, nextTabId ?? "");
expect(sheetTargets(store)).toEqual([fileTarget]);
});
it("closes a lower sheet and keeps the sheets above it", () => {
const store = createCompactStore();
const fileTabId = store
.getState()
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
const nextTarget = { kind: "file" as const, path: "src/other.ts" };
store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" });
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(nextTarget);
store.getState().closeTab(workspaceKey, fileTabId ?? "");
expect(sheetTargets(store)).toEqual([nextTarget]);
expect(tabKinds(store)).toEqual(["terminal"]);
});
it("brings a stacked target back to the top by dropping the sheets above it", () => {
const store = createCompactStore();
const fileTabId = store
.getState()
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
store
.getState()
.openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" });
const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
expect(tabId).toBe(fileTabId);
expect(sheetTargets(store)).toEqual([fileTarget]);
});
it("opens the top sheet as a tab and dismisses the sheets that would cover it", () => {
const store = createCompactStore();
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
const nextTabId = store
.getState()
.openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" });
store.getState().openSheetAsTab(workspaceKey, nextTabId ?? "");
expect(sheetTargets(store)).toEqual([]);
expect(tabKinds(store)).toEqual(["terminal", "file"]);
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(nextTabId);
});
it("closes the sheet when a tab target is revealed, and keeps it for background opens", () => {
const store = createCompactStore();
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
@@ -4572,7 +4631,7 @@ describe("compact workspace sheet", () => {
target: { kind: "file", path: "src/background.ts" },
intent: "background",
});
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(fileTarget);
expect(topSheet(store)?.tab.target).toEqual(fileTarget);
expect(tabKinds(store)).toEqual(["terminal", "file"]);
store.getState().openTab({
@@ -4580,7 +4639,7 @@ describe("compact workspace sheet", () => {
target: { kind: "terminal", terminalId: "term-2" },
intent: "reveal",
});
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
});
it("moves the sheet tab into the focused pane with the same tab id and state", () => {
@@ -4591,10 +4650,10 @@ describe("compact workspace sheet", () => {
if (!sheetTabId) throw new Error("Expected a sheet tab");
store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 });
const tabId = store.getState().openSheetAsTab(workspaceKey);
const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId);
expect(tabId).toBe(sheetTabId);
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
const layout = store.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneContainingTab(layout.root, sheetTabId);
expect(pane?.id).toBe(layout.focusedPaneId);
@@ -4616,7 +4675,7 @@ describe("compact workspace sheet", () => {
expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layoutBefore);
store.getState().closeTab(workspaceKey, sheetTabId);
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
expect(tabKinds(store)).toEqual(["terminal"]);
});
@@ -4639,19 +4698,21 @@ describe("compact workspace sheet", () => {
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
expect(store.getState().sheetByWorkspace).toEqual({});
expect(store.getState().sheetsByWorkspace).toEqual({});
expect(tabKinds(store)).toEqual(["terminal", "file"]);
});
it("keeps the sheet content as a tab when the layout widens", () => {
it("keeps every stacked sheet as a tab when the layout widens, focusing the top one", () => {
const store = createCompactStore();
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
const sheetTabId = store
.getState()
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
.openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" });
store.getState().setLayoutMode("split-panes");
expect(store.getState().sheetByWorkspace).toEqual({});
expect(store.getState().sheetsByWorkspace).toEqual({});
expect(tabKinds(store)).toEqual(["terminal", "file", "file"]);
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneContainingTab(layout.root, sheetTabId ?? "")?.focusedTabId).toBe(sheetTabId);
});
@@ -4669,7 +4730,7 @@ describe("compact workspace sheet", () => {
target: { kind: "agent", agentId: "child" },
intent: "reveal",
});
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
expect(topSheet(store)).toMatchObject({
tab: { target: { kind: "agent", agentId: "child" } },
policy: { openAsTab: true },
});
@@ -4679,7 +4740,7 @@ describe("compact workspace sheet", () => {
target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" },
intent: "reveal",
});
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
expect(topSheet(store)).toMatchObject({
tab: { target: { kind: "provider_subagent", subagentId: "task-1" } },
policy: { openAsTab: false },
});
@@ -4689,7 +4750,7 @@ describe("compact workspace sheet", () => {
.getState()
.openTab({ workspaceKey, target: { kind: "agent", agentId }, intent: "reveal" });
}
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
});
@@ -4715,9 +4776,9 @@ describe("compact workspace sheet", () => {
intent: "reveal",
parentTabId: childSheetTabId ?? undefined,
});
expect(store.getState().sheetByWorkspace[workspaceKey]?.parentTabId).toBe(rootTabId);
expect(topSheet(store)?.parentTabId).toBe(rootTabId);
store.getState().openSheetAsTab(workspaceKey);
store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? "");
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId);
});
@@ -4746,7 +4807,7 @@ describe("compact workspace sheet", () => {
intent: "reveal",
parentTabId: openChildSheet(),
});
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
let layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(terminalTabId);
@@ -4756,7 +4817,7 @@ describe("compact workspace sheet", () => {
intent: "reveal",
parentTabId: openChildSheet(),
});
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(secondRootTabId);
expect(layout.parentTabIdByTabId?.[secondRootTabId ?? ""]).toBe(rootTabId);
@@ -4798,22 +4859,45 @@ describe("compact workspace sheet", () => {
});
function sheetTabId(store: ReturnType<typeof createCompactStore>): string | null {
return store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId ?? null;
return topSheet(store)?.tab.tabId ?? null;
}
it("opens the next sheet target as a tab under a modified sheet", () => {
it("stacks the next sheet target over a modified sheet", () => {
const store = createCompactStore();
const modifiedSheetTabId = openModifiedFileSheet(store);
openModifiedFileSheet(store);
const nextTarget = { kind: "file" as const, path: "src/other.ts" };
const nextTabId = store
.getState()
.openTab({ workspaceKey, target: nextTarget, intent: "reveal" });
expect(sheetTabId(store)).toBe(modifiedSheetTabId);
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.state).toEqual({ scrollTop: 40 });
expect(stripTab(store, nextTabId ?? "")).toMatchObject({ target: nextTarget });
expect(focusedTabId(store)).toBe(nextTabId);
expect(sheetTabId(store)).toBe(nextTabId);
expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]);
expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.state).toEqual({
scrollTop: 40,
});
});
it("brings a covered sheet back to the top and keeps a modified sheet above it beneath", () => {
const store = createCompactStore();
const otherTarget = { kind: "file" as const, path: "src/other.ts" };
const otherTabId = store
.getState()
.openTab({ workspaceKey, target: otherTarget, intent: "reveal" });
const modifiedSheetTabId = openModifiedFileSheet(store);
const focusedBefore = focusedTabId(store);
const tabId = store
.getState()
.openTab({ workspaceKey, target: otherTarget, intent: "reveal" });
expect(tabId).toBe(otherTabId);
expect(sheetTargets(store)).toEqual([fileTarget, otherTarget]);
expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.tabId).toBe(
modifiedSheetTabId,
);
expect(stripTab(store, tabId ?? "")).toBeNull();
expect(focusedTabId(store)).toBe(focusedBefore);
});
it("opens a tab target under a modified sheet without closing it", () => {
@@ -4853,23 +4937,6 @@ describe("compact workspace sheet", () => {
expect(tabKinds(store)).toEqual(["terminal", "terminal"]);
});
it("replaces an unmodified sheet with the next sheet target", () => {
const store = createCompactStore();
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
store.getState().openTab({
workspaceKey,
target: { kind: "file", path: "src/other.ts" },
intent: "reveal",
});
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual({
kind: "file",
path: "src/other.ts",
});
expect(tabKinds(store)).toEqual(["terminal"]);
});
it("closes an unmodified sheet when another tab is chosen", () => {
const store = createCompactStore();
const secondTabId = store.getState().openTab({
@@ -4913,10 +4980,38 @@ describe("compact workspace sheet", () => {
};
store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: false });
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeDefined();
expect(topSheet(store)).toBeDefined();
store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: true });
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
});
it("drops a departed agent's sheet from the middle of the stack", () => {
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
]);
const store = createCompactStore();
const nextTarget = { kind: "file" as const, path: "src/other.ts" };
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
store.getState().openTab({
workspaceKey,
target: { kind: "agent", agentId: "child" },
intent: "reveal",
});
store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" });
store.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: false,
activeAgentIds: ["root"],
autoOpenAgentIds: [],
knownTerminalIds: ["term-1"],
standaloneTerminalIds: ["term-1"],
hasActivePendingTerminalCreate: false,
});
expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]);
});
it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => {
@@ -4941,7 +5036,7 @@ describe("compact workspace sheet", () => {
};
store.getState().reconcileTabs(workspaceKey, { ...snapshot, autoOpenAgentIds: [] });
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId).toBe(sheetTabId);
expect(topSheet(store)?.tab.tabId).toBe(sheetTabId);
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
@@ -4949,7 +5044,7 @@ describe("compact workspace sheet", () => {
]);
store.getState().reconcileTabs(workspaceKey, snapshot);
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(
collectAllTabs(layout.root).filter(
@@ -4975,7 +5070,7 @@ describe("compact workspace sheet", () => {
.replaceTab(workspaceKey, sheetTabId ?? "", { kind: "agent", agentId: "child" });
expect(replacedTabId).toBe(sheetTabId);
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
expect(topSheet(store)).toMatchObject({
tab: { tabId: sheetTabId, target: { kind: "agent", agentId: "child" } },
policy: { openAsTab: true },
});
@@ -4992,7 +5087,7 @@ describe("compact workspace sheet", () => {
terminalId: "term-2",
});
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
expect(replacedTabId).toBe(sheetTabId);
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(sheetTabId);
@@ -5008,12 +5103,12 @@ describe("compact workspace sheet", () => {
const received: string[][] = [];
const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store);
store.getState().openTab({
const sheetTabId = store.getState().openTab({
workspaceKey,
target: { kind: "agent", agentId: "child" },
intent: "reveal",
});
store.getState().closeSheet(workspaceKey);
store.getState().closeTab(workspaceKey, sheetTabId ?? "");
stop();
expect(received).toEqual([[], ["child"], []]);
+181 -117
View File
@@ -126,8 +126,11 @@ export interface WorkspaceSheet {
interface WorkspaceLayoutStore {
/** Published once by the app shell; callers never pass a form factor to open a tab. */
layoutMode: WorkspaceLayoutMode;
/** At most one sheet tab per workspace. Not persisted: a reload returns to the tab strip. */
sheetByWorkspace: Record<string, WorkspaceSheet>;
/**
* Each workspace's open sheets, bottom to top; only the top one is on screen. A workspace with
* no sheet has no entry. Not persisted: a reload returns to the tab strip.
*/
sheetsByWorkspace: Record<string, readonly WorkspaceSheet[]>;
layoutByWorkspace: Record<string, WorkspaceLayout>;
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
explorerSidebarWidthByWorkspace: Record<string, number>;
@@ -148,9 +151,8 @@ interface WorkspaceLayoutStore {
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
) => boolean;
setLayoutMode: (mode: WorkspaceLayoutMode) => void;
closeSheet: (workspaceKey: string) => void;
/** Moves the sheet's tab, keeping its tab id, into the focused pane. */
openSheetAsTab: (workspaceKey: string) => string | null;
/** Moves a sheet's tab, keeping its tab id, into the focused pane. */
openSheetAsTab: (workspaceKey: string, tabId: string) => string | null;
/** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */
autoOpenPullRequestTab: (
workspaceKey: string,
@@ -508,12 +510,12 @@ export function selectExplorerSidebarPaneId(
/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */
export function selectPaneFocusSuspended(
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetByWorkspace">,
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetsByWorkspace">,
workspaceKey: string,
): boolean {
return (
Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) ||
workspaceKey in state.sheetByWorkspace
workspaceKey in state.sheetsByWorkspace
);
}
@@ -682,15 +684,63 @@ function attachParentTab(input: {
});
}
function withoutSheet(
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
const NO_SHEETS: readonly WorkspaceSheet[] = [];
function getSheets(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
): Pick<WorkspaceLayoutStore, "sheetByWorkspace"> {
if (!(workspaceKey in state.sheetByWorkspace)) {
return { sheetByWorkspace: state.sheetByWorkspace };
): readonly WorkspaceSheet[] {
return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS;
}
function findSheet(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
tabId: string,
): WorkspaceSheet | undefined {
return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId);
}
function withSheets(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
sheets: readonly WorkspaceSheet[],
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
if (sheets.length > 0) {
return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } };
}
const { [workspaceKey]: _removed, ...sheetByWorkspace } = state.sheetByWorkspace;
return { sheetByWorkspace };
if (!(workspaceKey in state.sheetsByWorkspace)) {
return { sheetsByWorkspace: state.sheetsByWorkspace };
}
const { [workspaceKey]: _removed, ...sheetsByWorkspace } = state.sheetsByWorkspace;
return { sheetsByWorkspace };
}
function withoutSheet(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
tabId: string,
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
return withSheets(
state,
workspaceKey,
getSheets(state, workspaceKey).filter((sheet) => sheet.tab.tabId !== tabId),
);
}
function withSheetTab(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
tabId: string,
update: (sheet: WorkspaceSheet) => WorkspaceSheet,
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
return withSheets(
state,
workspaceKey,
getSheets(state, workspaceKey).map((sheet) =>
sheet.tab.tabId === tabId ? update(sheet) : sheet,
),
);
}
function isTargetOpenAsTab(layout: WorkspaceLayout | undefined, target: WorkspaceTabTarget) {
@@ -713,30 +763,16 @@ function resolveSheetPolicy(
}
/**
* The parent a newly opened tab links to. A target opened from inside the sheet names the sheet's
* tab, which the open replaces or closes, so it inherits the sheet's own parent instead.
* The parent a newly opened tab links to. A sheet tab is not in the layout, so a target opened
* from inside a sheet inherits that sheet's own parent instead.
*/
function resolveOpenParentTabId(
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
parentTabId: string | undefined,
): string | null {
const sheet = state.sheetByWorkspace[workspaceKey];
if (sheet && parentTabId === sheet.tab.tabId) return sheet.parentTabId;
return parentTabId ?? null;
}
/** A reveal of the resource the sheet already shows keeps the sheet's tab. */
function sheetKeepsTabFor(
sheet: WorkspaceSheet | undefined,
target: WorkspaceTabTarget,
intent: WorkspaceTabOpenIntent,
): sheet is WorkspaceSheet {
return (
intent === "reveal" &&
sheet !== undefined &&
panelResourceKey(sheet.tab.target) === panelResourceKey(target)
);
if (!parentTabId) return null;
return findSheet(state, workspaceKey, parentTabId)?.parentTabId ?? parentTabId;
}
function placeTargetInSheet(input: {
@@ -766,12 +802,16 @@ function placeTargetInSheet(input: {
};
}
/** Moves a workspace's sheet tab into its focused pane, keeping its tab id and state. */
/**
* Moves one sheet's tab into the workspace's focused pane, keeping its tab id and state. The
* other sheets stay; callers that bring the tab to the front dismiss them.
*/
function promoteSheetToTab(
state: WorkspaceLayoutStore,
workspaceKey: string,
tabId: string,
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
const sheet = state.sheetByWorkspace[workspaceKey];
const sheet = findSheet(state, workspaceKey, tabId);
if (!sheet) return null;
const { tab } = sheet;
const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
@@ -798,7 +838,7 @@ function promoteSheetToTab(
return {
tabId: result.tabId,
patch: {
...withoutSheet(state, workspaceKey),
...withoutSheet(state, workspaceKey, tab.tabId),
...withoutFocusRestoration(state, workspaceKey),
hiddenAgentIdsByWorkspace:
tab.target.kind === "agent"
@@ -817,37 +857,55 @@ function promoteSheetToTab(
* A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the
* panel and drop the work. Only the explicit, guarded close removes it.
*/
function sheetHoldsUnsavedWork(
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
workspaceKey: string,
): boolean {
const sheet = state.sheetByWorkspace[workspaceKey];
return sheet !== undefined && isSheetTabModified(workspaceKey, sheet.tab.tabId);
function sheetHoldsUnsavedWork(workspaceKey: string, sheet: WorkspaceSheet): boolean {
return isSheetTabModified(workspaceKey, sheet.tab.tabId);
}
/**
* Where an open lands in the sheet, or null when it opens as a tab. A reveal of the resource the
* sheet shows keeps its tab; any other target never displaces unsaved work and opens as a tab.
* Where an open lands in the sheet stack, or null when it opens as a tab. A new target goes on
* top. A reveal of a resource a sheet already shows keeps that sheet's tab and brings it back to
* the top; the sheets above it close, except ones holding unsaved work, which stay beneath it.
*/
function resolveSheetPlacement(
state: WorkspaceLayoutStore,
workspaceKey: string,
target: WorkspaceTabTarget,
intent: WorkspaceTabOpenIntent,
): { policy: CompactSheetPolicy; kept: WorkspaceSheet | undefined } | null {
): { policy: CompactSheetPolicy; keptIndex: number | null } | null {
const policy = resolveSheetPolicy(state, workspaceKey, target, intent);
if (!policy) return null;
const sheet = state.sheetByWorkspace[workspaceKey];
if (sheetKeepsTabFor(sheet, target, intent)) return { policy, kept: sheet };
return sheetHoldsUnsavedWork(state, workspaceKey) ? null : { policy, kept: undefined };
if (intent !== "reveal") return { policy, keptIndex: null };
const sheets = getSheets(state, workspaceKey);
const resourceKey = panelResourceKey(target);
const index = sheets.findIndex((sheet) => panelResourceKey(sheet.tab.target) === resourceKey);
return { policy, keptIndex: index === -1 ? null : index };
}
/** Closes the sheet because navigation moved on underneath it, unless it holds unsaved work. */
function dismissSheet(
/** Closes the sheets because navigation moved on underneath them, except ones with unsaved work. */
function dismissSheets(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
return withSheets(
state,
workspaceKey,
getSheets(state, workspaceKey).filter((sheet) => sheetHoldsUnsavedWork(workspaceKey, sheet)),
);
}
/** Moves a sheet's tab to the front of the tab strip, dismissing the sheets that would cover it. */
function openSheetTabInFront(
state: WorkspaceLayoutStore,
workspaceKey: string,
): Partial<WorkspaceLayoutStore> {
return sheetHoldsUnsavedWork(state, workspaceKey) ? {} : withoutSheet(state, workspaceKey);
tabId: string,
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
const promoted = promoteSheetToTab(state, workspaceKey, tabId);
if (!promoted) return null;
const next = { ...state, ...promoted.patch };
return {
tabId: promoted.tabId,
patch: { ...promoted.patch, ...dismissSheets(next, workspaceKey) },
};
}
/**
@@ -857,10 +915,10 @@ function dismissSheet(
*/
function sheetAgentNoLongerFits(
workspaceKey: string,
sheet: WorkspaceSheet | undefined,
sheet: WorkspaceSheet,
snapshot: WorkspaceTabSnapshot,
): boolean {
if (sheet?.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true;
return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null;
}
@@ -905,7 +963,7 @@ export function createWorkspaceLayoutStore(
return tabId;
},
layoutMode: "split-panes",
sheetByWorkspace: {},
sheetsByWorkspace: {},
layoutByWorkspace: {},
splitSizesByWorkspace: {},
explorerSidebarWidthByWorkspace: {},
@@ -933,17 +991,26 @@ export function createWorkspaceLayoutStore(
input.intent,
);
if (sheetPlacement) {
const sheets = getSheets(get(), normalizedWorkspaceKey);
const { keptIndex } = sheetPlacement;
const sheet = placeTargetInSheet({
kept: sheetPlacement.kept,
kept: keptIndex === null ? undefined : sheets[keptIndex],
target: normalizedTarget,
intent: input.intent,
policy: sheetPlacement.policy,
parentTabId,
state: input.state,
});
set((state) => ({
sheetByWorkspace: { ...state.sheetByWorkspace, [normalizedWorkspaceKey]: sheet },
}));
const below =
keptIndex === null
? sheets
: [
...sheets.slice(0, keptIndex),
...sheets
.slice(keptIndex + 1)
.filter((above) => sheetHoldsUnsavedWork(normalizedWorkspaceKey, above)),
];
set((state) => withSheets(state, normalizedWorkspaceKey, [...below, sheet]));
return sheet.tab.tabId;
}
const placement = getOpenTabPlacement(
@@ -990,7 +1057,7 @@ export function createWorkspaceLayoutStore(
set((state) => ({
...withoutFocusRestoration(state, normalizedWorkspaceKey),
// A tab brought to the front dismisses the sheet over it.
...(input.intent === "background" ? {} : dismissSheet(state, normalizedWorkspaceKey)),
...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)),
hiddenAgentIdsByWorkspace:
normalizedTarget.kind !== "agent"
? state.hiddenAgentIdsByWorkspace
@@ -1032,24 +1099,25 @@ export function createWorkspaceLayoutStore(
set((state) => {
if (state.layoutMode === mode) return state;
if (mode === "compact") return { layoutMode: mode };
// Widening keeps whatever the user had open in the sheet as an ordinary tab.
// Widening keeps whatever the user had open in sheets as ordinary tabs.
const next: WorkspaceLayoutStore = { ...state, layoutMode: mode };
for (const workspaceKey of Object.keys(state.sheetByWorkspace)) {
const promoted = promoteSheetToTab(next, workspaceKey);
Object.assign(next, promoted?.patch ?? withoutSheet(next, workspaceKey));
for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) {
for (const sheet of sheets) {
const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId);
Object.assign(
next,
promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId),
);
}
}
return next;
});
},
closeSheet: (workspaceKey) => {
openSheetAsTab: (workspaceKey, tabId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) return;
set((state) => withoutSheet(state, normalizedWorkspaceKey));
},
openSheetAsTab: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) return null;
const promoted = promoteSheetToTab(get(), normalizedWorkspaceKey);
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedWorkspaceKey || !normalizedTabId) return null;
const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId);
if (!promoted) return null;
set(promoted.patch);
return promoted.tabId;
@@ -1173,8 +1241,8 @@ export function createWorkspaceLayoutStore(
}
set((state) => {
if (state.sheetByWorkspace[normalizedWorkspaceKey]?.tab.tabId === normalizedTabId) {
return withoutSheet(state, normalizedWorkspaceKey);
if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) {
return withoutSheet(state, normalizedWorkspaceKey, normalizedTabId);
}
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
@@ -1283,8 +1351,8 @@ export function createWorkspaceLayoutStore(
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
// Choosing a tab dismisses the sheet that covers it.
...dismissSheet(state, normalizedWorkspaceKey),
// Choosing a tab dismisses the sheets that cover it.
...dismissSheets(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -1308,8 +1376,8 @@ export function createWorkspaceLayoutStore(
});
return nextLayout
? {
// Choosing a tab dismisses the sheet that covers it.
...dismissSheet(state, normalizedWorkspaceKey),
// Choosing a tab dismisses the sheets that cover it.
...dismissSheets(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -1323,29 +1391,27 @@ export function createWorkspaceLayoutStore(
const normalizedTabId = trimNonEmpty(tabId);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
const sheet = get().sheetByWorkspace[normalizedWorkspaceKey];
if (sheet?.tab.tabId === normalizedTabId) {
if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) {
const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget);
const replaced: WorkspaceSheet = {
...sheet,
policy: policy ?? sheet.policy,
tab: {
...sheet.tab,
target: normalizedTarget,
...(tabState !== undefined ? { state: tabState } : {}),
},
};
const next: WorkspaceLayoutStore = {
...get(),
sheetByWorkspace: { ...get().sheetByWorkspace, [normalizedWorkspaceKey]: replaced },
...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({
...sheet,
policy: policy ?? sheet.policy,
tab: {
...sheet.tab,
target: normalizedTarget,
...(tabState !== undefined ? { state: tabState } : {}),
},
})),
};
if (policy) {
set({ sheetByWorkspace: next.sheetByWorkspace });
set({ sheetsByWorkspace: next.sheetsByWorkspace });
return normalizedTabId;
}
// A target the sheet does not host becomes the tab it would have opened as.
const promoted = promoteSheetToTab(next, normalizedWorkspaceKey);
set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey));
// A target sheets do not host becomes the tab it would have opened as.
const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId);
set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId));
return promoted?.tabId ?? null;
}
const result = replaceTabTargetInLayout({
@@ -1378,18 +1444,14 @@ export function createWorkspaceLayoutStore(
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedWorkspaceKey || !normalizedTabId) return;
set((state) => {
const sheet = state.sheetByWorkspace[normalizedWorkspaceKey];
if (sheet?.tab.tabId === normalizedTabId) {
const { state: _previous, ...tab } = sheet.tab;
return {
sheetByWorkspace: {
...state.sheetByWorkspace,
[normalizedWorkspaceKey]: {
...sheet,
tab: tabState === undefined ? tab : { ...tab, state: tabState },
},
},
};
if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) {
return withSheetTab(state, normalizedWorkspaceKey, normalizedTabId, (sheet) => {
const { state: _previous, ...tab } = sheet.tab;
return {
...sheet,
tab: tabState === undefined ? tab : { ...tab, state: tabState },
};
});
}
const layout = setTabStateInLayout({
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
@@ -1485,11 +1547,11 @@ export function createWorkspaceLayoutStore(
);
}
}
const sheetAgentGone = sheetAgentNoLongerFits(
normalizedWorkspaceKey,
state.sheetByWorkspace[normalizedWorkspaceKey],
snapshot,
const sheets = getSheets(state, normalizedWorkspaceKey);
const fittingSheets = sheets.filter(
(sheet) => !sheetAgentNoLongerFits(normalizedWorkspaceKey, sheet, snapshot),
);
const sheetAgentGone = fittingSheets.length !== sheets.length;
if (
state.layoutByWorkspace[normalizedWorkspaceKey] &&
nextLayout === rawLayout &&
@@ -1500,7 +1562,7 @@ export function createWorkspaceLayoutStore(
}
return {
...(sheetAgentGone ? withoutSheet(state, normalizedWorkspaceKey) : {}),
...(sheetAgentGone ? withSheets(state, normalizedWorkspaceKey, fittingSheets) : {}),
pinnedAgentIdsByWorkspace,
layoutByWorkspace: {
...state.layoutByWorkspace,
@@ -2006,7 +2068,7 @@ export function createWorkspaceLayoutStore(
normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace ||
normalizedWorkspaceKey in state.sidePaneIdByWorkspace ||
normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace ||
normalizedWorkspaceKey in state.sheetByWorkspace;
normalizedWorkspaceKey in state.sheetsByWorkspace;
if (!hasAny) {
return state;
}
@@ -2035,7 +2097,7 @@ export function createWorkspaceLayoutStore(
const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } =
state.sidePaneIdByWorkspace;
return {
...withoutSheet(state, normalizedWorkspaceKey),
...withSheets(state, normalizedWorkspaceKey, NO_SHEETS),
pullRequestTabAutoOpenedByWorkspace,
layoutByWorkspace,
splitSizesByWorkspace,
@@ -2152,14 +2214,16 @@ export function observeOpenWorkspaceAgentIds(
store = useWorkspaceLayoutStore,
): () => void {
let previous: string[] | undefined;
const publish = (state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetByWorkspace">) => {
const publish = (
state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetsByWorkspace">,
) => {
const ids = new Set<string>();
const tabsByWorkspace = [
...Object.entries(state.layoutByWorkspace).map(
([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const,
),
...Object.entries(state.sheetByWorkspace).map(
([workspaceKey, sheet]) => [workspaceKey, [sheet.tab]] as const,
...Object.entries(state.sheetsByWorkspace).map(
([workspaceKey, sheets]) => [workspaceKey, sheets.map((sheet) => sheet.tab)] as const,
),
];
for (const [workspaceKey, tabs] of tabsByWorkspace) {
@@ -2177,7 +2241,7 @@ export function observeOpenWorkspaceAgentIds(
const unsubscribe = store.subscribe((state, before) => {
if (
state.layoutByWorkspace !== before.layoutByWorkspace ||
state.sheetByWorkspace !== before.sheetByWorkspace
state.sheetsByWorkspace !== before.sheetsByWorkspace
) {
publish(state);
}
+2 -10
View File
@@ -2,7 +2,7 @@ import { isCompactBreakpoint } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme";
/**
* Density sizes for surfaces that opt into touch sizing: rows, icons, and text grow on compact
* Density sizes for surfaces that opt into touch sizing: rows and icons grow on compact
* layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay
* desktop-sized everywhere, so a surface migrates by swapping its sizes for these.
*
@@ -11,10 +11,8 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
*/
export type DensityIconSize = keyof typeof ICON_SIZE;
type TextSize = "sm" | "base";
const COMPACT_ICON_SIZE: Record<DensityIconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
const COMPACT_TEXT_SIZE: Record<TextSize, keyof Theme["fontSize"]> = { sm: "base", base: "lg" };
// Compact layouts end where `md` begins; see `isCompactBreakpoint`.
function byLayout<T>(compact: T, desktop: T) {
@@ -25,9 +23,7 @@ export const density = {
/** A list row such as a sidebar entry. */
rowHeight: byLayout(44, 36),
/** A row in a tight group, such as the sidebar's navigation rows. */
tightRowHeight: byLayout(40, 28),
/** The line height of a row's `base` text; slots that align to its first line share it. */
rowLineHeight: byLayout(22, 20),
tightRowHeight: byLayout(36, 28),
/** A square icon-only button that sits in a row or toolbar. */
iconButton: byLayout(44, 28),
};
@@ -37,10 +33,6 @@ export function densityIconBox(size: DensityIconSize) {
return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]);
}
export function densityFontSize(theme: Theme, size: TextSize) {
return byLayout(theme.fontSize[COMPACT_TEXT_SIZE[size]], theme.fontSize[size]);
}
export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number {
return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size];
}
@@ -27,7 +27,7 @@ const WORKSPACE_KEY = "server-1:workspace-1";
beforeEach(() => {
useWorkspaceLayoutStore.setState({
layoutMode: "split-panes",
sheetByWorkspace: {},
sheetsByWorkspace: {},
layoutByWorkspace: {},
explorerSidebarPaneIdByWorkspace: {},
sidePaneIdByWorkspace: {},
@@ -56,7 +56,7 @@ describe("openPreferredWorkspacePreview", () => {
});
const state = useWorkspaceLayoutStore.getState();
expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab).toMatchObject({
expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.at(-1)?.tab).toMatchObject({
tabId,
target: { kind: "file", path: "src/preview.ts" },
});
@@ -96,7 +96,7 @@ describe("previews under a sheet with unsaved work", () => {
});
}
it("reuses one preview tab instead of adding a tab per link", () => {
it("stacks link previews over the sheet instead of dropping its unsaved work", () => {
const store = useWorkspaceLayoutStore.getState();
store.openTab({
workspaceKey: WORKSPACE_KEY,
@@ -114,18 +114,17 @@ describe("previews under a sheet with unsaved work", () => {
{ modified: true },
);
const firstPreviewTabId = previewFile("src/first.ts");
const secondPreviewTabId = previewFile("src/second.ts");
previewFile("src/first.ts");
previewFile("src/second.ts");
const state = useWorkspaceLayoutStore.getState();
expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab.target).toEqual(sheetTarget);
expect(secondPreviewTabId).toBe(firstPreviewTabId);
const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
const tabsById = new Map(collectAllTabs(root).map((tab) => [tab.tabId, tab.target]));
expect(findPaneById(root, "main")?.tabIds.map((tabId) => tabsById.get(tabId))).toEqual([
{ kind: "terminal", terminalId: "term-1" },
expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.map((sheet) => sheet.tab.target)).toEqual([
sheetTarget,
{ kind: "file", path: "src/first.ts" },
{ kind: "file", path: "src/second.ts" },
]);
const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
expect(findPaneById(root, "main")?.tabIds).toHaveLength(1);
});
});
@@ -8,6 +8,7 @@ export default Stub;
export const BottomSheetModal = Stub;
export const BottomSheetModalProvider = PassThrough;
export const BottomSheetBackdrop = Stub;
export const BottomSheetHandle = Stub;
export const BottomSheetScrollView = PassThrough;
export const BottomSheetView = PassThrough;
export const BottomSheetTextInput = Stub;
-96
View File
@@ -2801,102 +2801,6 @@ test("readFile drops an old daemon's over-budget binary chunks and reports the r
await expect(responsePromise).rejects.toThrow("File is too large to display");
});
async function connectFileClient(clientId: string) {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId,
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
return { mock, client };
}
function sendBinaryFile(
mock: ReturnType<typeof createMockTransport>,
requestId: string,
announcedSize: number,
chunks: string[],
) {
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId,
metadata: {
mime: "application/octet-stream",
size: announcedSize,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
}),
);
for (const chunk of chunks) {
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId,
payload: new TextEncoder().encode(chunk),
}),
);
}
}
test("readFile with timeout 0 outlives the default request timeout", async () => {
useHeartbeatClock();
const { mock, client } = await connectFileClient("clsk_file_no_timeout");
const responsePromise = client.readFile("/tmp/project", "slow.bin", "req-slow", undefined, 0);
await vi.advanceTimersByTimeAsync(60_001);
sendBinaryFile(mock, "req-slow", 5, ["hello"]);
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-slow" }),
);
const result = await responsePromise;
expect(result.size).toBe(5);
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
});
test("readFile rejects a binary transfer that ends before the announced size", async () => {
const { mock, client } = await connectFileClient("clsk_file_short");
const responsePromise = client.readFile("/tmp/project", "short.bin", "req-short");
sendBinaryFile(mock, "req-short", 10, ["hello"]);
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-short" }),
);
await expect(responsePromise).rejects.toThrow(
"File transfer incomplete: expected 10 bytes, received 5.",
);
});
test("readFile rejects a binary transfer that exceeds the announced size", async () => {
const { mock, client } = await connectFileClient("clsk_file_long");
const responsePromise = client.readFile("/tmp/project", "long.bin", "req-long");
const settled = responsePromise.then(
() => null,
(error: unknown) => error,
);
sendBinaryFile(mock, "req-long", 4, ["hello!"]);
expect(() =>
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-long" }),
),
).not.toThrow();
expect(await settled).toMatchObject({
message: "File transfer incomplete: expected 4 bytes, received 6.",
});
});
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
-21
View File
@@ -4758,7 +4758,6 @@ export class DaemonClient {
requestId?: string,
acceptBinary = false,
maxBytes?: number,
timeout?: number,
): Promise<FileExplorerPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
@@ -4771,7 +4770,6 @@ export class DaemonClient {
...(maxBytes ? { maxBytes } : {}),
},
responseType: "file_explorer_response",
timeout,
});
}
@@ -4790,17 +4788,11 @@ export class DaemonClient {
return payload.directory;
}
/**
* `timeout` bounds the whole transfer, not each frame. `0` disables the timer (as
* `workspace.create` does) for files that legitimately take longer than the default over a
* slow link; a dead connection still rejects on disconnect.
*/
async readFile(
cwd: string,
path: string,
requestId?: string,
maxBytes?: number,
timeout?: number,
): Promise<FileReadResult> {
const resolvedRequestId = this.createRequestId(requestId);
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes });
@@ -4812,7 +4804,6 @@ export class DaemonClient {
resolvedRequestId,
true,
maxBytes,
timeout,
);
if (payload.error) {
throw new Error(payload.error);
@@ -6549,18 +6540,6 @@ export class DaemonClient {
return;
}
const received = transfer.chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
if (received !== transfer.size) {
this.activeBinaryFileTransfers.delete(frame.requestId);
this.rejectWaitersForRequestId(
frame.requestId,
new Error(
`File transfer incomplete: expected ${transfer.size} bytes, received ${received}.`,
),
);
return;
}
const bytes = concatByteChunks(transfer.chunks, transfer.size);
this.activeBinaryFileTransfers.delete(frame.requestId);
this.completedBinaryFileReads.set(frame.requestId, {
@@ -1,6 +1,5 @@
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { delimiter, resolve } from "node:path";
import type { ProcessEnvRecord } from "../server/paseo-env.js";
import { execCommand } from "../utils/spawn.js";
import { windowsExecutableResolution } from "./windows.js";
@@ -121,29 +120,6 @@ export function executableExists(
return exists(executablePath) ? executablePath : null;
}
// PATH lookup that never runs the candidate, unlike findExecutable which probes
// each one with `--version`. A terminal spawn path cannot afford that probe: it
// would execute the profile command once before the terminal even opens.
// `path` overrides the PATH to search, so a caller can resolve against the
// environment its child process will actually get rather than the daemon's own.
export async function findExecutableWithoutProbing(
name: string,
options: { path?: string; cwd: string },
): Promise<string | null> {
if (!name) {
return null;
}
const path =
options.path === undefined
? undefined
: options.path
.split(delimiter)
.map((entry) => resolve(options.cwd, entry))
.join(delimiter);
const candidates = await enumerateCandidatesViaLibrary(name, { PATH: path });
return candidates[0] ?? null;
}
export interface ExecutableResolutionOptions {
probeTimeoutMs?: number;
env?: ProcessEnvRecord;
@@ -31,81 +31,6 @@ afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("checkout discard", () => {
test.each([".", "apps/web"])(
"discards selected Changes from the %s workspace without changing siblings",
async (workspacePath) => {
const cwd = tmpCwd();
function git(args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
}
try {
git(["init", "-b", "main"]);
git(["config", "user.name", "Paseo Test"]);
git(["config", "user.email", "test@getpaseo.local"]);
git(["config", "core.autocrlf", "false"]);
const nested = path.join(cwd, "apps", "web");
mkdirSync(nested, { recursive: true });
writeFileSync(path.join(nested, "target[ab].txt"), "target base\n");
writeFileSync(path.join(nested, "targeta.txt"), "sibling base\n");
writeFileSync(path.join(nested, "deleted.txt"), "deleted base\n");
git(["add", "."]);
git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]);
const creation = await ctx.client.createWorkspace({
source: { kind: "directory", path: path.join(cwd, workspacePath) },
title: "Discard regression",
});
const workspaceCwd = creation.workspace?.workspaceDirectory;
if (!workspaceCwd) {
throw new Error(creation.error ?? "Workspace creation failed");
}
writeFileSync(path.join(nested, "target[ab].txt"), "target edited\n");
writeFileSync(path.join(nested, "targeta.txt"), "sibling edited\n");
writeFileSync(path.join(nested, "staged.txt"), "staged new file\n");
writeFileSync(path.join(nested, "scratch.txt"), "untracked file\n");
git(["--literal-pathspecs", "add", "--", "apps/web/target[ab].txt", "apps/web/staged.txt"]);
git(["rm", "apps/web/deleted.txt"]);
const subscription = ctx.client.observeCheckoutDiff(workspaceCwd, { mode: "uncommitted" });
try {
const changes = await subscription.ready;
expect(changes.error).toBeNull();
expect(changes.files.map((file) => file.path).sort()).toEqual([
"apps/web/deleted.txt",
"apps/web/scratch.txt",
"apps/web/staged.txt",
"apps/web/target[ab].txt",
"apps/web/targeta.txt",
]);
const selectedPaths = changes.files
.filter((file) => file.path !== "apps/web/targeta.txt")
.map((file) => file.path);
const discarded = await ctx.client.checkoutDiscardChanges(workspaceCwd, {
paths: selectedPaths,
});
expect(discarded).toMatchObject({ success: true, error: null });
const target = await ctx.client.readFile(cwd, "apps/web/target[ab].txt");
expect(new TextDecoder().decode(target.bytes)).toBe("target base\n");
const restored = await ctx.client.readFile(cwd, "apps/web/deleted.txt");
expect(new TextDecoder().decode(restored.bytes)).toBe("deleted base\n");
const sibling = await ctx.client.readFile(cwd, "apps/web/targeta.txt");
expect(new TextDecoder().decode(sibling.bytes)).toBe("sibling edited\n");
await expect(ctx.client.readFile(cwd, "apps/web/staged.txt")).rejects.toThrow(/ENOENT/);
await expect(ctx.client.readFile(cwd, "apps/web/scratch.txt")).rejects.toThrow(/ENOENT/);
expect(git(["status", "--porcelain=v1", "-z"])).toBe(" M apps/web/targeta.txt\0");
} finally {
await subscription.release();
}
} finally {
// afterEach shuts down the daemon and its repository observers first.
onTestFinished(() => rmSync(cwd, { recursive: true, force: true }));
}
},
);
});
describe("file explorer", () => {
test.each(["..", "..."])(
"workspace suggestions resolve exact paths starting with %s",
@@ -8,13 +8,9 @@ import net from "node:net";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import { Buffer } from "node:buffer";
import { createHash, randomBytes } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import {
deriveSharedKey,
@@ -79,10 +75,6 @@ function decodeOfferFromFragmentUrl(url: string): {
return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 };
}
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function encodeCiphertext(ciphertext: ArrayBuffer): string {
return Buffer.from(new Uint8Array(ciphertext)).toString("base64");
}
@@ -868,67 +860,4 @@ async function waitForCapturedLog(
await stopRelay();
}
}, 90000);
test("reads a workspace file over the E2EE relay session byte-for-byte", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger, lines } = createCapturingLogger();
let daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>> | null = null;
let workspaceDir: string | null = null;
const fileName = "rapport données.bin";
const source = randomBytes(4 * FILE_EXPLORER_STREAM_CHUNK_BYTES + 123);
let client: DaemonClient | null = null;
try {
await startRelay({ useLocalRelay: true });
daemon = await createTestPaseoDaemon({
listen: "127.0.0.1",
logger,
relayEnabled: true,
relayEndpoint: `127.0.0.1:${relayPort}`,
});
workspaceDir = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-download-"));
await writeFile(path.join(workspaceDir, fileName), source);
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
client = new DaemonClient({
url: buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`,
useTls: false,
serverId,
role: "client",
}),
clientType: "cli",
connectTimeoutMs: 30_000,
e2ee: { enabled: true, daemonPublicKeyB64 },
reconnect: { enabled: false },
});
await client.connect();
const result = await client.readFile(workspaceDir, fileName, undefined, undefined, 0);
expect(result.size).toBe(source.byteLength);
expect(result.bytes.byteLength).toBe(source.byteLength);
expect(sha256(result.bytes)).toBe(sha256(source));
} catch (err) {
const tail = lines.slice(-50).join("");
// eslint-disable-next-line no-console
console.error("daemon logs (tail):\n", tail);
throw err;
} finally {
await client?.close().catch(() => undefined);
await daemon?.close();
await stopRelay();
if (workspaceDir) {
await rm(workspaceDir, { recursive: true, force: true });
}
}
}, 90000);
});
@@ -244,28 +244,3 @@ async function observeScriptLifecycles(
await subscription.ready;
return lifecycles;
}
test.runIf(process.platform !== "win32")(
"a missing command fails creation without leaving a terminal listed",
async () => {
const workspaceId = await createWorkspace("Missing command");
await expect(
sdk.terminals.create({
workspaceId,
name: "Missing command",
command: "paseo-test-absent-terminal-command",
}),
).rejects.toThrow("paseo-test-absent-terminal-command: command not found");
expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]);
const control = await sdk.terminals.create({
workspaceId,
command: process.execPath,
args: ["-e", "process.stdin.resume()"],
});
expect((await sdk.terminals.list({ workspaceId })).entries.map((entry) => entry.id)).toEqual([
control.id,
]);
await control.kill();
expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]);
},
);
+1 -187
View File
@@ -2,7 +2,6 @@ import { describe, it, expect, afterEach, vi } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import {
buildTerminalEnvironment,
assertTerminalCommandCanStart,
createTerminal,
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
resolveDefaultTerminalShell,
@@ -27,7 +26,7 @@ import { spawnSync } from "node:child_process";
import * as pty from "node-pty";
import xterm from "@xterm/headless";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import { delimiter, join } from "node:path";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises";
import { stripVTControlCharacters } from "node:util";
@@ -638,151 +637,6 @@ describe("createTerminal", () => {
expect(exitInfo.lastOutputLines.length).toBeGreaterThan(0);
expect(exitInfo.lastOutputLines[exitInfo.lastOutputLines.length - 1]).toBe("line-2999");
});
it.runIf(isPlatform("linux", "darwin"))(
"rejects a profile command that is not installed instead of opening a terminal",
async () => {
await expect(
createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Codex",
command: "paseo-qa-absent-codex",
args: [],
}),
).rejects.toThrow("paseo-qa-absent-codex: command not found");
},
);
it.runIf(isPlatform("linux", "darwin"))(
"opens a command found only on the PATH carried by the env option",
async () => {
// execve resolves the command against the child's PATH, which carries the
// per-cwd registered environment. A version-manager shim lives there and
// not necessarily on the daemon's own PATH.
const binDir = mkdtempSync(join(tmpdir(), "terminal-envpath-bin-"));
temporaryDirs.push(binDir);
const toolPath = join(binDir, "paseo-qa-envpath-tool");
writeFileSync(toolPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n");
chmodSync(toolPath, 0o755);
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Shim",
command: "paseo-qa-envpath-tool",
args: [],
env: { PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}` },
}),
);
await waitForLines(session, ["paseo-launch-ok"]);
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
},
);
it.runIf(isPlatform("linux", "darwin"))(
"reports command not found when the command is on neither PATH",
async () => {
await expect(
createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Codex",
command: "paseo-qa-absent-codex",
args: [],
env: { PATH: `/paseo-qa-empty${delimiter}${process.env.PATH ?? ""}` },
}),
).rejects.toThrow("paseo-qa-absent-codex: command not found");
},
);
it.runIf(isPlatform("linux", "darwin"))(
"opens a relative command path resolved against the terminal cwd",
async () => {
// execve resolves a path-ish command against the child's cwd, never the
// daemon's own working directory.
const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-cmd-"));
temporaryDirs.push(projectDir);
mkdirSync(join(projectDir, "scripts"));
const scriptPath = join(projectDir, "scripts", "dev.sh");
writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n");
chmodSync(scriptPath, 0o755);
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(projectDir),
name: "Dev script",
command: "./scripts/dev.sh",
args: [],
}),
);
await waitForLines(session, ["paseo-launch-ok"]);
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
},
);
it.runIf(isPlatform("linux", "darwin"))("opens an absolute command path", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "terminal-absolute-cmd-"));
temporaryDirs.push(projectDir);
const scriptPath = join(projectDir, "run.sh");
writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n");
chmodSync(scriptPath, 0o755);
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Absolute",
command: scriptPath,
args: [],
}),
);
await waitForLines(session, ["paseo-launch-ok"]);
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
});
it("leaves the command check to the terminal itself on Windows", async () => {
// Windows resolves inside resolveTerminalSpawnCommand, which has its own
// shim handling and its own fallback for an unresolved command.
await expect(
assertTerminalCommandCanStart({
command: "paseo-qa-absent-codex",
cwd: realpathSync(tmpdir()),
env: { PATH: "/paseo-qa-empty" },
platform: "win32",
}),
).resolves.toBeUndefined();
});
it.runIf(isPlatform("linux", "darwin"))(
"still opens a terminal for an installed command that exits non-zero",
async () => {
// A profile running a failing test suite must keep opening a tab with its
// output. Only a command that cannot start at all is reported as an error,
// and "exited non-zero" does not distinguish the two: a missing binary and
// `false` both surface as exit code 1 from node-pty.
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Failing suite",
command: "false",
args: [],
}),
);
const exitInfo = await new Promise<{ exitCode: number | null }>((resolve) => {
session.onExit((info) => resolve({ exitCode: info.exitCode }));
});
expect(exitInfo).toEqual({ exitCode: 1 });
},
);
});
describe.skipIf(isPlatform("win32"))("send input", () => {
@@ -1534,43 +1388,3 @@ describe.runIf(isPlatform("win32"))(".cmd shim argv round-trip on Windows", () =
expect(await roundTrip("first\nsecond")).toEqual(["first second"]);
});
});
it.runIf(isPlatform("linux", "darwin"))(
"opens a profile command from a PATH entry relative to the terminal cwd",
async () => {
const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-path-"));
mkdirSync(join(projectDir, "bin"));
const script = join(projectDir, "bin", "paseo-relative-path-tool");
writeFileSync(script, "#!/bin/sh\nprintf 'paseo-launch-ok\\n'\nexec /bin/sleep 30\n");
chmodSync(script, 0o755);
let session: TerminalSession | undefined;
try {
session = await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(projectDir),
command: "paseo-relative-path-tool",
env: { PATH: "bin" },
});
await waitForLines(session, ["paseo-launch-ok"]);
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
expect(session.cwd).toBe(realpathSync(projectDir));
expect(session.getExitInfo()).toBeNull();
} finally {
await session?.killAndWait({ gracefulTimeoutMs: 1000, forceTimeoutMs: 500 });
rmSync(projectDir, { recursive: true, force: true });
}
},
);
it.runIf(isPlatform("linux", "darwin"))(
"checks the exact command rather than accepting a trimmed executable name",
async () => {
await expect(
createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
command: " node ",
}),
).rejects.toThrow(" node : command not found");
},
);
+10 -95
View File
@@ -1,25 +1,14 @@
import * as pty from "node-pty";
import xterm, { type Terminal as TerminalType } from "@xterm/headless";
import { randomUUID } from "crypto";
import {
accessSync,
chmodSync,
constants,
existsSync,
mkdirSync,
readFileSync,
statSync,
} from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { tmpdir, userInfo } from "node:os";
import { basename, delimiter, dirname, extname, join, resolve as resolvePath } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { writePrivateFileAtomicSync } from "../server/private-files.js";
import {
findExecutable,
findExecutableWithoutProbing,
} from "../executable-resolution/executable-resolution.js";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js";
@@ -44,20 +33,6 @@ export interface TerminalExitInfo {
lastOutputLines: string[];
}
// The field is assigned explicitly rather than declared as a constructor
// parameter property: the terminal worker loads this module under
// --experimental-strip-types, which erases types but cannot emit the
// assignment a parameter property implies.
export class TerminalCommandNotFoundError extends Error {
readonly command: string;
constructor(command: string) {
super(`${command}: command not found`);
this.name = "TerminalCommandNotFoundError";
this.command = command;
}
}
export interface TerminalCommandFinishedInfo {
exitCode: number | null;
}
@@ -911,62 +886,6 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] {
return lines.slice(-limit);
}
function isExecutableFile(path: string): boolean {
try {
if (!statSync(path).isFile()) {
return false;
}
accessSync(path, constants.X_OK);
return true;
} catch {
return false;
}
}
export interface AssertTerminalCommandCanStartInput {
command: string | undefined;
// The terminal's own cwd and environment, not the daemon's: execve in the
// child resolves against those, so anything else produces false negatives.
cwd: string;
env: Record<string, string>;
platform?: NodeJS.Platform;
}
// node-pty spawns directly through execve on POSIX, with no shell, so a command
// that does not exist fails inside the spawn helper and surfaces as exit code 1
// with no output -- indistinguishable from `false` or any program that simply
// failed. Resolving it up front is the only signal that separates "cannot start"
// from "ran and exited non-zero", and it lets the create report the failure
// instead of handing back a terminal that dies immediately. Windows keeps
// resolving inside resolveTerminalSpawnCommand, which has its own shim handling
// and its own fallback for an unresolved command.
export async function assertTerminalCommandCanStart(
input: AssertTerminalCommandCanStartInput,
): Promise<void> {
const { command, cwd, env } = input;
const platform = input.platform ?? process.platform;
if (command === undefined || platform === "win32") {
return;
}
// A path-ish command is resolved by execve against the child's cwd, never
// through PATH and never against the daemon's working directory.
if (command.includes("/")) {
if (isExecutableFile(resolvePath(cwd, command))) {
return;
}
throw new TerminalCommandNotFoundError(command);
}
const resolved = await findExecutableWithoutProbing(command, {
path: env[getPathEnvKey(env)],
cwd,
});
if (!resolved) {
throw new TerminalCommandNotFoundError(command);
}
}
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
const {
cwd,
@@ -1026,23 +945,19 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
const { command: spawnCommand, args: spawnArgs } = command
? await resolveTerminalSpawnCommand(command, args)
: { command: resolvedShell, args: [] as string[] };
const terminalEnv = buildTerminalEnvironment({
shell: spawnCommand,
env: {
...env,
...activityEnv,
PASEO_WORKSPACE_ID: workspaceId,
},
});
await assertTerminalCommandCanStart({ command, cwd, env: terminalEnv });
const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
name: "xterm-256color",
cols,
rows,
cwd,
env: terminalEnv,
env: buildTerminalEnvironment({
shell: spawnCommand,
env: {
...env,
...activityEnv,
PASEO_WORKSPACE_ID: workspaceId,
},
}),
});
function emitTitleChange(nextTitle: string | undefined): void {
@@ -968,139 +968,3 @@ it("removes a killed worker terminal from terminalExit without duplicate snapsho
},
]);
});
it("does not resurrect a terminal that exited before its create response was handled", async () => {
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({
requestTimeoutMs: 50,
forkWorker: () => worker,
});
const snapshots: Array<{ cwd: string; terminalIds: string[] }> = [];
manager.subscribeTerminalsChanged((event) => {
snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) });
});
const created = manager.createTerminal({
id: "terminal-a",
cwd: "/workspace",
workspaceId: "ws-test",
});
const request = worker.sentMessages.find((message) => message.type === "createTerminal");
if (!request) {
throw new Error("createTerminal request not sent");
}
// The worker delivers all three messages in one tick, so the awaiting
// createTerminal continuation resumes only after the exit was processed.
const terminal = {
id: "terminal-a",
name: "Shell",
cwd: "/workspace",
workspaceId: "ws-test",
activity: null,
};
const state = createTerminalState();
const exitInfo = { exitCode: 0, signal: null, lastOutputLines: [] };
worker.emitWorkerMessage({ type: "terminalCreated", terminal, state });
worker.emitWorkerMessage({
type: "response",
requestId: request.requestId,
ok: true,
result: { terminal, state },
});
worker.emitWorkerMessage({ type: "terminalExit", terminalId: "terminal-a", info: exitInfo });
const session = await created;
expect(manager.getTerminal("terminal-a")).toBeUndefined();
expect(await manager.getTerminals("/workspace")).toEqual([]);
expect(session.getExitInfo()).toEqual(exitInfo);
expect(snapshots).toEqual([
{ cwd: "/workspace", terminalIds: ["terminal-a"] },
{ cwd: "/workspace", terminalIds: [] },
]);
});
it("replays the exit to a listener attached after a create that lost the race", async () => {
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({
requestTimeoutMs: 50,
forkWorker: () => worker,
});
const created = manager.createTerminal({
id: "terminal-a",
cwd: "/workspace",
workspaceId: "ws-test",
});
const request = worker.sentMessages.find((message) => message.type === "createTerminal");
if (!request) {
throw new Error("createTerminal request not sent");
}
const terminal = {
id: "terminal-a",
name: "Shell",
cwd: "/workspace",
workspaceId: "ws-test",
activity: null,
};
const state = createTerminalState();
const exitInfo = { exitCode: 0, signal: null, lastOutputLines: [] };
worker.emitWorkerMessage({ type: "terminalCreated", terminal, state });
worker.emitWorkerMessage({
type: "response",
requestId: request.requestId,
ok: true,
result: { terminal, state },
});
worker.emitWorkerMessage({ type: "terminalExit", terminalId: "terminal-a", info: exitInfo });
const session = await created;
// The session controller subscribes only after createTerminal resolves; that
// subscription is what turns into a terminal_exit for the client.
const observedExits: Array<{ exitCode: number | null }> = [];
session.onExit((info) => {
observedExits.push({ exitCode: info.exitCode });
});
await new Promise((resolve) => setImmediate(resolve));
expect(observedExits).toEqual([{ exitCode: 0 }]);
});
it("rejects createTerminal and registers nothing when the worker cannot start the command", async () => {
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({
requestTimeoutMs: 50,
forkWorker: () => worker,
});
const snapshots: Array<{ cwd: string; terminalIds: string[] }> = [];
manager.subscribeTerminalsChanged((event) => {
snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) });
});
const created = manager.createTerminal({
id: "terminal-a",
cwd: "/workspace",
workspaceId: "ws-test",
command: "paseo-qa-absent-codex",
});
const request = worker.sentMessages.find((message) => message.type === "createTerminal");
if (!request) {
throw new Error("createTerminal request not sent");
}
worker.emitWorkerMessage({
type: "response",
requestId: request.requestId,
ok: false,
error: "paseo-qa-absent-codex: command not found",
});
await expect(created).rejects.toThrow("paseo-qa-absent-codex: command not found");
expect(manager.getTerminal("terminal-a")).toBeUndefined();
expect(await manager.getTerminals("/workspace")).toEqual([]);
expect(snapshots).toEqual([]);
});
@@ -156,9 +156,6 @@ export function createWorkerTerminalManager(
const recordsById = new Map<string, WorkerTerminalRecord>();
const terminalIdsByCwd = new Map<string, Set<string>>();
const terminalActivityTokenById = new Map<string, string>();
// Creation owns a reference to the session independently of the live-terminal
// index. Only terminalCreated registers it; terminalExit removes it permanently.
const pendingCreationsById = new Map<string, { session: TerminalSession | null }>();
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
const terminalActivityListeners = new Set<TerminalActivityListener>();
const terminalWorkspaceContributionChangedListeners =
@@ -545,14 +542,10 @@ export function createWorkerTerminalManager(
function handleWorkerEvent(message: TerminalWorkerToParentMessage): void {
switch (message.type) {
case "terminalCreated": {
const session = registerRecord({
registerRecord({
info: asRequiredWorkerTerminalInfo(message.terminal),
state: message.state,
});
const pendingCreation = pendingCreationsById.get(message.terminal.id);
if (pendingCreation) {
pendingCreation.session = session;
}
emitTerminalsChanged({
cwd: message.terminal.cwd,
terminals: listTerminalItemsForCwd(message.terminal.cwd),
@@ -693,10 +686,12 @@ export function createWorkerTerminalManager(
const activityToken = createActivityToken();
const terminalActivityUrl = managerOptions.getTerminalActivityUrl?.() ?? null;
terminalActivityTokenById.set(terminalId, activityToken);
const creation = { session: null as TerminalSession | null };
pendingCreationsById.set(terminalId, creation);
let result: {
terminal: RequiredWorkerTerminalInfo;
state: TerminalState;
};
try {
await sendRequest({
result = (await sendRequest({
type: "createTerminal",
options: {
...options,
@@ -704,17 +699,16 @@ export function createWorkerTerminalManager(
activityToken,
activityUrl: terminalActivityUrl,
},
});
if (!creation.session) {
throw new Error("Terminal worker completed creation without a terminalCreated event");
}
return creation.session;
})) as {
terminal: RequiredWorkerTerminalInfo;
state: TerminalState;
};
} catch (error) {
terminalActivityTokenById.delete(terminalId);
throw error;
} finally {
pendingCreationsById.delete(terminalId);
}
const session = registerRecord({ info: result.terminal, state: result.state });
return session;
},
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void {
+6 -6
View File
@@ -3553,13 +3553,13 @@ export async function commitAll(cwd: string, message: string): Promise<void> {
}
export async function discardChanges(cwd: string, pathspecs: string[]): Promise<void> {
const worktreeRoot = await requireGitWorktreeRoot(cwd);
await requireGitRepo(cwd);
if (pathspecs.length === 0) {
return;
}
try {
await runGitCommand(["--literal-pathspecs", "reset", "-q", "HEAD", "--", ...pathspecs], {
cwd: worktreeRoot,
cwd,
timeout: DISCARD_CHANGES_TIMEOUT_MS,
});
} catch {
@@ -3567,7 +3567,7 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise<
await runGitCommand(
["--literal-pathspecs", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...pathspecs],
{
cwd: worktreeRoot,
cwd,
timeout: DISCARD_CHANGES_TIMEOUT_MS,
},
);
@@ -3579,7 +3579,7 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise<
const status = await runGitCommand(
["--literal-pathspecs", "status", "--porcelain=v1", "-z", "--", ...pathspecs],
{
cwd: worktreeRoot,
cwd,
timeout: DISCARD_CHANGES_TIMEOUT_MS,
},
);
@@ -3605,13 +3605,13 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise<
}
if (tracked.length > 0) {
await runGitCommand(["--literal-pathspecs", "checkout", "-q", "--", ...tracked], {
cwd: worktreeRoot,
cwd,
timeout: DISCARD_CHANGES_TIMEOUT_MS,
});
}
if (untracked.length > 0) {
await runGitCommand(["--literal-pathspecs", "clean", "-fd", "-q", "--", ...untracked], {
cwd: worktreeRoot,
cwd,
timeout: DISCARD_CHANGES_TIMEOUT_MS,
});
}