mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-20 08:12:53 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adda1a6322 | ||
|
|
cd102d08b6 | ||
|
|
ee7d422cc7 | ||
|
|
dfb6215de0 | ||
|
|
3081b99b06 | ||
|
|
adf27de7d0 | ||
|
|
ab6cdb7c89 | ||
|
|
0d61cca50c | ||
|
|
0eee4fd9b0 | ||
|
|
69ef741a73 | ||
|
|
90f3cab585 | ||
|
|
fc775b6db5 | ||
|
|
71e7cf6ff8 | ||
|
|
eda3e4318f | ||
|
|
91154ba4e8 | ||
|
|
05920a236c | ||
|
|
970b41cb24 | ||
|
|
9d2533183b | ||
|
|
18d6d00d66 | ||
|
|
dd18bc9001 |
+42
-16
@@ -11,7 +11,7 @@ contracts.
|
||||
|
||||
## Panel host contract
|
||||
|
||||
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||
Every panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||
fixed-target labels and icons from that registration, filter by host, and never substitute one
|
||||
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
|
||||
a compatible pane.
|
||||
@@ -21,7 +21,10 @@ so a pane’s + menu omits each while that pane already contains it. Closing one
|
||||
item available again. Other compatible tabs, including agents, terminals, files, and diffs,
|
||||
can move between Explorer and main panes.
|
||||
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
retention. The shared Explorer owns its tab rail; its shared frame owns visibility, activity and
|
||||
close-gesture lifetime. Platform adapters own resizing and inset geometry.
|
||||
The host keeps three recently used tabs plus modified tabs; unmodified tabs beyond that budget
|
||||
can remount. Native retained content stays live, while web chat uses its existing deferred Freeze.
|
||||
|
||||
## Sheets
|
||||
|
||||
@@ -40,11 +43,11 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh
|
||||
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
|
||||
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
|
||||
|
||||
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
|
||||
Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag
|
||||
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
|
||||
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
|
||||
Close, so the title keeps its width. The sheets present
|
||||
from inside the compact column's `SheetHostScope`, so their panels read the navigator and every
|
||||
from the shared workspace's `SheetHostScope`, so their panels read the navigator and every
|
||||
workspace provider an ordinary pane has without a context bridge
|
||||
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
|
||||
|
||||
@@ -56,12 +59,16 @@ the sheet's tab into a pane, and edits the autosave has not started writing are
|
||||
## Explorer sidebar
|
||||
|
||||
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
|
||||
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||
One Explorer host stays beside the center column across compact overlays and docks. Keep its
|
||||
providers and gesture roots at the same React position when width or appearance changes. The
|
||||
workspace route preserves panel ancestry during appearance updates. Appearance-dependent leaves
|
||||
subscribe to theme changes; the file editor reconfigures its existing editor theme compartment.
|
||||
Ordinary leaf routes retain the stack appearance policy.
|
||||
The shell is outside the workspace split canvas so it divides the full workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||
never read or modify that width.
|
||||
|
||||
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
|
||||
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
|
||||
opens. Compact and wide native layouts select the matching workspace tab in Explorer. Desktop Changes opens
|
||||
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
|
||||
Explorer sidebar setting. Automatic PR discovery follows that preference once per workspace without
|
||||
interrupting the user's work. Closing the tab opts that workspace out of future automatic opens,
|
||||
@@ -76,18 +83,37 @@ literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibili
|
||||
|
||||
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
|
||||
hide their close buttons through the panel manifest; close them from the tab context menu.
|
||||
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and
|
||||
omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer.
|
||||
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock, including Agent and terminal profiles.
|
||||
Web also supports dragging agents and terminals into Explorer.
|
||||
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
|
||||
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
|
||||
does not change workspace focus.
|
||||
does not change workspace focus. Web dragging shares the workspace drag scope; compact layouts
|
||||
accept only reordering inside the source pane.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
|
||||
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
|
||||
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
|
||||
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
|
||||
lifecycle.
|
||||
Cmd+E shows or hides Explorer without changing its selected view. All layouts use the same
|
||||
workspace tabs, launcher, and panel host. Below 600 points the shell is the swipeable Explorer
|
||||
overlay; at 600 points and above it reserves width beside the workspace. Native docking keeps its
|
||||
touch resize handle. Full workspace splits remain a separate web-only capability.
|
||||
|
||||
The dock reserves at least 240 points and the workspace 360. App navigation yields when it cannot
|
||||
share the remaining width with both. This keeps a portrait tablet from rendering three unusably
|
||||
narrow columns; widening restores navigation without changing its saved open state.
|
||||
|
||||
Compact panel gestures own overlay visibility; the workspace layout owns its tab selection and
|
||||
contents on every platform. Explicit pane destinations bypass compact sheet placement, so choosing
|
||||
a panel in Explorer's + menu keeps it there. Ordinary compact file and subagent look-ups still use
|
||||
the sheet policy. Resolving an existing main tab reveals center immediately; a new file sheet
|
||||
keeps Explorer underneath and closing the sheet uncovers it. Semantic navigation resolves the
|
||||
actual destination before revealing it, including deduplicated targets.
|
||||
|
||||
Compact overlay selection and saved dock visibility are independent. Folding changes geometry,
|
||||
not either mode’s visibility intent. Native workspace retention remains one workspace; web retains
|
||||
up to ten with its existing inactive TTL.
|
||||
|
||||
PR discovery belongs to the active workspace: split layouts discover while active, and other
|
||||
layouts discover while Explorer is active. The rail requests summary status; selected visible PR
|
||||
content also requests timeline data. Inactive consumers read cached data without request demand.
|
||||
Discovery uses ordinary PR tabs and their existing once-per-workspace close opt-out.
|
||||
|
||||
## Side pane
|
||||
|
||||
|
||||
+3
-1
@@ -157,7 +157,9 @@ a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||
every Unistyles runtime update.
|
||||
|
||||
Navigators keep their identity across appearance changes. `ThemedStack` remounts
|
||||
each screen's content below the native stack when appearance tokens change; a
|
||||
each ordinary screen's content below the native stack when appearance tokens change.
|
||||
Workspace screens own retained panel lifetimes and update appearance at their
|
||||
leaves through `screenOwnedAppearance` ([Explorer](explorer-sidebar.md)). A
|
||||
keyed wrapper above a stack remounts the stack itself, and Android crashes when
|
||||
that happens while a FragmentManager transaction is running, which settings
|
||||
hydration at startup makes likely. A screen that mounts a nested navigator (the
|
||||
|
||||
@@ -32,7 +32,9 @@ The UI worklet owns transient motion:
|
||||
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while
|
||||
The gesture hosts stay mounted. The Gesture Handler web patch keeps initially disabled hosts
|
||||
from capturing paragraph pointers; that capture confines browser selection to its starting paragraph.
|
||||
Native worklets own overlay opacity and pointer events while
|
||||
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
|
||||
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
|
||||
release. Native panel hosts and their dependent draggable lists
|
||||
|
||||
+27
-9
@@ -240,7 +240,7 @@ Do not put any other code modules in the plugin root.
|
||||
|
||||
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
|
||||
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
|
||||
`@getpaseo/plugin/client/ui`. Its `Icon` draws a Lucide name from the client's installed
|
||||
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
|
||||
so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
@@ -257,14 +257,14 @@ Classify every SDK export before adding it. All client entry points and implemen
|
||||
Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural
|
||||
dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts.
|
||||
|
||||
| Entry | Owns | May depend on |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
|
||||
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
|
||||
| Entry | Owns | May depend on |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/ui` | Host-provided UI components | Shared and client code |
|
||||
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
|
||||
|
||||
Server code imports shared helpers from the root and server capabilities from `/server`. Client
|
||||
code imports shared helpers from the root and client capabilities from `/client`. Neither runtime
|
||||
@@ -556,6 +556,24 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute file panels
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
|
||||
panel, and mounts it with a `PluginFile` handle; the panel reads, shows its own `Loading`, writes,
|
||||
and resolves conflicts. The bar only switches panels and shows the dot the panel sets through
|
||||
`tab.setModified`. Built-in panels (Markdown, HTML, Image, and Source, Paseo's editor) take the
|
||||
same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
|
||||
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
|
||||
make that panel faster instead. Source highlights a large file on native in idle-time slices,
|
||||
the lines around the opening line first.
|
||||
|
||||
The handle checks every write against the version it last read or wrote, so two panels or a panel
|
||||
and an agent cannot silently overwrite each other. The daemon reports the file's kind (text,
|
||||
image, binary) with its stat from a bounded sniff, which is how Source claims text files without a
|
||||
read. See the [public file panel reference](../public-docs/plugins/reference.md#file-panels) for
|
||||
the author API, selection, `DocumentFrame`, and the version requirement. The examples live in
|
||||
`plugin-examples/pdf-panel` and `plugin-examples/markdown-panel`.
|
||||
|
||||
## Contribute buttons
|
||||
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
|
||||
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
|
||||
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
|
||||
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
|
||||
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay. Automated Metro launches set `EXPO_UNSTABLE_HEADLESS=1` so Expo does not download the native DevTools shell into temporary test homes; its read-only cache breaks teardown. Browser fixtures and the standalone Electron browser runner share the developer-toast dismissal helper while retaining console logs. A pointer click moves hover away from the tested control and stalls behind app modal backdrops.
|
||||
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
|
||||
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
|
||||
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
|
||||
|
||||
@@ -45,6 +45,21 @@
|
||||
}
|
||||
);
|
||||
|
||||
checks = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
packages = self.packages.${system};
|
||||
license = (builtins.fromJSON (builtins.readFile ./package.json)).license;
|
||||
in
|
||||
{
|
||||
license-metadata =
|
||||
assert packages.paseo.meta.license.spdxId == license;
|
||||
assert packages.desktop.meta.license.spdxId == license;
|
||||
pkgs.runCommand "paseo-license-metadata" { } "touch $out";
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
nixosModules.paseo =
|
||||
{ pkgs, lib, ... }:
|
||||
|
||||
@@ -257,7 +257,7 @@ buildNpmPackage {
|
||||
meta = {
|
||||
description = "Paseo desktop app (Electron wrapper)";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
license = lib.licenses.asl20;
|
||||
mainProgram = "paseo-desktop";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-ObBdUmNSW+vDjzA7EbCDfZrB0nYDmcWmWe+UCoTMh/M=
|
||||
sha256-7B/Zyo/DaHdd3PVq8Hn1HG+x+wLdZBVxgx/cbtaM/S8=
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ buildNpmPackage rec {
|
||||
meta = {
|
||||
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
license = lib.licenses.asl20;
|
||||
mainProgram = "paseo";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
|
||||
Generated
+6649
-3440
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -137,16 +137,21 @@
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"react-dom": "19.2.3",
|
||||
"typescript": "^5.9.3",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"expo": "~57.0.30",
|
||||
"expo-router": "~57.0.28",
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-worklets": "0.8.3"
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-reanimated": "4.5.1",
|
||||
"react-native-worklets": "0.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ export default {
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
ios: { enableSceneSupport: true },
|
||||
android: {
|
||||
minSdkVersion: 29,
|
||||
kotlinVersion: "2.1.20",
|
||||
|
||||
@@ -2,48 +2,56 @@ import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel, openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visibleComposer(page: Page) {
|
||||
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
test("adds a changed file to the focused chat without replacing its composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "add-file-to-chat-",
|
||||
title: "Target chat",
|
||||
});
|
||||
const relativePath = "src/changed file.ts";
|
||||
|
||||
try {
|
||||
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
for (const width of [1400, 390]) {
|
||||
test(`adds a changed file to the focused chat without replacing its composer draft at ${width}px`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "add-file-to-chat-",
|
||||
title: "Target chat",
|
||||
});
|
||||
const relativePath = "src/changed file.ts";
|
||||
|
||||
const agentComposer = visibleComposer(page);
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
try {
|
||||
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||
await expect(attachment).toContainText("changed file.ts");
|
||||
await expect(attachment).toContainText(relativePath);
|
||||
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||
await expect(agentComposer).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
const agentComposer = visibleComposer(page);
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
|
||||
if (width < 600) await openChangesTreePanel(page);
|
||||
else await openChangesPanel(page);
|
||||
const changedFile = page.getByTestId("diff-file-0-toggle");
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||
|
||||
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||
if (width < 600)
|
||||
await expect(
|
||||
page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(attachment).toContainText("changed file.ts");
|
||||
await expect(attachment).toContainText(relativePath);
|
||||
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||
if (width >= 600) await expect(agentComposer).toBeFocused();
|
||||
else await expect(agentComposer).toBeEditable();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -833,13 +833,15 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
|
||||
const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
const compactChangesTab = page
|
||||
.getByTestId("workspace-tab-changes_tree")
|
||||
.filter({ visible: true });
|
||||
if (!(await compactChangesTab.isVisible())) {
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
}
|
||||
await expect(compactChangesTab).toBeVisible();
|
||||
await compactChangesTab.click();
|
||||
const compactExplorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
const compactExplorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(compactExplorer.getByTestId("changes-header")).toBeVisible();
|
||||
|
||||
const actions = compactExplorer.getByTestId("changes-actions-menu-trigger");
|
||||
@@ -876,6 +878,27 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
).toContainText("Scroll long lines");
|
||||
});
|
||||
|
||||
test("compact Explorer renders inline hunks with a saved tree-only desktop preference", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await page.addInitScript(
|
||||
({ key }) => {
|
||||
localStorage.setItem(key, JSON.stringify({ inlineDiff: false, layout: "split" }));
|
||||
},
|
||||
{ key: CHANGES_PREFERENCES_KEY },
|
||||
);
|
||||
const explorer = await openCompactChanges(page, workspace);
|
||||
await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible();
|
||||
await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(BEFORE);
|
||||
});
|
||||
|
||||
test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -1674,10 +1697,10 @@ async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promis
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await page.reload();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
const changesTab = page.getByTestId("workspace-tab-changes_tree").filter({ visible: true });
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
return explorer;
|
||||
}
|
||||
|
||||
@@ -45,10 +45,10 @@ async function openCompactActionsMenu(page: Page, workspaceId: string) {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
|
||||
await page.reload();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
const changesTab = page.getByTestId("workspace-tab-changes_tree").filter({ visible: true });
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const trigger = explorer.getByTestId("changes-actions-menu-trigger");
|
||||
|
||||
@@ -125,7 +125,7 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a
|
||||
});
|
||||
|
||||
const closeExplorer = page
|
||||
.getByTestId("explorer-header")
|
||||
.getByTestId("workspace-explorer-sidebar")
|
||||
.getByRole("button", { name: "Close Explorer sidebar" });
|
||||
await expect(closeExplorer).not.toBeInViewport();
|
||||
|
||||
@@ -809,7 +809,7 @@ async function expectUnmatchedFeedback(page: Page) {
|
||||
}
|
||||
|
||||
function compactReviewSurface(page: Page) {
|
||||
return page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
}
|
||||
async function openCompactReviewExplorer(
|
||||
page: Page,
|
||||
@@ -847,7 +847,10 @@ async function scrollReviewToLastLine(page: Page) {
|
||||
|
||||
async function revealCompactChanges(page: Page) {
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click();
|
||||
await page
|
||||
.getByTestId("workspace-explorer-sidebar")
|
||||
.getByTestId("workspace-tab-changes_tree")
|
||||
.click();
|
||||
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
|
||||
}
|
||||
async function expectReviewEditorBody(page: Page, body: string) {
|
||||
|
||||
@@ -149,7 +149,7 @@ for (const layout of [
|
||||
await expect(details.getByText(/tokens$/)).toHaveCount(0);
|
||||
const account = usageCard(details, "login-journey:account");
|
||||
await expect(account.getByText("Session", { exact: true })).toBeVisible();
|
||||
await expect(account.getByText("31%", { exact: true })).toBeVisible();
|
||||
await expect(account.getByText("31% used", { exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, `context-without-data-${layout.name}`);
|
||||
await layout.close(page);
|
||||
});
|
||||
@@ -161,7 +161,7 @@ for (const layout of [
|
||||
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
|
||||
usageCard(details, "login-journey:account").getByText("31% used", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { openSettingsSection, clickSettingsBackToWorkspace } from "../support/helpers/settings";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
@@ -136,10 +140,9 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0);
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible();
|
||||
const entries = await menu.getByRole("menuitem").allTextContents();
|
||||
expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan(
|
||||
@@ -191,13 +194,11 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
|
||||
}
|
||||
});
|
||||
|
||||
async function dragAgentIntoExplorer(
|
||||
async function dragTabIntoExplorer(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
agentId: string,
|
||||
tabId: string,
|
||||
) {
|
||||
const source = page
|
||||
.getByTestId("workspace-pane-main")
|
||||
.getByTestId(`workspace-tab-agent_${agentId}`);
|
||||
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
|
||||
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
|
||||
const sourceBox = await source.boundingBox();
|
||||
const destinationBox = await destination.boundingBox();
|
||||
@@ -213,7 +214,7 @@ async function dragAgentIntoExplorer(
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test("agents can still be dragged into Explorer when absent from its + menu", async ({
|
||||
test("moves agents into Explorer and keeps the shared Agent launcher available", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" });
|
||||
@@ -230,14 +231,13 @@ test("agents can still be dragged into Explorer when absent from its + menu", as
|
||||
});
|
||||
const main = page.getByTestId("workspace-pane-main");
|
||||
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
|
||||
await dragAgentIntoExplorer(page, agent.id);
|
||||
await dragTabIntoExplorer(page, `agent_${agent.id}`);
|
||||
await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
|
||||
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0);
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1);
|
||||
await page.keyboard.press("Escape");
|
||||
await testInfo.attach("agent-in-explorer", {
|
||||
body: await page.screenshot(),
|
||||
@@ -247,3 +247,273 @@ test("agents can still be dragged into Explorer when absent from its + menu", as
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("tablet prototype keeps Explorer tabs when resizing down to a phone", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "tablet-explorer-" });
|
||||
try {
|
||||
await page.setViewportSize({ width: 951, height: 669 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureExplorerSidebar(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
|
||||
await openExplorerAgentDraft(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await revealPhoneExplorer(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("phone-shared-explorer.png") });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openExplorerAgentDraft(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
await explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Agent/ }).click();
|
||||
await expect(explorerSidebar(page).getByRole("textbox").first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function revealPhoneExplorer(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click();
|
||||
await expect(explorerSidebar(page)).toBeVisible();
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "Close Explorer sidebar", exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
async function cycleWorkspaceTheme(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
await page.keyboard.press("Control+Alt+t");
|
||||
}
|
||||
|
||||
test("retains the Explorer host through appearance changes and folding", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-lifetime-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await openFilesPanel(page);
|
||||
const host = await explorer.elementHandle();
|
||||
if (!host) throw new Error("Explorer must be mounted before changing appearance");
|
||||
const previousColor = await explorer.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await cycleWorkspaceTheme(page);
|
||||
await expect
|
||||
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe(previousColor);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await ensureExplorerSidebar(page);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await expect(explorer).toBeVisible();
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function reorderCompactExplorerTabs(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
const explorer = explorerSidebar(page);
|
||||
const files = explorer.getByTestId("workspace-tab-files");
|
||||
const changes = explorer.getByTestId("workspace-tab-changes_tree");
|
||||
const source = await changes.boundingBox();
|
||||
const target = await files.boundingBox();
|
||||
if (!source || !target) throw new Error("Explorer tabs must be visible to reorder");
|
||||
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(source.x + source.width / 2 - 12, source.y + source.height / 2);
|
||||
await page.mouse.move(target.x + 3, target.y + target.height / 2, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const afterFiles = await files.boundingBox();
|
||||
const afterChanges = await changes.boundingBox();
|
||||
return Boolean(afterFiles && afterChanges && afterChanges.x < afterFiles.x);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("reorders compact Explorer tabs with a pointer without changing selection", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openFilesPanel(page);
|
||||
await reorderCompactExplorerTabs(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Exercise appearance at its consumers while the real workspace navigator retains the panels.
|
||||
test("updates populated code and diff appearance without losing the editor instance or draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-populated-appearance-" });
|
||||
const source = Array.from(
|
||||
{ length: 120 },
|
||||
(_, index) => `export const line${index} = "retained ${index}";`,
|
||||
).join("\n");
|
||||
try {
|
||||
await writeFile(`${workspace.repoPath}/appearance.ts`, source);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ codeFontSize: 13, theme: "light" }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"@paseo:changes-preferences",
|
||||
JSON.stringify({ inlineDiff: true, layout: "unified" }),
|
||||
);
|
||||
});
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFilesPanel(page);
|
||||
await openFileFromExplorer(page, "appearance.ts");
|
||||
const editor = page.getByTestId("file-source-editor").filter({ visible: true });
|
||||
const text = editor.locator(".cm-content");
|
||||
await expect(text).toBeVisible();
|
||||
await text.fill(`${source}\n// unsaved local edit`);
|
||||
const instance = await editor.elementHandle();
|
||||
const scroller = editor.locator(".cm-scroller");
|
||||
await scroller.evaluate((node) => {
|
||||
node.scrollTop = 400;
|
||||
});
|
||||
const scrollBefore = await scroller.evaluate((node) => node.scrollTop);
|
||||
await explorerSidebar(page).getByTestId("workspace-tab-changes_tree").click();
|
||||
const diff = explorerSidebar(page).getByTestId("git-diff-canvas");
|
||||
await expect(diff).toBeVisible();
|
||||
await page.keyboard.press("Control+,");
|
||||
await openSettingsSection(page, "appearance");
|
||||
await page.getByLabel("Code font size", { exact: true }).fill("19");
|
||||
await page.getByLabel("Code font size", { exact: true }).press("Tab");
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(text).toHaveCSS("font-size", "19px");
|
||||
expect(await instance!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("unsaved local edit");
|
||||
expect(await scroller.evaluate((node) => node.scrollTop)).toBe(scrollBefore);
|
||||
await expect(diff).toHaveCSS("font-size", "19px");
|
||||
await cycleWorkspaceTheme(page);
|
||||
expect(await instance!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("unsaved local edit");
|
||||
await dragTabIntoExplorer(page, "file_appearance.ts");
|
||||
await expect(explorerSidebar(page).getByTestId("file-source-editor")).toBeVisible();
|
||||
const explorerEditor = await explorerSidebar(page)
|
||||
.getByTestId("file-source-editor")
|
||||
.elementHandle();
|
||||
await text.fill(`${source}\n// retained Explorer edit`);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("retained Explorer edit");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("retains populated Explorer chat and terminal attachments through appearance, hide and fold", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "explorer-chat-appearance-",
|
||||
title: "Retained chat",
|
||||
initialPrompt: "Show the retention marker",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "Explorer populated paragraph.\n\n```ts\nconst retained = 42;\n```",
|
||||
},
|
||||
});
|
||||
const attaches: string[] = [];
|
||||
page.on("websocket", (socket) =>
|
||||
socket.on("framesent", (frame) => {
|
||||
if (typeof frame.payload !== "string") return;
|
||||
try {
|
||||
const message = JSON.parse(frame.payload).message;
|
||||
if (message?.type === "subscribe_terminal_request") attaches.push(message.terminalId);
|
||||
} catch {
|
||||
/* Binary terminal traffic is not a JSON request. */
|
||||
}
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await openAgentRoute(page, workspace);
|
||||
await ensureExplorerSidebar(page);
|
||||
await dragTabIntoExplorer(page, `agent_${workspace.agentId}`);
|
||||
const explorer = explorerSidebar(page);
|
||||
const paragraph = explorer.getByText("Explorer populated paragraph.", { exact: true });
|
||||
await expect(paragraph).toBeVisible();
|
||||
const composer = explorer.getByRole("textbox", { name: "Message agent..." });
|
||||
await composer.fill("Unsubmitted Explorer draft");
|
||||
const composerNode = await composer.elementHandle();
|
||||
await page.keyboard.press("Control+,");
|
||||
await openSettingsSection(page, "appearance");
|
||||
await page.getByLabel("Content font size", { exact: true }).fill("20");
|
||||
await page.getByLabel("Content font size", { exact: true }).press("Tab");
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(paragraph).toHaveCSS("font-size", "20px");
|
||||
expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(composer).toHaveValue("Unsubmitted Explorer draft");
|
||||
const previousColor = await paragraph.evaluate((node) => getComputedStyle(node).color);
|
||||
await cycleWorkspaceTheme(page);
|
||||
await expect
|
||||
.poll(() => paragraph.evaluate((node) => getComputedStyle(node).color))
|
||||
.not.toBe(previousColor);
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
await page
|
||||
.getByRole("menuitem", { name: /^Terminal/ })
|
||||
.first()
|
||||
.click();
|
||||
const terminal = explorer.getByTestId("terminal-surface");
|
||||
await expect(terminal).toBeVisible();
|
||||
await expect.poll(() => attaches.length).toBe(1);
|
||||
const terminalNode = await terminal.elementHandle();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await cycleWorkspaceTheme(page);
|
||||
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
expect(attaches).toHaveLength(1);
|
||||
await explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`).click();
|
||||
expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(composer).toHaveValue("Unsubmitted Explorer draft");
|
||||
await expect(paragraph).toHaveCSS("font-size", "20px");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("compact file sheets cover Explorer and return to its retained file list", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-sheet-layer-" });
|
||||
try {
|
||||
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await openFilesPanel(page);
|
||||
const host = await explorer.elementHandle();
|
||||
await openFileFromExplorer(page, "sheet-marker.txt");
|
||||
await expect(page.getByText("Explorer sheet marker", { exact: true })).toBeVisible();
|
||||
// A real click also proves the sheet draws above the retained Explorer overlay.
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(page.getByText("Explorer sheet marker", { exact: true })).toHaveCount(0);
|
||||
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
expect(await host!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,45 +56,12 @@ async function replaceEditorText(page: Page, content: string): Promise<void> {
|
||||
await editor.type(content);
|
||||
}
|
||||
|
||||
async function replaceFileAfterDeletionWasObserved(input: {
|
||||
page: Page;
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
content: string;
|
||||
}): Promise<void> {
|
||||
const { page, gate, filePath, relativePath, content } = input;
|
||||
// Recreate the observed race without faking file state: the daemon publishes the
|
||||
// deletion, then a real read succeeds after the replacement while its ready event waits.
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
gate.holdFileReads(relativePath);
|
||||
await unlink(filePath);
|
||||
await gate.waitForHeldFileRead();
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
gate.releaseHeldFileRead();
|
||||
await expectOnlyFileCallout(page, "File deleted on disk");
|
||||
gate.holdNextReadyFileUpdate(relativePath);
|
||||
await writeFile(filePath, content, "utf8");
|
||||
await gate.waitForHeldReadyFileUpdate();
|
||||
await expect.poll(() => readFile(filePath, "utf8")).toBe(content);
|
||||
}
|
||||
|
||||
async function expectOnlyFileCallout(page: Page, title: string): Promise<void> {
|
||||
await expect(fileCallout(page)).toHaveCount(1);
|
||||
await expect(fileCallout(page)).toContainText(title);
|
||||
await expect(filePane(page).getByText(title, { exact: true })).toHaveCount(1);
|
||||
}
|
||||
|
||||
async function expectCleanReplacementCanReload(page: Page): Promise<void> {
|
||||
await expectOnlyFileCallout(page, "Changed on disk");
|
||||
await expect(filePane(page).getByText("File deleted on disk", { exact: true })).toHaveCount(0);
|
||||
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
|
||||
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled();
|
||||
await fileCallout(page).getByRole("button", { name: "Reload" }).click();
|
||||
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectDeletedFileNotice(page: Page): Promise<void> {
|
||||
await expectOnlyFileCallout(page, "File deleted on disk");
|
||||
await expect(fileCallout(page)).toContainText("The open copy is preserved.");
|
||||
@@ -140,7 +107,7 @@ test.describe("Workspace file change conflicts", () => {
|
||||
relativePath,
|
||||
content: "const preserved = true;\n",
|
||||
});
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await expect(visibleEditor(page)).toContainText("const preserved = true;");
|
||||
await recordFileCallouts(page);
|
||||
|
||||
await restoreFileAfterWatcherObservedTemporaryAbsence({ gate, filePath, relativePath });
|
||||
@@ -189,162 +156,79 @@ test.describe("Workspace file change conflicts", () => {
|
||||
relativePath,
|
||||
content: "const before = true;\n",
|
||||
});
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await expect(visibleEditor(page)).toContainText("const before = true;");
|
||||
await recordFileCallouts(page);
|
||||
gate.holdFileReads(relativePath);
|
||||
gate.holdNextReadyFileUpdate(relativePath);
|
||||
|
||||
await replaceEditorText(page, "const saved = true;\n");
|
||||
await expect.poll(() => readFile(filePath, "utf8")).toContain("const saved = true;");
|
||||
await gate.waitForHeldFileRead();
|
||||
await gate.waitForHeldReadyFileUpdate();
|
||||
gate.releaseHeldReadyFileUpdate();
|
||||
|
||||
await expect(visibleEditor(page)).toContainText("const saved = true;");
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
await expectNoFileCalloutWasRendered(page);
|
||||
gate.releaseHeldFileRead();
|
||||
});
|
||||
|
||||
test("a clean file replaced on disk offers one working reload action", async ({
|
||||
test("a viewed file deleted and written back shows the new file", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-clean-replacement-",
|
||||
relativePath: "inventory.md",
|
||||
content: "# Before\n",
|
||||
});
|
||||
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
|
||||
await replaceFileAfterDeletionWasObserved({
|
||||
page,
|
||||
gate,
|
||||
filePath,
|
||||
relativePath: "inventory.md",
|
||||
content: "# After\n",
|
||||
});
|
||||
gate.releaseHeldReadyFileUpdate();
|
||||
await expectCleanReplacementCanReload(page);
|
||||
|
||||
await unlink(filePath);
|
||||
await expect(filePane(page).getByText("The file was deleted.", { exact: true })).toBeVisible();
|
||||
await writeFile(filePath, "# After\n", "utf8");
|
||||
|
||||
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a deleted file shows one explanatory notice and no resolution actions", async ({
|
||||
test("a deleted file keeps the open copy with one notice and no resolution actions", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-deleted-",
|
||||
relativePath: "deleted.md",
|
||||
content: "# Present\n",
|
||||
relativePath: "deleted.ts",
|
||||
content: "const present = true;\n",
|
||||
});
|
||||
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
|
||||
await expect(visibleEditor(page)).toContainText("const present = true;");
|
||||
await recordFileCallouts(page);
|
||||
await unlink(filePath);
|
||||
await expectDeletedFileNotice(page);
|
||||
await expectFileCalloutWasRendered(page, "File deleted on disk");
|
||||
await expect(visibleEditor(page)).toContainText("const present = true;");
|
||||
});
|
||||
|
||||
test("a file check error offers a working retry", async ({ page, withWorkspace }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const relativePath = "retry.md";
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-check-retry-",
|
||||
relativePath,
|
||||
content: "# Before\n",
|
||||
});
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
|
||||
|
||||
await unlink(filePath);
|
||||
await mkdir(filePath);
|
||||
await expectOnlyFileCallout(page, "Couldn't check file on disk");
|
||||
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeEnabled();
|
||||
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0);
|
||||
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
|
||||
|
||||
gate.holdNextReadyFileUpdate(relativePath);
|
||||
await rm(filePath, { recursive: true });
|
||||
await writeFile(filePath, "# After\n", "utf8");
|
||||
await gate.waitForHeldReadyFileUpdate();
|
||||
gate.holdFileReads(relativePath);
|
||||
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
|
||||
await gate.waitForHeldFileRead();
|
||||
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeDisabled();
|
||||
gate.releaseHeldFileRead();
|
||||
await expectCleanReplacementCanReload(page);
|
||||
gate.releaseHeldReadyFileUpdate();
|
||||
});
|
||||
|
||||
test("retry clears a transient error when the file is unchanged", async ({
|
||||
test("a path that stops being a file keeps the open copy and follows the file back", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const relativePath = "unchanged.md";
|
||||
const relativePath = "retry.ts";
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-check-unchanged-",
|
||||
prefix: "file-check-error-",
|
||||
relativePath,
|
||||
content: "# Unchanged\n",
|
||||
content: "const before = true;\n",
|
||||
});
|
||||
const parkedPath = `${filePath}.parked`;
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await expect(filePane(page).getByText("Unchanged", { exact: true })).toBeVisible();
|
||||
await expect(visibleEditor(page)).toContainText("const before = true;");
|
||||
|
||||
await rename(filePath, parkedPath);
|
||||
await unlink(filePath);
|
||||
await mkdir(filePath);
|
||||
await expectOnlyFileCallout(page, "Couldn't check file on disk");
|
||||
await gate.waitForFileUpdate(relativePath, "error");
|
||||
await expect(visibleEditor(page)).toContainText("const before = true;");
|
||||
|
||||
gate.holdNextReadyFileUpdate(relativePath);
|
||||
await rm(filePath, { recursive: true });
|
||||
await rename(parkedPath, filePath);
|
||||
await gate.waitForHeldReadyFileUpdate();
|
||||
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
|
||||
await writeFile(filePath, "const after = true;\n", "utf8");
|
||||
|
||||
await expect(visibleEditor(page)).toContainText("const after = true;");
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0);
|
||||
gate.releaseHeldReadyFileUpdate();
|
||||
});
|
||||
|
||||
test("a read-only file error preserves its message and offers retry", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const relativePath = "artifact.bin";
|
||||
const binaryContent = new Uint8Array([0, 1, 2, 3, 255]);
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-read-only-retry-",
|
||||
relativePath,
|
||||
content: binaryContent,
|
||||
});
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await expect(
|
||||
filePane(page).getByText("Binary preview unavailable", { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await unlink(filePath);
|
||||
await mkdir(filePath);
|
||||
await expect(
|
||||
filePane(page).getByText("Requested path is not a file", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(filePane(page).getByRole("button", { name: "Retry" })).toBeEnabled();
|
||||
|
||||
gate.holdNextReadyFileUpdate(relativePath);
|
||||
await rm(filePath, { recursive: true });
|
||||
await writeFile(filePath, binaryContent);
|
||||
await gate.waitForHeldReadyFileUpdate();
|
||||
gate.holdFileReads(relativePath);
|
||||
await filePane(page).getByRole("button", { name: "Retry" }).click();
|
||||
await gate.waitForHeldFileRead();
|
||||
await expect(
|
||||
filePane(page).getByText("Requested path is not a file", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
gate.releaseHeldFileRead();
|
||||
|
||||
await expect(
|
||||
filePane(page).getByText("Binary preview unavailable", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
filePane(page).getByText("Requested path is not a file", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
gate.releaseHeldReadyFileUpdate();
|
||||
});
|
||||
|
||||
test("a changed file with local edits offers overwrite and a working reload", async ({
|
||||
|
||||
@@ -55,6 +55,10 @@ async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||
await expectFileTabOpen(page, filename);
|
||||
}
|
||||
|
||||
function filePanelSwitcher(page: Page) {
|
||||
return page.getByTestId("file-panel-switcher").filter({ visible: true });
|
||||
}
|
||||
|
||||
function htmlPreview(page: Page) {
|
||||
return {
|
||||
host: page.getByTestId("file-html-preview"),
|
||||
@@ -62,11 +66,9 @@ function htmlPreview(page: Page) {
|
||||
};
|
||||
}
|
||||
|
||||
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
|
||||
const option = page.getByTestId("file-panel-bar").getByRole("button", {
|
||||
name: view,
|
||||
exact: true,
|
||||
});
|
||||
/** Switches the file tab to one of its panels from the bar's switcher. */
|
||||
async function selectFilePanel(page: Page, title: string): Promise<void> {
|
||||
const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true });
|
||||
await option.click();
|
||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
@@ -128,7 +130,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
await openAssistantMarkdownLink(page, "Missing capture");
|
||||
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
|
||||
await expect(page.getByText(/ENOENT/)).toBeVisible();
|
||||
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
@@ -317,11 +319,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
title: "Unsupported large source",
|
||||
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(session.cwd, "too-large.txt"),
|
||||
Buffer.alloc(51 * 1024 * 1024),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(path.join(session.cwd, "too-large.txt"), "x".repeat(51 * 1024 * 1024), "utf8");
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
@@ -484,9 +482,9 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
relativePath,
|
||||
);
|
||||
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
|
||||
const modeControl = page.getByTestId("file-preview-mode");
|
||||
const modeControl = filePanelSwitcher(page);
|
||||
await expect(modeControl).toBeVisible();
|
||||
await selectFileView(page, "Source");
|
||||
await selectFilePanel(page, "Source");
|
||||
|
||||
const editorHost = page.getByTestId("file-source-editor");
|
||||
const content = editor(page);
|
||||
@@ -548,7 +546,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
await selectFileView(page, "Source");
|
||||
await selectFilePanel(page, "Source");
|
||||
|
||||
const markdownScroller = page
|
||||
.getByTestId("file-source-editor")
|
||||
@@ -680,7 +678,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
|
||||
test("refreshes Markdown and images while switching between Markdown and Source", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
@@ -695,15 +693,15 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
|
||||
await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible();
|
||||
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
||||
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||
|
||||
await selectFileView(page, "Source");
|
||||
await selectFilePanel(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await replaceEditorText(page, "# Saved from source\n");
|
||||
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||
await selectFileView(page, "Preview");
|
||||
await selectFilePanel(page, "Markdown");
|
||||
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
@@ -786,10 +784,10 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
);
|
||||
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
|
||||
|
||||
await selectFileView(page, "Source");
|
||||
await selectFilePanel(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(preview.host).toHaveCount(0);
|
||||
await selectFileView(page, "Preview");
|
||||
await selectFilePanel(page, "HTML");
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ async function openMarkdownPreview(page: Page) {
|
||||
await openFileFromExplorer(page, "README.md");
|
||||
await expectFileTabOpen(page, "README.md");
|
||||
await page
|
||||
.getByTestId("file-panel-bar")
|
||||
.getByRole("button", { name: "Preview", exact: true })
|
||||
.getByTestId("file-panel-switcher")
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Markdown", exact: true })
|
||||
.click();
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
}
|
||||
|
||||
@@ -247,9 +247,7 @@ test.describe("New workspace: fullscreen composer across the compact breakpoint"
|
||||
await expectNewWorkspaceDraft(page, `${draft} wide`);
|
||||
});
|
||||
|
||||
// Terminal mode and its prompt are screen state, and the shell remounts the screen
|
||||
// at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone
|
||||
// layout only.
|
||||
// Exercise the terminal prompt's fullscreen and compact launch sheet on the phone.
|
||||
await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
// The compact launch control opens a sheet, not the desktop menu.
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Frame, TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
expectFileTabOpen,
|
||||
} from "../support/helpers/file-explorer";
|
||||
|
||||
// Full Chromium includes the PDF viewer; the default headless shell does not.
|
||||
test.use({ channel: "chromium" });
|
||||
|
||||
function samplePdf() {
|
||||
const stream = "BT /F1 24 Tf 72 720 Td (Paseo PDF panel example) Tj ET";
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
let pdf = "%PDF-1.4\n%\u0000\u00ff\n";
|
||||
const offsets = objects.map((object, index) => {
|
||||
const offset = Buffer.byteLength(pdf);
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
return offset;
|
||||
});
|
||||
const xref = Buffer.byteLength(pdf);
|
||||
pdf += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}`;
|
||||
pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||
return pdf;
|
||||
}
|
||||
|
||||
async function openFile(page: Page, name: string) {
|
||||
await openFileFromExplorer(page, name);
|
||||
await expectFileTabOpen(page, name);
|
||||
}
|
||||
|
||||
const WIDE = { width: 1280, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string) {
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
await testInfo.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
/** Captures the current state wide, then compact once `ready` holds there, and returns to wide. */
|
||||
async function captureWideAndCompact(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
ready: () => Promise<void>,
|
||||
) {
|
||||
await capture(page, testInfo, `${name}-wide`);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await ready();
|
||||
await capture(page, testInfo, `${name}-compact`);
|
||||
await page.setViewportSize(WIDE);
|
||||
await ready();
|
||||
}
|
||||
|
||||
function filePanelSwitcher(page: Page) {
|
||||
return page.getByTestId("file-panel-switcher").filter({ visible: true });
|
||||
}
|
||||
|
||||
/** Turns on vim keybindings without discarding settings the test writes later. */
|
||||
async function enableVimKeybindings(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const key = "@paseo:app-settings";
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, vimKeybindings: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseRichMarkdown(page: Page) {
|
||||
await page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }).click();
|
||||
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Use", exact: true }).click();
|
||||
}
|
||||
|
||||
async function replaceMarkdown(page: Page, text: string) {
|
||||
await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text);
|
||||
}
|
||||
|
||||
async function selectPanel(page: Page, title: string) {
|
||||
await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectPanels(page: Page, titles: string[]) {
|
||||
for (const title of titles) {
|
||||
await expect(
|
||||
filePanelSwitcher(page).getByRole("button", { name: title, exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
|
||||
async function pdfViewer(page: Page): Promise<Frame> {
|
||||
const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/;
|
||||
const find = () =>
|
||||
page.frames().find((frame) => !frame.isDetached() && viewerUrl.test(frame.url())) ?? null;
|
||||
await expect.poll(() => find() !== null).toBe(true);
|
||||
return find()!;
|
||||
}
|
||||
|
||||
async function expectPdfViewerReady(page: Page) {
|
||||
// A narrow viewer collapses its toolbar to Print and More actions.
|
||||
const viewer = await pdfViewer(page);
|
||||
await expect(viewer.getByRole("button", { name: "Print", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
async function expectMarkdownEditor(page: Page, text: string) {
|
||||
const editor = page.getByRole("textbox", { name: "Rich Markdown editor", exact: true });
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(editor).toContainText(text);
|
||||
await expect(editor).toBeInViewport({ ratio: 1 });
|
||||
// Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu.
|
||||
await expect(filePanelSwitcher(page)).toContainText("Rich Markdown");
|
||||
await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
test("directory examples mount the PDF frame and write Markdown through the file handle", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const pdf = await copyPluginExample("pdf-panel");
|
||||
const markdown = await copyPluginExample("markdown-panel");
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "npm.cmd" : "npm",
|
||||
["install", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||
{ cwd: markdown.directory, stdio: "pipe" },
|
||||
);
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "file-panel-examples-",
|
||||
repo: {
|
||||
files: [
|
||||
{
|
||||
path: "notes.md",
|
||||
content: "# Markdown panel example\n\nEdit **Markdown** through Paseo.\n",
|
||||
},
|
||||
{ path: "second.md", content: "# Remembered panel\n" },
|
||||
{ path: "report.pdf", content: samplePdf() },
|
||||
],
|
||||
},
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(pdf.directory);
|
||||
await client.installDirectoryPlugin(markdown.directory);
|
||||
await enableVimKeybindings(page);
|
||||
await page.setViewportSize(WIDE);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openFile(page, "report.pdf");
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
|
||||
"src",
|
||||
/^blob:/,
|
||||
);
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).not.toHaveAttribute(
|
||||
"sandbox",
|
||||
);
|
||||
const documentUrl = await page
|
||||
.getByTitle("Document preview", { exact: true })
|
||||
.getAttribute("src");
|
||||
expect(
|
||||
await page.evaluate(
|
||||
async (url) => (await fetch(url!)).blob().then((blob) => blob.type),
|
||||
documentUrl,
|
||||
),
|
||||
).toBe("application/pdf");
|
||||
await expectPdfViewerReady(page);
|
||||
await expect(
|
||||
(await pdfViewer(page)).getByRole("textbox", { name: "Page number", exact: true }),
|
||||
).toHaveValue("1");
|
||||
await captureWideAndCompact(page, testInfo, "pdf-example", async () => {
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).toBeInViewport();
|
||||
await expectPdfViewerReady(page);
|
||||
});
|
||||
|
||||
await openFile(page, "notes.md");
|
||||
await expect(page.getByRole("radio", { name: "Markdown — Paseo", exact: true })).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
|
||||
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
|
||||
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
});
|
||||
await chooseRichMarkdown(page);
|
||||
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
|
||||
"Markdown panel example",
|
||||
);
|
||||
gate.holdNextClientRequest("fs.file.write.request");
|
||||
await replaceMarkdown(page, "# Edited by Rich Markdown\n\nSaved by the panel.\n");
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
|
||||
await capture(page, testInfo, "markdown-example-dirty-wide");
|
||||
gate.releaseHeldClientRequest();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
|
||||
.toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
|
||||
await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]);
|
||||
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
|
||||
await expectMarkdownEditor(page, "Edited by Rich Markdown");
|
||||
});
|
||||
|
||||
// Source is Paseo's editor; its cursor and vim mode are in its own status row.
|
||||
await selectPanel(page, "Source");
|
||||
const source = page.getByTestId("file-source-editor").filter({ visible: true });
|
||||
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
|
||||
await source.locator(".cm-line").nth(2).click();
|
||||
const bar = page.getByTestId("file-source-status").filter({ visible: true });
|
||||
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
|
||||
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
|
||||
await capture(page, testInfo, "markdown-example-source-wide");
|
||||
// The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source.
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
|
||||
await filePanelSwitcher(page).click();
|
||||
await page.getByRole("menuitem", { name: "Source", exact: true }).click();
|
||||
await expect(filePanelSwitcher(page)).toHaveText("Source");
|
||||
await source.locator(".cm-line").nth(2).click();
|
||||
await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport();
|
||||
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport();
|
||||
await capture(page, testInfo, "markdown-example-source-compact");
|
||||
await page.setViewportSize(WIDE);
|
||||
await selectPanel(page, "Rich Markdown");
|
||||
await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n");
|
||||
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText(
|
||||
"# External update",
|
||||
);
|
||||
await replaceMarkdown(page, "# Local edit\n");
|
||||
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Conflicting external edit\n");
|
||||
await expect(page.getByTestId("rich-markdown-conflict")).toBeVisible();
|
||||
await capture(page, testInfo, "markdown-example-conflict-wide");
|
||||
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
|
||||
.toBe("# Local edit\n");
|
||||
|
||||
await openFile(page, "second.md");
|
||||
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
|
||||
"Remembered panel",
|
||||
);
|
||||
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expectMarkdownEditor(page, "Remembered panel");
|
||||
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
|
||||
} finally {
|
||||
await client.removePlugin("pdf-panel");
|
||||
await client.removePlugin("markdown-panel");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
await pdf.cleanup();
|
||||
await markdown.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { expectFileTabOpen, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import {
|
||||
BUILTIN_MARKDOWN_OPTION,
|
||||
MARKDOWN_PANEL_ID,
|
||||
PLUGIN_MARKDOWN_OPTION,
|
||||
expectBuiltinMarkdown,
|
||||
expectPluginMarkdown,
|
||||
filePanelPicker,
|
||||
installMarkdownPanelPlugin,
|
||||
openMarkdown,
|
||||
storedFilePanelAssociations,
|
||||
} from "../support/helpers/markdown-file-panel-plugin";
|
||||
|
||||
async function choosePanel(page: Page, option: string, options: { remember: boolean }) {
|
||||
await page.getByRole("radio", { name: option, exact: true }).click();
|
||||
if (options.remember) {
|
||||
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
|
||||
}
|
||||
await page.getByRole("button", { name: "Use", exact: true }).click();
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
}
|
||||
|
||||
async function openWithFromTabMenu(page: Page, name: string, option: string) {
|
||||
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open with…" }).hover();
|
||||
await page.getByRole("menuitem", { name: option }).click();
|
||||
}
|
||||
|
||||
async function toggleAlwaysUseFromFileBar(page: Page) {
|
||||
await page.getByTestId("file-panel-bar-menu").filter({ visible: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Always use for .md" }).click();
|
||||
}
|
||||
|
||||
function filePanelSwitcher(page: Page) {
|
||||
return page.getByTestId("file-panel-switcher").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function expectSwitcherPanels(page: Page, titles: string[]) {
|
||||
await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles);
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string) {
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
await testInfo.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
test("opens Markdown with the built-in panel and Source beside it without asking", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "file-panel-builtins-",
|
||||
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
|
||||
});
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openMarkdown(page, "notes.md");
|
||||
await expectBuiltinMarkdown(page, "notes heading");
|
||||
await expectSwitcherPanels(page, ["Markdown", "Source"]);
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
await capture(page, testInfo, "file-panel-builtins-wide");
|
||||
|
||||
await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click();
|
||||
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
|
||||
await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click();
|
||||
await expectBuiltinMarkdown(page, "notes heading");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expectBuiltinMarkdown(page, "notes heading");
|
||||
await expect(
|
||||
filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
await capture(page, testInfo, "file-panel-builtins-compact");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("asks once which panel opens Markdown and remembers it when told to", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "file-panel-picker-",
|
||||
repo: {
|
||||
files: ["first", "second", "third", "fourth"].map((name) => ({
|
||||
path: `${name}.md`,
|
||||
content: `# ${name} heading\n`,
|
||||
})),
|
||||
},
|
||||
});
|
||||
const plugin = await installMarkdownPanelPlugin();
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
// First open: the built-in renders under the picker.
|
||||
await openMarkdown(page, "first.md");
|
||||
await expect(filePanelPicker(page)).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked();
|
||||
await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked();
|
||||
await expectBuiltinMarkdown(page, "first heading");
|
||||
await capture(page, testInfo, "file-panel-picker-wide");
|
||||
|
||||
// Don't ask me again: the plugin renders and later opens use it silently.
|
||||
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: true });
|
||||
await expectPluginMarkdown(page, "first.md");
|
||||
expect(await storedFilePanelAssociations(page)).toEqual({ "ext:md": MARKDOWN_PANEL_ID });
|
||||
await openMarkdown(page, "second.md");
|
||||
await expectPluginMarkdown(page, "second.md");
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
|
||||
// Forgetting from the file bar keeps the tab on the plugin.
|
||||
await toggleAlwaysUseFromFileBar(page);
|
||||
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
|
||||
await expectPluginMarkdown(page, "second.md");
|
||||
|
||||
// An unremembered choice applies to this tab only; the next open asks again.
|
||||
await openMarkdown(page, "third.md");
|
||||
await expect(filePanelPicker(page)).toBeVisible();
|
||||
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: false });
|
||||
await expectPluginMarkdown(page, "third.md");
|
||||
expect(await storedFilePanelAssociations(page)).toEqual({});
|
||||
|
||||
// "Open with…" switches the open tab back to the built-in in place.
|
||||
await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION);
|
||||
await expectBuiltinMarkdown(page, "third heading");
|
||||
await expectFileTabOpen(page, "third.md");
|
||||
|
||||
// Dismissing keeps the built-in; reopening the tab after a reload asks again.
|
||||
await openMarkdown(page, "fourth.md");
|
||||
await expect(filePanelPicker(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
await expectBuiltinMarkdown(page, "fourth heading");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.reload();
|
||||
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
await capture(page, testInfo, "file-panel-picker-compact");
|
||||
} finally {
|
||||
await plugin.uninstall();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openSettings } from "../support/helpers/app";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
BUILTIN_MARKDOWN_OPTION,
|
||||
MARKDOWN_PANEL_ID,
|
||||
PLUGIN_MARKDOWN_OPTION,
|
||||
SOURCE_OPTION,
|
||||
expectPluginMarkdown,
|
||||
filePanelPicker,
|
||||
installMarkdownPanelPlugin,
|
||||
openMarkdown,
|
||||
storedFilePanelAssociations,
|
||||
} from "../support/helpers/markdown-file-panel-plugin";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
const ASK_EVERY_TIME = "Ask every time";
|
||||
|
||||
function markdownRow(page: Page) {
|
||||
return page.getByTestId("file-panel-settings-row-ext:md");
|
||||
}
|
||||
|
||||
function markdownDropdown(page: Page, value: string) {
|
||||
return markdownRow(page).getByRole("button", { name: `.md: ${value}`, exact: true });
|
||||
}
|
||||
|
||||
function askAgain(page: Page) {
|
||||
return markdownRow(page).getByRole("button", { name: "Ask again for .md", exact: true });
|
||||
}
|
||||
|
||||
async function openFilesSettings(page: Page) {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "files");
|
||||
await expect(page.getByTestId("file-panel-settings")).toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string) {
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
await testInfo.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function expectMenuOpen(page: Page) {
|
||||
await expect(page.getByTestId("file-panel-settings-menu")).toHaveCSS("opacity", "1");
|
||||
}
|
||||
|
||||
test("Settings > Files chooses which panel opens .md and asks again on request", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "file-panel-settings-",
|
||||
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
|
||||
});
|
||||
const plugin = await installMarkdownPanelPlugin();
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFilesSettings(page);
|
||||
|
||||
// Nothing remembered: the row asks every time and has nothing to forget.
|
||||
await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(askAgain(page)).toBeDisabled();
|
||||
await capture(page, testInfo, "file-panel-settings-wide");
|
||||
|
||||
await markdownDropdown(page, ASK_EVERY_TIME).click();
|
||||
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
|
||||
await expectMenuOpen(page);
|
||||
await capture(page, testInfo, "file-panel-settings-wide-menu");
|
||||
await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click();
|
||||
await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible();
|
||||
await expect
|
||||
.poll(() => storedFilePanelAssociations(page))
|
||||
.toEqual({
|
||||
"ext:md": MARKDOWN_PANEL_ID,
|
||||
});
|
||||
await capture(page, testInfo, "file-panel-settings-wide-chosen");
|
||||
|
||||
// The next open uses the plugin without asking.
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openMarkdown(page, "notes.md");
|
||||
await expectPluginMarkdown(page, "notes.md");
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/settings/files");
|
||||
await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(askAgain(page)).toBeInViewport({ ratio: 1 });
|
||||
await capture(page, testInfo, "file-panel-settings-compact");
|
||||
await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click();
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
|
||||
await expectMenuOpen(page);
|
||||
await capture(page, testInfo, "file-panel-settings-compact-menu");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
|
||||
|
||||
// Ask again forgets the choice; the next open asks.
|
||||
await askAgain(page).click();
|
||||
await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible();
|
||||
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await plugin.uninstall();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Settings > Files explains where file panels come from when no plugin adds one", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto("/settings/files");
|
||||
const empty = page.getByTestId("file-panel-settings-empty");
|
||||
await expect(empty).toHaveText("File panels appear here when a plugin adds one", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await capture(page, testInfo, "file-panel-settings-empty-wide");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(empty).toBeVisible();
|
||||
await capture(page, testInfo, "file-panel-settings-empty-compact");
|
||||
});
|
||||
|
||||
test("Settings > Files lets a panel no host has be forgotten but not chosen", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ filePanelAssociations: { "ext:pdf": "plugin:none/none" } }),
|
||||
);
|
||||
});
|
||||
await page.goto("/settings/files");
|
||||
const row = page.getByTestId("file-panel-settings-row-ext:pdf");
|
||||
const dropdown = row.getByRole("button", { name: ".pdf: Not on a connected host" });
|
||||
await expect(dropdown).toBeDisabled({ timeout: 30_000 });
|
||||
await row.getByRole("button", { name: "Ask again for .pdf", exact: true }).click();
|
||||
await expect(page.getByTestId("file-panel-settings-empty")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
expectFileTabOpen,
|
||||
} from "../support/helpers/file-explorer";
|
||||
|
||||
const PLUGIN_ID = "file-panel-e2e";
|
||||
const PDF = Buffer.from("%PDF-1.4\n%\x00\xff\n1 0 obj<</Type/Catalog>>endobj\n%%EOF", "binary");
|
||||
const SOURCE = `import React, { useEffect, useState } from "react";
|
||||
import { Text, View, Pressable } from "react-native";
|
||||
import {
|
||||
PluginFileConflictError,
|
||||
useFileBytes,
|
||||
useFileUrl,
|
||||
usePaseo,
|
||||
useWorkspace,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui";
|
||||
function Pdf({ file, workspaceId }) {
|
||||
usePaseo();
|
||||
useQueryClient();
|
||||
const workspace = useWorkspace(workspaceId, value => ({ id: value.id }));
|
||||
const bytes = useFileBytes(file);
|
||||
const url = useFileUrl(file);
|
||||
const [urlBytes, setUrlBytes] = useState(-1);
|
||||
const href = url.status === "ready" ? url.url : null;
|
||||
useEffect(() => {
|
||||
if (href) fetch(href).then(response => response.arrayBuffer()).then(body => setUrlBytes(body.byteLength));
|
||||
}, [href]);
|
||||
if (bytes.status !== "ready" || !href) return <Loading />;
|
||||
return <View style={{ flex: 1 }}>
|
||||
<Text>PDF plugin: {file.name}</Text>
|
||||
<Text>Path {file.path}</Text>
|
||||
<Text>Bytes {bytes.bytes.length}; URL bytes {urlBytes}</Text>
|
||||
<Text>Workspace {workspace?.id}</Text>
|
||||
<DocumentFrame url={href} testID="plugin-pdf-frame" />
|
||||
</View>;
|
||||
}
|
||||
// Reads once and writes on demand, so a change on disk since that read rejects the write.
|
||||
function Markdown({ file, tab }) {
|
||||
const [text, setText] = useState(null);
|
||||
const [status, setStatus] = useState("");
|
||||
useEffect(() => { file.readText().then(setText); }, [file]);
|
||||
const save = (next) => {
|
||||
tab.setModified(true);
|
||||
file.writeText(next).then(
|
||||
() => { tab.setModified(false); setText(next); },
|
||||
(error) => setStatus(error instanceof PluginFileConflictError ? "Write rejected: changed on disk" : String(error)),
|
||||
);
|
||||
};
|
||||
if (text === null) return <Loading />;
|
||||
return <View>
|
||||
<Text>Plugin Markdown: {text}</Text>
|
||||
<Pressable accessibilityRole="button" onPress={() => save("# Saved by plugin\\n")}>
|
||||
<Text>Edit Markdown through plugin</Text>
|
||||
</Pressable>
|
||||
<Text>{status}</Text>
|
||||
</View>;
|
||||
}
|
||||
export default function contribute(client) {
|
||||
client.addFilePanel({ id: "pdf", title: "PDF", icon: "FileText",
|
||||
files: { extensions: ["pdf", "txt"] }, Component: Pdf });
|
||||
client.addFilePanel({ id: "markdown", title: "Plugin Markdown", icon: "FileText",
|
||||
files: { extensions: ["md"] }, Component: Markdown });
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
async function openWorkspaceFile(page: Page, name: string) {
|
||||
await openFileFromExplorer(page, name);
|
||||
await expectFileTabOpen(page, name);
|
||||
}
|
||||
async function associateMarkdownWithPlugin(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const key = "@paseo:app-settings";
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
settings.filePanelAssociations = { "ext:md": "plugin:file-panel-e2e/markdown" };
|
||||
localStorage.setItem(key, JSON.stringify(settings));
|
||||
});
|
||||
await page.reload();
|
||||
}
|
||||
async function editMarkdownThroughPlugin(page: Page) {
|
||||
await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click();
|
||||
}
|
||||
async function selectSource(page: Page) {
|
||||
await page
|
||||
.getByTestId("file-panel-switcher")
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Source", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
test("directory plugins render PDF bytes and write Markdown through the file handle", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-file-panels-plugin-"));
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "file-panels-",
|
||||
repo: {
|
||||
files: [
|
||||
{ path: "notes.md", content: "# Built-in Markdown\n" },
|
||||
{ path: "bytes.txt", content: "Bytes panel text document" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(workspace.repoPath, "report.pdf"), PDF);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openWorkspaceFile(page, "report.pdf");
|
||||
await expect(page.getByText("PDF plugin: report.pdf", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(`Bytes ${PDF.length}; URL bytes ${PDF.length}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(`Workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
|
||||
"src",
|
||||
/^blob:/,
|
||||
);
|
||||
await expect(
|
||||
page.getByText(`Path ${path.join(workspace.repoPath, "report.pdf")}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
const pdfScreenshot = testInfo.outputPath("plugin-pdf-web.png");
|
||||
await page.screenshot({ path: pdfScreenshot });
|
||||
await testInfo.attach("plugin-pdf-web", { path: pdfScreenshot, contentType: "image/png" });
|
||||
|
||||
// A plugin panel claiming a text file competes with Source, so Paseo asks over the plugin.
|
||||
await openWorkspaceFile(page, "bytes.txt");
|
||||
await expect(page.getByText("Open .txt files with", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByText("Open .txt files with", { exact: true })).toBeHidden();
|
||||
await expect(page.getByText("PDF plugin: bytes.txt", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
|
||||
"sandbox",
|
||||
"",
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.frameLocator('[title="Document preview"]')
|
||||
.getByText("Bytes panel text document", { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
await expect(page.getByText("Open .md files with", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Built-in Markdown", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Edit Markdown through plugin" })).toHaveCount(0);
|
||||
await associateMarkdownWithPlugin(page);
|
||||
await expect(
|
||||
page.getByText("Plugin Markdown: # Built-in Markdown", { exact: true }),
|
||||
).toBeVisible();
|
||||
gate.holdNextClientRequest("fs.file.write.request");
|
||||
await editMarkdownThroughPlugin(page);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
|
||||
gate.releaseHeldClientRequest();
|
||||
await expect(
|
||||
page.getByText("Plugin Markdown: # Saved by plugin", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
|
||||
.toBe("# Saved by plugin\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
|
||||
|
||||
// The disk changed since the plugin's last read or write: the write is rejected, not applied.
|
||||
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Changed outside\n");
|
||||
await editMarkdownThroughPlugin(page);
|
||||
await expect(page.getByText("Write rejected: changed on disk", { exact: true })).toBeVisible();
|
||||
expect(await readFile(path.join(workspace.repoPath, "notes.md"), "utf8")).toBe(
|
||||
"# Changed outside\n",
|
||||
);
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
|
||||
let closePrompt = "";
|
||||
page.once("dialog", async (dialog) => {
|
||||
closePrompt = dialog.message();
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page
|
||||
.getByTestId("workspace-tab-file_notes.md")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
await expect.poll(() => closePrompt).toContain("discard");
|
||||
|
||||
await selectSource(page);
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"),
|
||||
).toHaveText("# Changed outside");
|
||||
const markdownScreenshot = testInfo.outputPath("plugin-markdown-source-web.png");
|
||||
await page.screenshot({ path: markdownScreenshot });
|
||||
await testInfo.attach("plugin-markdown-source-web", {
|
||||
path: markdownScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await client.patchDaemonConfig({
|
||||
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
|
||||
});
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -5,15 +5,11 @@ import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "plugin-host-ui-e2e";
|
||||
|
||||
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
@@ -91,12 +87,6 @@ async function useCompactLayout(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
}
|
||||
|
||||
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await page.getByRole("button", { name: "Host UI", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openPluginModal(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
|
||||
}
|
||||
@@ -158,7 +148,6 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
|
||||
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
|
||||
await useCompactLayout(page);
|
||||
await reopenHostUiPluginFromCompactSidebar(page);
|
||||
await openPluginModal(page);
|
||||
await expectCompactPluginSheet(page);
|
||||
await savePluginIssue(page);
|
||||
|
||||
@@ -2,10 +2,6 @@ import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
openMobileAgentSidebar,
|
||||
expectMobileAgentSidebarVisible,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function openExample(page: Page, example: string) {
|
||||
await page.getByRole("button", { name: `Open ${example}`, exact: true }).click();
|
||||
@@ -122,9 +118,7 @@ async function openWideExamples(page: Page) {
|
||||
|
||||
async function openCompactExamples(page: Page) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectDeniedCopyFeedback(page: Page) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
withTimelinePlugin,
|
||||
viewInlineThinkingExample,
|
||||
requestPluginTimeline,
|
||||
interactWithStreamingCard,
|
||||
expectWholeCompletedCard,
|
||||
@@ -33,3 +34,9 @@ test("Overview preserves both consecutive tool plugin cards", async ({ page }, i
|
||||
await expectBothConsecutiveTools(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("inline-thinking example renders a streamed thought through host UI", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
await viewInlineThinkingExample(page, info);
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ function pluginClientSource(input: {
|
||||
}): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Icon } from "@getpaseo/plugin/client/react-native";
|
||||
import { Icon } from "@getpaseo/plugin/client/ui";
|
||||
import { useAgent, useWorkspace, openExternalUrl } from "@getpaseo/plugin/client";
|
||||
import { ExternalLink } from "@getpaseo/plugin/client/ui";
|
||||
import { recordComposerOpen } from "./shared/rpc";
|
||||
|
||||
@@ -15,6 +15,7 @@ async function closeMenuSheet(page: Page): Promise<void> {
|
||||
|
||||
test("project and workspace kebabs open action sheets on compact layouts", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-sheet-" });
|
||||
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-filter-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -26,6 +27,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
|
||||
/^Grouping/,
|
||||
/^Title/,
|
||||
"Show",
|
||||
"Project",
|
||||
"Show background",
|
||||
]);
|
||||
await closeMenuSheet(page);
|
||||
@@ -53,6 +55,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async
|
||||
});
|
||||
await expect(page.getByText("Workspace actions", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await secondProject.cleanup();
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -624,7 +624,9 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(page.getByTestId("sidebar-settings")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps app navigation beside the Explorer pane", async ({ page }) => {
|
||||
test("yields app navigation to Explorer and restores it when Explorer closes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
|
||||
|
||||
try {
|
||||
@@ -636,7 +638,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
|
||||
@@ -4,9 +4,10 @@ import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import {
|
||||
@@ -131,7 +132,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
|
||||
await refreshAllUsage(page);
|
||||
await usage.waitForListRequests(requestCount + 1);
|
||||
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(group.getByText("41%", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("41% used", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
@@ -178,7 +179,7 @@ test.describe("usage modal", () => {
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(hostFilter(page)).toHaveCount(0);
|
||||
await qaScreenshot(page, "usage-modal-one-host");
|
||||
await expect(group.getByText("31%")).toBeVisible();
|
||||
await expect(group.getByText("31% used")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
|
||||
@@ -240,15 +241,15 @@ test.describe("usage modal", () => {
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromItem(page);
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const alphaRefresh = group.getByTestId("usage-refresh").first();
|
||||
await alphaRefresh.click();
|
||||
await expect
|
||||
.poll(() => forcedRefreshes(usage))
|
||||
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
|
||||
await expect(group.getByText("58%")).toBeVisible();
|
||||
await expect(group.getByText("12%")).toBeVisible();
|
||||
await expect(group.getByText("58% used")).toBeVisible();
|
||||
await expect(group.getByText("12% used")).toBeVisible();
|
||||
|
||||
await group.getByTestId("usage-refresh").nth(1).hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
@@ -332,7 +333,7 @@ test.describe("usage modal", () => {
|
||||
await hostFilter(page).click();
|
||||
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
||||
await qaScreenshot(page, "usage-modal-picked-host");
|
||||
@@ -347,7 +348,7 @@ test.describe("usage modal", () => {
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
await openUsageFromItem(page);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await secondary.close().catch(() => undefined);
|
||||
@@ -513,7 +514,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expect(screen.getByText("69% left")).toBeVisible();
|
||||
await showUsageAs(page, "used");
|
||||
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, 31% · resets /,
|
||||
/^Pin Claude Session, 31% used · resets /,
|
||||
);
|
||||
await openUsageOptions(page);
|
||||
await closeUsageOptions(page);
|
||||
@@ -576,7 +577,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
|
||||
},
|
||||
]);
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42%");
|
||||
await expectLoginUsageOnly(page, "42% used");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
@@ -599,6 +600,7 @@ for (const compact of [false, true]) {
|
||||
}) => {
|
||||
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
|
||||
try {
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(
|
||||
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
|
||||
);
|
||||
@@ -606,7 +608,10 @@ for (const compact of [false, true]) {
|
||||
if (compact) await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectLoginErrors(page, ["Claude: Initial failure"]);
|
||||
await expectLoginUsageFreshness(page, "No successful update", compact);
|
||||
await test.step("developer errors do not interrupt freshness inspection", async () => {
|
||||
await page.evaluate(() => console.error("E2E: developer toast while Usage is open"));
|
||||
await expectLoginUsageFreshness(page, "No successful update", compact);
|
||||
});
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
@@ -615,7 +620,8 @@ for (const compact of [false, true]) {
|
||||
// Move only the browser clock; real daemon fetches continue using the host clock.
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42%");
|
||||
await expectLoginUsageOnly(page, "42% used");
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({ status: "error", error: "Rate limited" });
|
||||
@@ -640,8 +646,9 @@ for (const compact of [false, true]) {
|
||||
});
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43%");
|
||||
await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageOnly(page, "43% used");
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
@@ -651,7 +658,88 @@ for (const compact of [false, true]) {
|
||||
|
||||
async function expectRetainedLoginUsage(page: Page, message: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible();
|
||||
await expect(card.getByText(/^42% used(?:$| ·)/)).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
|
||||
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
|
||||
}
|
||||
|
||||
async function switchToJapanese(page: Page) {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "general");
|
||||
await page.getByRole("button", { name: "System", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "日本語 - Japanese", exact: true }).click();
|
||||
await page.getByRole("button", { name: "戻る", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openJapaneseUsage(page: Page) {
|
||||
await page.getByRole("button", { name: "使用状況", exact: true }).click();
|
||||
await expect(page.getByTestId("usage-modal").first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("usage percentages state their meaning and follow the selected language", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const login = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 70, summary: true }],
|
||||
});
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expect(page.getByText("70% used", { exact: true })).toBeVisible();
|
||||
await switchToJapanese(page);
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/);
|
||||
await openJapaneseUsage(page);
|
||||
await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "設定", exact: true }).last().click();
|
||||
await expect(page.getByText("割合", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("使用済み", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("残り", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await login.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("open usage clocks advance without refreshing or reopening", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const now = new Date();
|
||||
const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString();
|
||||
const login = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 42, resetsAt: reset },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 },
|
||||
],
|
||||
balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }],
|
||||
});
|
||||
try {
|
||||
await openCompactUsageWithClock(page, now);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
|
||||
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
|
||||
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
|
||||
// After two browser minutes the latest displayed tick is one or two minutes old.
|
||||
await advanceUsageClock(page, 120_000);
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
|
||||
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
|
||||
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
|
||||
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
|
||||
/^resets (28|29)m$/,
|
||||
);
|
||||
} finally {
|
||||
await login.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openCompactUsageWithClock(page: Page, now: Date) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.clock.install({ time: now });
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
}
|
||||
|
||||
async function advanceUsageClock(page: Page, milliseconds: number) {
|
||||
await page.clock.fastForward(milliseconds);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ test.describe("usage settings", () => {
|
||||
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("70%")).toBeVisible();
|
||||
await expect(card.getByText("70% used")).toBeVisible();
|
||||
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
@@ -131,11 +131,11 @@ test.describe("usage settings", () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
const card = page.getByTestId("usage-card");
|
||||
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("23% used")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await refreshAllUsage(page);
|
||||
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
|
||||
await expect(card.getByText("64%")).toBeVisible();
|
||||
await expect(card.getByText("64% used")).toBeVisible();
|
||||
});
|
||||
|
||||
test("asks to update a host without usage support and never calls it", async ({ page }) => {
|
||||
|
||||
@@ -4,8 +4,8 @@ env APP_ID=sh.paseo.debug
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-explorer-toggle\""
|
||||
wait "id=\"explorer-tab-files\"" 10000
|
||||
press "id=\"explorer-tab-files\""
|
||||
wait "id=\"workspace-tab-files\"" 10000
|
||||
press "id=\"workspace-tab-files\""
|
||||
wait "id=\"file-explorer-row-0\"" 10000
|
||||
longpress "id=\"file-explorer-row-0\"" 600
|
||||
wait "id=\"file-explorer-row-0-context-menu-content\"" 10000
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Native retained status-ring regression
|
||||
|
||||
This runs the production native ring and clock inside real retained panels with
|
||||
Reanimated on Android. A probe consumes the same public rotation hook and counts
|
||||
UI-runtime value changes. No Reanimated mocks or provider processes are used.
|
||||
|
||||
Use an Android development client compatible with the checkout's native dependencies.
|
||||
In a disposable checkout, temporarily replace `packages/app/index.ts` with:
|
||||
|
||||
```tsx
|
||||
import "./src/polyfills/install-early";
|
||||
import "./src/styles/unistyles";
|
||||
import { registerRootComponent } from "expo";
|
||||
import NativeRingRegression from "./e2e/mobile/status-ring/native";
|
||||
registerRootComponent(NativeRingRegression);
|
||||
```
|
||||
|
||||
Start Metro in `packages/app`:
|
||||
|
||||
```sh
|
||||
APP_VARIANT=development npx expo start --localhost --port 8096
|
||||
```
|
||||
|
||||
With one Android emulator running, open that bundle:
|
||||
|
||||
```sh
|
||||
agent-device open sh.paseo.debug 'exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8096' --platform android --session status-ring --metro-port 8096
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/status-ring/android.mjs status-ring http://127.0.0.1:8096
|
||||
```
|
||||
|
||||
The test checks visible rotation, hidden-panel inactivity while another ring is
|
||||
visible, inactivity with both panels hidden, and synchronized resumption. Each
|
||||
sample reads counters on the UI runtime; elapsed time includes device-command
|
||||
latency. The baseline fails at the first hidden-panel assertion.
|
||||
|
||||
Restore the original `packages/app/index.ts`, close the agent-device session,
|
||||
and stop Metro afterward. The fixture substitutes running rings for a live agent
|
||||
and toggles retained panels directly; it does not exercise workspace navigation
|
||||
or measure whole-app CPU usage. It is excluded from routine unit tests because
|
||||
it requires a native runtime.
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const [, , session, metroUrl] = process.argv;
|
||||
assert(session && metroUrl, "Pass an agent-device session and Metro URL serving native.tsx");
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
function evaluate(expression) {
|
||||
const value = ad("cdp", "runtime", "eval", "--expr", expression, "--json").trim();
|
||||
return value === "undefined" ? undefined : JSON.parse(value);
|
||||
}
|
||||
let target;
|
||||
const deadline = Date.now() + 30_000;
|
||||
do {
|
||||
target = ad("cdp", "target", "list", "--url", metroUrl)
|
||||
.split("\n")
|
||||
.find((line) => line.includes("sh.paseo.debug"));
|
||||
if (!target) await delay(250);
|
||||
} while (!target && Date.now() < deadline);
|
||||
assert(target, "Open the Android dev client on this Metro bundle first");
|
||||
ad("cdp", "target", "select", target.split(/\s+/)[0]);
|
||||
let ready = false;
|
||||
do {
|
||||
ready = evaluate("Object.keys(globalThis.__ringReaders ?? {}).length === 2");
|
||||
if (!ready) await delay(250);
|
||||
} while (!ready && Date.now() < deadline);
|
||||
assert(ready, "The two native probes must mount before sampling");
|
||||
async function sample() {
|
||||
evaluate("Object.values(globalThis.__ringReaders).forEach(read => read())");
|
||||
await delay(100);
|
||||
return JSON.parse(evaluate("JSON.stringify(globalThis.__ringSamples)"));
|
||||
}
|
||||
async function observe(label) {
|
||||
const before = await sample();
|
||||
await delay(600);
|
||||
const after = await sample();
|
||||
const deltas = Object.fromEntries(
|
||||
Object.keys(before).map((key) => [key, after[key].updates - before[key].updates]),
|
||||
);
|
||||
console.log(JSON.stringify({ label, deltas, after }));
|
||||
return { deltas, after };
|
||||
}
|
||||
const visible = await observe("both visible");
|
||||
assert(visible.deltas.first > 0 && visible.deltas.second > 0);
|
||||
assert.equal(visible.after.first.rotation, visible.after.second.rotation);
|
||||
ad("press", 'label="HIDE FIRST"');
|
||||
await delay(200);
|
||||
const hidden = await observe("first hidden, second visible");
|
||||
assert.equal(hidden.deltas.first, 0, "A retained hidden ring must stop UI-thread rotation updates");
|
||||
assert(hidden.deltas.second > 0, "Visible rings must continue rotating");
|
||||
ad("press", 'label="HIDE SECOND"');
|
||||
await delay(200);
|
||||
const allHidden = await observe("both hidden");
|
||||
assert.equal(allHidden.deltas.first, 0);
|
||||
assert.equal(allHidden.deltas.second, 0);
|
||||
ad("press", 'label="SHOW FIRST"');
|
||||
ad("press", 'label="SHOW SECOND"');
|
||||
await delay(200);
|
||||
const restored = await observe("both shown again");
|
||||
assert(restored.deltas.first > 0 && restored.deltas.second > 0);
|
||||
assert.equal(
|
||||
restored.after.first.rotation,
|
||||
restored.after.second.rotation,
|
||||
"Restored rings must share the current phase",
|
||||
);
|
||||
console.log("PASS native retained-ring lifecycle");
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useLayoutEffect, useState } from "react";
|
||||
import { Button, Text, View } from "react-native";
|
||||
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { useStatusRingRotation } from "@/components/status-ring/clock";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
|
||||
interface Sample {
|
||||
updates: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __ringSamples: Record<string, Sample>;
|
||||
var __ringReaders: Record<string, () => void>;
|
||||
}
|
||||
|
||||
function recordSample(name: string, value: { updates: number; rotation: number }) {
|
||||
globalThis.__ringSamples[name] = value;
|
||||
}
|
||||
|
||||
function Probe({ name }: { name: string }) {
|
||||
const rotation = useStatusRingRotation();
|
||||
const updates = useSharedValue(0);
|
||||
useAnimatedReaction(
|
||||
() => rotation.value,
|
||||
() => {
|
||||
updates.value += 1;
|
||||
},
|
||||
);
|
||||
// Read the real UI runtime, without scheduling a continuous JS observer.
|
||||
useLayoutEffect(() => {
|
||||
globalThis.__ringSamples ??= {};
|
||||
globalThis.__ringReaders ??= {};
|
||||
globalThis.__ringReaders[name] = () =>
|
||||
scheduleOnUI(() => {
|
||||
"worklet";
|
||||
const sample = { updates: updates.value, rotation: rotation.value };
|
||||
scheduleOnRN(recordSample, name, sample);
|
||||
});
|
||||
return () => {
|
||||
delete globalThis.__ringReaders[name];
|
||||
};
|
||||
}, [name, rotation, updates]);
|
||||
return <StatusRing />;
|
||||
}
|
||||
|
||||
const containerStyle = { flex: 1, padding: 60, backgroundColor: "white" };
|
||||
|
||||
export default function NativeRingRegression() {
|
||||
const [first, setFirst] = useState(true);
|
||||
const [second, setSecond] = useState(true);
|
||||
const toggleFirst = useCallback(() => setFirst((value) => !value), []);
|
||||
const toggleSecond = useCallback(() => setSecond((value) => !value), []);
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Text>Native retained status rings</Text>
|
||||
<Button title={first ? "Hide first" : "Show first"} onPress={toggleFirst} />
|
||||
<Button title={second ? "Hide second" : "Show second"} onPress={toggleSecond} />
|
||||
<RetainedPanel active={first}>
|
||||
<Probe name="first" />
|
||||
</RetainedPanel>
|
||||
<RetainedPanel active={second}>
|
||||
<Probe name="second" />
|
||||
</RetainedPanel>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** @param {import('@playwright/test').Page} page */
|
||||
export async function installDeveloperToastDismissal(page) {
|
||||
// Developer chrome must not cover app actions or move the test's pointer/focus.
|
||||
// Console errors remain in the runner's logs.
|
||||
const dismiss = page.locator("#error-toast").getByRole("button", { name: "", exact: true });
|
||||
await page.addLocatorHandler(dismiss, () => dismiss.dispatchEvent("click"), {
|
||||
// A new logged error can replace the toast before the old one disappears.
|
||||
noWaitAfter: true,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { installDeveloperToastDismissal } from "./developer-ui.mjs";
|
||||
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
|
||||
import { startE2EWorker } from "./helpers/e2e-worker";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
@@ -130,6 +131,8 @@ const test = daemonTest.extend<{
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
await installDeveloperToastDismissal(page);
|
||||
|
||||
const entries: string[] = [];
|
||||
|
||||
page.on("console", (message) => {
|
||||
|
||||
@@ -134,6 +134,8 @@ function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>):
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: "none",
|
||||
// Automation never opens native DevTools or installs its desktop shell.
|
||||
EXPO_UNSTABLE_HEADLESS: "1",
|
||||
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_WEB_PLATFORM: "electron" } : {}),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
@@ -363,10 +363,11 @@ export async function resizeComposerViewport(
|
||||
await page.setViewportSize(sizes[orientation]);
|
||||
// Before the app has loaded there is no layout to wait for.
|
||||
if (page.url() === "about:blank") return;
|
||||
// Crossing the compact breakpoint remounts the shell and the editor. Anything that
|
||||
// follows (a shortcut, a locator) belongs to the layout the new size implies, so
|
||||
// wait for that layout before returning.
|
||||
await expect(page.getByTestId("compact-shell")).toHaveCount(orientation === "landscape" ? 0 : 1);
|
||||
// The shell and editor survive the breakpoint. Wait for the shell's layout
|
||||
// marker to update before interacting with the resized composer.
|
||||
await expect(
|
||||
page.getByTestId(orientation === "landscape" ? "wide-shell" : "compact-shell"),
|
||||
).toBeVisible();
|
||||
}
|
||||
export async function selectComposerDraftRange(
|
||||
page: Page,
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Pr
|
||||
await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 });
|
||||
const card = contextWindowDetails(page);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toBeInViewport();
|
||||
await expect(card).toBeInViewport({ ratio: 1 });
|
||||
return card;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { expectFileTabOpen, openFileFromExplorer } from "./file-explorer";
|
||||
|
||||
/** A directory plugin whose "Rich Markdown" file panel claims `.md`, next to the built-in. */
|
||||
export const MARKDOWN_PANEL_PLUGIN_ID = "file-panel-picker-e2e";
|
||||
export const MARKDOWN_PANEL_ID = `plugin:${MARKDOWN_PANEL_PLUGIN_ID}/rich-markdown`;
|
||||
export const BUILTIN_MARKDOWN_OPTION = "Markdown — Paseo";
|
||||
export const PLUGIN_MARKDOWN_OPTION = `Rich Markdown — ${MARKDOWN_PANEL_PLUGIN_ID}`;
|
||||
export const SOURCE_OPTION = "Source — Paseo";
|
||||
|
||||
const SOURCE = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
function RichMarkdown({ file }) {
|
||||
return <View><Text>Rich Markdown plugin: {file.name}</Text></View>;
|
||||
}
|
||||
export default function contribute(client) {
|
||||
client.addFilePanel({ id: "rich-markdown", title: "Rich Markdown", icon: "FileType",
|
||||
files: { extensions: ["md"] }, Component: RichMarkdown });
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
/** Enables plugins on the test daemon and installs the plugin; `uninstall` restores both. */
|
||||
export async function installMarkdownPanelPlugin(): Promise<{ uninstall(): Promise<void> }> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-markdown-file-panel-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
const uninstall = async () => {
|
||||
await client.removePlugin(MARKDOWN_PANEL_PLUGIN_ID);
|
||||
await client.patchDaemonConfig({
|
||||
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
|
||||
});
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
};
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: MARKDOWN_PANEL_PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
} catch (error) {
|
||||
await uninstall();
|
||||
throw error;
|
||||
}
|
||||
return { uninstall };
|
||||
}
|
||||
|
||||
export function filePanelPicker(page: Page) {
|
||||
return page.getByText("Open .md files with", { exact: true });
|
||||
}
|
||||
|
||||
export async function openMarkdown(page: Page, name: string): Promise<void> {
|
||||
await openFileFromExplorer(page, name);
|
||||
await expectFileTabOpen(page, name);
|
||||
}
|
||||
|
||||
export async function expectBuiltinMarkdown(page: Page, heading: string): Promise<void> {
|
||||
await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectPluginMarkdown(page: Page, name: string): Promise<void> {
|
||||
await expect(page.getByText(`Rich Markdown plugin: ${name}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function storedFilePanelAssociations(page: Page): Promise<unknown> {
|
||||
return page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem("@paseo:app-settings") ?? "{}");
|
||||
return settings.filePanelAssociations ?? {};
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
|
||||
@@ -274,3 +274,40 @@ export async function withPopoverTimelinePlugin(run: () => Promise<void>): Promi
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
const THOUGHT =
|
||||
"Need to find the scroll container, the layout effect that watches for new messages, and any gesture handler that might fight with programmatic scrolling. Probably a ref on the FlatList plus a near-bottom threshold.";
|
||||
|
||||
/** Exercises the maintained inline-thinking renderer against real streamed reasoning. */
|
||||
export async function viewInlineThinkingExample(page: Page, info: TestInfo): Promise<void> {
|
||||
info.setTimeout(120_000);
|
||||
const example = await copyPluginExample("inline-thinking");
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "inline-thinking-",
|
||||
title: "Inline thinking example",
|
||||
model: "one-minute-stream",
|
||||
});
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
|
||||
await requestPluginTimeline(agent);
|
||||
const thought = page.getByText(/^Need to find the scroll container/);
|
||||
await expect(thought).toBeVisible({ timeout: 60_000 });
|
||||
await expect(thought).not.toHaveText(THOUGHT);
|
||||
await info.attach("inline-thinking-streaming", {
|
||||
body: await page.screenshot({ path: info.outputPath("inline-thinking-streaming.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expect(thought).toHaveText(THOUGHT, { timeout: 30_000 });
|
||||
} finally {
|
||||
await client.removePlugin("inline-thinking");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
await client.close();
|
||||
await agent.cleanup();
|
||||
await example.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const SECTION_LABELS = {
|
||||
appearance: "Appearance",
|
||||
sidebar: "Sidebar",
|
||||
editor: "Editor",
|
||||
files: "Files",
|
||||
terminal: "Terminal",
|
||||
browser: "Browser",
|
||||
shortcuts: "Shortcuts",
|
||||
|
||||
@@ -276,15 +276,22 @@ export async function swipeOpenMobileExplorer(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function closeMobileExplorer(page: Page): Promise<void> {
|
||||
const closeButton = page.getByTestId("explorer-close");
|
||||
const closeButton = page
|
||||
.getByTestId("workspace-explorer-sidebar")
|
||||
.getByRole("button", { name: "Close Explorer sidebar", exact: true });
|
||||
await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 });
|
||||
await closeButton.click();
|
||||
}
|
||||
|
||||
export async function expectMobileExplorerVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 });
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeInViewport({
|
||||
ratio: 1,
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectMobileExplorerHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport({
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ useDefaultAndroidSdkVersions()
|
||||
def skiaPackage = new File(["node", "--print", "require.resolve('@shopify/react-native-skia/package.json')"].execute(null, projectDir).text.trim()).parentFile
|
||||
|
||||
android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
namespace "sh.paseo.diffprototype"
|
||||
defaultConfig {
|
||||
externalNativeBuild {
|
||||
|
||||
+39
-38
@@ -58,11 +58,11 @@
|
||||
"@marijn/find-cluster-break": "^1.0.2",
|
||||
"@mattermost/react-native-paste-input": "2.0.1",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
"@react-native-masked-view/masked-view": "0.3.2",
|
||||
"@react-native/normalize-colors": "0.86.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
"@shopify/react-native-skia": "2.2.12",
|
||||
"@shopify/react-native-skia": "2.6.2",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
@@ -75,30 +75,30 @@
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.212",
|
||||
"@xterm/xterm": "^6.1.0-beta.213",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^54.0.18",
|
||||
"expo-asset": "~12.0.12",
|
||||
"expo-audio": "~1.0.13",
|
||||
"expo-build-properties": "^1.0.9",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "~8.0.7",
|
||||
"expo-constants": "~18.0.9",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-dev-client": "^6.0.15",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-localization": "~17.0.9",
|
||||
"expo-notifications": "^0.32.16",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-sharing": "^14.0.8",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo": "~57.0.30",
|
||||
"expo-asset": "~57.0.20",
|
||||
"expo-audio": "~57.0.5",
|
||||
"expo-build-properties": "~57.0.22",
|
||||
"expo-camera": "~57.0.6",
|
||||
"expo-clipboard": "~57.0.2",
|
||||
"expo-constants": "~57.0.22",
|
||||
"expo-crypto": "~57.0.4",
|
||||
"expo-dev-client": "~57.0.20",
|
||||
"expo-document-picker": "~57.0.3",
|
||||
"expo-file-system": "~57.0.7",
|
||||
"expo-haptics": "~57.0.3",
|
||||
"expo-image": "~57.0.5",
|
||||
"expo-image-manipulator": "~57.0.21",
|
||||
"expo-image-picker": "~57.0.20",
|
||||
"expo-keep-awake": "~57.0.2",
|
||||
"expo-linking": "~57.0.13",
|
||||
"expo-localization": "~57.0.2",
|
||||
"expo-notifications": "~57.0.23",
|
||||
"expo-router": "~57.0.28",
|
||||
"expo-sharing": "~57.0.23",
|
||||
"expo-splash-screen": "~57.0.10",
|
||||
"expo-sqlite": "~57.0.4",
|
||||
"expo-system-ui": "~57.0.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
@@ -111,25 +111,25 @@
|
||||
"micromark-util-decode-string": "2.0.1",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-freeze": "1.0.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-native": "0.81.5",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "~4.3.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
"react-native-reanimated": "4.5.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
"react-native-worklets": "~0.8.3",
|
||||
"react-native-webview": "13.16.1",
|
||||
"react-native-worklets": "0.10.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
@@ -151,11 +151,12 @@
|
||||
"@vitest/browser": "^4.1.7",
|
||||
"@vitest/browser-playwright": "^4.1.7",
|
||||
"@xterm/headless": "^6.1.0-beta.213",
|
||||
"babel-preset-expo": "~57.0.16",
|
||||
"dotenv": "^17.2.3",
|
||||
"eas-cli": "^16.24.1",
|
||||
"esbuild": "0.28.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"eslint-config-expo": "~57.0.2",
|
||||
"expo-gradle-jvmargs": "^1.1.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^20.0.3",
|
||||
|
||||
@@ -4,26 +4,33 @@ const {
|
||||
IOSConfig,
|
||||
withAppDelegate,
|
||||
withDangerousMod,
|
||||
withInfoPlist,
|
||||
withPodfile,
|
||||
} = require("expo/config-plugins");
|
||||
|
||||
const MODULE_SETUP = " PasteInputModule.setup(factory.rootViewFactory)";
|
||||
const REACT_NATIVE_START_END = " launchOptions: launchOptions)\n#endif";
|
||||
const BRIDGING_HEADER_IMPORT = "#import <react-native-paste-input/PasteInputModule.h>";
|
||||
const PODFILE_NEW_ARCH_DISABLED =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
|
||||
const PODFILE_NEW_ARCH_ENABLED =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'";
|
||||
const SCENE_DELEGATE = `
|
||||
@objc(PaseoSceneDelegate)
|
||||
class PaseoSceneDelegate: ExpoAppSceneDelegate {
|
||||
override func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
guard let provider = UIApplication.shared.delegate as? ExpoReactNativeFactoryProvider,
|
||||
let factory = provider.reactNativeFactory else { return }
|
||||
PasteInputModule.setup(factory.rootViewFactory)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function configurePasteInputAppDelegate(contents) {
|
||||
const configured = contents.replace(`\n${MODULE_SETUP}`, "");
|
||||
if (!configured.includes(REACT_NATIVE_START_END)) {
|
||||
throw new Error("Could not register paste input after React Native starts");
|
||||
if (contents.includes("class PaseoSceneDelegate:")) return contents;
|
||||
if (!contents.includes("ExpoReactNativeFactoryProvider")) {
|
||||
throw new Error("Paste input requires Expo scene support");
|
||||
}
|
||||
return configured.replace(
|
||||
REACT_NATIVE_START_END,
|
||||
` launchOptions: launchOptions)\n${MODULE_SETUP}\n#endif`,
|
||||
);
|
||||
return `${contents.trimEnd()}\n${SCENE_DELEGATE}`;
|
||||
}
|
||||
|
||||
function configurePasteInputBridgingHeader(contents) {
|
||||
@@ -33,19 +40,6 @@ function configurePasteInputBridgingHeader(contents) {
|
||||
return `${contents.trimEnd()}\n${BRIDGING_HEADER_IMPORT}\n`;
|
||||
}
|
||||
|
||||
function configurePasteInputPodfile(contents) {
|
||||
if (contents.includes(PODFILE_NEW_ARCH_ENABLED)) {
|
||||
return contents;
|
||||
}
|
||||
if (!contents.includes(PODFILE_NEW_ARCH_DISABLED)) {
|
||||
throw new Error("Could not configure the new architecture for paste input");
|
||||
}
|
||||
return contents.replace(
|
||||
PODFILE_NEW_ARCH_DISABLED,
|
||||
`${PODFILE_NEW_ARCH_DISABLED}\n${PODFILE_NEW_ARCH_ENABLED}`,
|
||||
);
|
||||
}
|
||||
|
||||
function withPasteInput(config) {
|
||||
const withDelegate = withAppDelegate(config, (modConfig) => {
|
||||
if (modConfig.modResults.language !== "swift") {
|
||||
@@ -54,7 +48,18 @@ function withPasteInput(config) {
|
||||
modConfig.modResults.contents = configurePasteInputAppDelegate(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
const withPods = withPodfile(withDelegate, (modConfig) => {
|
||||
const withScene = withInfoPlist(withDelegate, (modConfig) => {
|
||||
const scenes =
|
||||
modConfig.modResults.UIApplicationSceneManifest?.UISceneConfigurations
|
||||
?.UIWindowSceneSessionRoleApplication;
|
||||
if (!scenes || scenes.length !== 1) {
|
||||
throw new Error("Paste input requires Expo's single scene configuration");
|
||||
}
|
||||
scenes[0].UISceneDelegateClassName = "PaseoSceneDelegate";
|
||||
return modConfig;
|
||||
});
|
||||
const withPods = withPodfile(withScene, (modConfig) => {
|
||||
// Paste input checks this legacy flag even though RN 0.86 only supports Fabric.
|
||||
modConfig.modResults.contents = configurePasteInputPodfile(modConfig.modResults.contents);
|
||||
return modConfig;
|
||||
});
|
||||
@@ -79,4 +84,9 @@ function withPasteInputBridgingHeader(config) {
|
||||
module.exports = withPasteInput;
|
||||
module.exports.configurePasteInputAppDelegate = configurePasteInputAppDelegate;
|
||||
module.exports.configurePasteInputBridgingHeader = configurePasteInputBridgingHeader;
|
||||
|
||||
function configurePasteInputPodfile(contents) {
|
||||
const flag = "ENV['RCT_NEW_ARCH_ENABLED'] = '1'";
|
||||
return contents.includes(flag) ? contents : `${flag}\n${contents}`;
|
||||
}
|
||||
module.exports.configurePasteInputPodfile = configurePasteInputPodfile;
|
||||
|
||||
@@ -7,22 +7,14 @@ const {
|
||||
} = require("./with-paste-input");
|
||||
|
||||
describe("withPasteInput", () => {
|
||||
it("registers the module after React Native starts", () => {
|
||||
const source = [
|
||||
"import ReactAppDependencyProvider",
|
||||
"",
|
||||
" bindReactNativeFactory(factory)",
|
||||
" factory.startReactNative(",
|
||||
' withModuleName: "main",',
|
||||
" in: window,",
|
||||
" launchOptions: launchOptions)",
|
||||
"#endif",
|
||||
].join("\n");
|
||||
it("registers paste input after Expo connects the React Native scene", () => {
|
||||
const source = "class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {}";
|
||||
const configured = configurePasteInputAppDelegate(source);
|
||||
|
||||
expect(configured).toContain(
|
||||
" launchOptions: launchOptions)\n PasteInputModule.setup(factory.rootViewFactory)\n#endif",
|
||||
expect(configured).toContain("class PaseoSceneDelegate: ExpoAppSceneDelegate");
|
||||
expect(configured.indexOf("super.scene(")).toBeLessThan(
|
||||
configured.indexOf("PasteInputModule.setup("),
|
||||
);
|
||||
expect(configured).toContain("provider.reactNativeFactory");
|
||||
expect(configurePasteInputAppDelegate(configured)).toBe(configured);
|
||||
});
|
||||
|
||||
@@ -33,15 +25,11 @@ describe("withPasteInput", () => {
|
||||
expect(configured).toContain("#import <react-native-paste-input/PasteInputModule.h>");
|
||||
expect(configurePasteInputBridgingHeader(configured)).toBe(configured);
|
||||
});
|
||||
|
||||
it("sets the new architecture environment before CocoaPods evaluates dependencies", () => {
|
||||
it("enables the legacy new-architecture flag required by the paste input podspec", () => {
|
||||
const source =
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
|
||||
"require File.join(File.dirname(`node --print \"require.resolve('expo/package.json')\"`), 'scripts/autolinking')";
|
||||
const configured = configurePasteInputPodfile(source);
|
||||
|
||||
expect(configured).toContain(
|
||||
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'",
|
||||
);
|
||||
expect(configured).toBe("ENV['RCT_NEW_ARCH_ENABLED'] = '1'\n" + source);
|
||||
expect(configurePasteInputPodfile(configured)).toBe(configured);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// After Expo prebuild, run from packages/app/android:
|
||||
// ./gradlew --init-script ../scripts/verify-android-ndk.gradle verifyAndroidNdk
|
||||
gradle.projectsEvaluated {
|
||||
rootProject.tasks.register('verifyAndroidNdk') {
|
||||
doLast {
|
||||
def expected = rootProject.ext.ndkVersion
|
||||
def nativeProjects = rootProject.subprojects.findAll { subproject ->
|
||||
def android = subproject.extensions.findByName('android')
|
||||
android != null && (android.externalNativeBuild.cmake.path != null ||
|
||||
android.externalNativeBuild.ndkBuild.path != null)
|
||||
}
|
||||
assert !nativeProjects.empty : 'No native Android modules found'
|
||||
println "Project NDK: ${expected}"
|
||||
def mismatches = nativeProjects.findAll { subproject ->
|
||||
def actual = subproject.android.ndkVersion
|
||||
println "${subproject.path} NDK: ${actual}"
|
||||
actual != expected
|
||||
}
|
||||
assert mismatches.empty : "Native Android modules must use project NDK ${expected}: ${mismatches*.path}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useFocusEffect } from "expo-router/react-navigation";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native";
|
||||
import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -445,11 +445,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId ?? "",
|
||||
}),
|
||||
checkout: {
|
||||
serverId: resolvedServerId,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
},
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
|
||||
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
|
||||
import "@/styles/unistyles";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
@@ -41,7 +44,7 @@ import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
||||
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
|
||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||
import { WorkspaceExplorerOpenGesture } from "@/mobile-panels/explorer-open-gesture";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
@@ -90,7 +93,6 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
|
||||
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
@@ -468,17 +470,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const viewportWidth = windowWidth - insets.left - insets.right;
|
||||
|
||||
const cycleTheme = useCallback(() => {
|
||||
void updateSettings({ theme: getNextThemePreference(settings.theme) });
|
||||
}, [settings.theme, updateSettings]);
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const explorerSidebarPresentation = resolveExplorerSidebarPresentation({
|
||||
isCompact: isCompactLayout,
|
||||
});
|
||||
const usesCompactExplorerHost = explorerSidebarPresentation !== "pane";
|
||||
useCompactWebViewportZoomLock(isCompactLayout);
|
||||
const pathname = usePathname();
|
||||
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
|
||||
@@ -516,7 +516,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
useActiveWorktreeNewAction();
|
||||
useGlobalNewWorkspaceAction();
|
||||
|
||||
const activeWorkspace = useActiveWorkspaceSelection();
|
||||
const explorerVisible = useIsExplorerSidebarOpen({
|
||||
isCompact: isCompactLayout,
|
||||
workspaceKey: activeWorkspace
|
||||
? `${activeWorkspace.serverId}:${activeWorkspace.workspaceId}`
|
||||
: null,
|
||||
});
|
||||
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
|
||||
explorerVisible: isWorkspaceRoute && explorerVisible,
|
||||
isSettingsRoute: pathname.includes("/settings"),
|
||||
});
|
||||
const desktopSidebarMounted = hasMountedDesktopSidebar && !isWorkspaceFocusModeEnabled;
|
||||
@@ -548,28 +556,25 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
if (isWeb) {
|
||||
themedSidebarChrome = <AppearanceStyleBoundary>{sidebarChrome}</AppearanceStyleBoundary>;
|
||||
}
|
||||
const workspaceChromeStyle = useMemo(
|
||||
() => [rowStyle, !isCompactLayout && { paddingLeft: insets.left, paddingRight: insets.right }],
|
||||
[insets.left, insets.right, isCompactLayout],
|
||||
);
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
<View style={workspaceChromeStyle}>
|
||||
<WorkspaceLayoutModePublisher />
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||
{themedSidebarChrome}
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
{usesCompactExplorerHost ? (
|
||||
<CompactExplorerSidebarHost
|
||||
enabled={chromeEnabled}
|
||||
presentation={explorerSidebarPresentation === "dock" ? "dock" : "overlay"}
|
||||
<WorkspaceExplorerOpenGesture enabled={chromeEnabled && isCompactLayout}>
|
||||
<WindowChromeRegion
|
||||
corners={isCompactLayout && chromeEnabled ? "both" : appChromeLayout.contentCorners}
|
||||
>
|
||||
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</WindowChromeRegion>
|
||||
</CompactExplorerSidebarHost>
|
||||
) : (
|
||||
<WindowChromeRegion corners={appChromeLayout.contentCorners}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</WindowChromeRegion>
|
||||
)}
|
||||
</WorkspaceExplorerOpenGesture>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -620,15 +625,27 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
</View>
|
||||
);
|
||||
|
||||
const content = isCompactLayout ? (
|
||||
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
|
||||
) : (
|
||||
surface
|
||||
return (
|
||||
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
|
||||
{surface}
|
||||
</AppContainerLifetime>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContainerLifetime({
|
||||
children,
|
||||
compactGesturesEnabled,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
compactGesturesEnabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComposerEditingSessionsProvider>
|
||||
<CommandCenterProvider>{content}</CommandCenterProvider>
|
||||
<CommandCenterProvider>
|
||||
<MobileGestureWrapper chromeEnabled={compactGesturesEnabled}>
|
||||
{children}
|
||||
</MobileGestureWrapper>
|
||||
</CommandCenterProvider>
|
||||
</ComposerEditingSessionsProvider>
|
||||
);
|
||||
}
|
||||
@@ -662,10 +679,15 @@ function MobileGestureWrapper({
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const openGesture = useOpenAgentListGesture(chromeEnabled);
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<View collapsable={false} style={layoutStyles.surfaceFill} testID="compact-shell">
|
||||
<View
|
||||
collapsable={false}
|
||||
style={layoutStyles.surfaceFill}
|
||||
testID={isCompactLayout ? "compact-shell" : "wide-shell"}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
|
||||
@@ -10,6 +10,8 @@ const HOST_STACK_SCREEN_OPTIONS = {
|
||||
animation: "none" as const,
|
||||
};
|
||||
|
||||
const SCREEN_OWNED_APPEARANCE = ["workspace/[workspaceId]/index"] as const;
|
||||
|
||||
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
|
||||
|
||||
export default function HostRouteLayout() {
|
||||
@@ -34,7 +36,10 @@ function KnownHostRoute() {
|
||||
}
|
||||
|
||||
const stack = (
|
||||
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<ThemedStack
|
||||
screenOptions={HOST_STACK_SCREEN_OPTIONS}
|
||||
screenOwnedAppearance={SCREEN_OWNED_APPEARANCE}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="workspace/[workspaceId]/index" />
|
||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useNavigation } from "expo-router/react-navigation";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
|
||||
@@ -33,7 +33,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
@@ -943,7 +943,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
paddingTop: theme.spacing[12],
|
||||
},
|
||||
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
||||
backdrop: { ...StyleSheet.absoluteFill, backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
||||
panel: {
|
||||
width: 640,
|
||||
height: 560,
|
||||
|
||||
@@ -66,7 +66,7 @@ export interface SheetHeader {
|
||||
}
|
||||
|
||||
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
|
||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
|
||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFill };
|
||||
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -77,7 +77,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
desktopOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import {
|
||||
DaemonConnectionTestError,
|
||||
getConnectionAuthFailureReason,
|
||||
} from "@/utils/test-daemon-connection";
|
||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
@@ -195,92 +192,6 @@ function draftFromConnectionUri(uri: string): DirectConnectionDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTransportMessage(message: string | null | undefined): string | null {
|
||||
if (!message) return null;
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatTechnicalTransportDetails(
|
||||
details: (string | null)[],
|
||||
labels: DirectConnectionLabels,
|
||||
): string | null {
|
||||
const unique = Array.from(
|
||||
new Set(
|
||||
details
|
||||
.map((value) => normalizeTransportMessage(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
if (unique.length === 0) return null;
|
||||
|
||||
const allGeneric = unique.every((value) => {
|
||||
const lower = value.toLowerCase();
|
||||
return lower === "transport error" || lower === "transport closed";
|
||||
});
|
||||
|
||||
if (allGeneric) {
|
||||
return labels.noAdditionalDetails(unique[0] ?? "");
|
||||
}
|
||||
|
||||
return unique.join(" — ");
|
||||
}
|
||||
|
||||
function buildConnectionFailureCopy(input: {
|
||||
endpoint: string;
|
||||
error: unknown;
|
||||
labels: DirectConnectionLabels;
|
||||
}): { title: string; detail: string | null; raw: string | null } {
|
||||
const { endpoint, error, labels } = input;
|
||||
const title = labels.failedToConnect(endpoint);
|
||||
|
||||
const raw = (() => {
|
||||
if (error instanceof DaemonConnectionTestError) {
|
||||
return (
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
|
||||
normalizeTransportMessage(error.message)
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return normalizeTransportMessage(error.message);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const rawLower = raw?.toLowerCase() ?? "";
|
||||
let detail: string | null = null;
|
||||
|
||||
if (getConnectionAuthFailureReason(error)) {
|
||||
detail = error instanceof Error ? error.message : raw;
|
||||
} else if (rawLower.includes("timed out")) {
|
||||
detail = labels.timedOut;
|
||||
} else if (
|
||||
rawLower.includes("econnrefused") ||
|
||||
rawLower.includes("connection refused") ||
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = labels.refused;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = labels.hostUnreachable;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("tls") ||
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail = labels.tlsError;
|
||||
} else {
|
||||
detail = labels.unableToConnect;
|
||||
}
|
||||
|
||||
return { title, detail, raw };
|
||||
}
|
||||
|
||||
export interface AddHostModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
@@ -1007,7 +1007,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingTop: theme.spacing[12],
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
panel: {
|
||||
|
||||
@@ -20,54 +20,53 @@ describe("appearanceStyleBoundaryKey", () => {
|
||||
// This is a placement contract against the production JSX, not a simulated native mount.
|
||||
// Native Gesture Handler/Fabric lifetime behavior is verified on the release app.
|
||||
describe("retained native gesture host appearance placement", () => {
|
||||
it.each([
|
||||
["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"]],
|
||||
[
|
||||
"./compact-explorer-sidebar-host.tsx",
|
||||
["explorer", "themedExplorer", "CompactExplorerSidebar", "NativeExplorerSidebarDock"],
|
||||
],
|
||||
] as const)("keeps native hosts outside appearance keys in %s", (path, hosts) => {
|
||||
const source = ts.createSourceFile(
|
||||
path,
|
||||
readFileSync(new URL(path, import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
let hostsFound = 0;
|
||||
let webKeysFound = 0;
|
||||
function visit(node: ts.Node) {
|
||||
let name: string | undefined;
|
||||
if (ts.isIdentifier(node)) name = node.text;
|
||||
if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source);
|
||||
if (name && hosts.some((host) => host === name)) {
|
||||
hostsFound++;
|
||||
let webOnly = false;
|
||||
let keyed = false;
|
||||
for (
|
||||
let child = node, parent = node.parent;
|
||||
parent;
|
||||
child = parent, parent = parent.parent
|
||||
) {
|
||||
if (
|
||||
ts.isIfStatement(parent) &&
|
||||
parent.expression.getText(source) === "isWeb" &&
|
||||
parent.thenStatement === child
|
||||
)
|
||||
webOnly = true;
|
||||
if (
|
||||
ts.isJsxElement(parent) &&
|
||||
parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary"
|
||||
)
|
||||
keyed = true;
|
||||
it.each([["../app/_layout.tsx", ["sidebarChrome", "themedSidebarChrome"], true]] as const)(
|
||||
"keeps native hosts outside appearance keys in %s",
|
||||
(path, hosts, hasWebKeys) => {
|
||||
const source = ts.createSourceFile(
|
||||
path,
|
||||
readFileSync(new URL(path, import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
let hostsFound = 0;
|
||||
let webKeysFound = 0;
|
||||
function visit(node: ts.Node) {
|
||||
let name: string | undefined;
|
||||
if (ts.isIdentifier(node)) name = node.text;
|
||||
if (ts.isJsxSelfClosingElement(node)) name = node.tagName.getText(source);
|
||||
if (name && hosts.some((host) => host === name)) {
|
||||
hostsFound++;
|
||||
let webOnly = false;
|
||||
let keyed = false;
|
||||
for (
|
||||
let child = node, parent = node.parent;
|
||||
parent;
|
||||
child = parent, parent = parent.parent
|
||||
) {
|
||||
if (
|
||||
ts.isIfStatement(parent) &&
|
||||
parent.expression.getText(source) === "isWeb" &&
|
||||
parent.thenStatement === child
|
||||
)
|
||||
webOnly = true;
|
||||
if (
|
||||
ts.isJsxElement(parent) &&
|
||||
parent.openingElement.tagName.getText(source) === "AppearanceStyleBoundary"
|
||||
)
|
||||
keyed = true;
|
||||
}
|
||||
if (keyed && webOnly) webKeysFound++;
|
||||
expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (keyed && webOnly) webKeysFound++;
|
||||
expect(keyed && !webOnly, `${name} is remounted on native appearance changes`).toBe(false);
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(source);
|
||||
expect(hostsFound).toBeGreaterThan(0);
|
||||
expect(webKeysFound).toBeGreaterThan(0);
|
||||
});
|
||||
visit(source);
|
||||
expect(hostsFound).toBeGreaterThan(0);
|
||||
if (hasWebKeys) expect(webKeysFound).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveCompactExplorerSidebarHostModel,
|
||||
type CompactExplorerSidebarHostModel,
|
||||
} from "@/components/compact-explorer-sidebar-host-state";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
archivingAt: input.archivingAt ?? null,
|
||||
statusEnteredAt: null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function createModel(
|
||||
overrides: Partial<CompactExplorerSidebarHostModel> = {},
|
||||
): CompactExplorerSidebarHostModel {
|
||||
return {
|
||||
serverId: overrides.serverId ?? "server-1",
|
||||
workspaceId: overrides.workspaceId ?? "workspace-a",
|
||||
persistenceKey: overrides.persistenceKey ?? "server-1:workspace-a",
|
||||
workspaceRoot: overrides.workspaceRoot ?? "/repo/a",
|
||||
isGit: overrides.isGit ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveCompactExplorerSidebarHostModel", () => {
|
||||
it("retains the last workspace root for the same active selection while the workspace reloads", () => {
|
||||
const previous = createModel();
|
||||
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous,
|
||||
selection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
workspace: null,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
persistenceKey: "server-1:workspace-a",
|
||||
workspaceRoot: "/repo/a",
|
||||
isGit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("switches ownership to the active workspace instead of leaking the previous one", () => {
|
||||
const previous = createModel();
|
||||
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous,
|
||||
selection: { serverId: "server-1", workspaceId: "workspace-b" },
|
||||
workspace: null,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-b",
|
||||
persistenceKey: "server-1:workspace-b",
|
||||
workspaceRoot: "",
|
||||
isGit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not retain a previous owner when there is no active workspace selection", () => {
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous: createModel(),
|
||||
selection: null,
|
||||
workspace: null,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the current workspace directory when it is available", () => {
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous: null,
|
||||
selection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
workspace: createWorkspace({ id: "workspace-a", workspaceDirectory: "/repo/current" }),
|
||||
isGit: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
persistenceKey: "server-1:workspace-a",
|
||||
workspaceRoot: "/repo/current",
|
||||
isGit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export interface CompactExplorerSidebarHostModel {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
persistenceKey: string;
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
interface ResolveCompactExplorerSidebarHostModelInput {
|
||||
previous: CompactExplorerSidebarHostModel | null;
|
||||
selection: ActiveWorkspaceSelection | null;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function resolveCompactExplorerSidebarHostModel(
|
||||
input: ResolveCompactExplorerSidebarHostModelInput,
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const serverId = trimNonEmpty(input.selection?.serverId);
|
||||
const workspaceId = trimNonEmpty(input.selection?.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistenceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!persistenceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousForSelection =
|
||||
input.previous &&
|
||||
input.previous.serverId === serverId &&
|
||||
input.previous.workspaceId === workspaceId
|
||||
? input.previous
|
||||
: null;
|
||||
|
||||
return {
|
||||
serverId,
|
||||
workspaceId,
|
||||
persistenceKey,
|
||||
workspaceRoot:
|
||||
trimNonEmpty(input.workspace?.workspaceDirectory) ??
|
||||
previousForSelection?.workspaceRoot ??
|
||||
"",
|
||||
isGit: input.workspace ? input.isGit : (previousForSelection?.isGit ?? input.isGit),
|
||||
};
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
CompactExplorerSidebar,
|
||||
NativeExplorerSidebarDock,
|
||||
} from "@/components/compact-explorer-sidebar";
|
||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
|
||||
import {
|
||||
resolveCompactExplorerSidebarHostModel,
|
||||
type CompactExplorerSidebarHostModel,
|
||||
} from "@/components/compact-explorer-sidebar-host-state";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
|
||||
interface CompactExplorerOpenGestureSurfaceProps {
|
||||
children: ReactNode;
|
||||
enabled: boolean;
|
||||
onOpenExplorer: () => void;
|
||||
}
|
||||
|
||||
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
||||
|
||||
function CompactExplorerOpenGestureSurface({
|
||||
children,
|
||||
enabled,
|
||||
onOpenExplorer,
|
||||
}: CompactExplorerOpenGestureSurfaceProps) {
|
||||
const explorerOpenGesture = useOpenFileExplorerGesture({
|
||||
enabled,
|
||||
onOpen: onOpenExplorer,
|
||||
});
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<View style={styles.fill}>{children}</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveCompactExplorerSidebarModel(
|
||||
enabled: boolean,
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||
const isExplorerActive = useIsMobilePanelActive("file-explorer");
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null);
|
||||
const { checkoutQuery } = useWorkspaceCheckoutStatus({
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused: enabled && selection !== null,
|
||||
normalizedServerId: selection?.serverId ?? "",
|
||||
normalizedWorkspaceId: selection?.workspaceId ?? "",
|
||||
workspaceDirectory: workspace?.workspaceDirectory || null,
|
||||
});
|
||||
const resolvedModel = useMemo(
|
||||
() =>
|
||||
resolveCompactExplorerSidebarHostModel({
|
||||
previous: isExplorerActive ? retainedModelRef.current : null,
|
||||
selection,
|
||||
workspace,
|
||||
isGit: checkoutQuery.data?.isGit ?? false,
|
||||
}),
|
||||
[checkoutQuery.data?.isGit, isExplorerActive, selection, workspace],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selection) {
|
||||
retainedModelRef.current = null;
|
||||
if (enabled && isExplorerActive) {
|
||||
showMobileAgent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isExplorerActive) {
|
||||
retainedModelRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (resolvedModel) {
|
||||
retainedModelRef.current = resolvedModel;
|
||||
}
|
||||
}, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]);
|
||||
|
||||
return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null;
|
||||
}
|
||||
|
||||
interface CompactExplorerSidebarHostProps {
|
||||
children: ReactNode;
|
||||
enabled: boolean;
|
||||
presentation: "overlay" | "dock";
|
||||
}
|
||||
|
||||
export function CompactExplorerSidebarHost({
|
||||
children,
|
||||
enabled,
|
||||
presentation,
|
||||
}: CompactExplorerSidebarHostProps) {
|
||||
const model = useActiveCompactExplorerSidebarModel(enabled);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
|
||||
const openWorkspaceTabInFocusedPane = useCallback(
|
||||
(workspaceKey: string, target: WorkspaceTabTarget) =>
|
||||
openTab({ workspaceKey, target, intent: "reveal" }),
|
||||
[openTab],
|
||||
);
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
|
||||
const handleOpenExplorer = useCallback(() => {
|
||||
if (!model?.workspaceRoot) {
|
||||
return;
|
||||
}
|
||||
openCompactFileExplorer({
|
||||
serverId: model.serverId,
|
||||
cwd: model.workspaceRoot,
|
||||
isGit: model.isGit,
|
||||
});
|
||||
}, [model, openCompactFileExplorer]);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceFileFromExplorer({
|
||||
filePath,
|
||||
persistenceKey: model.persistenceKey,
|
||||
closeExplorerAfterOpen: presentation === "overlay",
|
||||
showMobileAgent,
|
||||
openWorkspaceTabInFocusedPane,
|
||||
focusWorkspaceTab,
|
||||
});
|
||||
},
|
||||
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const nextWidth = event.nativeEvent.layout.width;
|
||||
setContainerWidth((current) => (current === nextWidth ? current : nextWidth));
|
||||
}, []);
|
||||
|
||||
const explorer =
|
||||
enabled && model ? (
|
||||
<DiffDocumentWorkspaceCacheProvider key={model.persistenceKey}>
|
||||
{presentation === "dock" ? (
|
||||
<NativeExplorerSidebarDock
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
workspaceRoot={model.workspaceRoot}
|
||||
isGit={model.isGit}
|
||||
persistenceKey={model.persistenceKey}
|
||||
containerWidth={containerWidth}
|
||||
onOpenFile={handleOpenFile}
|
||||
/>
|
||||
) : (
|
||||
<CompactExplorerSidebar
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
workspaceRoot={model.workspaceRoot}
|
||||
isGit={model.isGit}
|
||||
onOpenFile={handleOpenFile}
|
||||
/>
|
||||
)}
|
||||
</DiffDocumentWorkspaceCacheProvider>
|
||||
) : null;
|
||||
|
||||
let themedExplorer = explorer;
|
||||
if (isWeb) {
|
||||
themedExplorer = <AppearanceStyleBoundary>{explorer}</AppearanceStyleBoundary>;
|
||||
}
|
||||
|
||||
if (presentation === "dock") {
|
||||
return (
|
||||
<View style={styles.row} onLayout={handleContainerLayout}>
|
||||
<View style={styles.fill}>{children}</View>
|
||||
{themedExplorer}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CompactExplorerOpenGestureSurface
|
||||
enabled={enabled && Boolean(model?.workspaceRoot)}
|
||||
onOpenExplorer={handleOpenExplorer}
|
||||
>
|
||||
{children}
|
||||
</CompactExplorerOpenGestureSurface>
|
||||
{themedExplorer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
fill: {
|
||||
flex: 1,
|
||||
},
|
||||
row: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
},
|
||||
} as const;
|
||||
@@ -1,574 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsCompactFileExplorerOpen,
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { ChangesSurface } from "@/git/diff-pane";
|
||||
import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/keyboard/shift";
|
||||
import { shouldUseCompactExplorerKeyboardPadding } from "@/keyboard/shift";
|
||||
import { WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanel, RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
|
||||
import { PullRequestContent } from "@/panels/pull-request";
|
||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import {
|
||||
EXPLORER_TAB_RAIL_INSET,
|
||||
explorerSidebarCloseButtonLayout,
|
||||
resolveExplorerSidebarWidth,
|
||||
} from "@/components/explorer-sidebar-layout";
|
||||
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
|
||||
const ThemedX = withUnistyles(X);
|
||||
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
|
||||
interface ExplorerSidebarProps {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
interface ExplorerSidebarSharedState {
|
||||
explorerTab: ExplorerTab;
|
||||
handleTabPress: (tab: ExplorerTab) => void;
|
||||
}
|
||||
|
||||
function useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
}: Pick<ExplorerSidebarProps, "serverId" | "workspaceRoot" | "isGit">): ExplorerSidebarSharedState {
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
|
||||
},
|
||||
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
|
||||
);
|
||||
|
||||
return { explorerTab, handleTabPress };
|
||||
}
|
||||
|
||||
export function CompactExplorerSidebar({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
onOpenFile,
|
||||
}: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isActive = useIsMobilePanelActive("file-explorer");
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
const usePanelKeyboardPadding = shouldUseCompactExplorerKeyboardPadding({ isGit, explorerTab });
|
||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
enabled: usePanelKeyboardPadding,
|
||||
});
|
||||
const { gesture: closeGesture } = useCloseFileExplorerGesture();
|
||||
|
||||
const handleClose = useCallback(
|
||||
(reason: string) => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason,
|
||||
isOpen: isActive,
|
||||
});
|
||||
showMobileAgent();
|
||||
},
|
||||
[isActive, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||
|
||||
const mobileSidebarStyle = useMemo(
|
||||
() => [
|
||||
{
|
||||
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
|
||||
paddingBottom: usePanelKeyboardPadding ? 0 : insets.bottom,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
mobileKeyboardInsetStyle,
|
||||
],
|
||||
[
|
||||
insets.bottom,
|
||||
insets.top,
|
||||
mobileKeyboardInsetStyle,
|
||||
theme.colors.surfaceSidebar,
|
||||
usePanelKeyboardPadding,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={isActive}>
|
||||
<MobilePanelOverlay
|
||||
panel="file-explorer"
|
||||
closeGesture={closeGesture}
|
||||
panelStyle={mobileSidebarStyle}
|
||||
>
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isOpen={isActive}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</MobilePanelOverlay>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
}
|
||||
|
||||
interface NativeExplorerSidebarDockProps extends ExplorerSidebarProps {
|
||||
persistenceKey: string;
|
||||
containerWidth: number;
|
||||
}
|
||||
|
||||
export function NativeExplorerSidebarDock({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
onOpenFile,
|
||||
persistenceKey,
|
||||
containerWidth,
|
||||
}: NativeExplorerSidebarDockProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const storedWidth = useWorkspaceLayoutStore(
|
||||
(state) => state.explorerSidebarWidthByWorkspace[persistenceKey],
|
||||
);
|
||||
const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar);
|
||||
const visibleWidth = resolveExplorerSidebarWidth({
|
||||
requestedWidth: storedWidth,
|
||||
containerWidth,
|
||||
});
|
||||
const resizeWidth = useSharedValue(visibleWidth);
|
||||
const startWidthRef = useRef(visibleWidth);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
resizeWidth.value = visibleWidth;
|
||||
}, [resizeWidth, visibleWidth]);
|
||||
|
||||
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
|
||||
const commitWidth = useCallback(
|
||||
(width: number) => resizeExplorerSidebar(persistenceKey, width),
|
||||
[persistenceKey, resizeExplorerSidebar],
|
||||
);
|
||||
const resizeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.enabled(true)
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onBegin(() => scheduleOnRN(showResizeGrip))
|
||||
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||
.onStart((event) => {
|
||||
startWidthRef.current = visibleWidth + event.translationX;
|
||||
resizeWidth.value = visibleWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
resizeWidth.value = resolveExplorerSidebarWidth({
|
||||
requestedWidth: startWidthRef.current - event.translationX,
|
||||
containerWidth,
|
||||
});
|
||||
})
|
||||
.onEnd(() => runOnJS(commitWidth)(resizeWidth.value))
|
||||
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
|
||||
[commitWidth, containerWidth, hideResizeGrip, resizeWidth, showResizeGrip, visibleWidth],
|
||||
);
|
||||
const animatedWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
|
||||
const dockStyle = useMemo(
|
||||
() => [
|
||||
styles.nativeDock,
|
||||
{
|
||||
display: isOpen ? ("flex" as const) : ("none" as const),
|
||||
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
animatedWidthStyle,
|
||||
],
|
||||
[animatedWidthStyle, insets.top, isOpen, theme.colors.surfaceSidebar],
|
||||
);
|
||||
const dockContentStyle = useMemo(
|
||||
() => [styles.nativeDockContent, { borderLeftColor: theme.colors.border }],
|
||||
[theme.colors.border],
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<Animated.View style={dockStyle} testID="native-explorer-sidebar-dock">
|
||||
<View style={dockContentStyle}>
|
||||
<SidebarResizeHandle
|
||||
edge="left"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID="native-explorer-sidebar-resize-handle"
|
||||
/>
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={showMobileAgent}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</RetainedPanelActivity>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExplorerTabButtonProps {
|
||||
tab: ExplorerTab;
|
||||
active: boolean;
|
||||
label?: string;
|
||||
onTabPress: (tab: ExplorerTab) => void;
|
||||
testID: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ExplorerTabButton({
|
||||
tab,
|
||||
active,
|
||||
label,
|
||||
onTabPress,
|
||||
testID,
|
||||
children,
|
||||
}: ExplorerTabButtonProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const handlePress = useCallback(() => onTabPress(tab), [onTabPress, tab]);
|
||||
const tabStyle = useMemo(
|
||||
() => [styles.tab(isCompact), active && styles.tabActive],
|
||||
[active, isCompact],
|
||||
);
|
||||
const tabTextStyle = useMemo(() => [styles.tabText, active && styles.tabTextActive], [active]);
|
||||
return (
|
||||
<Pressable testID={testID} style={tabStyle} onPress={handlePress}>
|
||||
{children}
|
||||
{label !== undefined ? <Text style={tabTextStyle}>{label}</Text> : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarContentProps {
|
||||
activeTab: ExplorerTab;
|
||||
onTabPress: (tab: ExplorerTab) => void;
|
||||
onClose: () => void;
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
isOpen: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
function ExplorerSidebarContent({
|
||||
activeTab,
|
||||
onTabPress,
|
||||
onClose,
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
isOpen,
|
||||
onOpenFile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const closeButtonLayout = explorerSidebarCloseButtonLayout(isCompact);
|
||||
const closeButtonStyle = useMemo(
|
||||
() => ({ width: closeButtonLayout.size, height: closeButtonLayout.size }),
|
||||
[closeButtonLayout.size],
|
||||
);
|
||||
// The close glyph shares the trailing rail with the toolbar rows below it.
|
||||
const headerRightSectionStyle = useMemo(
|
||||
() => [styles.headerRightSection, { paddingRight: closeButtonLayout.trailingPadding }],
|
||||
[closeButtonLayout.trailingPadding],
|
||||
);
|
||||
const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({
|
||||
serverId,
|
||||
cwd: workspaceRoot,
|
||||
isGit,
|
||||
requested: activeTab === "pr",
|
||||
enabled: isOpen,
|
||||
timelineEnabled: activeTab === "pr",
|
||||
});
|
||||
const requestedTab: ExplorerTab =
|
||||
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
|
||||
const prTabLabel = formatPrTabLabel(prPane.prNumber);
|
||||
const availableTabs = useMemo<ExplorerTab[]>(() => {
|
||||
const tabs: ExplorerTab[] = isGit ? ["changes", "files"] : ["files"];
|
||||
if (isGit && showPrTab) tabs.push("pr");
|
||||
return tabs;
|
||||
}, [isGit, showPrTab]);
|
||||
const { mountedTabIds } = useMountedTabSet({
|
||||
activeTabId: resolvedTab,
|
||||
allTabIds: availableTabs,
|
||||
cap: availableTabs.length,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header with tabs and close button */}
|
||||
<WindowChromeSafeArea placement="inline" style={styles.header} testID="explorer-header">
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.tabsContainer(isCompact)}>
|
||||
{isGit && (
|
||||
<ExplorerTabButton
|
||||
tab="changes"
|
||||
active={resolvedTab === "changes"}
|
||||
label={t("workspace.tabs.explorerSidebar.changes")}
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-changes"
|
||||
/>
|
||||
)}
|
||||
<ExplorerTabButton
|
||||
tab="files"
|
||||
active={resolvedTab === "files"}
|
||||
label={t("workspace.tabs.explorerSidebar.files")}
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-files"
|
||||
/>
|
||||
{isGit && showPrTab && (
|
||||
<ExplorerTabButton
|
||||
tab="pr"
|
||||
active={resolvedTab === "pr"}
|
||||
label={prTabLabel}
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-pr"
|
||||
>
|
||||
<PullRequestTabIcon
|
||||
forge={prPane.forge}
|
||||
size={13}
|
||||
color={
|
||||
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
</ExplorerTabButton>
|
||||
)}
|
||||
</View>
|
||||
<View style={headerRightSectionStyle}>
|
||||
<ToolbarButton
|
||||
compact={isCompact}
|
||||
style={closeButtonStyle}
|
||||
hitSlop={closeButtonLayout.hitSlop}
|
||||
label={t("workspace.tabs.explorerSidebar.close")}
|
||||
onPress={onClose}
|
||||
testID="explorer-close"
|
||||
nativeID="explorer-close"
|
||||
>
|
||||
<ThemedX size={closeButtonLayout.iconSize} uniProps={mutedIconColorMapping} />
|
||||
</ToolbarButton>
|
||||
</View>
|
||||
</WindowChromeSafeArea>
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{mountedTabIds.has("changes") ? (
|
||||
<RetainedPanel active={resolvedTab === "changes"}>
|
||||
<ChangedFilesPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
) : null}
|
||||
{mountedTabIds.has("files") ? (
|
||||
<RetainedPanel active={resolvedTab === "files"}>
|
||||
<FilesPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
) : null}
|
||||
{mountedTabIds.has("pr") ? (
|
||||
<RetainedPanel active={resolvedTab === "pr"}>
|
||||
<PrTabContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
prPane={prPane}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangedFilesPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isOpen,
|
||||
onOpenFile,
|
||||
}: Pick<
|
||||
SidebarContentProps,
|
||||
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
|
||||
>) {
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
const [changesState, setChangesState] = useState<ChangesState>(() =>
|
||||
changesStateSchema.parse(defaultChangesState),
|
||||
);
|
||||
return (
|
||||
<ChangesSurface
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
enabled={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
state={changesState}
|
||||
onStateChange={setChangesState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
onOpenFile,
|
||||
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
return (
|
||||
<FileExplorerPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const PrTabContent = PullRequestContent;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
nativeDock: {
|
||||
position: "relative",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
nativeDockContent: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
borderLeftWidth: 1,
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
header: {
|
||||
position: "relative",
|
||||
height: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||
md: HEADER_INNER_HEIGHT,
|
||||
},
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
tabsContainer: (isCompact: boolean) => ({
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[1],
|
||||
// With the tab's own horizontal padding this puts the label on the pane's
|
||||
// leading rail, the same way the desktop tab rail does.
|
||||
paddingLeft: isCompact ? EXPLORER_TAB_RAIL_INSET : theme.spacing[2],
|
||||
}),
|
||||
tab: (isCompact: boolean) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: isCompact ? theme.spacing[2] : theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
}),
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
tabTextActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
tabTextMuted: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
headerRightSection: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
contentArea: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
||||
|
||||
const labels = {
|
||||
hostRequired: "host required",
|
||||
invalidPort: "invalid port",
|
||||
invalidConnection: "invalid connection",
|
||||
failedToConnect: (endpoint: string) => `Failed: ${endpoint}`,
|
||||
noAdditionalDetails: (detail: string) => `No details: ${detail}`,
|
||||
timedOut: "timeout",
|
||||
refused: "refused",
|
||||
hostNotFound: "DNS failure",
|
||||
hostUnreachable: "unreachable",
|
||||
tlsError: "TLS failure",
|
||||
unableToConnect: "connection failure",
|
||||
};
|
||||
|
||||
function failure(message: string) {
|
||||
return buildConnectionFailureCopy({
|
||||
endpoint: "tcp://example.test:443?ssl=true",
|
||||
error: new DaemonConnectionTestError(message, {
|
||||
reason: "transport error",
|
||||
lastError: message,
|
||||
}),
|
||||
labels,
|
||||
});
|
||||
}
|
||||
|
||||
describe("direct connection failure copy", () => {
|
||||
it("identifies Android certificate validation errors without calling them DNS failures", () => {
|
||||
const message =
|
||||
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
|
||||
expect(failure(message)).toEqual({
|
||||
title: "Failed: tcp://example.test:443?ssl=true",
|
||||
detail: labels.tlsError,
|
||||
raw: `transport error — ${message}`,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["certificate not found", labels.tlsError],
|
||||
["SSL handshake failed", labels.tlsError],
|
||||
["TLS handshake failed", labels.tlsError],
|
||||
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
|
||||
["Host not found", labels.hostNotFound],
|
||||
["connect ECONNREFUSED", labels.refused],
|
||||
["connection timed out", labels.timedOut],
|
||||
["EHOSTUNREACH", labels.hostUnreachable],
|
||||
["unexpected transport failure", labels.unableToConnect],
|
||||
])("classifies %s", (message, expected) => {
|
||||
expect(failure(message).detail).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves authentication failures and their technical details", () => {
|
||||
const message = "Invalid daemon password";
|
||||
expect(
|
||||
buildConnectionFailureCopy({
|
||||
endpoint: "example.test:6767",
|
||||
error: new DaemonConnectionTestError(message, {
|
||||
reason: message,
|
||||
lastError: message,
|
||||
authFailureReason: "incorrect_password",
|
||||
}),
|
||||
labels,
|
||||
}),
|
||||
).toMatchObject({ detail: message, raw: message });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
DaemonConnectionTestError,
|
||||
getConnectionAuthFailureReason,
|
||||
} from "@/utils/test-daemon-connection";
|
||||
|
||||
interface ConnectionFailureLabels {
|
||||
failedToConnect: (endpoint: string) => string;
|
||||
noAdditionalDetails: (detail: string) => string;
|
||||
timedOut: string;
|
||||
refused: string;
|
||||
hostNotFound: string;
|
||||
hostUnreachable: string;
|
||||
tlsError: string;
|
||||
unableToConnect: string;
|
||||
}
|
||||
|
||||
function normalizeTransportMessage(message: string | null | undefined): string | null {
|
||||
if (!message) return null;
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatTechnicalTransportDetails(
|
||||
details: (string | null)[],
|
||||
labels: ConnectionFailureLabels,
|
||||
): string | null {
|
||||
const unique = Array.from(
|
||||
new Set(
|
||||
details
|
||||
.map((value) => normalizeTransportMessage(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
if (unique.length === 0) return null;
|
||||
|
||||
const allGeneric = unique.every((value) => {
|
||||
const lower = value.toLowerCase();
|
||||
return lower === "transport error" || lower === "transport closed";
|
||||
});
|
||||
|
||||
if (allGeneric) {
|
||||
return labels.noAdditionalDetails(unique[0] ?? "");
|
||||
}
|
||||
|
||||
return unique.join(" — ");
|
||||
}
|
||||
|
||||
export function buildConnectionFailureCopy(input: {
|
||||
endpoint: string;
|
||||
error: unknown;
|
||||
labels: ConnectionFailureLabels;
|
||||
}): { title: string; detail: string | null; raw: string | null } {
|
||||
const { endpoint, error, labels } = input;
|
||||
const title = labels.failedToConnect(endpoint);
|
||||
|
||||
const raw = (() => {
|
||||
if (error instanceof DaemonConnectionTestError) {
|
||||
return (
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
|
||||
normalizeTransportMessage(error.message)
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return normalizeTransportMessage(error.message);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const rawLower = raw?.toLowerCase() ?? "";
|
||||
let detail: string | null = null;
|
||||
|
||||
if (getConnectionAuthFailureReason(error)) {
|
||||
detail = error instanceof Error ? error.message : raw;
|
||||
} else if (rawLower.includes("timed out")) {
|
||||
detail = labels.timedOut;
|
||||
} else if (
|
||||
rawLower.includes("econnrefused") ||
|
||||
rawLower.includes("connection refused") ||
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = labels.refused;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("certpathvalidator") ||
|
||||
rawLower.includes("trust anchor") ||
|
||||
rawLower.includes("tls") ||
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail = labels.tlsError;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = labels.hostUnreachable;
|
||||
} else {
|
||||
detail = labels.unableToConnect;
|
||||
}
|
||||
|
||||
return { title, detail, raw };
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useHostReportsUsage } from "@/usage";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ContextWindowDetails } from "./context-window-details";
|
||||
import { ContextWindowSheet } from "./context-window-sheet";
|
||||
import { formatContextPercentage } from "./context-window-meter.utils";
|
||||
|
||||
interface ContextWindowMeterProps {
|
||||
serverId: string;
|
||||
@@ -150,7 +151,7 @@ export function ContextWindowMeter({
|
||||
showPercentage = false,
|
||||
glyphSize,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
|
||||
const showsUsage = useHostReportsUsage(serverId);
|
||||
@@ -192,7 +193,9 @@ export function ContextWindowMeter({
|
||||
);
|
||||
const percentageLabel =
|
||||
showPercentage && context ? (
|
||||
<Text style={styles.percentageLabel}>{`${context.percentage}%`}</Text>
|
||||
<Text style={styles.percentageLabel}>
|
||||
{formatContextPercentage(context.percentage, i18n.resolvedLanguage)}
|
||||
</Text>
|
||||
) : null;
|
||||
const accessibilityLabel = context
|
||||
? t("contextWindow.accessibility", { percentage: context.percentage })
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatContextPercentage } from "./context-window-meter.utils";
|
||||
|
||||
describe("context window meter formatting", () => {
|
||||
it("localizes percentages without hiding context overflow", () => {
|
||||
expect(formatContextPercentage(125, "en")).toBe("125%");
|
||||
expect(formatContextPercentage(42, "fr")).toBe("42\u00a0%");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,10 @@
|
||||
export function formatContextPercentage(value: number, locale?: string): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value / 100);
|
||||
}
|
||||
|
||||
export function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Math.round(value / 1_000_000)}m`;
|
||||
|
||||
@@ -101,3 +101,25 @@ describe("desktop sidebar layout", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("reserves room for Explorer and the workspace before showing app navigation", () => {
|
||||
const contentMinimumWidth = resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: false,
|
||||
explorerVisible: true,
|
||||
});
|
||||
expect(contentMinimumWidth).toBe(600);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
contentMinimumWidth,
|
||||
requestedSidebarWidth: 320,
|
||||
viewportWidth: 744,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
contentMinimumWidth,
|
||||
requestedSidebarWidth: 320,
|
||||
viewportWidth: 951,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EXPLORER_WORKSPACE_MIN_WIDTH } from "@/components/explorer-sidebar-layout";
|
||||
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
|
||||
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
||||
|
||||
@@ -62,8 +63,12 @@ export function resolveDesktopSidebarWidth(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDesktopAppContentMinimum(input: { isSettingsRoute: boolean }): number {
|
||||
return input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0;
|
||||
export function resolveDesktopAppContentMinimum(input: {
|
||||
isSettingsRoute: boolean;
|
||||
explorerVisible?: boolean;
|
||||
}): number {
|
||||
if (input.isSettingsRoute) return SETTINGS_DESKTOP_SPLIT_MIN_WIDTH;
|
||||
return input.explorerVisible ? EXPLORER_WORKSPACE_MIN_WIDTH : 0;
|
||||
}
|
||||
|
||||
export function canDesktopAppSidebarShare(input: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
explorerSidebarCloseButtonLayout,
|
||||
resolveExplorerSidebarDockSizes,
|
||||
resolveExplorerSidebarWidth,
|
||||
} from "@/components/explorer-sidebar-layout";
|
||||
@@ -17,24 +16,7 @@ describe("Explorer sidebar layout", () => {
|
||||
it("has no fixed maximum while preserving room for the workspace body", () => {
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 100, containerWidth: 1200 })).toBe(240);
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1600 })).toBe(900);
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(800);
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(350);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Explorer close action", () => {
|
||||
it("preserves the wide-native touch target and glyph rail", () => {
|
||||
const layout = explorerSidebarCloseButtonLayout(false);
|
||||
expect(layout.size).toBe(34);
|
||||
expect(layout.iconSize).toBe(18);
|
||||
expect(layout.size + layout.hitSlop * 2).toBe(50);
|
||||
expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(16);
|
||||
});
|
||||
|
||||
it("keeps the compact close glyph on the pane rail", () => {
|
||||
const layout = explorerSidebarCloseButtonLayout(true);
|
||||
expect(layout.size).toBe(32);
|
||||
expect(layout.iconSize).toBe(18);
|
||||
expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(8);
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(840);
|
||||
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(390);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,13 @@
|
||||
import {
|
||||
paneContentToolbarIconSize,
|
||||
paneContentToolbarTrailingPadding,
|
||||
} from "@/components/ui/pane-content-toolbar";
|
||||
import { smallIconButtonChromeFrameSize } from "@/components/ui/icon-button-chrome";
|
||||
|
||||
export function explorerSidebarCloseButtonLayout(compact: boolean) {
|
||||
// The inline native dock retains its padded close action and touch slop.
|
||||
if (!compact) {
|
||||
return { size: 34, iconSize: 18, hitSlop: 8, trailingPadding: 8 };
|
||||
}
|
||||
return {
|
||||
size: smallIconButtonChromeFrameSize(compact),
|
||||
iconSize: paneContentToolbarIconSize(compact),
|
||||
hitSlop: 0,
|
||||
trailingPadding: paneContentToolbarTrailingPadding(compact, "glyph"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Outer inset of an Explorer tab rail. With each tab's own horizontal padding it puts
|
||||
* the tab label on the pane's shared leading rail (`treeRowPaddingLeft(0)`).
|
||||
*/
|
||||
export const EXPLORER_TAB_RAIL_INSET = 4;
|
||||
|
||||
const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 320;
|
||||
const MIN_EXPLORER_SIDEBAR_WIDTH = 240;
|
||||
const MIN_WORKSPACE_BODY_WIDTH = 400;
|
||||
const MIN_WORKSPACE_BODY_WIDTH = 360;
|
||||
export const EXPLORER_WORKSPACE_MIN_WIDTH = MIN_EXPLORER_SIDEBAR_WIDTH + MIN_WORKSPACE_BODY_WIDTH;
|
||||
|
||||
export function resolveExplorerSidebarWidth(input: {
|
||||
requestedWidth?: number;
|
||||
containerWidth: number;
|
||||
}): number {
|
||||
"worklet";
|
||||
const requestedWidth = input.requestedWidth ?? DEFAULT_EXPLORER_SIDEBAR_WIDTH;
|
||||
const maximumVisibleWidth =
|
||||
input.containerWidth > 0
|
||||
|
||||
@@ -46,7 +46,7 @@ export function FileDropBackdrop() {
|
||||
|
||||
const positionStyles = RNStyleSheet.create({
|
||||
overlay: {
|
||||
...RNStyleSheet.absoluteFillObject,
|
||||
...RNStyleSheet.absoluteFill,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
@@ -55,7 +55,7 @@ const positionStyles = RNStyleSheet.create({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
opacity: 0.7,
|
||||
},
|
||||
|
||||
@@ -52,8 +52,15 @@ export function CompactHeader({
|
||||
}: CompactHeaderProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const rootStyle = useMemo(
|
||||
() => [styles.root, { paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE }],
|
||||
[insets.top],
|
||||
() => [
|
||||
styles.root,
|
||||
{
|
||||
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
|
||||
marginLeft: insets.left,
|
||||
marginRight: insets.right,
|
||||
},
|
||||
],
|
||||
[insets.top, insets.left, insets.right],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -679,7 +679,9 @@ function DesktopSidebar({
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const viewportWidth = windowWidth - insets.left - insets.right;
|
||||
const visibleSidebarWidth = resolveDesktopSidebarWidth({
|
||||
requestedWidth: sidebarWidth,
|
||||
viewportWidth,
|
||||
|
||||
@@ -2761,7 +2761,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
},
|
||||
workspaceArchivingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: `${theme.colors.surface0}cc`,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useWorkspaceTabDrag } from "./workspace-tab-drag-context";
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
@@ -11,35 +12,14 @@ import {
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { View, Text, type LayoutChangeEvent } from "react-native";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
import {
|
||||
resolveExplorerSidebarDockSizes,
|
||||
resolveExplorerSidebarWidth,
|
||||
} from "@/components/explorer-sidebar-layout";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import {
|
||||
hasMultipleVisiblePanes,
|
||||
@@ -48,41 +28,26 @@ import {
|
||||
} from "@/components/split-container-focus";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
import {
|
||||
removeWindowChromeCorner,
|
||||
WindowChromeRegion,
|
||||
WindowChromeSafeArea,
|
||||
useWindowChromeCorners,
|
||||
removeWindowChromeCorner,
|
||||
type WindowChromeCorners,
|
||||
} from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import {
|
||||
computeTabDropPreview,
|
||||
type TabDropPreview,
|
||||
} from "@/components/split-container-tab-drop-preview";
|
||||
import {
|
||||
SplitDropZone,
|
||||
resolveSplitDropPosition,
|
||||
type SplitDropZoneHover,
|
||||
} from "@/components/split-drop-zone";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
getWorkspacePaneDescriptors,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import { type TabDropPreview } from "@/components/split-container-tab-drop-preview";
|
||||
import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content";
|
||||
import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host";
|
||||
import {
|
||||
WorkspaceDesktopTabsRow,
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
|
||||
import {
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
collectAllPanes,
|
||||
createDefaultLayout,
|
||||
findPaneById,
|
||||
useWorkspaceLayoutStore,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
@@ -90,14 +55,11 @@ import {
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { recordRenderProfileReasons, RenderProfile } from "@/utils/render-profiler";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
|
||||
|
||||
interface SplitContainerProps {
|
||||
layout: WorkspaceLayout;
|
||||
renderMainHeader?: () => ReactNode;
|
||||
renderExplorerSidebarHeaderAction?: () => ReactNode;
|
||||
workspaceKey: string;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
@@ -140,41 +102,6 @@ interface SplitContainerProps {
|
||||
onExitFocusMode: () => void;
|
||||
}
|
||||
|
||||
interface WorkspaceTabDragData {
|
||||
kind: "workspace-tab";
|
||||
paneId: string;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface SplitPaneDropData {
|
||||
kind: "split-pane-drop";
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
const EMPTY_SPLIT_NODES: SplitNode[] = [];
|
||||
const EMPTY_SPLIT_SIZES: number[] = [];
|
||||
const EXPLORER_SIDEBAR_RESIZE_GROUP_ID = "explorer-sidebar";
|
||||
|
||||
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
|
||||
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
|
||||
}
|
||||
|
||||
function isSplitPaneDropData(data: unknown): data is SplitPaneDropData {
|
||||
return (
|
||||
typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop"
|
||||
);
|
||||
}
|
||||
|
||||
function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined {
|
||||
return isWorkspaceTabDragData(data) ? data : undefined;
|
||||
}
|
||||
|
||||
function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined {
|
||||
if (isWorkspaceTabDragData(data)) return data;
|
||||
if (isSplitPaneDropData(data)) return data;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface SplitNodeViewProps extends Omit<
|
||||
SplitContainerProps,
|
||||
"layout" | "onMoveTabToPane" | "onSelectTabInPane"
|
||||
@@ -212,105 +139,12 @@ interface SplitPaneViewProps extends Omit<
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
}
|
||||
|
||||
interface DragMoveRects {
|
||||
translatedRect: { left: number; top: number; width: number; height: number };
|
||||
overRect: { left: number; top: number; width: number; height: number };
|
||||
}
|
||||
|
||||
function resolveDragMoveRects(
|
||||
event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">,
|
||||
): DragMoveRects | null {
|
||||
const translatedRect = event.active.rect.current.translated;
|
||||
const overRect = event.over?.rect;
|
||||
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { translatedRect, overRect };
|
||||
}
|
||||
|
||||
function computeTabOverDropPreview(input: {
|
||||
activeData: WorkspaceTabDragData;
|
||||
overData: WorkspaceTabDragData;
|
||||
rects: DragMoveRects;
|
||||
panesById: Map<string, SplitPane>;
|
||||
uiTabs: WorkspaceTab[];
|
||||
}): TabDropPreview | null {
|
||||
const { activeData, overData, rects, panesById, uiTabs } = input;
|
||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||
if (!targetPane) {
|
||||
return null;
|
||||
}
|
||||
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||
return computeTabDropPreview({
|
||||
activePaneId: activeData.paneId,
|
||||
activeTabId: activeData.tabId,
|
||||
overPaneId: overData.paneId,
|
||||
overTabId: overData.tabId,
|
||||
targetTabs,
|
||||
activeRect: {
|
||||
left: rects.translatedRect.left,
|
||||
width: rects.translatedRect.width,
|
||||
},
|
||||
overRect: {
|
||||
left: rects.overRect.left,
|
||||
width: rects.overRect.width,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function computePaneOverDropPreview(input: {
|
||||
overData: SplitPaneDropData;
|
||||
rects: DragMoveRects;
|
||||
}): SplitDropZoneHover | null {
|
||||
const { overData, rects } = input;
|
||||
const centerX = rects.translatedRect.left + rects.translatedRect.width / 2;
|
||||
const centerY = rects.translatedRect.top + rects.translatedRect.height / 2;
|
||||
const relativeX = centerX - rects.overRect.left;
|
||||
const relativeY = centerY - rects.overRect.top;
|
||||
if (
|
||||
Number.isNaN(relativeX) ||
|
||||
Number.isNaN(relativeY) ||
|
||||
relativeX < 0 ||
|
||||
relativeX > rects.overRect.width ||
|
||||
relativeY < 0 ||
|
||||
relativeY > rects.overRect.height
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
paneId: overData.paneId,
|
||||
position: resolveSplitDropPosition({
|
||||
width: rects.overRect.width,
|
||||
height: rects.overRect.height,
|
||||
x: relativeX,
|
||||
y: relativeY,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const dropCollisionDetection: CollisionDetection = (args) => {
|
||||
const pointerHits = pointerWithin(args);
|
||||
const tabHits = pointerHits.filter(
|
||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab",
|
||||
);
|
||||
if (tabHits.length > 0) {
|
||||
return tabHits;
|
||||
}
|
||||
|
||||
const paneHits = pointerHits.filter(
|
||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop",
|
||||
);
|
||||
if (paneHits.length > 0) {
|
||||
return paneHits;
|
||||
}
|
||||
|
||||
return closestCenter(args);
|
||||
};
|
||||
const EMPTY_SPLIT_NODES: SplitNode[] = [];
|
||||
const EMPTY_SPLIT_SIZES: number[] = [];
|
||||
|
||||
export function SplitContainer({
|
||||
layout,
|
||||
renderMainHeader,
|
||||
renderExplorerSidebarHeaderAction,
|
||||
workspaceKey,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
@@ -335,8 +169,6 @@ export function SplitContainer({
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onMoveTabToPane,
|
||||
onSelectTabInPane,
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
focusModeEnabled,
|
||||
@@ -344,9 +176,7 @@ export function SplitContainer({
|
||||
}: SplitContainerProps) {
|
||||
const inheritedWindowChromeCorners = useWindowChromeCorners();
|
||||
const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none";
|
||||
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
|
||||
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
|
||||
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(null);
|
||||
const { activeDragTabId, dropPreview, tabDropPreview } = useWorkspaceTabDrag();
|
||||
const [maximizedPane, setMaximizedPane] = useState<{
|
||||
workspaceKey: string;
|
||||
paneId: string;
|
||||
@@ -357,22 +187,6 @@ export function SplitContainer({
|
||||
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
|
||||
const explorerSidebarPane = useMemo(
|
||||
() => findPaneById(layout.root, explorerSidebarPaneId),
|
||||
[layout.root, explorerSidebarPaneId],
|
||||
);
|
||||
const mainRoot = useMemo(
|
||||
() => removePaneFromSplitTree(layout.root, explorerSidebarPaneId),
|
||||
[layout.root, explorerSidebarPaneId],
|
||||
@@ -408,434 +222,68 @@ export function SplitContainer({
|
||||
}),
|
||||
[focusModeEnabled, layout.focusedPaneId, mainRoot],
|
||||
);
|
||||
const storedExplorerSidebarWidth = useWorkspaceLayoutStore(
|
||||
(state) => state.explorerSidebarWidthByWorkspace[workspaceKey],
|
||||
);
|
||||
const resizeExplorerSidebar = useWorkspaceLayoutStore((state) => state.resizeExplorerSidebar);
|
||||
const [workspaceShellWidth, setWorkspaceShellWidth] = useState(0);
|
||||
const [previewExplorerSidebarWidth, setPreviewExplorerSidebarWidth] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const requestedExplorerSidebarWidth = previewExplorerSidebarWidth ?? storedExplorerSidebarWidth;
|
||||
const explorerSidebarWidth = resolveExplorerSidebarWidth({
|
||||
requestedWidth: requestedExplorerSidebarWidth,
|
||||
containerWidth: workspaceShellWidth,
|
||||
});
|
||||
const explorerSidebarDockSizes = useMemo(
|
||||
() =>
|
||||
resolveExplorerSidebarDockSizes({
|
||||
requestedWidth: requestedExplorerSidebarWidth,
|
||||
containerWidth: workspaceShellWidth,
|
||||
}),
|
||||
[requestedExplorerSidebarWidth, workspaceShellWidth],
|
||||
);
|
||||
const renderExplorerSidebarDock = Boolean(
|
||||
!focusModeEnabled && explorerSidebarPane && explorerSidebarPane.hidden !== true,
|
||||
);
|
||||
const mainColumnWindowChromeCorners = renderExplorerSidebarDock
|
||||
? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right")
|
||||
: inheritedWindowChromeCorners;
|
||||
const explorerPane = explorerSidebarPaneId
|
||||
? collectAllPanes(layout.root).find((pane) => pane.id === explorerSidebarPaneId)
|
||||
: null;
|
||||
const mainColumnWindowChromeCorners =
|
||||
!focusModeEnabled && explorerPane && !explorerPane.hidden
|
||||
? removeWindowChromeCorner(inheritedWindowChromeCorners, "top-right")
|
||||
: inheritedWindowChromeCorners;
|
||||
const mainColumnStyle = styles.mainColumn;
|
||||
const explorerSidebarDockStyle = useMemo(
|
||||
() => [styles.explorerSidebarDock, inlineUnistylesStyle({ width: explorerSidebarWidth })],
|
||||
[explorerSidebarWidth],
|
||||
);
|
||||
const handleWorkspaceShellLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const nextWidth = event.nativeEvent.layout.width;
|
||||
setWorkspaceShellWidth((current) => (current === nextWidth ? current : nextWidth));
|
||||
}, []);
|
||||
const previewExplorerSidebarResize = useCallback(
|
||||
(_groupId: string, sizes: number[]) => {
|
||||
const nextRatio = sizes[1];
|
||||
if (nextRatio !== undefined) {
|
||||
setPreviewExplorerSidebarWidth(
|
||||
resolveExplorerSidebarWidth({
|
||||
requestedWidth: nextRatio * workspaceShellWidth,
|
||||
containerWidth: workspaceShellWidth,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[workspaceShellWidth],
|
||||
);
|
||||
const commitExplorerSidebarResize = useCallback(
|
||||
(_groupId: string, sizes: number[]) => {
|
||||
setPreviewExplorerSidebarWidth(null);
|
||||
const nextRatio = sizes[1];
|
||||
if (nextRatio !== undefined) {
|
||||
resizeExplorerSidebar(
|
||||
workspaceKey,
|
||||
resolveExplorerSidebarWidth({
|
||||
requestedWidth: nextRatio * workspaceShellWidth,
|
||||
containerWidth: workspaceShellWidth,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[resizeExplorerSidebar, workspaceKey, workspaceShellWidth],
|
||||
);
|
||||
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = asWorkspaceTabDragData(event.active.data.current);
|
||||
if (!data) {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
setActiveDragTabId(data.tabId);
|
||||
}, []);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
}, []);
|
||||
|
||||
const updateDropPreview = useCallback(
|
||||
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
if (activeData?.kind !== "workspace-tab") {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rects = resolveDragMoveRects(event);
|
||||
if (!rects) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null;
|
||||
const destinationPaneId =
|
||||
overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop"
|
||||
? overData.paneId
|
||||
: null;
|
||||
const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main";
|
||||
if (
|
||||
!activeTab ||
|
||||
!panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost)
|
||||
) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (overData?.kind === "workspace-tab") {
|
||||
const preview = computeTabOverDropPreview({
|
||||
activeData,
|
||||
overData,
|
||||
rects,
|
||||
panesById,
|
||||
uiTabs,
|
||||
});
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(preview);
|
||||
return;
|
||||
}
|
||||
|
||||
setTabDropPreview(null);
|
||||
if (overData?.kind !== "split-pane-drop") {
|
||||
setDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDropPreview(computePaneOverDropPreview({ overData, rects }));
|
||||
},
|
||||
[normalizedServerId, panesById, explorerSidebarPaneId, uiTabs],
|
||||
);
|
||||
|
||||
const applyTabDropEnd = useCallback(
|
||||
(input: { activeData: WorkspaceTabDragData; overData: WorkspaceTabDragData }): void => {
|
||||
const { activeData, overData } = input;
|
||||
const sourcePane = panesById.get(activeData.paneId) ?? null;
|
||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||
if (!sourcePane || !targetPane) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
|
||||
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
|
||||
const resolvedTabDropPreview =
|
||||
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
|
||||
if (sourceIndex < 0 || !resolvedTabDropPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeData.paneId === overData.paneId) {
|
||||
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
|
||||
const nextTabs = arrayMove(
|
||||
sourceTabs,
|
||||
sourceIndex,
|
||||
resolvedTabDropPreview.insertionIndex,
|
||||
);
|
||||
onReorderTabsInPane(
|
||||
activeData.paneId,
|
||||
nextTabs.map((tab) => tab.tabId),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
|
||||
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
|
||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
|
||||
},
|
||||
[onMoveTabToPane, onReorderTabsInPane, panesById, tabDropPreview, uiTabs],
|
||||
);
|
||||
|
||||
const applyPaneDropEnd = useCallback(
|
||||
(input: { activeData: WorkspaceTabDragData; overData: SplitPaneDropData }): void => {
|
||||
const { activeData, overData } = input;
|
||||
if (dropPreview?.paneId !== overData.paneId) {
|
||||
return;
|
||||
}
|
||||
if (dropPreview.position === "center") {
|
||||
if (activeData.paneId !== overData.paneId) {
|
||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSplitPane({
|
||||
tabId: activeData.tabId,
|
||||
targetPaneId: overData.paneId,
|
||||
position: dropPreview.position,
|
||||
});
|
||||
},
|
||||
[dropPreview, onMoveTabToPane, onSplitPane],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
setActiveDragTabId(null);
|
||||
|
||||
if (activeData?.kind === "workspace-tab" && event.over) {
|
||||
if (overData?.kind === "workspace-tab") {
|
||||
applyTabDropEnd({ activeData, overData });
|
||||
} else if (overData?.kind === "split-pane-drop") {
|
||||
applyPaneDropEnd({ activeData, overData });
|
||||
}
|
||||
}
|
||||
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
},
|
||||
[applyTabDropEnd, applyPaneDropEnd],
|
||||
);
|
||||
|
||||
return (
|
||||
<RenderProfile id="SplitContainer">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={dropCollisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={updateDropPreview}
|
||||
onDragOver={updateDropPreview}
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<View style={styles.workspaceShell} onLayout={handleWorkspaceShellLayout}>
|
||||
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
|
||||
<View style={mainColumnStyle}>
|
||||
{renderMainHeader?.()}
|
||||
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
|
||||
{renderRoot ? (
|
||||
<SplitNodeView
|
||||
node={renderRoot}
|
||||
workspaceKey={workspaceKey}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={layout.focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</WindowChromeRegion>
|
||||
{renderExplorerSidebarDock && explorerSidebarPane ? (
|
||||
<>
|
||||
<ResizeHandle
|
||||
testID="workspace-explorer-sidebar-resize-handle"
|
||||
direction="horizontal"
|
||||
hitAreaAlignment="end"
|
||||
groupId={EXPLORER_SIDEBAR_RESIZE_GROUP_ID}
|
||||
index={0}
|
||||
sizes={explorerSidebarDockSizes}
|
||||
containerSize={workspaceShellWidth}
|
||||
onPreviewResizeSplit={previewExplorerSidebarResize}
|
||||
onResizeSplit={commitExplorerSidebarResize}
|
||||
<View style={styles.workspaceShell}>
|
||||
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
|
||||
<View style={mainColumnStyle}>
|
||||
{renderMainHeader?.()}
|
||||
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
|
||||
{renderRoot ? (
|
||||
<SplitNodeView
|
||||
node={renderRoot}
|
||||
workspaceKey={workspaceKey}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={layout.focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
<View style={explorerSidebarDockStyle}>
|
||||
<ExplorerSidebarDock
|
||||
pane={explorerSidebarPane}
|
||||
uiTabs={uiTabs}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
closingTabIds={closingTabIds}
|
||||
onSelectTab={onSelectTabInPane}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreview={tabDropPreview}
|
||||
headerAction={renderExplorerSidebarHeaderAction?.()}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDragTabId ? (
|
||||
<DragOverlayTabChip
|
||||
tabId={activeDragTabId}
|
||||
uiTabs={uiTabs}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</RenderProfile>
|
||||
);
|
||||
}
|
||||
|
||||
function DragOverlayTabChip({
|
||||
tabId,
|
||||
uiTabs,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
tabId: string;
|
||||
uiTabs: WorkspaceTab[];
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const tab = uiTabs.find((t) => t.tabId === tabId);
|
||||
const descriptor = useMemo<WorkspaceTabDescriptor | null>(
|
||||
() =>
|
||||
tab
|
||||
? {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
}
|
||||
: null,
|
||||
[tab],
|
||||
);
|
||||
if (!descriptor) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DragOverlayTabChipInner
|
||||
tab={descriptor}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DragOverlayTabChipInner({
|
||||
tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const chipStyle = useMemo(
|
||||
() => [
|
||||
styles.dragOverlayChip,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
],
|
||||
[theme.colors.surface1, theme.colors.borderAccent],
|
||||
);
|
||||
const chipLabelStyle = useMemo(
|
||||
() => [styles.dragOverlayLabel, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={tab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => {
|
||||
const label =
|
||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||
|
||||
return (
|
||||
<View style={chipStyle}>
|
||||
<WorkspaceTabIcon presentation={presentation} active size={14} backdrop="surface1" />
|
||||
<Text numberOfLines={1} style={chipLabelStyle}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
</WindowChromeRegion>
|
||||
</View>
|
||||
</RenderProfile>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1317,21 +765,6 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
);
|
||||
});
|
||||
|
||||
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
|
||||
const next = new Map<string, SplitPane>();
|
||||
function visit(current: SplitNode) {
|
||||
if (current.kind === "pane") {
|
||||
next.set(current.pane.id, current.pane);
|
||||
return;
|
||||
}
|
||||
for (const child of current.group.children) {
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
visit(node);
|
||||
return next;
|
||||
}
|
||||
|
||||
function getNodeKey(node: SplitNode): string {
|
||||
if (node.kind === "pane") {
|
||||
return node.pane.id;
|
||||
@@ -1396,12 +829,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
explorerSidebarDock: {
|
||||
flexShrink: 0,
|
||||
minWidth: 240,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
group: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
@@ -1436,18 +863,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
},
|
||||
dragOverlayChip: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
maxWidth: 200,
|
||||
},
|
||||
dragOverlayLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -147,7 +147,7 @@ function getPreviewFrameStyle(position: SplitDropZonePosition) {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
zIndex: 40,
|
||||
},
|
||||
previewOverlay: {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnUI } from "react-native-worklets";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { getStatusRingRotation } from "@/components/status-ring/geometry";
|
||||
|
||||
const sharedRotation = makeMutable(getStatusRingRotation(Date.now()));
|
||||
const activeRingCount = makeMutable(0);
|
||||
const clockRunning = makeMutable(false);
|
||||
let nextListenerId = 1;
|
||||
|
||||
function advanceSharedRotation(): void {
|
||||
"worklet";
|
||||
@@ -18,13 +20,21 @@ function advanceSharedRotation(): void {
|
||||
requestAnimationFrame(advanceSharedRotation);
|
||||
}
|
||||
|
||||
function registerStatusRing(registered: SharedValue<boolean>): void {
|
||||
function registerStatusRing(
|
||||
rotation: SharedValue<number>,
|
||||
registered: SharedValue<boolean>,
|
||||
listenerId: number,
|
||||
): void {
|
||||
"worklet";
|
||||
if (registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = true;
|
||||
rotation.value = getStatusRingRotation(Date.now());
|
||||
sharedRotation.addListener(listenerId, (value) => {
|
||||
rotation.value = value;
|
||||
});
|
||||
activeRingCount.value += 1;
|
||||
|
||||
if (!clockRunning.value) {
|
||||
@@ -34,25 +44,31 @@ function registerStatusRing(registered: SharedValue<boolean>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterStatusRing(registered: SharedValue<boolean>): void {
|
||||
function unregisterStatusRing(registered: SharedValue<boolean>, listenerId: number): void {
|
||||
"worklet";
|
||||
if (!registered.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = false;
|
||||
sharedRotation.removeListener(listenerId);
|
||||
activeRingCount.value -= 1;
|
||||
}
|
||||
|
||||
export function useStatusRingRotation(): SharedValue<number> {
|
||||
const active = useRetainedPanelActive();
|
||||
// Hidden retained views must not subscribe their animated styles to the shared clock.
|
||||
const rotation = useSharedValue(getStatusRingRotation(Date.now()));
|
||||
const registered = useSharedValue(false);
|
||||
const [listenerId] = useState(() => nextListenerId++);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scheduleOnUI(registerStatusRing, registered);
|
||||
if (!active) return;
|
||||
scheduleOnUI(registerStatusRing, rotation, registered, listenerId);
|
||||
return () => {
|
||||
scheduleOnUI(unregisterStatusRing, registered);
|
||||
scheduleOnUI(unregisterStatusRing, registered, listenerId);
|
||||
};
|
||||
}, [registered]);
|
||||
}, [active, listenerId, registered, rotation]);
|
||||
|
||||
return sharedRotation;
|
||||
return rotation;
|
||||
}
|
||||
|
||||
@@ -1171,7 +1171,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 0,
|
||||
},
|
||||
attachOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.16)",
|
||||
|
||||
@@ -1765,7 +1765,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
desktopOverlayWeb: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
pointerEvents: "auto" as const,
|
||||
},
|
||||
desktopBackdrop: {
|
||||
|
||||
@@ -81,6 +81,6 @@ export function FloatingPanelPortalHost({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
host: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,13 +14,16 @@ import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "rea
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||
import {
|
||||
@@ -326,28 +329,31 @@ function HoverCardSurface({
|
||||
triggerRef: RefObject<View | null>;
|
||||
contentRef: RefObject<View | null>;
|
||||
}): ReactElement {
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
const [anchor, setAnchor] = useState<{ triggerRect: Rect; hostRect: Rect } | null>(null);
|
||||
const [contentSize, setContentSize] = useState<Size | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
let cancelled = false;
|
||||
void measureElement(triggerRef.current).then((rect) => {
|
||||
if (!cancelled) setTriggerRect(rect);
|
||||
void Promise.all([
|
||||
measureElement(triggerRef.current),
|
||||
measureFloatingPanelPortalHost(portalHostName),
|
||||
]).then(([triggerRect, hostRect]) => {
|
||||
if (!cancelled && hostRect) setAnchor({ triggerRect, hostRect });
|
||||
return undefined;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [triggerRef]);
|
||||
}, [portalHostName, triggerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRect || !contentSize) return;
|
||||
if (!anchor || !contentSize) return;
|
||||
const { width, height } = Dimensions.get("window");
|
||||
const { x, y } = computePosition({
|
||||
triggerRect,
|
||||
triggerRect: anchor.triggerRect,
|
||||
contentSize,
|
||||
displayArea: { x: 0, y: 0, width, height },
|
||||
placement,
|
||||
@@ -355,8 +361,8 @@ function HoverCardSurface({
|
||||
alignment,
|
||||
offset,
|
||||
});
|
||||
setPosition({ x, y });
|
||||
}, [alignment, contentSize, offset, placement, triggerRect]);
|
||||
setPosition({ x: x - anchor.hostRect.x, y: y - anchor.hostRect.y });
|
||||
}, [alignment, anchor, contentSize, offset, placement]);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
@@ -377,7 +383,7 @@ function HoverCardSurface({
|
||||
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
|
||||
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<Portal hostName={portalHostName}>
|
||||
<View pointerEvents="box-none" style={[styles.portalOverlay, { zIndex: layer }]}>
|
||||
<FloatingSurface
|
||||
ref={contentRef}
|
||||
|
||||
@@ -86,6 +86,6 @@ const styles = StyleSheet.create({
|
||||
minHeight: 0,
|
||||
},
|
||||
host: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -527,7 +527,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
overlayWeb: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
pointerEvents: "auto" as const,
|
||||
},
|
||||
backdrop: {
|
||||
|
||||
@@ -66,9 +66,9 @@ export const PressHighlight = forwardRef<View, PressHighlightProps>(function Pre
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
highlight: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
highlightFill: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 0,
|
||||
},
|
||||
badgeBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
opacity: theme.opacity[50],
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { TabDropPreview } from "./split-container-tab-drop-preview";
|
||||
import type { SplitDropZoneHover } from "./split-drop-zone";
|
||||
export const WorkspaceTabDragContext = createContext<{
|
||||
activeDragTabId: string | null;
|
||||
dropPreview: SplitDropZoneHover | null;
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
externalDndContext: boolean;
|
||||
}>({ activeDragTabId: null, dropPreview: null, tabDropPreview: null, externalDndContext: false });
|
||||
export function useWorkspaceTabDrag() {
|
||||
return useContext(WorkspaceTabDragContext);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function acceptsWorkspaceTabDrop(
|
||||
allowCrossPane: boolean,
|
||||
sourcePaneId: string,
|
||||
destination: { kind: "workspace-tab" | "split-pane-drop"; paneId: string },
|
||||
): boolean {
|
||||
return (
|
||||
allowCrossPane || (destination.kind === "workspace-tab" && sourcePaneId === destination.paneId)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import type { WorkspaceTabDragScope as WebScope } from "./workspace-tab-drag-scope.web";
|
||||
export function WorkspaceTabDragScope({ children }: ComponentProps<typeof WebScope>) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { computeTabDropPreview, type TabDropPreview } from "./split-container-tab-drop-preview";
|
||||
import { resolveSplitDropPosition, type SplitDropZoneHover } from "./split-drop-zone";
|
||||
import { getWorkspacePaneDescriptors } from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
collectAllPanes,
|
||||
createWorkspaceLayoutWithExplorerSidebar,
|
||||
type WorkspaceLayout,
|
||||
type SplitPane,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
|
||||
import { acceptsWorkspaceTabDrop } from "./workspace-tab-drag-policy";
|
||||
import { WorkspaceTabDragContext } from "./workspace-tab-drag-context";
|
||||
interface WorkspaceTabDragData {
|
||||
kind: "workspace-tab";
|
||||
paneId: string;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface SplitPaneDropData {
|
||||
kind: "split-pane-drop";
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
|
||||
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
|
||||
}
|
||||
|
||||
function isSplitPaneDropData(data: unknown): data is SplitPaneDropData {
|
||||
return (
|
||||
typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop"
|
||||
);
|
||||
}
|
||||
|
||||
function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined {
|
||||
return isWorkspaceTabDragData(data) ? data : undefined;
|
||||
}
|
||||
|
||||
function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined {
|
||||
if (isWorkspaceTabDragData(data)) return data;
|
||||
if (isSplitPaneDropData(data)) return data;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface DragMoveRects {
|
||||
translatedRect: { left: number; top: number; width: number; height: number };
|
||||
overRect: { left: number; top: number; width: number; height: number };
|
||||
}
|
||||
|
||||
function resolveDragMoveRects(
|
||||
event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">,
|
||||
): DragMoveRects | null {
|
||||
const translatedRect = event.active.rect.current.translated;
|
||||
const overRect = event.over?.rect;
|
||||
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { translatedRect, overRect };
|
||||
}
|
||||
|
||||
function computeTabOverDropPreview(input: {
|
||||
activeData: WorkspaceTabDragData;
|
||||
overData: WorkspaceTabDragData;
|
||||
rects: DragMoveRects;
|
||||
panesById: Map<string, SplitPane>;
|
||||
uiTabs: WorkspaceTab[];
|
||||
}): TabDropPreview | null {
|
||||
const { activeData, overData, rects, panesById, uiTabs } = input;
|
||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||
if (!targetPane) {
|
||||
return null;
|
||||
}
|
||||
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||
return computeTabDropPreview({
|
||||
activePaneId: activeData.paneId,
|
||||
activeTabId: activeData.tabId,
|
||||
overPaneId: overData.paneId,
|
||||
overTabId: overData.tabId,
|
||||
targetTabs,
|
||||
activeRect: {
|
||||
left: rects.translatedRect.left,
|
||||
width: rects.translatedRect.width,
|
||||
},
|
||||
overRect: {
|
||||
left: rects.overRect.left,
|
||||
width: rects.overRect.width,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function computePaneOverDropPreview(input: {
|
||||
overData: SplitPaneDropData;
|
||||
rects: DragMoveRects;
|
||||
}): SplitDropZoneHover | null {
|
||||
const { overData, rects } = input;
|
||||
const centerX = rects.translatedRect.left + rects.translatedRect.width / 2;
|
||||
const centerY = rects.translatedRect.top + rects.translatedRect.height / 2;
|
||||
const relativeX = centerX - rects.overRect.left;
|
||||
const relativeY = centerY - rects.overRect.top;
|
||||
if (
|
||||
Number.isNaN(relativeX) ||
|
||||
Number.isNaN(relativeY) ||
|
||||
relativeX < 0 ||
|
||||
relativeX > rects.overRect.width ||
|
||||
relativeY < 0 ||
|
||||
relativeY > rects.overRect.height
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
paneId: overData.paneId,
|
||||
position: resolveSplitDropPosition({
|
||||
width: rects.overRect.width,
|
||||
height: rects.overRect.height,
|
||||
x: relativeX,
|
||||
y: relativeY,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const dropCollisionDetection: CollisionDetection = (args) => {
|
||||
const pointerHits = pointerWithin(args);
|
||||
const tabHits = pointerHits.filter(
|
||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab",
|
||||
);
|
||||
if (tabHits.length > 0) {
|
||||
return tabHits;
|
||||
}
|
||||
|
||||
const paneHits = pointerHits.filter(
|
||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop",
|
||||
);
|
||||
if (paneHits.length > 0) {
|
||||
return paneHits;
|
||||
}
|
||||
|
||||
return closestCenter(args);
|
||||
};
|
||||
|
||||
const EMPTY_LAYOUT = createWorkspaceLayoutWithExplorerSidebar();
|
||||
|
||||
interface WorkspaceTabDragScopeProps {
|
||||
children: ReactNode;
|
||||
layout: WorkspaceLayout | null;
|
||||
explorerSidebarPaneId: string | null;
|
||||
allowCrossPane: boolean;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
uiTabs: WorkspaceTab[];
|
||||
onMoveTabToPane: (tabId: string, paneId: string) => void;
|
||||
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
|
||||
onSplitPane: (input: {
|
||||
tabId: string;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}) => void;
|
||||
}
|
||||
export function WorkspaceTabDragScope({
|
||||
children,
|
||||
layout,
|
||||
explorerSidebarPaneId,
|
||||
allowCrossPane,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
uiTabs,
|
||||
onMoveTabToPane,
|
||||
onReorderTabsInPane,
|
||||
onSplitPane,
|
||||
}: WorkspaceTabDragScopeProps) {
|
||||
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
|
||||
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
|
||||
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const panesById = useMemo(
|
||||
() => new Map(collectAllPanes((layout ?? EMPTY_LAYOUT).root).map((pane) => [pane.id, pane])),
|
||||
[layout],
|
||||
);
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = asWorkspaceTabDragData(event.active.data.current);
|
||||
if (!data) {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
setActiveDragTabId(data.tabId);
|
||||
}, []);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
}, []);
|
||||
|
||||
const updateDropPreview = useCallback(
|
||||
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
if (
|
||||
!activeData ||
|
||||
!overData ||
|
||||
!acceptsWorkspaceTabDrop(allowCrossPane, activeData.paneId, overData)
|
||||
) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rects = resolveDragMoveRects(event);
|
||||
if (!rects) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTab = uiTabs.find((tab) => tab.tabId === activeData.tabId) ?? null;
|
||||
const destinationPaneId =
|
||||
overData?.kind === "workspace-tab" || overData?.kind === "split-pane-drop"
|
||||
? overData.paneId
|
||||
: null;
|
||||
const destinationHost = destinationPaneId === explorerSidebarPaneId ? "explorer" : "main";
|
||||
if (
|
||||
!activeTab ||
|
||||
!panelTargetSupportsHost(normalizedServerId, activeTab.target, destinationHost)
|
||||
) {
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (overData?.kind === "workspace-tab") {
|
||||
const preview = computeTabOverDropPreview({
|
||||
activeData,
|
||||
overData,
|
||||
rects,
|
||||
panesById,
|
||||
uiTabs,
|
||||
});
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(preview);
|
||||
return;
|
||||
}
|
||||
|
||||
setTabDropPreview(null);
|
||||
if (overData?.kind !== "split-pane-drop") {
|
||||
setDropPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDropPreview(computePaneOverDropPreview({ overData, rects }));
|
||||
},
|
||||
[allowCrossPane, normalizedServerId, panesById, explorerSidebarPaneId, uiTabs],
|
||||
);
|
||||
|
||||
const applyTabDropEnd = useCallback(
|
||||
(input: { activeData: WorkspaceTabDragData; overData: WorkspaceTabDragData }): void => {
|
||||
const { activeData, overData } = input;
|
||||
const sourcePane = panesById.get(activeData.paneId) ?? null;
|
||||
const targetPane = panesById.get(overData.paneId) ?? null;
|
||||
if (!sourcePane || !targetPane) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
|
||||
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
|
||||
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
|
||||
const resolvedTabDropPreview =
|
||||
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
|
||||
if (sourceIndex < 0 || !resolvedTabDropPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeData.paneId === overData.paneId) {
|
||||
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
|
||||
const nextTabs = arrayMove(
|
||||
sourceTabs,
|
||||
sourceIndex,
|
||||
resolvedTabDropPreview.insertionIndex,
|
||||
);
|
||||
onReorderTabsInPane(
|
||||
activeData.paneId,
|
||||
nextTabs.map((tab) => tab.tabId),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
|
||||
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
|
||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
|
||||
},
|
||||
[onMoveTabToPane, onReorderTabsInPane, panesById, tabDropPreview, uiTabs],
|
||||
);
|
||||
|
||||
const applyPaneDropEnd = useCallback(
|
||||
(input: { activeData: WorkspaceTabDragData; overData: SplitPaneDropData }): void => {
|
||||
const { activeData, overData } = input;
|
||||
if (dropPreview?.paneId !== overData.paneId) {
|
||||
return;
|
||||
}
|
||||
if (dropPreview.position === "center") {
|
||||
if (activeData.paneId !== overData.paneId) {
|
||||
onMoveTabToPane(activeData.tabId, overData.paneId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSplitPane({
|
||||
tabId: activeData.tabId,
|
||||
targetPaneId: overData.paneId,
|
||||
position: dropPreview.position,
|
||||
});
|
||||
},
|
||||
[dropPreview, onMoveTabToPane, onSplitPane],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
setActiveDragTabId(null);
|
||||
|
||||
if (
|
||||
activeData &&
|
||||
overData &&
|
||||
event.over &&
|
||||
acceptsWorkspaceTabDrop(allowCrossPane, activeData.paneId, overData)
|
||||
) {
|
||||
if (overData?.kind === "workspace-tab") {
|
||||
applyTabDropEnd({ activeData, overData });
|
||||
} else if (overData?.kind === "split-pane-drop") {
|
||||
applyPaneDropEnd({ activeData, overData });
|
||||
}
|
||||
}
|
||||
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
},
|
||||
[allowCrossPane, applyTabDropEnd, applyPaneDropEnd],
|
||||
);
|
||||
|
||||
const context = useMemo(
|
||||
() => ({ activeDragTabId, dropPreview, tabDropPreview, externalDndContext: true }),
|
||||
[activeDragTabId, dropPreview, tabDropPreview],
|
||||
);
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={dropCollisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={updateDropPreview}
|
||||
onDragOver={updateDropPreview}
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<WorkspaceTabDragContext value={context}>{children}</WorkspaceTabDragContext>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDragTabId ? (
|
||||
<DragOverlayTabChip
|
||||
tabId={activeDragTabId}
|
||||
uiTabs={uiTabs}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
function DragOverlayTabChip({
|
||||
tabId,
|
||||
uiTabs,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
tabId: string;
|
||||
uiTabs: WorkspaceTab[];
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const tab = uiTabs.find((t) => t.tabId === tabId);
|
||||
const descriptor = useMemo<WorkspaceTabDescriptor | null>(
|
||||
() =>
|
||||
tab
|
||||
? {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
}
|
||||
: null,
|
||||
[tab],
|
||||
);
|
||||
if (!descriptor) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DragOverlayTabChipInner
|
||||
tab={descriptor}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DragOverlayTabChipInner({
|
||||
tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
}: {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={tab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => {
|
||||
const label =
|
||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||
|
||||
return (
|
||||
<View style={styles.dragOverlayChip}>
|
||||
<WorkspaceTabIcon presentation={presentation} active size={14} backdrop="surface1" />
|
||||
<Text numberOfLines={1} style={styles.dragOverlayLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
dragOverlayChip: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
maxWidth: 200,
|
||||
},
|
||||
dragOverlayLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
@@ -415,11 +415,10 @@ export function WorkspaceDraftAgentTab({
|
||||
openWorkspaceChanges({
|
||||
isCompact: isCompactFormFactor,
|
||||
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }),
|
||||
checkout: { serverId, cwd: composerState.workingDir, isGit: true },
|
||||
preferences: openInSidePane,
|
||||
});
|
||||
},
|
||||
[composerState.workingDir, isCompactFormFactor, openInSidePane, serverId, workspaceId],
|
||||
[isCompactFormFactor, openInSidePane, serverId, workspaceId],
|
||||
);
|
||||
|
||||
const {
|
||||
|
||||
@@ -33,7 +33,7 @@ export {
|
||||
getIsElectronMac as getIsElectronRuntimeMac,
|
||||
} from "./platform";
|
||||
|
||||
/** Compact layouts are the breakpoints below `md`. */
|
||||
/** Below 600 points, sidebars overlay the workspace. Medium and wider layouts dock them. */
|
||||
export function isCompactBreakpoint(breakpoint: string | undefined): boolean {
|
||||
return breakpoint === "xs" || breakpoint === "sm";
|
||||
}
|
||||
|
||||
@@ -56,10 +56,7 @@ export type { DraftInput } from "@/stores/draft-store";
|
||||
export type {
|
||||
Agent,
|
||||
ExplorerEntry,
|
||||
ExplorerFile,
|
||||
ExplorerEntryKind,
|
||||
ExplorerFileKind,
|
||||
ExplorerEncoding,
|
||||
AgentFileExplorerState,
|
||||
} from "@/stores/session-store";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useFocusEffect } from "expo-router/react-navigation";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ArrowUpRight, Check, Terminal } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user