Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* Inputs: installed Ghost snapshots and user actions. `embedded` mounts the same
* catalog inside Settings; `onSelectCatalogTab` keeps Plugins / Skills in-panel.
* Outputs: the Plugin list/detail UI, focus-stable installed queue, and Plugin action flows.
* Outputs: the Plugin list/detail UI, recommendation-aware return navigation, and Plugin action flows.
* [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
*/

Expand Down Expand Up @@ -117,6 +117,7 @@ import {
PluginManagementLayout,
PluginManagementPage,
} from './PluginManagementLayout';
import { usePluginListScrollRestoration } from './lib/usePluginListScrollRestoration';
import { GhostPagePanelHost } from './GhostPagePanelHost';
import { GhostPluginDetailView } from './GhostPluginDetailView';
import {
Expand Down Expand Up @@ -489,6 +490,15 @@ export function GhostPluginPage({
}, [ignoredRoundKey]);
const [originFilter, setOriginFilter] = useState<PluginPresentationFilter>('all');
const [marketDetail, setMarketDetail] = useState<PluginMarketDetail | null>(null);
// 列表与详情是互斥分支;列表节点会卸载,返回时需显式恢复进入前的滚动位置。
const pluginCatalogVisible = marketDetail === null && selectedId === null;
const {
listRef: pluginCatalogListRef,
onListScroll: onPluginCatalogScroll,
capture: capturePluginCatalogScroll,
requestRestore: requestPluginCatalogScrollRestore,
clearPendingRestore: clearPluginCatalogScrollRestore,
} = usePluginListScrollRestoration(pluginCatalogVisible);
const [marketBusyId, setMarketBusyId] = useState<string | null>(null);
// 市场操作的同步互斥锁。React state 在提交前有窗口期,快速连点会让多个回调
// 都读到 null;ref 先到先得,state 只驱动按钮禁用等 UI 展示。每次占锁都返回
Expand Down Expand Up @@ -550,13 +560,14 @@ export function GhostPluginPage({
}
}, []);
useEffect(() => {
clearPluginCatalogScrollRestore();
setMarketSnapshot(null);
setMarketDetail(null);
marketBusyLockRef.current = null;
setMarketBusyId(null);
marketDetailRequestRef.current += 1;
void refreshMarket();
}, [refreshMarket, mode, dataOwnerId]);
}, [clearPluginCatalogScrollRestore, refreshMarket, mode, dataOwnerId]);
const refreshMarketOnForeground = useCallback(() => refreshMarket(true), [refreshMarket]);
usePluginMarketForegroundRefresh(refreshMarketOnForeground, lastMarketRefreshAtRef);
useEffect(() => {
Expand Down Expand Up @@ -1303,6 +1314,7 @@ export function GhostPluginPage({
// 与 handleMarketUpdate 共用同一互斥锁:更新进行中不叠加其它市场操作。
const marketBusyLease = acquireMarketBusy(pluginId);
if (!marketBusyLease) return;
capturePluginCatalogScroll();
const requestId = ++marketDetailRequestRef.current;
try {
const detail = await window.electronAPI.pluginMarket.detail(pluginId);
Expand All @@ -1323,8 +1335,14 @@ export function GhostPluginPage({
releaseMarketBusy(marketBusyLease);
}
},
[acquireMarketBusy, isMarketBusyLeaseActive, releaseMarketBusy, t],
[acquireMarketBusy, capturePluginCatalogScroll, isMarketBusyLeaseActive, releaseMarketBusy, t],
);
const handleMarketBack = useCallback(() => {
cancelPendingPluginSuggestion(recommendationNonce ?? undefined);
requestPluginCatalogScrollRestore();
marketDetailRequestRef.current += 1;
setMarketDetail(null);
}, [recommendationNonce, requestPluginCatalogScrollRestore]);

const refreshVisibleMarketDetail = useCallback(async (pluginId: string) => {
// A background icon renewal may observe navigation, but must never invalidate a
Expand Down Expand Up @@ -1510,11 +1528,7 @@ export function GhostPluginPage({
<MarketPluginDetailView
detail={marketDetail}
busy={marketBusyId === marketDetail.pluginId}
onBack={() => {
cancelPendingPluginSuggestion(recommendationNonce ?? undefined);
marketDetailRequestRef.current += 1;
setMarketDetail(null);
}}
onBack={handleMarketBack}
onInstall={
canOfferMarketInstall(mode, marketDetail.ghostId)
? () => void handleInstallFromMarket()
Expand Down Expand Up @@ -1596,10 +1610,12 @@ export function GhostPluginPage({
>
<div className="flex min-h-0 flex-1">
<main
ref={pluginCatalogListRef}
className={cn(
'min-h-0 w-full min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]',
embedded ? 'bg-transparent' : 'bg-[var(--surface)]',
)}
onScroll={onPluginCatalogScroll}
>
<PluginManagementPage>
{recommendationNotice}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
* [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
*/

import { useCallback, useState, type UIEvent } from 'react';
import { useCallback, useEffect, useState, type UIEvent } from 'react';
import { ArrowLeft } from 'lucide-react';

import { WINDOW_DRAG_STYLE, WINDOW_NO_DRAG_STYLE } from '@/components/layout/windowDrag';
import { useMacFullscreen } from '@/hooks/useMacFullscreen';
import { isEditableKeyboardTarget } from '@/lib/editableKeyboardTarget';
import { cn } from '@/lib/utils';

interface PluginDetailTopBarProps {
Expand Down Expand Up @@ -67,6 +68,29 @@ export function usePluginDetailScrolled(): {
export function PluginDetailTopBar({ label, onBack, scrolled }: PluginDetailTopBarProps) {
const { isMac } = useMacFullscreen();

// Both installed and market detail surfaces share this escape hatch. Let
// nested dialogs and editable controls consume Escape first.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (
event.key !== 'Escape' ||
event.defaultPrevented ||
event.isComposing ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
isEditableKeyboardTarget(event.target)
) {
return;
}
event.preventDefault();
onBack();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onBack]);

return (
<div
data-testid="plugin-detail-top-bar"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/** @vitest-environment jsdom */

import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { PluginMarketDetail } from '../../../../shared/pluginMarket';
import { __resetInstalledGhostsStoreForTest } from '@/cindy-brain/useInstalledGhosts';
import {
cancelPendingPluginSuggestion,
getPendingPluginSuggestion,
readyPendingPluginSuggestion,
startPendingPluginSuggestion,
type PluginSuggestionRequest,
} from '@/features/cc-agent/pendingPluginSuggestion';
import { GhostPluginPage } from '../GhostPluginPage';

const { auth, translation } = vi.hoisted(() => ({
auth: { user: { membershipKind: 'personal' }, mode: 'local', dataOwnerId: 'navigation-owner' },
translation: {
t: (key: string) => key,
i18n: { language: 'en', resolvedLanguage: 'en' },
},
}));

vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => auth }));
vi.mock('react-i18next', async (importOriginal) => ({
...(await importOriginal<typeof import('react-i18next')>()),
useTranslation: () => translation,
}));
vi.mock('@/components/ui/confirm-dialog-provider', () => ({
useConfirmDialog: () => ({ confirm: vi.fn() }),
}));

const detail: PluginMarketDetail = {
pluginId: 'navigation-market-plugin',
ghostId: 'navigation-plugin',
name: 'Navigation Plugin',
description: 'Navigation regression fixture',
author: 'Cindy',
scope: 'public',
organizationId: null,
defaultInstall: false,
releaseId: 'navigation-release',
version: '1.0.0',
publishedAt: '2026-09-07T00:00:00.000Z',
icon: null,
installState: 'not-installed',
enabled: null,
sourceType: 'server',
sourceMarketName: null,
manifest: {
schemaVersion: 2,
id: 'navigation-plugin',
name: 'Navigation Plugin',
version: '1.0.0',
kind: 'chip',
entry: 'main.js',
},
};

const suggestion: PluginSuggestionRequest = {
ownerId: 'navigation-owner',
targetKey: 'local',
workingDir: null,
model: 'test',
effort: 'medium',
permissionMode: 'default',
files: [],
suggestion: {
id: 'plugin:navigation',
category: 'email',
label: 'Navigation suggestion',
prompt: 'Do not continue after returning from plugin details',
pluginId: detail.ghostId,
},
};

const loadDetail = vi.fn<() => Promise<PluginMarketDetail>>();

beforeEach(() => {
translation.i18n.language = 'en';
translation.i18n.resolvedLanguage = 'en';
cancelPendingPluginSuggestion();
__resetInstalledGhostsStoreForTest();
loadDetail.mockReset().mockResolvedValue(detail);
vi.stubGlobal('electronAPI', {
platform: 'win32',
sidebarSettings: {
loadSnapshot: () => ({ hiddenMainViewGhostIds: [], dataOwnerId: null, ownerGeneration: 0 }),
onHiddenMainViewGhostIdsChanged: () => () => {},
},
ghosts: {
listSync: () => ({ ghosts: [] }),
recentUsageSync: () => ({ ids: [] }),
onChanged: () => () => {},
onRecentUsageChanged: () => () => {},
},
pluginMarket: {
snapshot: async () => ({
items: [detail],
unavailableReason: null,
customSourceNames: [],
unavailableCustomSourceNames: [],
}),
detail: loadDetail,
},
setApplicationMenuLocale: async () => {},
});
});

afterEach(() => {
cleanup();
cancelPendingPluginSuggestion();
__resetInstalledGhostsStoreForTest();
vi.unstubAllGlobals();
});

function page(entry: string) {
return (
<MemoryRouter initialEntries={[entry]}>
<GhostPluginPage />
</MemoryRouter>
);
}

async function openFromCatalog() {
const plugin = await screen.findByText(detail.name);
const catalog = screen.getByRole('main');
fireEvent.scroll(catalog, { target: { scrollTop: 640 } });
fireEvent.click(plugin);
await screen.findByRole('button', { name: 'settings.ghosts.detail.backToList' });
expect(catalog.isConnected).toBe(false);
}

describe('plugin page return navigation', () => {
it.each(['button', 'Escape'] as const)(
'%s cancels the recommendation and restores the catalog scroll position',
async (method) => {
const nonce = startPendingPluginSuggestion(suggestion);
render(page(`/plugins?recommendation=${nonce}`));
await openFromCatalog();
expect(screen.getByRole('status')).toBeTruthy();
expect(getPendingPluginSuggestion()?.nonce).toBe(nonce);

if (method === 'button') {
fireEvent.click(screen.getByRole('button', { name: 'settings.ghosts.detail.backToList' }));
} else {
fireEvent.keyDown(window, { key: 'Escape' });
}

expect(screen.getByRole('main').scrollTop).toBe(640);
expect(
screen.queryByRole('button', { name: 'settings.ghosts.detail.backToList' }),
).toBeNull();
expect(screen.queryByRole('status')).toBeNull();
expect(getPendingPluginSuggestion()).toBeNull();
expect(readyPendingPluginSuggestion(nonce, suggestion.ownerId, detail.ghostId)).toBeNull();
},
);

it('restores ordinary catalog navigation without a pending recommendation', async () => {
render(page('/plugins'));
await openFromCatalog();
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.getByRole('main').scrollTop).toBe(640);
expect(getPendingPluginSuggestion()).toBeNull();
});

it('does not reopen details when a locale refresh completes after returning', async () => {
const nonce = startPendingPluginSuggestion(suggestion);
const mounted = render(page(`/plugins?recommendation=${nonce}`));
await openFromCatalog();
let finishRefresh!: (value: PluginMarketDetail) => void;
loadDetail.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishRefresh = resolve;
}),
);
translation.i18n.language = 'ja';
translation.i18n.resolvedLanguage = 'ja';
mounted.rerender(page(`/plugins?recommendation=${nonce}`));
await waitFor(() => expect(loadDetail).toHaveBeenCalledTimes(2));

fireEvent.keyDown(window, { key: 'Escape' });
await act(async () => {
finishRefresh({ ...detail, name: 'Refreshed Plugin' });
});

expect(screen.getByRole('main').scrollTop).toBe(640);
expect(screen.queryByRole('button', { name: 'settings.ghosts.detail.backToList' })).toBeNull();
expect(getPendingPluginSuggestion()).toBeNull();
});
});
Loading