From c1a1e8fe15e3381b75ce6a0420437f81b8e0b011 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 26 May 2026 04:29:56 -0400 Subject: [PATCH] =?UTF-8?q?feat:app=E7=AB=AF=E5=8F=AF=E4=BB=A5=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E6=89=93=E5=BC=80=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=99=A8=E9=80=89=E6=8B=A9=E6=96=87=E4=BB=B6=E5=A4=B9=E4=BC=A0?= =?UTF-8?q?=E5=85=A5=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/layout/Sidebar.test.tsx | 96 ++++++++++++++++++- web/src/components/layout/Sidebar.tsx | 6 +- .../RuntimeProvider.lifecycle.test.tsx | 94 ++++++++++++++++++ web/src/context/RuntimeProvider.tsx | 17 ++++ 4 files changed, 206 insertions(+), 7 deletions(-) diff --git a/web/src/components/layout/Sidebar.test.tsx b/web/src/components/layout/Sidebar.test.tsx index 1524b17b..51536ab4 100644 --- a/web/src/components/layout/Sidebar.test.tsx +++ b/web/src/components/layout/Sidebar.test.tsx @@ -9,20 +9,27 @@ import { useUIStore } from '@/stores/useUIStore' import { useWorkspaceStore } from '@/stores/useWorkspaceStore' let mockGatewayAPI: any = null +let mockRuntime: { + mode: 'browser' | 'electron' + selectWorkdir: ReturnType + pickWorkspaceDirectory: ReturnType +} const appCss = readFileSync('src/index.css', 'utf-8') vi.mock('@/context/RuntimeProvider', () => ({ useGatewayAPI: () => mockGatewayAPI, - useRuntime: () => ({ - mode: 'browser', - selectWorkdir: vi.fn(), - }), + useRuntime: () => mockRuntime, })) describe('Sidebar ProviderModal', () => { beforeEach(() => { vi.restoreAllMocks() cleanup() + mockRuntime = { + mode: 'browser', + selectWorkdir: vi.fn(), + pickWorkspaceDirectory: vi.fn(), + } mockGatewayAPI = { listMCPServers: vi.fn().mockResolvedValue({ payload: { @@ -374,6 +381,87 @@ describe('Sidebar ProviderModal', () => { expect(switchWorkspace).toHaveBeenCalledTimes(1) }) + it('keeps browser workspace creation as manual path entry without a directory picker', () => { + const { container } = render() + const addWorkspaceButton = container.querySelector('.sidebar-section-header .btn') + expect(addWorkspaceButton).toBeInstanceOf(HTMLButtonElement) + + fireEvent.click(addWorkspaceButton as HTMLButtonElement) + + expect(screen.getByText('新建工作区')).toBeInTheDocument() + expect(screen.getByPlaceholderText('例如:/Users/me/projects/foo')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '浏览' })).not.toBeInTheDocument() + }) + + it('uses the Electron directory picker to fill the workspace path', async () => { + mockRuntime = { + mode: 'electron', + selectWorkdir: vi.fn().mockResolvedValue('D:\\projects\\neo-code'), + pickWorkspaceDirectory: vi.fn().mockResolvedValue('D:\\projects\\neo-code'), + } + + const { container } = render() + const addWorkspaceButton = container.querySelector('.sidebar-section-header .btn') + fireEvent.click(addWorkspaceButton as HTMLButtonElement) + + fireEvent.click(screen.getByRole('button', { name: '浏览' })) + + await waitFor(() => { + expect(mockRuntime.pickWorkspaceDirectory).toHaveBeenCalledTimes(1) + expect(mockRuntime.selectWorkdir).not.toHaveBeenCalled() + expect(screen.getByPlaceholderText('例如:/Users/me/projects/foo')).toHaveValue('D:\\projects\\neo-code') + }) + expect(screen.getByText('新建工作区')).toBeInTheDocument() + }) + + it('keeps the previous workspace path when Electron directory selection is canceled', async () => { + mockRuntime = { + mode: 'electron', + selectWorkdir: vi.fn().mockResolvedValue(''), + pickWorkspaceDirectory: vi.fn().mockResolvedValue(null), + } + + const { container } = render() + const addWorkspaceButton = container.querySelector('.sidebar-section-header .btn') + fireEvent.click(addWorkspaceButton as HTMLButtonElement) + + const pathInput = screen.getByPlaceholderText('例如:/Users/me/projects/foo') + fireEvent.change(pathInput, { target: { value: 'D:\\existing' } }) + fireEvent.click(screen.getByRole('button', { name: '浏览' })) + + await waitFor(() => { + expect(mockRuntime.pickWorkspaceDirectory).toHaveBeenCalledTimes(1) + }) + expect(mockRuntime.selectWorkdir).not.toHaveBeenCalled() + expect(pathInput).toHaveValue('D:\\existing') + expect(screen.getByText('新建工作区')).toBeInTheDocument() + }) + + it('submits the selected workspace path through the workspace store and closes the dialog', async () => { + const createWorkspace = vi.fn().mockResolvedValue(undefined) + useWorkspaceStore.setState({ + createWorkspace, + changing: false, + } as any) + + const { container } = render() + const addWorkspaceButton = container.querySelector('.sidebar-section-header .btn') + fireEvent.click(addWorkspaceButton as HTMLButtonElement) + + fireEvent.change(screen.getByPlaceholderText('例如:/Users/me/projects/foo'), { + target: { value: 'D:\\projects\\neo-code' }, + }) + fireEvent.change(screen.getByPlaceholderText('留空则使用路径'), { + target: { value: 'NeoCode' }, + }) + fireEvent.click(screen.getByRole('button', { name: '创建' })) + + await waitFor(() => { + expect(createWorkspace).toHaveBeenCalledWith('D:\\projects\\neo-code', mockGatewayAPI, 'NeoCode') + }) + expect(screen.queryByText('新建工作区')).not.toBeInTheDocument() + }) + it('immediately dispatches collapsed-rail actions', async () => { const toggleSidebar = vi.fn() const prepareNewChat = vi.fn() diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index 2e2a2157..5716d0af 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -331,7 +331,7 @@ export default function Sidebar({ collapsed }: SidebarProps) { {createWorkspaceOpen && ( setCreateWorkspaceOpen(false)} /> @@ -513,7 +513,7 @@ function CreateWorkspaceDialog({ electronMode, onPickDirectory, onSubmit, onClose, }: { electronMode: boolean - onPickDirectory?: () => Promise + onPickDirectory?: () => Promise onSubmit: (path: string, name?: string) => Promise onClose: () => void }) { @@ -561,7 +561,7 @@ function CreateWorkspaceDialog({ placeholder="例如:/Users/me/projects/foo" autoFocus /> {electronMode && onPickDirectory && ( - + )} diff --git a/web/src/context/RuntimeProvider.lifecycle.test.tsx b/web/src/context/RuntimeProvider.lifecycle.test.tsx index 2a385d0e..b7fea941 100644 --- a/web/src/context/RuntimeProvider.lifecycle.test.tsx +++ b/web/src/context/RuntimeProvider.lifecycle.test.tsx @@ -151,6 +151,100 @@ describe('RuntimeProvider lifecycle', () => { expect(runtimeSnapshot.status).toBe('needs_config') }) + it('pickWorkspaceDirectory is a no-op in browser mode', async () => { + sessionStorage.setItem( + 'neocode.browserRuntimeConfig', + JSON.stringify({ mode: 'browser', gatewayBaseURL: 'http://127.0.0.1:8080', token: 'tok' }), + ) + let runtimeSnapshot: any = null + render( + + { runtimeSnapshot = rt }} /> + , + ) + await waitFor(() => expect(runtimeSnapshot?.status).toBe('connected')) + + let selected: string | null = 'unexpected' + await act(async () => { + selected = await runtimeSnapshot.pickWorkspaceDirectory() + }) + + expect(selected).toBeNull() + expect(runtimeSnapshot.workdir).toBe('') + }) + + it('pickWorkspaceDirectory returns the selected Electron directory without restarting the Gateway', async () => { + const pickDirectory = vi.fn().mockResolvedValue({ + canceled: false, + filePaths: ['D:\\projects\\neo-code'], + }) + const selectWorkdir = vi.fn().mockResolvedValue({ canceled: false, workdir: 'D:\\other' }) + Object.defineProperty(window, 'electronAPI', { + value: { + getAddress: vi.fn().mockResolvedValue('127.0.0.1:8080'), + getToken: vi.fn().mockResolvedValue('tok'), + getWorkdir: vi.fn().mockResolvedValue('D:\\initial'), + pickDirectory, + selectWorkdir, + }, + configurable: true, + writable: true, + }) + let runtimeSnapshot: any = null + render( + + { runtimeSnapshot = rt }} /> + , + ) + await waitFor(() => expect(runtimeSnapshot?.status).toBe('connected')) + await waitFor(() => expect(runtimeSnapshot?.workdir).toBe('D:\\initial')) + + let selected: string | null = '' + await act(async () => { + selected = await runtimeSnapshot.pickWorkspaceDirectory() + }) + + expect(selected).toBe('D:\\projects\\neo-code') + expect(pickDirectory).toHaveBeenCalledTimes(1) + expect(selectWorkdir).not.toHaveBeenCalled() + expect(runtimeSnapshot.workdir).toBe('D:\\initial') + }) + + it('pickWorkspaceDirectory returns null when Electron directory selection is canceled', async () => { + const pickDirectory = vi.fn().mockResolvedValue({ + canceled: true, + filePaths: ['D:\\projects\\ignored'], + }) + Object.defineProperty(window, 'electronAPI', { + value: { + getAddress: vi.fn().mockResolvedValue('127.0.0.1:8080'), + getToken: vi.fn().mockResolvedValue('tok'), + getWorkdir: vi.fn().mockResolvedValue('D:\\initial'), + pickDirectory, + selectWorkdir: vi.fn(), + }, + configurable: true, + writable: true, + }) + let runtimeSnapshot: any = null + render( + + { runtimeSnapshot = rt }} /> + , + ) + await waitFor(() => expect(runtimeSnapshot?.status).toBe('connected')) + await waitFor(() => expect(runtimeSnapshot?.workdir).toBe('D:\\initial')) + + let selected: string | null = '' + await act(async () => { + selected = await runtimeSnapshot.pickWorkspaceDirectory() + }) + + expect(selected).toBeNull() + expect(pickDirectory).toHaveBeenCalledTimes(1) + expect(runtimeSnapshot.workdir).toBe('D:\\initial') + }) + it('restores workspace context before rebinding session on reconnect', async () => { sessionStorage.setItem( 'neocode.browserRuntimeConfig', diff --git a/web/src/context/RuntimeProvider.tsx b/web/src/context/RuntimeProvider.tsx index d48b6370..6356d82c 100644 --- a/web/src/context/RuntimeProvider.tsx +++ b/web/src/context/RuntimeProvider.tsx @@ -44,6 +44,7 @@ interface RuntimeContextValue { connectBrowser: (input: BrowserConnectInput) => Promise startLocalGateway: (port: number) => Promise selectWorkdir: () => Promise + pickWorkspaceDirectory: () => Promise retry: () => Promise resetBrowserConfig: () => void } @@ -300,6 +301,20 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { } }, [mode, workdir]) + const pickWorkspaceDirectory = useCallback(async () => { + if (!window.electronAPI || mode !== 'electron') return null + try { + const result = await window.electronAPI.pickDirectory() + if (!result.canceled && result.filePaths.length > 0) { + return result.filePaths[0] + } + return null + } catch (err) { + console.error('pickWorkspaceDirectory failed:', err) + return null + } + }, [mode]) + const startLocalGateway = useCallback(async (port: number) => { setError('') try { @@ -410,6 +425,7 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { connectBrowser, startLocalGateway, selectWorkdir, + pickWorkspaceDirectory, retry, resetBrowserConfig, }), [ @@ -426,6 +442,7 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { connectBrowser, startLocalGateway, selectWorkdir, + pickWorkspaceDirectory, retry, resetBrowserConfig, ])