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
96 changes: 92 additions & 4 deletions web/src/components/layout/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>
pickWorkspaceDirectory: ReturnType<typeof vi.fn>
}
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: {
Expand Down Expand Up @@ -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(<Sidebar />)
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(<Sidebar />)
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(<Sidebar />)
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(<Sidebar />)
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()
Expand Down
6 changes: 3 additions & 3 deletions web/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ export default function Sidebar({ collapsed }: SidebarProps) {
{createWorkspaceOpen && (
<CreateWorkspaceDialog
electronMode={runtime.mode === 'electron'}
onPickDirectory={runtime.mode === 'electron' ? runtime.selectWorkdir : undefined}
onPickDirectory={runtime.mode === 'electron' ? runtime.pickWorkspaceDirectory : undefined}
onSubmit={handleCreateWorkspace}
onClose={() => setCreateWorkspaceOpen(false)}
/>
Expand Down Expand Up @@ -513,7 +513,7 @@ function CreateWorkspaceDialog({
electronMode, onPickDirectory, onSubmit, onClose,
}: {
electronMode: boolean
onPickDirectory?: () => Promise<string>
onPickDirectory?: () => Promise<string | null>
onSubmit: (path: string, name?: string) => Promise<void>
onClose: () => void
}) {
Expand Down Expand Up @@ -561,7 +561,7 @@ function CreateWorkspaceDialog({
placeholder="例如:/Users/me/projects/foo" autoFocus
/>
{electronMode && onPickDirectory && (
<button className="btn btn-secondary" onClick={handlePick}>浏览</button>
<button type="button" className="btn btn-secondary" onClick={handlePick}>浏览</button>
)}
</div>
</label>
Expand Down
94 changes: 94 additions & 0 deletions web/src/context/RuntimeProvider.lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<RuntimeProvider>
<RuntimeProbe onReady={(rt) => { runtimeSnapshot = rt }} />
</RuntimeProvider>,
)
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(
<RuntimeProvider>
<RuntimeProbe onReady={(rt) => { runtimeSnapshot = rt }} />
</RuntimeProvider>,
)
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(
<RuntimeProvider>
<RuntimeProbe onReady={(rt) => { runtimeSnapshot = rt }} />
</RuntimeProvider>,
)
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',
Expand Down
17 changes: 17 additions & 0 deletions web/src/context/RuntimeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ interface RuntimeContextValue {
connectBrowser: (input: BrowserConnectInput) => Promise<void>
startLocalGateway: (port: number) => Promise<void>
selectWorkdir: () => Promise<string>
pickWorkspaceDirectory: () => Promise<string | null>
retry: () => Promise<void>
resetBrowserConfig: () => void
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -410,6 +425,7 @@ export function RuntimeProvider({ children }: { children: ReactNode }) {
connectBrowser,
startLocalGateway,
selectWorkdir,
pickWorkspaceDirectory,
retry,
resetBrowserConfig,
}), [
Expand All @@ -426,6 +442,7 @@ export function RuntimeProvider({ children }: { children: ReactNode }) {
connectBrowser,
startLocalGateway,
selectWorkdir,
pickWorkspaceDirectory,
retry,
resetBrowserConfig,
])
Expand Down
Loading