mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d61a6d2880 | ||
|
|
6abf6e4690 | ||
|
|
b041a83fc6 | ||
|
|
0a10ebed2f | ||
|
|
88142d8655 |
@@ -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
@@ -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).
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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]: "" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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"], []]);
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user