mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
fix(app): route workspace actions semantically (#3896)
* fix(app): route workspace actions semantically Separate Explorer visibility from view navigation, and give composer changes and pull request opens semantic routing owners. Unify diff destinations, keep compact PR navigation in Explorer, and derive maximize availability from visible workspace panes. * test(app): cover composer changes routing Update the browser journey for the Explorer-first composer action, then assert the second press follows the shared diff destination.
This commit is contained in:
@@ -28,8 +28,11 @@ workspace, including the header. It has its own persisted width and resize handl
|
||||
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 layouts follow the
|
||||
source-specific side-pane preference. Callers request the content and never choose the shell.
|
||||
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
|
||||
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
|
||||
Explorer sidebar setting. Callers request the content and never choose the shell.
|
||||
The composer Changes pill is a two-stage desktop action: it first reveals Explorer on Changes, then
|
||||
routes later presses to the working diff through the shared diff preference.
|
||||
|
||||
The persisted layout still contains the Explorer pane so tabs survive reloads. The renderer removes
|
||||
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
|
||||
@@ -40,7 +43,7 @@ toggles the singleton Files and Changes views. Individual tab menus close instan
|
||||
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
|
||||
an Explorer tab does not change workspace focus.
|
||||
|
||||
Cmd+E selects Changes for Git workspaces and Files otherwise. Compact layouts use the combined
|
||||
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.
|
||||
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
|
||||
@@ -70,9 +73,13 @@ new target and never yanks an existing tab out of a user-selected pane.
|
||||
|
||||
## Routing preferences
|
||||
|
||||
Desktop **Settings → Layout → Open in side pane** has independent switches for Explorer Files,
|
||||
Explorer Changes, chat files, diff files, subagents, pull requests, and Changes links. All switches
|
||||
default off. Mobile ignores them.
|
||||
Desktop **Settings → Layout → Open location** has independent Main panel or On the side choices for
|
||||
Explorer Files, diffs, chat files, files opened from diffs, and subagents. They default to Main
|
||||
panel. Mobile ignores them.
|
||||
|
||||
Pull requests have a three-way open location: Main panel, On the side, or Explorer sidebar. Explorer
|
||||
sidebar is the default. Compact layouts always open pull requests in Explorer regardless of this
|
||||
desktop preference.
|
||||
|
||||
Panels request an implicit open through the narrow `openPreferredTarget(target, source)` pane
|
||||
contract. Entry points outside panels use `openPreferredWorkspaceTarget`. Do not branch on a
|
||||
|
||||
@@ -2,7 +2,7 @@ import { rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -10,6 +10,22 @@ function visibleMainPane(page: Page) {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
function composerChangesPill(page: Page) {
|
||||
return page.getByTestId("composer-diff-stat-pill");
|
||||
}
|
||||
|
||||
async function revealComposerChangesInExplorer(page: Page) {
|
||||
await composerChangesPill(page).click();
|
||||
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function openComposerDiff(page: Page) {
|
||||
await composerChangesPill(page).click();
|
||||
}
|
||||
|
||||
async function seedChangedAgent(repoPrefix: string) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
@@ -38,9 +54,11 @@ async function seedChangedAgent(repoPrefix: string) {
|
||||
}
|
||||
}
|
||||
|
||||
test("composer diff stat opens Changes in the configured side pane", async ({ page }) => {
|
||||
test("composer diff stat reveals Changes, then opens the diff in the configured side pane", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((settingsKey) => {
|
||||
localStorage.setItem(settingsKey, JSON.stringify({ openInSidePane: { changesLinks: true } }));
|
||||
localStorage.setItem(settingsKey, JSON.stringify({ openInSidePane: { diffs: true } }));
|
||||
}, APP_SETTINGS_KEY);
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||
|
||||
@@ -51,11 +69,12 @@ test("composer diff stat opens Changes in the configured side pane", async ({ pa
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const pill = page.getByTestId("composer-diff-stat-pill");
|
||||
const pill = composerChangesPill(page);
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pill).toContainText("+2");
|
||||
await expect(pill).toContainText("-0");
|
||||
await pill.click();
|
||||
await revealComposerChangesInExplorer(page);
|
||||
await openComposerDiff(page);
|
||||
|
||||
const sidePane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
@@ -107,7 +126,9 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a
|
||||
}
|
||||
});
|
||||
|
||||
test("composer diff stat opens Changes in the focused pane by default", async ({ page }) => {
|
||||
test("composer diff stat reveals Changes, then opens the diff in the focused pane by default", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
|
||||
|
||||
try {
|
||||
@@ -117,7 +138,8 @@ test("composer diff stat opens Changes in the focused pane by default", async ({
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
await page.getByTestId("composer-diff-stat-pill").click();
|
||||
await revealComposerChangesInExplorer(page);
|
||||
await openComposerDiff(page);
|
||||
|
||||
const mainPane = visibleMainPane(page);
|
||||
await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasMultipleVisiblePanes,
|
||||
resolveSplitContainerRoot,
|
||||
splitNodeContainsPane,
|
||||
} from "@/components/split-container-focus";
|
||||
@@ -9,6 +10,10 @@ const pane = (id: string): SplitNode => ({
|
||||
kind: "pane",
|
||||
pane: { id, tabIds: [], focusedTabId: null },
|
||||
});
|
||||
const hiddenPane = (id: string): SplitNode => ({
|
||||
kind: "pane",
|
||||
pane: { id, tabIds: [], focusedTabId: null, hidden: true },
|
||||
});
|
||||
const root: SplitNode = {
|
||||
kind: "group",
|
||||
group: {
|
||||
@@ -45,4 +50,15 @@ describe("split focus root", () => {
|
||||
resolveSplitContainerRoot({ root, focusedPaneId: "left", focusModeEnabled: false }),
|
||||
).toEqual({ root, usesFallbackStrip: false });
|
||||
});
|
||||
|
||||
it("offers pane maximize only when another pane is visible", () => {
|
||||
expect(hasMultipleVisiblePanes(root)).toBe(true);
|
||||
expect(hasMultipleVisiblePanes(pane("left"))).toBe(false);
|
||||
expect(
|
||||
hasMultipleVisiblePanes({
|
||||
...root,
|
||||
group: { ...root.group, children: [pane("left"), hiddenPane("right")] },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,23 @@ export function splitNodeContainsPane(node: SplitNode, paneId: string): boolean
|
||||
return node.group.children.some((child) => splitNodeContainsPane(child, paneId));
|
||||
}
|
||||
|
||||
/** Whether a workspace pane has another visible pane it can be maximized over. */
|
||||
export function hasMultipleVisiblePanes(node: SplitNode): boolean {
|
||||
let visiblePaneCount = 0;
|
||||
const visit = (current: SplitNode): void => {
|
||||
if (current.kind === "pane") {
|
||||
if (current.pane.hidden !== true) visiblePaneCount += 1;
|
||||
return;
|
||||
}
|
||||
for (const child of current.group.children) {
|
||||
visit(child);
|
||||
if (visiblePaneCount > 1) return;
|
||||
}
|
||||
};
|
||||
visit(node);
|
||||
return visiblePaneCount > 1;
|
||||
}
|
||||
|
||||
function findPane(node: SplitNode, paneId: string): SplitPane | null {
|
||||
if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null;
|
||||
for (const child of node.group.children) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "@/components/explorer-sidebar-layout";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import {
|
||||
hasMultipleVisiblePanes,
|
||||
resolveSplitContainerRoot,
|
||||
splitNodeContainsPane,
|
||||
} from "@/components/split-container-focus";
|
||||
@@ -185,7 +186,7 @@ interface SplitNodeViewProps extends Omit<
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
windowChromeCorners: WindowChromeCorners;
|
||||
maximizedPaneId: string | null;
|
||||
workspaceHasSplits: boolean;
|
||||
workspaceHasMultiplePanes: boolean;
|
||||
onTogglePaneMaximized: (paneId: string) => void;
|
||||
}
|
||||
|
||||
@@ -374,18 +375,18 @@ export function SplitContainer({
|
||||
() => removePaneFromSplitTree(layout.root, explorerSidebarPaneId),
|
||||
[layout.root, explorerSidebarPaneId],
|
||||
);
|
||||
const workspaceHasSplits = mainRoot?.kind === "group";
|
||||
const workspaceHasMultiplePanes = Boolean(mainRoot && hasMultipleVisiblePanes(mainRoot));
|
||||
useEffect(() => {
|
||||
if (
|
||||
maximizedPaneId &&
|
||||
(focusModeEnabled ||
|
||||
!workspaceHasSplits ||
|
||||
!workspaceHasMultiplePanes ||
|
||||
!mainRoot ||
|
||||
!splitNodeContainsPane(mainRoot, maximizedPaneId))
|
||||
) {
|
||||
setMaximizedPane(null);
|
||||
}
|
||||
}, [focusModeEnabled, mainRoot, maximizedPaneId, workspaceHasSplits]);
|
||||
}, [focusModeEnabled, mainRoot, maximizedPaneId, workspaceHasMultiplePanes]);
|
||||
const handleTogglePaneMaximized = useCallback(
|
||||
(paneId: string) => {
|
||||
setMaximizedPane((current) =>
|
||||
@@ -693,7 +694,7 @@ export function SplitContainer({
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
workspaceHasSplits={workspaceHasSplits}
|
||||
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
@@ -955,7 +956,7 @@ function SplitNodeView({
|
||||
tabDropPreview,
|
||||
windowChromeCorners,
|
||||
maximizedPaneId,
|
||||
workspaceHasSplits,
|
||||
workspaceHasMultiplePanes,
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
@@ -1041,7 +1042,7 @@ function SplitNodeView({
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
workspaceHasSplits={workspaceHasSplits}
|
||||
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
@@ -1095,7 +1096,7 @@ function SplitNodeView({
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={windowChromeCorners}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
workspaceHasSplits={workspaceHasSplits}
|
||||
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
@@ -1153,7 +1154,7 @@ function SplitPaneView({
|
||||
dropPreview,
|
||||
tabDropPreview,
|
||||
maximizedPaneId,
|
||||
workspaceHasSplits,
|
||||
workspaceHasMultiplePanes,
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
@@ -1285,7 +1286,7 @@ function SplitPaneView({
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
showPaneSplitActions={!focusModeEnabled}
|
||||
showPaneMaximizeAction={workspaceHasSplits && !focusModeEnabled}
|
||||
showPaneMaximizeAction={workspaceHasMultiplePanes && !focusModeEnabled}
|
||||
paneMaximized={paneId === maximizedPaneId}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
onSplitRight={handleSplitRight}
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceDraftTabSetup,
|
||||
} from "@/workspace-tabs/model";
|
||||
import { openWorkspaceSupportingView } from "@/workspace-tabs/open-supporting-view";
|
||||
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
@@ -453,8 +453,7 @@ export function WorkspaceDraftAgentTab({
|
||||
if (attachment.kind !== "review") {
|
||||
return;
|
||||
}
|
||||
openWorkspaceSupportingView({
|
||||
view: "changes",
|
||||
openWorkspaceChanges({
|
||||
isCompact: isCompactFormFactor,
|
||||
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }),
|
||||
checkout: { serverId, cwd: composerState.workingDir, isGit: true },
|
||||
|
||||
@@ -102,8 +102,8 @@ import { DiffTooLargeState } from "@/git/diff-too-large-state";
|
||||
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { openWorkspaceSupportingView } from "@/workspace-tabs/open-supporting-view";
|
||||
import type { OpenInSidePanePreferences } from "@/hooks/use-settings";
|
||||
import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view";
|
||||
import type { PullRequestOpenLocation } from "@/hooks/use-settings";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
@@ -1511,13 +1511,13 @@ function useDiffTabNavigation({
|
||||
workspaceId,
|
||||
cwd,
|
||||
isMobile,
|
||||
openInSidePane,
|
||||
pullRequestOpenLocation,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
isMobile: boolean;
|
||||
openInSidePane: OpenInSidePanePreferences;
|
||||
pullRequestOpenLocation: PullRequestOpenLocation;
|
||||
}) {
|
||||
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
|
||||
const openWorkspaceTab = useCallback(
|
||||
@@ -1545,14 +1545,13 @@ function useDiffTabNavigation({
|
||||
);
|
||||
const openPullRequest = useCallback(() => {
|
||||
if (!persistenceKey) return;
|
||||
openWorkspaceSupportingView({
|
||||
view: "pull-request",
|
||||
openWorkspacePullRequest({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
checkout: { serverId, cwd, isGit: true },
|
||||
preferences: openInSidePane,
|
||||
destination: pullRequestOpenLocation,
|
||||
});
|
||||
}, [cwd, isMobile, openInSidePane, persistenceKey, serverId]);
|
||||
}, [cwd, isMobile, persistenceKey, pullRequestOpenLocation, serverId]);
|
||||
return {
|
||||
openDiff,
|
||||
openCommit,
|
||||
@@ -1633,7 +1632,7 @@ export function ChangesSurface({
|
||||
workspaceId,
|
||||
cwd,
|
||||
isMobile,
|
||||
openInSidePane: appSettings.openInSidePane,
|
||||
pullRequestOpenLocation: appSettings.pullRequestOpenLocation,
|
||||
});
|
||||
const refreshSupported = useSessionStore(
|
||||
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
saveAppSettings as saveAppSettingsPure,
|
||||
type AppSettings,
|
||||
type OpenInSidePanePreferences,
|
||||
type PullRequestOpenLocation,
|
||||
type DesktopSettingsBridge,
|
||||
type KeyValueStorage,
|
||||
type ReleaseChannel,
|
||||
@@ -73,6 +74,7 @@ export type {
|
||||
AppSettings,
|
||||
AppLanguage,
|
||||
OpenInSidePanePreferences,
|
||||
PullRequestOpenLocation,
|
||||
DesktopSettingsBridge,
|
||||
KeyValueStorage,
|
||||
ReleaseChannel,
|
||||
|
||||
@@ -197,6 +197,37 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
expect(result.chatOutlineEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("collapses legacy diff destinations into the former Explorer choice", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
openInSidePane: { explorerChanges: true, changesLinks: false },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.openInSidePane.diffs).toBe(true);
|
||||
expect(result.openInSidePane).not.toHaveProperty("explorerChanges");
|
||||
expect(result.openInSidePane).not.toHaveProperty("changesLinks");
|
||||
});
|
||||
|
||||
it("defaults PRs to Explorer and preserves the legacy side choice", async () => {
|
||||
const defaults = await loadAppSettingsFromStorage(makeDeps());
|
||||
const legacySide = await loadAppSettingsFromStorage(
|
||||
makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ openInSidePane: { pullRequests: true } }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(defaults.pullRequestOpenLocation).toBe("explorer");
|
||||
expect(legacySide.pullRequestOpenLocation).toBe("side");
|
||||
expect(legacySide.openInSidePane).not.toHaveProperty("pullRequests");
|
||||
});
|
||||
|
||||
it("uses the native terminal renderer by default", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export type SendBehavior = ActiveTurnBehavior | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||||
export type WorkspaceTitleSource = "title" | "branch";
|
||||
export type PullRequestOpenLocation = "main" | "side" | "explorer";
|
||||
/** What a sidebar workspace row shows in the space to the right of its title. */
|
||||
export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
|
||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||
@@ -87,26 +88,23 @@ export interface AppSettings {
|
||||
vimKeybindings: boolean;
|
||||
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
|
||||
openInSidePane: OpenInSidePanePreferences;
|
||||
pullRequestOpenLocation: PullRequestOpenLocation;
|
||||
}
|
||||
|
||||
export interface OpenInSidePanePreferences {
|
||||
explorerFiles: boolean;
|
||||
explorerChanges: boolean;
|
||||
diffs: boolean;
|
||||
chatFiles: boolean;
|
||||
diffFiles: boolean;
|
||||
subagents: boolean;
|
||||
pullRequests: boolean;
|
||||
changesLinks: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES: OpenInSidePanePreferences = {
|
||||
explorerFiles: false,
|
||||
explorerChanges: false,
|
||||
diffs: false,
|
||||
chatFiles: false,
|
||||
diffFiles: false,
|
||||
subagents: false,
|
||||
pullRequests: false,
|
||||
changesLinks: false,
|
||||
};
|
||||
|
||||
export interface Settings extends AppSettings {
|
||||
@@ -137,6 +135,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
chatOutlineEnabled: true,
|
||||
vimKeybindings: false,
|
||||
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
|
||||
pullRequestOpenLocation: "explorer",
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
@@ -232,14 +231,26 @@ const StoredAppSettingsSchema = z
|
||||
openInSidePane: z
|
||||
.object({
|
||||
explorerFiles: z.boolean().catch(false),
|
||||
explorerChanges: z.boolean().catch(false),
|
||||
diffs: z.boolean().optional(),
|
||||
// COMPAT(diffDestinationPreference): legacy split preferences, remove after 2027-02-26.
|
||||
explorerChanges: z.boolean().optional(),
|
||||
changesLinks: z.boolean().optional(),
|
||||
chatFiles: z.boolean().catch(false),
|
||||
diffFiles: z.boolean().catch(false),
|
||||
subagents: z.boolean().catch(false),
|
||||
pullRequests: z.boolean().catch(false),
|
||||
changesLinks: z.boolean().catch(false),
|
||||
// COMPAT(pullRequestOpenLocation): legacy side-pane toggle, remove after 2027-02-26.
|
||||
pullRequests: z.boolean().optional(),
|
||||
})
|
||||
.catch(DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES),
|
||||
.transform(({ explorerChanges, changesLinks, pullRequests, ...preferences }) => ({
|
||||
...preferences,
|
||||
diffs: preferences.diffs ?? explorerChanges ?? changesLinks ?? false,
|
||||
legacyPullRequestsInSidePane: pullRequests,
|
||||
}))
|
||||
.catch({
|
||||
...DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
|
||||
legacyPullRequestsInSidePane: undefined,
|
||||
}),
|
||||
pullRequestOpenLocation: z.enum(["main", "side", "explorer"]).optional(),
|
||||
// COMPAT(explorerSidebarRouting): replaced by source-specific side-pane preferences in v0.6.
|
||||
openSupportingTabsInSidePanel: z.boolean().optional().catch(undefined),
|
||||
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
|
||||
@@ -247,6 +258,7 @@ const StoredAppSettingsSchema = z
|
||||
releaseChannel: z.enum(["stable", "beta"]).optional().catch(undefined),
|
||||
})
|
||||
.transform((stored) => {
|
||||
const { legacyPullRequestsInSidePane, ...openInSidePane } = stored.openInSidePane;
|
||||
const needsWrite =
|
||||
(stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) ||
|
||||
stored.contentFontSize === undefined;
|
||||
@@ -264,6 +276,9 @@ const StoredAppSettingsSchema = z
|
||||
stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed");
|
||||
return {
|
||||
...stored,
|
||||
openInSidePane,
|
||||
pullRequestOpenLocation:
|
||||
stored.pullRequestOpenLocation ?? (legacyPullRequestsInSidePane ? "side" : "explorer"),
|
||||
uiBaseFontSize,
|
||||
contentFontSize: stored.contentFontSize ?? uiBaseFontSize,
|
||||
sidebarChecksDisplay,
|
||||
|
||||
@@ -1892,15 +1892,16 @@ export const en = {
|
||||
destinations: {
|
||||
main: "Main panel",
|
||||
side: "On the side",
|
||||
explorer: "Explorer sidebar",
|
||||
},
|
||||
sources: {
|
||||
explorerFiles: {
|
||||
label: "Selecting a file in Explorer",
|
||||
description: "Open files selected in the Explorer sidebar beside your work",
|
||||
},
|
||||
explorerChanges: {
|
||||
label: "Selecting a change in Explorer",
|
||||
description: "Open diffs selected in the Explorer sidebar beside your work",
|
||||
diffs: {
|
||||
label: "Opening a diff",
|
||||
description: "Open diffs from Explorer and agent conversations beside your work",
|
||||
},
|
||||
chatFiles: {
|
||||
label: "Opening a file from an agent chat",
|
||||
@@ -1918,10 +1919,6 @@ export const en = {
|
||||
label: "Opening a pull request from Changes",
|
||||
description: "Open pull request details beside Changes",
|
||||
},
|
||||
changesLinks: {
|
||||
label: "Opening Changes from an agent",
|
||||
description: "Open diff stats and review attachments beside the conversation",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -94,7 +94,7 @@ import {
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { openWorkspaceSupportingView } from "@/workspace-tabs/open-supporting-view";
|
||||
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
@@ -1626,8 +1626,7 @@ function ActiveAgentComposer({
|
||||
if (attachment.kind !== "review") {
|
||||
return;
|
||||
}
|
||||
openWorkspaceSupportingView({
|
||||
view: "changes",
|
||||
openWorkspaceChanges({
|
||||
isCompact: isCompactFormFactor,
|
||||
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
checkout: { serverId, cwd, isGit: true },
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { TodoEntry } from "@/types/stream";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside";
|
||||
import { openWorkspaceSupportingView } from "@/workspace-tabs/open-supporting-view";
|
||||
import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
|
||||
/**
|
||||
* The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above
|
||||
@@ -99,8 +99,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
if (!workspaceKey) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceSupportingView({
|
||||
view: "changes",
|
||||
openComposerChanges({
|
||||
isCompact,
|
||||
workspaceKey,
|
||||
checkout: { serverId, cwd, isGit: true },
|
||||
|
||||
@@ -100,8 +100,7 @@ function ChangesPanel() {
|
||||
const isTree = target.kind === "changes_tree";
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(path: string) =>
|
||||
openPreferredTarget({ kind: "file", path }, isTree ? "explorerChanges" : "diffFiles"),
|
||||
(path: string) => openPreferredTarget({ kind: "file", path }, isTree ? "diffs" : "diffFiles"),
|
||||
[isTree, openPreferredTarget],
|
||||
);
|
||||
|
||||
@@ -109,7 +108,7 @@ function ChangesPanel() {
|
||||
(path: string) =>
|
||||
openPreferredTarget(
|
||||
{ kind: "working_diff", focusPath: path, focusRequestId: Date.now() },
|
||||
"explorerChanges",
|
||||
"diffs",
|
||||
),
|
||||
[openPreferredTarget],
|
||||
);
|
||||
|
||||
@@ -4,21 +4,23 @@ import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { useAppSettings, type OpenInSidePanePreferences } from "@/hooks/use-settings";
|
||||
import {
|
||||
useAppSettings,
|
||||
type OpenInSidePanePreferences,
|
||||
type PullRequestOpenLocation,
|
||||
} from "@/hooks/use-settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
const SOURCES = [
|
||||
"explorerFiles",
|
||||
"explorerChanges",
|
||||
"diffs",
|
||||
"chatFiles",
|
||||
"diffFiles",
|
||||
"subagents",
|
||||
"pullRequests",
|
||||
"changesLinks",
|
||||
] as const satisfies readonly (keyof OpenInSidePanePreferences)[];
|
||||
|
||||
type OpenDestination = "main" | "side";
|
||||
type LayoutPreferenceSource = keyof OpenInSidePanePreferences | "pullRequests";
|
||||
|
||||
function destinationTriggerStyle({
|
||||
pressed,
|
||||
@@ -29,20 +31,21 @@ function destinationTriggerStyle({
|
||||
|
||||
function LayoutPreferenceRow({
|
||||
source,
|
||||
value,
|
||||
destination,
|
||||
first,
|
||||
allowExplorer,
|
||||
onDestinationChange,
|
||||
}: {
|
||||
source: keyof OpenInSidePanePreferences;
|
||||
value: boolean;
|
||||
source: LayoutPreferenceSource;
|
||||
destination: PullRequestOpenLocation;
|
||||
first: boolean;
|
||||
allowExplorer?: boolean;
|
||||
onDestinationChange: (
|
||||
source: keyof OpenInSidePanePreferences,
|
||||
destination: OpenDestination,
|
||||
source: LayoutPreferenceSource,
|
||||
destination: PullRequestOpenLocation,
|
||||
) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const destination: OpenDestination = value ? "side" : "main";
|
||||
const destinationLabel = t(`settings.layout.openInSidePane.destinations.${destination}`);
|
||||
const selectMain = useCallback(
|
||||
() => onDestinationChange(source, "main"),
|
||||
@@ -52,6 +55,10 @@ function LayoutPreferenceRow({
|
||||
() => onDestinationChange(source, "side"),
|
||||
[onDestinationChange, source],
|
||||
);
|
||||
const selectExplorer = useCallback(
|
||||
() => onDestinationChange(source, "explorer"),
|
||||
[onDestinationChange, source],
|
||||
);
|
||||
const label = t(`settings.layout.openInSidePane.sources.${source}.label`);
|
||||
return (
|
||||
<View style={[settingsStyles.row, first ? null : settingsStyles.rowBorder]}>
|
||||
@@ -73,6 +80,11 @@ function LayoutPreferenceRow({
|
||||
<DropdownMenuItem selected={destination === "side"} onSelect={selectSide}>
|
||||
{t("settings.layout.openInSidePane.destinations.side")}
|
||||
</DropdownMenuItem>
|
||||
{allowExplorer ? (
|
||||
<DropdownMenuItem selected={destination === "explorer"} onSelect={selectExplorer}>
|
||||
{t("settings.layout.openInSidePane.destinations.explorer")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
@@ -83,7 +95,11 @@ export function LayoutSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const handleDestinationChange = useCallback(
|
||||
(source: keyof OpenInSidePanePreferences, destination: OpenDestination) => {
|
||||
(source: LayoutPreferenceSource, destination: PullRequestOpenLocation) => {
|
||||
if (source === "pullRequests") {
|
||||
void updateSettings({ pullRequestOpenLocation: destination });
|
||||
return;
|
||||
}
|
||||
void updateSettings({
|
||||
openInSidePane: { ...settings.openInSidePane, [source]: destination === "side" },
|
||||
});
|
||||
@@ -97,12 +113,19 @@ export function LayoutSection() {
|
||||
<Fragment key={source}>
|
||||
<LayoutPreferenceRow
|
||||
source={source}
|
||||
value={settings.openInSidePane[source]}
|
||||
destination={settings.openInSidePane[source] ? "side" : "main"}
|
||||
first={index === 0}
|
||||
onDestinationChange={handleDestinationChange}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
<LayoutPreferenceRow
|
||||
source="pullRequests"
|
||||
destination={settings.pullRequestOpenLocation}
|
||||
first={false}
|
||||
allowExplorer
|
||||
onDestinationChange={handleDestinationChange}
|
||||
/>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
openPreferredWorkspaceTarget,
|
||||
openWorkspaceTargetBeside,
|
||||
} from "@/workspace-tabs/open-beside";
|
||||
import { openWorkspaceSupportingView } from "@/workspace-tabs/open-supporting-view";
|
||||
import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view";
|
||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import { traceInstant } from "@/performance/native-trace";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
@@ -1829,6 +1829,7 @@ function WorkspaceScreenContent({
|
||||
[openTab],
|
||||
);
|
||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||
const pullRequestOpenLocation = useSettings((settings) => settings.pullRequestOpenLocation);
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane);
|
||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||
@@ -2986,12 +2987,11 @@ function WorkspaceScreenContent({
|
||||
return true;
|
||||
}
|
||||
if (action.target === "pull-request") {
|
||||
openWorkspaceSupportingView({
|
||||
view: "pull-request",
|
||||
openWorkspacePullRequest({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
checkout: activeExplorerCheckout,
|
||||
preferences: openInSidePane,
|
||||
destination: pullRequestOpenLocation,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -3017,9 +3017,9 @@ function WorkspaceScreenContent({
|
||||
activeExplorerCheckout,
|
||||
focusedPaneTabState.pane?.id,
|
||||
isMobile,
|
||||
openInSidePane,
|
||||
openWorkspaceTabFocused,
|
||||
persistenceKey,
|
||||
pullRequestOpenLocation,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
selectExplorerSidebarPaneId,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
@@ -31,7 +32,11 @@ beforeEach(() => {
|
||||
sidePaneIdByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
});
|
||||
usePanelStore.setState({ mobilePanel: { target: "agent", revision: 0 } });
|
||||
usePanelStore.setState({
|
||||
mobilePanel: { target: "agent", revision: 0 },
|
||||
explorerTab: "files",
|
||||
explorerTabByCheckout: {},
|
||||
});
|
||||
});
|
||||
|
||||
describe("Explorer sidebar", () => {
|
||||
@@ -80,9 +85,35 @@ describe("Explorer sidebar", () => {
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
checkout: CHECKOUT,
|
||||
};
|
||||
toggleExplorerSidebar(input);
|
||||
expect(isExplorerSidebarOpen(input)).toBe(true);
|
||||
openExplorerSidebarView({ ...input, view: "files" });
|
||||
toggleExplorerSidebar(input);
|
||||
expect(isExplorerSidebarOpen(input)).toBe(false);
|
||||
toggleExplorerSidebar(input);
|
||||
expect(isExplorerSidebarOpen(input)).toBe(true);
|
||||
const openedState = useWorkspaceLayoutStore.getState();
|
||||
const openedLayout = openedState.layoutByWorkspace[WORKSPACE_KEY];
|
||||
const explorerPaneId = selectExplorerSidebarPaneId(openedState, WORKSPACE_KEY);
|
||||
const explorerPane =
|
||||
openedLayout && explorerPaneId ? findPaneById(openedLayout.root, explorerPaneId) : null;
|
||||
const activeExplorerTarget =
|
||||
openedLayout && explorerPane
|
||||
? collectAllTabs(openedLayout.root).find((tab) => tab.tabId === explorerPane.focusedTabId)
|
||||
?.target.kind
|
||||
: null;
|
||||
expect(activeExplorerTarget).toBe("files");
|
||||
});
|
||||
|
||||
it("toggles the compact Explorer without changing its selected view", () => {
|
||||
usePanelStore.getState().setExplorerTabForCheckout({ ...CHECKOUT, tab: "files" });
|
||||
const input = {
|
||||
isCompact: true,
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
checkout: CHECKOUT,
|
||||
};
|
||||
|
||||
toggleExplorerSidebar(input);
|
||||
|
||||
expect(isExplorerSidebarOpen(input)).toBe(true);
|
||||
expect(usePanelStore.getState().explorerTab).toBe("files");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,11 +95,10 @@ export function toggleExplorerSidebar(input: ExplorerSidebarInput): void {
|
||||
return;
|
||||
}
|
||||
if (!input.workspaceKey) return;
|
||||
const view: ExplorerSidebarView = input.checkout?.isGit ? "changes" : "files";
|
||||
if (isExplorerSidebarOpen(input)) {
|
||||
hideExplorerSidebar(input);
|
||||
} else {
|
||||
openExplorerSidebarView({ ...input, view });
|
||||
showExplorerSidebar(input);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
export type OpenInSidePaneSource = keyof OpenInSidePanePreferences;
|
||||
export type WorkspaceTargetOpenLocation = "main" | "side";
|
||||
|
||||
interface OpenWorkspaceTargetInput {
|
||||
workspaceKey: string | null;
|
||||
@@ -25,6 +26,11 @@ export interface OpenPreferredWorkspaceTargetInput extends OpenWorkspaceTargetIn
|
||||
preferences: OpenInSidePanePreferences;
|
||||
}
|
||||
|
||||
interface OpenWorkspaceTargetAtLocationInput extends OpenWorkspaceTargetInput {
|
||||
isCompact: boolean;
|
||||
location: WorkspaceTargetOpenLocation;
|
||||
}
|
||||
|
||||
interface OpenPreferredWorkspacePreviewInput extends OpenPreferredWorkspaceTargetInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -77,6 +83,19 @@ function canReplacePreview(input: {
|
||||
/** Opens an implicit target according to its source-specific desktop preference. */
|
||||
export function openPreferredWorkspaceTarget(
|
||||
input: OpenPreferredWorkspaceTargetInput,
|
||||
): string | null {
|
||||
return openWorkspaceTargetAtLocation({
|
||||
isCompact: input.isCompact,
|
||||
workspaceKey: input.workspaceKey,
|
||||
target: input.target,
|
||||
location: input.preferences[input.source] ? "side" : "main",
|
||||
parentTabId: input.parentTabId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens an implicit target at the user's preferred main or side location. */
|
||||
export function openWorkspaceTargetAtLocation(
|
||||
input: OpenWorkspaceTargetAtLocationInput,
|
||||
): string | null {
|
||||
if (!input.workspaceKey) return null;
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
@@ -85,7 +104,7 @@ export function openPreferredWorkspaceTarget(
|
||||
layout &&
|
||||
collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target)),
|
||||
);
|
||||
const shouldOpenBeside = !input.isCompact && input.preferences[input.source];
|
||||
const shouldOpenBeside = !input.isCompact && input.location === "side";
|
||||
let placement: WorkspaceTabPlacement | undefined;
|
||||
if (shouldOpenBeside && !targetAlreadyExists) {
|
||||
const paneId = store.ensureSidePane(input.workspaceKey);
|
||||
|
||||
@@ -10,8 +10,16 @@ vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
|
||||
import { DEFAULT_APP_SETTINGS } from "@/hooks/use-settings";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { openWorkspaceSupportingView } from "@/workspace-tabs/open-supporting-view";
|
||||
import {
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
openComposerChanges,
|
||||
openWorkspaceChanges,
|
||||
openWorkspacePullRequest,
|
||||
} from "@/workspace-tabs/open-supporting-view";
|
||||
|
||||
const WORKSPACE_KEY = "server-1:workspace-1";
|
||||
const CHECKOUT = { serverId: "server-1", cwd: "/tmp/repo", isGit: true };
|
||||
@@ -30,13 +38,9 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("openWorkspaceSupportingView", () => {
|
||||
it.each([
|
||||
["changes", "changes"],
|
||||
["pull-request", "pr"],
|
||||
] as const)("opens %s in the compact Explorer", (view, explorerTab) => {
|
||||
openWorkspaceSupportingView({
|
||||
view,
|
||||
describe("openWorkspaceChanges", () => {
|
||||
it("opens Changes in the compact Explorer", () => {
|
||||
openWorkspaceChanges({
|
||||
isCompact: true,
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
checkout: CHECKOUT,
|
||||
@@ -44,21 +48,133 @@ describe("openWorkspaceSupportingView", () => {
|
||||
});
|
||||
|
||||
expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer");
|
||||
expect(usePanelStore.getState().explorerTab).toBe(explorerTab);
|
||||
expect(usePanelStore.getState().explorerTab).toBe("changes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openComposerChanges", () => {
|
||||
const input = {
|
||||
isCompact: false,
|
||||
supportsPaneSplits: true,
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
checkout: CHECKOUT,
|
||||
preferences: DEFAULT_APP_SETTINGS.openInSidePane,
|
||||
};
|
||||
|
||||
it("opens the desktop Explorer on Changes when it is closed", () => {
|
||||
openComposerChanges(input);
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const explorerPaneId = state.explorerSidebarPaneIdByWorkspace[WORKSPACE_KEY];
|
||||
const layout = state.layoutByWorkspace[WORKSPACE_KEY];
|
||||
const explorerPane =
|
||||
layout && explorerPaneId ? findPaneById(layout.root, explorerPaneId) : null;
|
||||
const explorerTabKinds = layout
|
||||
? collectAllTabs(layout.root)
|
||||
.filter((tab) => explorerPane?.tabIds.includes(tab.tabId))
|
||||
.map((tab) => tab.target.kind)
|
||||
: [];
|
||||
|
||||
expect(explorerPane?.hidden).not.toBe(true);
|
||||
expect(explorerTabKinds).toContain("changes_tree");
|
||||
expect(layout && collectAllTabs(layout.root).map((tab) => tab.target.kind)).not.toContain(
|
||||
"working_diff",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the diff through Changes link routing when the desktop Explorer is open", () => {
|
||||
openComposerChanges(input);
|
||||
openComposerChanges({
|
||||
...input,
|
||||
preferences: { ...input.preferences, diffs: true },
|
||||
});
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const layout = state.layoutByWorkspace[WORKSPACE_KEY];
|
||||
const sidePaneId = state.sidePaneIdByWorkspace[WORKSPACE_KEY];
|
||||
const sidePane = layout && sidePaneId ? findPaneById(layout.root, sidePaneId) : null;
|
||||
const sideTabKinds = layout
|
||||
? collectAllTabs(layout.root)
|
||||
.filter((tab) => sidePane?.tabIds.includes(tab.tabId))
|
||||
.map((tab) => tab.target.kind)
|
||||
: [];
|
||||
|
||||
expect(sideTabKinds).toContain("working_diff");
|
||||
});
|
||||
|
||||
it("keeps opening the compact Explorer on Changes", () => {
|
||||
openComposerChanges({ ...input, isCompact: true });
|
||||
openComposerChanges({ ...input, isCompact: true });
|
||||
|
||||
expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer");
|
||||
expect(usePanelStore.getState().explorerTab).toBe("changes");
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the combined Explorer dock on native layouts without pane splits", () => {
|
||||
openWorkspaceSupportingView({
|
||||
view: "pull-request",
|
||||
isCompact: false,
|
||||
supportsPaneSplits: false,
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
checkout: CHECKOUT,
|
||||
preferences: DEFAULT_APP_SETTINGS.openInSidePane,
|
||||
describe("openWorkspacePullRequest", () => {
|
||||
const input = {
|
||||
isCompact: false,
|
||||
supportsPaneSplits: true,
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
checkout: CHECKOUT,
|
||||
};
|
||||
|
||||
it.each(["main", "side", "explorer"] as const)(
|
||||
"opens the compact PR view in Explorer when the desktop preference is %s",
|
||||
(destination) => {
|
||||
openWorkspacePullRequest({ ...input, isCompact: true, destination });
|
||||
|
||||
expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer");
|
||||
expect(usePanelStore.getState().explorerTab).toBe("pr");
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("opens PRs in Explorer by default", () => {
|
||||
openWorkspacePullRequest({
|
||||
...input,
|
||||
destination: DEFAULT_APP_SETTINGS.pullRequestOpenLocation,
|
||||
});
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const layout = state.layoutByWorkspace[WORKSPACE_KEY];
|
||||
const explorerPaneId = state.explorerSidebarPaneIdByWorkspace[WORKSPACE_KEY];
|
||||
const explorerPane =
|
||||
layout && explorerPaneId ? findPaneById(layout.root, explorerPaneId) : null;
|
||||
const pullRequestTab = layout
|
||||
? collectAllTabs(layout.root).find((tab) => tab.target.kind === "pull_request")
|
||||
: null;
|
||||
|
||||
expect(explorerPane?.tabIds).toContain(pullRequestTab?.tabId);
|
||||
expect(explorerPane?.hidden).not.toBe(true);
|
||||
});
|
||||
|
||||
it("opens PRs in the main panel when configured", () => {
|
||||
openWorkspacePullRequest({ ...input, destination: "main" });
|
||||
|
||||
expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer");
|
||||
expect(usePanelStore.getState().explorerTab).toBe("pr");
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const layout = state.layoutByWorkspace[WORKSPACE_KEY];
|
||||
const mainPane = layout ? findPaneById(layout.root, layout.focusedPaneId) : null;
|
||||
const pullRequestTab = layout
|
||||
? collectAllTabs(layout.root).find((tab) => tab.target.kind === "pull_request")
|
||||
: null;
|
||||
|
||||
expect(mainPane?.tabIds).toContain(pullRequestTab?.tabId);
|
||||
expect(state.sidePaneIdByWorkspace[WORKSPACE_KEY]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("opens PRs in the side panel when configured", () => {
|
||||
openWorkspacePullRequest({ ...input, destination: "side" });
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const layout = state.layoutByWorkspace[WORKSPACE_KEY];
|
||||
const sidePaneId = state.sidePaneIdByWorkspace[WORKSPACE_KEY];
|
||||
const sidePane = layout && sidePaneId ? findPaneById(layout.root, sidePaneId) : null;
|
||||
const pullRequestTab = layout
|
||||
? collectAllTabs(layout.root).find((tab) => tab.target.kind === "pull_request")
|
||||
: null;
|
||||
|
||||
expect(sidePane?.tabIds).toContain(pullRequestTab?.tabId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,66 +1,69 @@
|
||||
import type { OpenInSidePanePreferences } from "@/hooks/use-settings";
|
||||
import type { OpenInSidePanePreferences, PullRequestOpenLocation } from "@/hooks/use-settings";
|
||||
import type { ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import {
|
||||
isExplorerSidebarOpen,
|
||||
openExplorerSidebarView,
|
||||
usesCompactExplorerSidebar,
|
||||
type ExplorerSidebarView,
|
||||
} from "@/workspace-tabs/explorer-sidebar";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import {
|
||||
openPreferredWorkspaceTarget,
|
||||
type OpenInSidePaneSource,
|
||||
openWorkspaceTargetAtLocation,
|
||||
} from "@/workspace-tabs/open-beside";
|
||||
|
||||
export type WorkspaceSupportingView = "changes" | "pull-request";
|
||||
|
||||
interface SupportingViewRoute {
|
||||
explorerView: ExplorerSidebarView;
|
||||
target: WorkspaceTabTarget;
|
||||
source: OpenInSidePaneSource;
|
||||
}
|
||||
|
||||
const SUPPORTING_VIEW_ROUTES: Record<WorkspaceSupportingView, SupportingViewRoute> = {
|
||||
changes: {
|
||||
explorerView: "changes",
|
||||
target: { kind: "working_diff" },
|
||||
source: "changesLinks",
|
||||
},
|
||||
"pull-request": {
|
||||
explorerView: "pr",
|
||||
target: { kind: "pull_request" },
|
||||
source: "pullRequests",
|
||||
},
|
||||
};
|
||||
|
||||
interface OpenWorkspaceSupportingViewInput {
|
||||
view: WorkspaceSupportingView;
|
||||
interface WorkspaceViewInput {
|
||||
isCompact: boolean;
|
||||
workspaceKey: string | null;
|
||||
checkout: ExplorerCheckoutContext | null;
|
||||
preferences: OpenInSidePanePreferences;
|
||||
supportsPaneSplits?: boolean;
|
||||
}
|
||||
|
||||
/** Opens supporting workspace content in the shell appropriate for the current layout. */
|
||||
export function openWorkspaceSupportingView(
|
||||
input: OpenWorkspaceSupportingViewInput,
|
||||
): string | null {
|
||||
const route = SUPPORTING_VIEW_ROUTES[input.view];
|
||||
interface OpenWorkspaceChangesInput extends WorkspaceViewInput {
|
||||
preferences: OpenInSidePanePreferences;
|
||||
}
|
||||
|
||||
interface OpenWorkspacePullRequestInput extends WorkspaceViewInput {
|
||||
destination: PullRequestOpenLocation;
|
||||
}
|
||||
|
||||
function openExplorerView(input: WorkspaceViewInput, view: ExplorerSidebarView): void {
|
||||
openExplorerSidebarView({ ...input, view });
|
||||
}
|
||||
|
||||
/** Opens the workspace Changes view according to the current layout and diff preference. */
|
||||
export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string | null {
|
||||
if (usesCompactExplorerSidebar(input)) {
|
||||
openExplorerSidebarView({
|
||||
isCompact: input.isCompact,
|
||||
supportsPaneSplits: input.supportsPaneSplits,
|
||||
workspaceKey: input.workspaceKey,
|
||||
checkout: input.checkout,
|
||||
view: route.explorerView,
|
||||
});
|
||||
openExplorerView(input, "changes");
|
||||
return null;
|
||||
}
|
||||
return openPreferredWorkspaceTarget({
|
||||
isCompact: input.isCompact,
|
||||
workspaceKey: input.workspaceKey,
|
||||
target: route.target,
|
||||
source: route.source,
|
||||
target: { kind: "working_diff" },
|
||||
source: "diffs",
|
||||
preferences: input.preferences,
|
||||
});
|
||||
}
|
||||
|
||||
/** Reveals Changes from the composer, then opens its diff on a subsequent desktop action. */
|
||||
export function openComposerChanges(input: OpenWorkspaceChangesInput): string | null {
|
||||
if (usesCompactExplorerSidebar(input) || isExplorerSidebarOpen(input)) {
|
||||
return openWorkspaceChanges(input);
|
||||
}
|
||||
openExplorerView(input, "changes");
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Opens the workspace pull request at its semantic destination. */
|
||||
export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput): string | null {
|
||||
if (usesCompactExplorerSidebar(input) || input.destination === "explorer") {
|
||||
openExplorerView(input, "pr");
|
||||
return null;
|
||||
}
|
||||
return openWorkspaceTargetAtLocation({
|
||||
isCompact: input.isCompact,
|
||||
workspaceKey: input.workspaceKey,
|
||||
target: { kind: "pull_request" },
|
||||
location: input.destination,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user