mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix(desktop): search unopened workspace files #1023
This commit is contained in:
parent
d048a3b3c4
commit
86393ac179
@ -56,4 +56,25 @@ describe('sessionsApi', () => {
|
||||
expect(url).toBe('http://127.0.0.1:3456/api/sessions/session-1/trace/calls/call-1')
|
||||
expect(init).toMatchObject({ method: 'GET' })
|
||||
})
|
||||
|
||||
it('searches the session workspace with an encoded query', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
state: 'ok',
|
||||
query: 'Mental Health Controller',
|
||||
truncated: false,
|
||||
entries: [],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
const result = await sessionsApi.searchWorkspace('session-1', 'Mental Health Controller')
|
||||
|
||||
expect(result.query).toBe('Mental Health Controller')
|
||||
expect(result.truncated).toBe(false)
|
||||
const [url, init] = fetchMock.mock.calls[0]!
|
||||
expect(url).toBe('http://127.0.0.1:3456/api/sessions/session-1/workspace/search?query=Mental+Health+Controller')
|
||||
expect(init).toMatchObject({ method: 'GET' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -270,6 +270,13 @@ export type WorkspaceTreeResult = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type WorkspaceSearchResult = {
|
||||
state: 'ok'
|
||||
query: string
|
||||
truncated: boolean
|
||||
entries: WorkspaceTreeEntry[]
|
||||
}
|
||||
|
||||
export type WorkspaceDiffResult = {
|
||||
state: 'ok' | 'missing' | 'not_git_repo' | 'error'
|
||||
path: string
|
||||
@ -392,6 +399,11 @@ export const sessionsApi = {
|
||||
return api.get<WorkspaceTreeResult>(buildWorkspacePath(sessionId, 'tree', workspacePath))
|
||||
},
|
||||
|
||||
searchWorkspace(sessionId: string, query: string) {
|
||||
const params = new URLSearchParams({ query })
|
||||
return api.get<WorkspaceSearchResult>(`/api/sessions/${sessionId}/workspace/search?${params}`)
|
||||
},
|
||||
|
||||
getWorkspaceFile(sessionId: string, workspacePath: string) {
|
||||
return api.get<WorkspaceReadFileResult>(buildWorkspacePath(sessionId, 'file', workspacePath))
|
||||
},
|
||||
|
||||
@ -31,6 +31,7 @@ if (typeof document === 'undefined') {
|
||||
type WorkspaceApiMocks = {
|
||||
getWorkspaceStatusMock: ReturnType<typeof vi.fn>
|
||||
getWorkspaceTreeMock: ReturnType<typeof vi.fn>
|
||||
searchWorkspaceMock: ReturnType<typeof vi.fn>
|
||||
getWorkspaceFileMock: ReturnType<typeof vi.fn>
|
||||
getWorkspaceDiffMock: ReturnType<typeof vi.fn>
|
||||
}
|
||||
@ -258,6 +259,7 @@ vi.mock('../../api/sessions', () => ({
|
||||
mocks = {
|
||||
getWorkspaceStatusMock: vi.fn(),
|
||||
getWorkspaceTreeMock: vi.fn(),
|
||||
searchWorkspaceMock: vi.fn(),
|
||||
getWorkspaceFileMock: vi.fn(),
|
||||
getWorkspaceDiffMock: vi.fn(),
|
||||
}
|
||||
@ -266,6 +268,7 @@ vi.mock('../../api/sessions', () => ({
|
||||
return {
|
||||
getWorkspaceStatus: mocks.getWorkspaceStatusMock,
|
||||
getWorkspaceTree: mocks.getWorkspaceTreeMock,
|
||||
searchWorkspace: mocks.searchWorkspaceMock,
|
||||
getWorkspaceFile: mocks.getWorkspaceFileMock,
|
||||
getWorkspaceDiff: mocks.getWorkspaceDiffMock,
|
||||
}
|
||||
@ -393,7 +396,7 @@ describe('WorkspacePanel', () => {
|
||||
await statusRequest.promise
|
||||
})
|
||||
|
||||
expect(view.getByPlaceholderText('Filter files...')).toBeTruthy()
|
||||
expect(view.getByPlaceholderText('Filter changed files...')).toBeTruthy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeTruthy()
|
||||
@ -430,7 +433,7 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.queryByTestId('workspace-file-navigator')).toBeNull()
|
||||
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
||||
expect(view.getByTestId('workspace-file-navigator').className).toContain('absolute')
|
||||
expect(view.queryByTestId('workspace-file-navigator-header')).toBeNull()
|
||||
expect(view.getByTestId('workspace-file-navigator-header')).toBeTruthy()
|
||||
expect(view.queryByText('1 file')).toBeNull()
|
||||
expect(view.getByTestId('workspace-file-navigator').className).toContain('w-[min(280px,100%)]')
|
||||
expect(view.getByTestId('workspace-review-layout').className).toContain('grid-cols-1')
|
||||
@ -526,7 +529,7 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.queryByText('claude-code-haha')).toBeNull()
|
||||
expect(view.queryByText('main')).toBeNull()
|
||||
|
||||
const filter = view.getByPlaceholderText('Filter files...')
|
||||
const filter = view.getByPlaceholderText('Filter changed files...')
|
||||
expect(view.queryByText('3 files')).toBeNull()
|
||||
fireEvent.change(filter, { target: { value: 'theme' } })
|
||||
|
||||
@ -614,7 +617,7 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.getByText('App.tsx')).toBeTruthy()
|
||||
expect(view.getByText('theme.css')).toBeTruthy()
|
||||
|
||||
fireEvent.change(view.getByPlaceholderText('Filter files...'), { target: { value: 'theme' } })
|
||||
fireEvent.change(view.getByPlaceholderText('Filter changed files...'), { target: { value: 'theme' } })
|
||||
|
||||
expect(view.getByText('desktop/src')).toBeTruthy()
|
||||
expect(view.queryByText('docs')).toBeNull()
|
||||
@ -859,11 +862,224 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
expect(view.queryByText('No changes')).toBeNull()
|
||||
|
||||
fireEvent.change(view.getByPlaceholderText('Filter files...'), { target: { value: 'readme' } })
|
||||
getMocks().searchWorkspaceMock.mockResolvedValueOnce({
|
||||
state: 'ok',
|
||||
query: 'readme',
|
||||
truncated: false,
|
||||
entries: [{ name: 'README.md', path: 'README.md', isDirectory: false }],
|
||||
})
|
||||
fireEvent.change(view.getByPlaceholderText('Search all files...'), { target: { value: 'readme' } })
|
||||
|
||||
expect(view.getByRole('status').textContent).toBe('1 of 2 items')
|
||||
expect(view.queryByText('src')).toBeNull()
|
||||
expect(await view.findByText('1 search results')).toBeTruthy()
|
||||
expect(view.getByText('README.md')).toBeTruthy()
|
||||
expect(view.queryByText('src')).toBeNull()
|
||||
})
|
||||
|
||||
it('searches unopened directories in a deep Java project without expanding the tree first', async () => {
|
||||
const sessionId = 'session-deep-java-search'
|
||||
const targetPath = 'services/mental-health-service/src/main/java/com/example/campus/mentalhealth/controller/MentalHealthTrendController.java'
|
||||
|
||||
getMocks().searchWorkspaceMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
query: 'MentalHealthTrendController',
|
||||
truncated: false,
|
||||
entries: [{
|
||||
name: 'MentalHealthTrendController.java',
|
||||
path: targetPath,
|
||||
isDirectory: false,
|
||||
}],
|
||||
})
|
||||
getMocks().getWorkspaceFileMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: targetPath,
|
||||
content: 'package com.example.campus;\n\npublic final class MentalHealthTrendController {}\n',
|
||||
language: 'java',
|
||||
size: 82,
|
||||
})
|
||||
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
[sessionId]: { isOpen: true, activeView: 'all', hasUserSelectedView: true },
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
[sessionId]: {
|
||||
state: 'ok',
|
||||
workDir: '/repo/campus-agent-platform',
|
||||
repoName: 'campus-agent-platform',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
treeBySessionPath: {
|
||||
...state.treeBySessionPath,
|
||||
[sessionId]: {
|
||||
'': {
|
||||
state: 'ok',
|
||||
path: '',
|
||||
entries: [
|
||||
{ name: 'identity-domain', path: 'identity-domain', isDirectory: true },
|
||||
{ name: 'identity-application', path: 'identity-application', isDirectory: true },
|
||||
{ name: 'identity-adapter', path: 'identity-adapter', isDirectory: true },
|
||||
{ name: 'services', path: 'services', isDirectory: true },
|
||||
{ name: 'build.gradle', path: 'build.gradle', isDirectory: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel(sessionId)
|
||||
fireEvent.change(view.getByPlaceholderText('Search all files...'), {
|
||||
target: { value: 'MentalHealthTrendController' },
|
||||
})
|
||||
|
||||
expect(await view.findByText('MentalHealthTrendController.java')).toBeTruthy()
|
||||
expect(view.getByText('services/mental-health-service/src/main/java/com/example/campus/mentalhealth/controller')).toBeTruthy()
|
||||
expect(view.queryByRole('button', { name: 'services' })).toBeNull()
|
||||
expect(getMocks().searchWorkspaceMock).toHaveBeenCalledWith(sessionId, 'MentalHealthTrendController')
|
||||
expect(getMocks().getWorkspaceTreeMock).not.toHaveBeenCalledWith(sessionId, 'services')
|
||||
|
||||
await clickElement(view.getByRole('button', {
|
||||
name: 'MentalHealthTrendController.java, services/mental-health-service/src/main/java/com/example/campus/mentalhealth/controller',
|
||||
}))
|
||||
expect((await view.findByTestId('workspace-preview-header')).textContent).toContain(targetPath)
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(view.getByTestId('workspace-preview-header'))
|
||||
})
|
||||
|
||||
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
||||
const searchInput = view.getByPlaceholderText('Search all files...') as HTMLInputElement
|
||||
expect(searchInput.value).toBe('MentalHealthTrendController')
|
||||
expect(view.getByText('MentalHealthTrendController.java')).toBeTruthy()
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(searchInput)
|
||||
})
|
||||
|
||||
const staleSearch = deferred<{
|
||||
state: 'ok'
|
||||
query: string
|
||||
truncated: boolean
|
||||
entries: Array<{ name: string; path: string; isDirectory: boolean }>
|
||||
}>()
|
||||
getMocks().searchWorkspaceMock.mockReset()
|
||||
getMocks().searchWorkspaceMock
|
||||
.mockReturnValueOnce(staleSearch.promise)
|
||||
.mockResolvedValueOnce({
|
||||
state: 'ok',
|
||||
query: 'JdbcOrganizationHierarchyRepository',
|
||||
truncated: false,
|
||||
entries: [{
|
||||
name: 'JdbcOrganizationHierarchyRepository.java',
|
||||
path: 'identity-adapter/src/main/java/com/example/campus/identity/adapter/persistence/mysql/JdbcOrganizationHierarchyRepository.java',
|
||||
isDirectory: false,
|
||||
}, {
|
||||
name: 'JdbcOrganizationHierarchyRepositoryTest.java',
|
||||
path: 'identity-adapter/src/test/java/com/example/campus/identity/adapter/persistence/mysql/JdbcOrganizationHierarchyRepositoryTest.java',
|
||||
isDirectory: false,
|
||||
}],
|
||||
})
|
||||
|
||||
fireEvent.change(view.getByPlaceholderText('Search all files...'), {
|
||||
target: { value: 'DeepOrganizationHierarchySearchService' },
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(getMocks().searchWorkspaceMock).toHaveBeenCalledWith(sessionId, 'DeepOrganizationHierarchySearchService')
|
||||
})
|
||||
fireEvent.change(view.getByPlaceholderText('Search all files...'), {
|
||||
target: { value: 'JdbcOrganizationHierarchyRepository' },
|
||||
})
|
||||
|
||||
expect(await view.findByText('JdbcOrganizationHierarchyRepository.java')).toBeTruthy()
|
||||
staleSearch.resolve({
|
||||
state: 'ok',
|
||||
query: 'DeepOrganizationHierarchySearchService',
|
||||
truncated: false,
|
||||
entries: [{
|
||||
name: 'DeepOrganizationHierarchySearchService.java',
|
||||
path: 'identity-application/src/main/java/com/example/campus/identity/application/query/DeepOrganizationHierarchySearchService.java',
|
||||
isDirectory: false,
|
||||
}],
|
||||
})
|
||||
await flushReactWork()
|
||||
expect(view.queryByText('DeepOrganizationHierarchySearchService.java')).toBeNull()
|
||||
expect(view.getByText('JdbcOrganizationHierarchyRepository.java')).toBeTruthy()
|
||||
|
||||
const currentSearchInput = view.getByPlaceholderText('Search all files...')
|
||||
fireEvent.keyDown(currentSearchInput, { key: 'ArrowDown' })
|
||||
const currentResult = view.getByRole('button', {
|
||||
name: /JdbcOrganizationHierarchyRepository\.java, identity-adapter\/src\/main/,
|
||||
})
|
||||
expect(document.activeElement).toBe(currentResult)
|
||||
const nextResult = view.getByRole('button', {
|
||||
name: /JdbcOrganizationHierarchyRepositoryTest\.java/,
|
||||
})
|
||||
fireEvent.keyDown(currentResult, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(nextResult)
|
||||
fireEvent.keyDown(nextResult, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(currentResult)
|
||||
fireEvent.keyDown(currentResult, { key: 'End' })
|
||||
expect(document.activeElement).toBe(nextResult)
|
||||
fireEvent.keyDown(nextResult, { key: 'Home' })
|
||||
expect(document.activeElement).toBe(currentResult)
|
||||
fireEvent.keyDown(currentResult, { key: 'Escape' })
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(currentSearchInput)
|
||||
})
|
||||
expect(view.getByText('services')).toBeTruthy()
|
||||
expect(view.queryByText('JdbcOrganizationHierarchyRepository.java')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows workspace search errors and empty results without falling back to the loaded root tree', async () => {
|
||||
const sessionId = 'session-workspace-search-states'
|
||||
getMocks().searchWorkspaceMock
|
||||
.mockRejectedValueOnce(new Error('Workspace search failed'))
|
||||
.mockResolvedValueOnce({ state: 'ok', query: 'missing-class', truncated: false, entries: [] })
|
||||
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
[sessionId]: { isOpen: true, activeView: 'all', hasUserSelectedView: true },
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
[sessionId]: {
|
||||
state: 'ok',
|
||||
workDir: '/repo/campus-agent-platform',
|
||||
repoName: 'campus-agent-platform',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
treeBySessionPath: {
|
||||
...state.treeBySessionPath,
|
||||
[sessionId]: {
|
||||
'': {
|
||||
state: 'ok',
|
||||
path: '',
|
||||
entries: [{ name: 'services', path: 'services', isDirectory: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel(sessionId)
|
||||
fireEvent.change(view.getByPlaceholderText('Search all files...'), {
|
||||
target: { value: 'broken-search' },
|
||||
})
|
||||
expect(await view.findByText('Workspace search failed')).toBeTruthy()
|
||||
expect(view.queryByText('services')).toBeNull()
|
||||
|
||||
fireEvent.change(view.getByPlaceholderText('Search all files...'), {
|
||||
target: { value: 'missing-class' },
|
||||
})
|
||||
expect(await view.findByText('No matching files')).toBeTruthy()
|
||||
expect(view.queryByText('services')).toBeNull()
|
||||
})
|
||||
|
||||
it('lazy loads the root tree, expands directories, and opens file previews from the all-files view', async () => {
|
||||
@ -1104,17 +1320,17 @@ describe('WorkspacePanel', () => {
|
||||
|
||||
expect(view.getByTestId('workspace-code').textContent).toContain('+new')
|
||||
expect(view.queryByRole('button', { name: 'Changed files' })).toBeNull()
|
||||
expect(view.queryByPlaceholderText('Filter files...')).toBeNull()
|
||||
expect(view.queryByPlaceholderText('Filter changed files...')).toBeNull()
|
||||
|
||||
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
||||
|
||||
expect(view.queryByRole('button', { name: 'Changed files' })).toBeNull()
|
||||
expect(view.getByPlaceholderText('Filter files...')).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
|
||||
expect(view.getByPlaceholderText('Filter changed files...')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: 'Hide file navigator' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps a preview navigator scoped to changed files when the previous view was all files', async () => {
|
||||
it('preserves the all-files navigator when a preview is already open', async () => {
|
||||
getMocks().getWorkspaceTreeMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: '',
|
||||
@ -1172,10 +1388,14 @@ describe('WorkspacePanel', () => {
|
||||
expect(getMocks().getWorkspaceTreeMock).not.toHaveBeenCalled()
|
||||
|
||||
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
|
||||
await flushReactWork()
|
||||
await waitFor(() => {
|
||||
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-preview-hidden-tree', '')
|
||||
})
|
||||
|
||||
expect(getMocks().getWorkspaceTreeMock).not.toHaveBeenCalled()
|
||||
expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: 'All files' })).toBeTruthy()
|
||||
expect(view.getByPlaceholderText('Search all files...')).toBeTruthy()
|
||||
expect(view.getByText('src')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses theme tokens for the panel, preview header, and code surface in dark mode', async () => {
|
||||
@ -1237,6 +1457,74 @@ describe('WorkspacePanel', () => {
|
||||
expect(classNameContains(codeSurface, 'bg-white')).toBe(false)
|
||||
})
|
||||
|
||||
it('syntax highlights Java source previews instead of rendering them as plain text', async () => {
|
||||
const sessionId = 'session-java-preview'
|
||||
const javaSource = [
|
||||
'package com.example.campus;',
|
||||
'',
|
||||
'import java.util.List;',
|
||||
'',
|
||||
'public final class MentalHealthTrendController {',
|
||||
' private final List<String> campusIds;',
|
||||
'',
|
||||
' public int countVisibleOrganizations() {',
|
||||
' return campusIds.size();',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
[sessionId]: {
|
||||
isOpen: true,
|
||||
activeView: 'all',
|
||||
hasUserSelectedView: true,
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
[sessionId]: {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
[sessionId]: [{
|
||||
id: 'file:src/MentalHealthTrendController.java',
|
||||
path: 'src/MentalHealthTrendController.java',
|
||||
kind: 'file',
|
||||
title: 'MentalHealthTrendController.java',
|
||||
language: 'java',
|
||||
content: javaSource,
|
||||
state: 'ok',
|
||||
size: javaSource.length,
|
||||
}],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
[sessionId]: 'file:src/MentalHealthTrendController.java',
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel(sessionId)
|
||||
await waitFor(() => {
|
||||
expect(view.getByTestId('workspace-code').getAttribute('data-highlight-engine')).toBe('shiki')
|
||||
})
|
||||
const tokens = Array.from(view.getByTestId('workspace-code').querySelectorAll<HTMLElement>('[data-workspace-token]'))
|
||||
const tokenColor = (text: string) => tokens.find((token) => token.textContent === text)?.style.color
|
||||
|
||||
expect(tokenColor('package')).toBe('var(--color-code-keyword)')
|
||||
expect(tokenColor('MentalHealthTrendController')).toBe('var(--color-code-type)')
|
||||
expect(tokenColor('countVisibleOrganizations')).toBe('var(--color-code-function)')
|
||||
})
|
||||
|
||||
it('can expand long diff previews beyond the default rendered line cap', async () => {
|
||||
const longDiff = Array.from({ length: 2300 }, (_, index) => `+line ${index + 1}`).join('\n')
|
||||
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type RefObject } from 'react'
|
||||
import { CircleAlert, Code2, File as FileIcon, FileText, FolderOpen, Image as ImageIcon, MessageCircle, PanelRightClose, PanelRightOpen, RefreshCw, Search, Settings2, X, type LucideIcon } from 'lucide-react'
|
||||
import { Highlight } from 'prism-react-renderer'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceFileStatus,
|
||||
WorkspaceTreeEntry,
|
||||
WorkspaceTreeResult,
|
||||
import {
|
||||
sessionsApi,
|
||||
type WorkspaceSearchResult,
|
||||
type WorkspaceChangedFile,
|
||||
type WorkspaceFileStatus,
|
||||
type WorkspaceTreeEntry,
|
||||
type WorkspaceTreeResult,
|
||||
} from '../../api/sessions'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
@ -31,6 +33,7 @@ import {
|
||||
} from './WorkspaceCodeSurface'
|
||||
import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith'
|
||||
import { getFileIdentity, getWorkspaceStatusLabel, type WorkspaceFileIdentity } from './fileIdentity'
|
||||
import type { WorkspaceDiffHighlightToken } from './workspaceDiffHighlighter'
|
||||
|
||||
type WorkspacePanelProps = {
|
||||
sessionId: string
|
||||
@ -118,6 +121,7 @@ const EMPTY_EXPANDED_PATHS: string[] = []
|
||||
const SELECTION_MENU_OFFSET = 10
|
||||
const SELECTION_MENU_WIDTH = 158
|
||||
const SELECTION_MENU_HEIGHT = 44
|
||||
const WORKSPACE_SEARCH_DEBOUNCE_MS = 250
|
||||
const FILE_BADGE_META: Record<string, { label: string; className: string }> = {
|
||||
ts: { label: 'TS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
|
||||
tsx: { label: 'TSX', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
|
||||
@ -378,11 +382,13 @@ function PanelMessage({
|
||||
message,
|
||||
tone = 'muted',
|
||||
compact = false,
|
||||
announce = true,
|
||||
}: {
|
||||
icon: string
|
||||
message: string
|
||||
tone?: 'muted' | 'error'
|
||||
compact?: boolean
|
||||
announce?: boolean
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'error'
|
||||
@ -392,7 +398,7 @@ function PanelMessage({
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 ${compact ? 'py-2 text-[11px]' : 'py-8 text-xs'} ${toneClass}`}
|
||||
role={tone === 'error' ? 'alert' : 'status'}
|
||||
role={announce ? tone === 'error' ? 'alert' : 'status' : undefined}
|
||||
>
|
||||
<span className={`material-symbols-outlined shrink-0 text-[16px] ${icon === 'progress_activity' ? 'animate-spin' : ''}`}>
|
||||
{icon}
|
||||
@ -427,35 +433,62 @@ function WorkspaceFilterInput({
|
||||
value,
|
||||
onChange,
|
||||
summary,
|
||||
mode,
|
||||
loading = false,
|
||||
inputRef,
|
||||
onFocusFirstResult,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
summary?: string
|
||||
mode: 'changed' | 'all'
|
||||
loading?: boolean
|
||||
inputRef: RefObject<HTMLInputElement>
|
||||
onFocusFirstResult?: () => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const placeholder = mode === 'all'
|
||||
? t('workspace.searchAllPlaceholder')
|
||||
: t('workspace.filterChangedPlaceholder')
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-[var(--color-text-primary)]/10 px-3 pb-2.5 pt-2.5">
|
||||
<label className="flex h-9 items-center gap-2 rounded-[7px] bg-[var(--color-surface-container-low)] px-2.5 text-[var(--color-text-tertiary)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--color-text-primary)_8%,transparent)] transition-[background-color,box-shadow] duration-200 ease-out focus-within:bg-[var(--color-surface)] focus-within:shadow-[inset_0_0_0_1px_var(--color-info),0_0_0_3px_color-mix(in_srgb,var(--color-info)_12%,transparent)]">
|
||||
<div className="flex h-9 items-center gap-2 rounded-[7px] bg-[var(--color-surface-container-low)] px-2.5 text-[var(--color-text-tertiary)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--color-text-primary)_8%,transparent)] transition-[background-color,box-shadow] duration-200 ease-out focus-within:bg-[var(--color-surface)] focus-within:shadow-[inset_0_0_0_1px_var(--color-info),0_0_0_3px_color-mix(in_srgb,var(--color-info)_12%,transparent)]">
|
||||
<Search size={15} strokeWidth={1.9} aria-hidden="true" className="shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
aria-label={t('workspace.filterPlaceholder')}
|
||||
placeholder={t('workspace.filterPlaceholder')}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && value) {
|
||||
event.preventDefault()
|
||||
onChange('')
|
||||
} else if (event.key === 'ArrowDown' && onFocusFirstResult) {
|
||||
event.preventDefault()
|
||||
onFocusFirstResult()
|
||||
}
|
||||
}}
|
||||
aria-label={placeholder}
|
||||
placeholder={placeholder}
|
||||
className="min-w-0 flex-1 bg-transparent text-[13px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
{loading && (
|
||||
<RefreshCw size={13} aria-hidden="true" className="shrink-0 animate-spin" />
|
||||
)}
|
||||
{value.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('workspace.clearFilter')}
|
||||
onClick={() => onChange('')}
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={() => {
|
||||
onChange('')
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<X size={13} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
{summary && (
|
||||
<div
|
||||
role="status"
|
||||
@ -482,6 +515,15 @@ function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) {
|
||||
)
|
||||
}
|
||||
|
||||
function workspaceCodeTokenStyle(token: WorkspaceDiffHighlightToken): CSSProperties {
|
||||
const fontStyle = token.fontStyle ?? 0
|
||||
return {
|
||||
color: token.color,
|
||||
fontStyle: fontStyle & 1 ? 'italic' : undefined,
|
||||
fontWeight: fontStyle & 2 ? 700 : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function CodeSurface({
|
||||
value,
|
||||
language,
|
||||
@ -500,6 +542,7 @@ function CodeSurface({
|
||||
const [commentDraft, setCommentDraft] = useState('')
|
||||
const [showAllLines, setShowAllLines] = useState(false)
|
||||
const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null)
|
||||
const [shikiTokensByLine, setShikiTokensByLine] = useState<WorkspaceDiffHighlightToken[][] | null>(null)
|
||||
const lines = value.split('\n')
|
||||
const visibleLines = showAllLines ? lines : lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT)
|
||||
const activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : ''
|
||||
@ -513,6 +556,27 @@ function CodeSurface({
|
||||
setSelectionMenu(null)
|
||||
}, [language, value])
|
||||
|
||||
useEffect(() => {
|
||||
if (usePlainLargePreview) {
|
||||
setShikiTokensByLine(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setShikiTokensByLine(null)
|
||||
void import('./workspaceDiffHighlighter')
|
||||
.then(({ highlightWorkspaceCode }) => highlightWorkspaceCode({ value: visibleCode, language }))
|
||||
.then((result) => {
|
||||
if (!cancelled && result.engine === 'shiki') setShikiTokensByLine(result.tokensByLine)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setShikiTokensByLine(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [language, usePlainLargePreview, visibleCode])
|
||||
|
||||
const dismissSelectionMenu = useCallback(() => {
|
||||
setSelectionMenu(null)
|
||||
}, [])
|
||||
@ -648,6 +712,40 @@ function CodeSurface({
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
) : shikiTokensByLine ? (
|
||||
<pre
|
||||
data-workspace-code=""
|
||||
data-testid="workspace-code"
|
||||
data-highlight-engine="shiki"
|
||||
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55]"
|
||||
style={{ color: 'var(--color-code-fg)', background: 'transparent' }}
|
||||
>
|
||||
{shikiTokensByLine.map((line, index) => {
|
||||
const lineNumber = index + 1
|
||||
return (
|
||||
<div key={lineNumber}>
|
||||
<div
|
||||
data-workspace-line-number={lineNumber}
|
||||
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{renderLineNumberButton(lineNumber)}
|
||||
<span className="whitespace-pre pr-6">
|
||||
{line.length === 0 ? ' ' : line.map((token, tokenIndex) => (
|
||||
<span
|
||||
key={`${tokenIndex}:${token.content}`}
|
||||
data-workspace-token=""
|
||||
style={workspaceCodeTokenStyle(token)}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
{renderLineCommentEditor(lineNumber)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
) : (
|
||||
<Highlight
|
||||
theme={workspacePrismTheme}
|
||||
@ -658,6 +756,7 @@ function CodeSurface({
|
||||
<pre
|
||||
data-workspace-code=""
|
||||
data-testid="workspace-code"
|
||||
data-highlight-engine="prism"
|
||||
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55]"
|
||||
style={{ color: 'var(--color-code-fg)', background: 'transparent' }}
|
||||
>
|
||||
@ -860,6 +959,93 @@ function ChangedFileRow({
|
||||
)
|
||||
}
|
||||
|
||||
function moveWorkspaceSearchResultFocus(
|
||||
event: ReactKeyboardEvent<HTMLButtonElement>,
|
||||
direction: 'next' | 'previous' | 'first' | 'last',
|
||||
) {
|
||||
const list = event.currentTarget.closest('[data-workspace-search-results]')
|
||||
const results = list
|
||||
? Array.from(list.querySelectorAll<HTMLButtonElement>('[data-workspace-search-result]'))
|
||||
: []
|
||||
if (results.length === 0) return
|
||||
|
||||
const currentIndex = results.indexOf(event.currentTarget)
|
||||
const targetIndex = direction === 'first'
|
||||
? 0
|
||||
: direction === 'last'
|
||||
? results.length - 1
|
||||
: direction === 'next'
|
||||
? Math.min(currentIndex + 1, results.length - 1)
|
||||
: Math.max(currentIndex - 1, 0)
|
||||
results[targetIndex]?.focus()
|
||||
}
|
||||
|
||||
function WorkspaceSearchResultRow({
|
||||
entry,
|
||||
active,
|
||||
onOpen,
|
||||
onContextMenu,
|
||||
onClearSearch,
|
||||
}: {
|
||||
entry: WorkspaceTreeEntry
|
||||
active: boolean
|
||||
onOpen: () => void
|
||||
onContextMenu: (event: MouseEvent, path: string, isDirectory: boolean) => void
|
||||
onClearSearch: () => void
|
||||
}) {
|
||||
const normalizedPath = entry.path.replace(/\\/g, '/')
|
||||
const lastSlash = normalizedPath.lastIndexOf('/')
|
||||
const parentPath = lastSlash >= 0 ? normalizedPath.slice(0, lastSlash) : '.'
|
||||
|
||||
return (
|
||||
<div role="listitem">
|
||||
<button
|
||||
type="button"
|
||||
data-workspace-search-result=""
|
||||
data-workspace-file-path={entry.path}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
aria-label={`${entry.name}, ${parentPath}`}
|
||||
title={normalizedPath}
|
||||
onClick={onOpen}
|
||||
onContextMenu={(event) => onContextMenu(event, entry.path, false)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
moveWorkspaceSearchResultFocus(event, 'next')
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
moveWorkspaceSearchResultFocus(event, 'previous')
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
moveWorkspaceSearchResultFocus(event, 'first')
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault()
|
||||
moveWorkspaceSearchResultFocus(event, 'last')
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClearSearch()
|
||||
}
|
||||
}}
|
||||
className={`group mx-2 flex min-h-12 w-[calc(100%-16px)] items-start gap-2 rounded-[7px] px-2.5 py-2 text-left transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] ${
|
||||
active
|
||||
? 'bg-[var(--color-info-container)] shadow-[inset_3px_0_0_var(--color-info)]'
|
||||
: 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<FileTypeBadge name={entry.name} subtle={!active} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate font-[var(--font-mono)] text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{parentPath}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
sessionId,
|
||||
entry,
|
||||
@ -889,6 +1075,7 @@ function TreeNode({
|
||||
type="button"
|
||||
onClick={() => onOpenFile(entry.path)}
|
||||
onContextMenu={(event) => onFileContextMenu(event, entry.path, false)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
className={`group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors ${
|
||||
isActive
|
||||
? 'bg-[var(--color-surface-selected)] shadow-[inset_0_0_0_1.5px_var(--color-border-focus)]'
|
||||
@ -912,7 +1099,7 @@ function TreeNode({
|
||||
className="group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ paddingLeft: indent }}
|
||||
>
|
||||
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)] transition-colors group-hover:text-[var(--color-text-primary)]">
|
||||
<span aria-hidden="true" className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)] transition-colors group-hover:text-[var(--color-text-primary)]">
|
||||
{isVisuallyExpanded ? 'expand_more' : 'chevron_right'}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[15px] font-medium text-[var(--color-text-primary)]">{entry.name}</span>
|
||||
@ -985,6 +1172,10 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
const t = useTranslation()
|
||||
const addToast = useUIStore((state) => state.addToast)
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
const [workspaceSearch, setWorkspaceSearch] = useState<WorkspaceSearchResult | null>(null)
|
||||
const [workspaceSearchLoading, setWorkspaceSearchLoading] = useState(false)
|
||||
const [workspaceSearchError, setWorkspaceSearchError] = useState<string | null>(null)
|
||||
const [workspaceSearchRevision, setWorkspaceSearchRevision] = useState(0)
|
||||
const [isViewMenuOpen, setIsViewMenuOpen] = useState(false)
|
||||
const [isNavigatorOpen, setIsNavigatorOpen] = useState(forceVisible)
|
||||
const [previewTabContextMenu, setPreviewTabContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
|
||||
@ -1021,18 +1212,27 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
isOpen: false,
|
||||
chatState: 'idle',
|
||||
})
|
||||
const workspaceSearchRequestIdRef = useRef(0)
|
||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||
const previewHeaderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const rootTree = treeByPath['']
|
||||
const rootTreeKey = makeTreeStateKey(sessionId, '')
|
||||
const rootTreeLoading = treeLoadingByPath[rootTreeKey] ?? false
|
||||
const rootTreeError = treeErrorsByPath[rootTreeKey] ?? null
|
||||
const normalizedFilterQuery = normalizeFilterQuery(filterQuery)
|
||||
const expandedPathSet = new Set(expandedPaths)
|
||||
const activePreviewTab =
|
||||
previewTabs.find((tab) => tab.id === activePreviewTabId) ?? previewTabs[previewTabs.length - 1] ?? null
|
||||
const hasPreviewTabs = previewTabs.length > 0
|
||||
const isNavigatorVisible = !hasPreviewTabs || isNavigatorOpen
|
||||
const navigatorView = hasPreviewTabs ? 'changed' : activeView
|
||||
const navigatorView = activeView
|
||||
const hasWorkspaceSearch = navigatorView === 'all' && normalizedFilterQuery.length > 0
|
||||
const activeWorkspaceSearch = workspaceSearch
|
||||
&& normalizeFilterQuery(workspaceSearch.query) === normalizedFilterQuery
|
||||
? workspaceSearch
|
||||
: null
|
||||
const displayedWorkspaceSearch = activeWorkspaceSearch ?? workspaceSearch
|
||||
const expandedPathSet = new Set(expandedPaths)
|
||||
const activeTreePath = activePreviewTab?.kind === 'file' ? activePreviewTab.path : null
|
||||
const activeChangedFile = activePreviewTab
|
||||
? status?.changedFiles.find((file) => file.path === activePreviewTab.path) ?? null
|
||||
@ -1051,17 +1251,19 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
: [],
|
||||
[normalizedFilterQuery, rootTree, treeByPath],
|
||||
)
|
||||
const visibleEntryCount = navigatorView === 'changed' ? filteredChangedFiles.length : filteredRootEntries.length
|
||||
const totalEntryCount = navigatorView === 'changed'
|
||||
? status?.changedFiles.length ?? 0
|
||||
: rootTree?.state === 'ok' ? rootTree.entries.length : 0
|
||||
const visibleEntryCount = filteredChangedFiles.length
|
||||
const totalEntryCount = status?.changedFiles.length ?? 0
|
||||
const filterSummary = navigatorView === 'changed'
|
||||
? normalizedFilterQuery
|
||||
? t('workspace.filteredFilesCount', { visible: visibleEntryCount, total: totalEntryCount })
|
||||
: t('workspace.filesCount', { count: totalEntryCount })
|
||||
: normalizedFilterQuery
|
||||
? t('workspace.filteredItemsCount', { visible: visibleEntryCount, total: totalEntryCount })
|
||||
: t('workspace.itemsCount', { count: totalEntryCount })
|
||||
: !normalizedFilterQuery || workspaceSearchError
|
||||
? undefined
|
||||
: workspaceSearchLoading || !activeWorkspaceSearch
|
||||
? t('workspace.searching')
|
||||
: activeWorkspaceSearch.truncated
|
||||
? t('workspace.searchResultsTruncated', { count: activeWorkspaceSearch.entries.length })
|
||||
: t('workspace.searchResultsCount', { count: activeWorkspaceSearch.entries.length })
|
||||
const activePreviewRequestKey = activePreviewTab
|
||||
? makePreviewStateKey(sessionId, activePreviewTab.id)
|
||||
: null
|
||||
@ -1098,6 +1300,38 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
void loadTree(sessionId, '')
|
||||
}, [isNavigatorVisible, loadTree, navigatorView, rootTree, rootTreeError, rootTreeLoading, sessionId, shouldRender])
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = workspaceSearchRequestIdRef.current + 1
|
||||
workspaceSearchRequestIdRef.current = requestId
|
||||
|
||||
if (!shouldRender || navigatorView !== 'all' || !normalizedFilterQuery) {
|
||||
if (!normalizedFilterQuery) setWorkspaceSearch(null)
|
||||
setWorkspaceSearchLoading(false)
|
||||
setWorkspaceSearchError(null)
|
||||
return
|
||||
}
|
||||
|
||||
setWorkspaceSearchLoading(true)
|
||||
setWorkspaceSearchError(null)
|
||||
let cancelled = false
|
||||
const timer = window.setTimeout(() => {
|
||||
void sessionsApi.searchWorkspace(sessionId, filterQuery.trim()).then((result) => {
|
||||
if (cancelled || workspaceSearchRequestIdRef.current !== requestId) return
|
||||
setWorkspaceSearch(result)
|
||||
setWorkspaceSearchLoading(false)
|
||||
}).catch((error) => {
|
||||
if (cancelled || workspaceSearchRequestIdRef.current !== requestId) return
|
||||
setWorkspaceSearchLoading(false)
|
||||
setWorkspaceSearchError(error instanceof Error ? error.message : t('workspace.loadError'))
|
||||
})
|
||||
}, WORKSPACE_SEARCH_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [filterQuery, navigatorView, normalizedFilterQuery, sessionId, shouldRender, t, workspaceSearchRevision])
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewTabContextMenu && !fileContextMenu) return
|
||||
const close = () => {
|
||||
@ -1131,19 +1365,38 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
if (activePreviewTab) {
|
||||
void openPreview(sessionId, activePreviewTab.path, activePreviewTab.kind)
|
||||
}
|
||||
if (navigatorView === 'all') {
|
||||
if (hasWorkspaceSearch) {
|
||||
setWorkspaceSearchRevision((revision) => revision + 1)
|
||||
} else if (navigatorView === 'all') {
|
||||
void loadTree(sessionId, '')
|
||||
}
|
||||
}
|
||||
|
||||
const focusPreviewAfterOpen = () => {
|
||||
window.setTimeout(() => previewHeaderRef.current?.focus(), 0)
|
||||
}
|
||||
|
||||
const handleOpenDiff = (path: string) => {
|
||||
setIsNavigatorOpen(forceVisible)
|
||||
void openPreview(sessionId, path, 'diff')
|
||||
focusPreviewAfterOpen()
|
||||
}
|
||||
|
||||
const handleOpenFile = (path: string) => {
|
||||
setIsNavigatorOpen(forceVisible)
|
||||
void openPreview(sessionId, path, 'file')
|
||||
focusPreviewAfterOpen()
|
||||
}
|
||||
|
||||
const clearWorkspaceSearch = () => {
|
||||
setFilterQuery('')
|
||||
window.requestAnimationFrame(() => filterInputRef.current?.focus())
|
||||
}
|
||||
|
||||
const focusFirstSearchResult = () => {
|
||||
document.querySelector<HTMLButtonElement>(
|
||||
`[data-testid="workspace-panel"] [data-workspace-search-result]`,
|
||||
)?.focus()
|
||||
}
|
||||
|
||||
const addWorkspacePathToChat = (path: string, isDirectory = false) => {
|
||||
@ -1302,6 +1555,69 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
}
|
||||
|
||||
const renderAllFilesView = () => {
|
||||
if (hasWorkspaceSearch) {
|
||||
if (workspaceSearchLoading && !displayedWorkspaceSearch) {
|
||||
return <PanelMessage announce={false} icon="progress_activity" message={t('workspace.searching')} />
|
||||
}
|
||||
if (workspaceSearchError && !displayedWorkspaceSearch) {
|
||||
return (
|
||||
<div role="alert" className="mx-3 my-3 rounded-[8px] border border-[var(--color-error)]/20 bg-[var(--color-error)]/6 p-3 text-[12px] text-[var(--color-error)]">
|
||||
<div className="flex items-start gap-2">
|
||||
<CircleAlert size={15} aria-hidden="true" className="mt-0.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 leading-5">{workspaceSearchError}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWorkspaceSearchRevision((revision) => revision + 1)}
|
||||
className="mt-2 rounded-[6px] border border-[var(--color-error)]/30 px-2 py-1 font-medium hover:bg-[var(--color-error)]/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-error)]/25"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!displayedWorkspaceSearch) {
|
||||
return <PanelMessage announce={false} icon="progress_activity" message={t('workspace.searching')} />
|
||||
}
|
||||
if (!workspaceSearchLoading && activeWorkspaceSearch?.entries.length === 0) {
|
||||
return <PanelMessage announce={false} icon="search_off" message={t('workspace.noMatchingFiles')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
aria-label={t('workspace.searchResults')}
|
||||
aria-busy={workspaceSearchLoading}
|
||||
data-workspace-search-results=""
|
||||
className="space-y-0.5 py-1"
|
||||
>
|
||||
{workspaceSearchError && (
|
||||
<div role="alert" className="mx-3 mb-2 flex items-center gap-2 rounded-[7px] bg-[var(--color-error)]/6 px-2.5 py-2 text-[11px] text-[var(--color-error)]">
|
||||
<CircleAlert size={14} aria-hidden="true" className="shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{workspaceSearchError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWorkspaceSearchRevision((revision) => revision + 1)}
|
||||
className="shrink-0 rounded-[5px] px-1.5 py-1 font-medium hover:bg-[var(--color-error)]/10"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{displayedWorkspaceSearch.entries.map((entry) => (
|
||||
<WorkspaceSearchResultRow
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
active={activeTreePath === entry.path}
|
||||
onOpen={() => handleOpenFile(entry.path)}
|
||||
onContextMenu={handleFileContextMenu}
|
||||
onClearSearch={clearWorkspaceSearch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (rootTreeLoading && !rootTree) {
|
||||
return <PanelMessage icon="progress_activity" message={t('common.loading')} />
|
||||
}
|
||||
@ -1375,8 +1691,10 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div
|
||||
ref={previewHeaderRef}
|
||||
tabIndex={-1}
|
||||
data-testid="workspace-preview-header"
|
||||
className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--color-text-primary)]/10 bg-[var(--color-surface)] px-3 text-[12px]"
|
||||
className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--color-text-primary)]/10 bg-[var(--color-surface)] px-3 text-[12px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-info)]/35"
|
||||
>
|
||||
<FileText size={15} strokeWidth={1.8} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
<span className="min-w-0 truncate font-medium text-[var(--color-text-primary)]">{activePreviewTab.path}</span>
|
||||
@ -1400,7 +1718,11 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
<ToolbarIconButton
|
||||
Icon={isNavigatorVisible ? PanelRightClose : PanelRightOpen}
|
||||
label={isNavigatorVisible ? t('workspace.hideNavigator') : t('workspace.showNavigator')}
|
||||
onClick={() => setIsNavigatorOpen((open) => !open)}
|
||||
onClick={() => setIsNavigatorOpen((open) => {
|
||||
const nextOpen = !open
|
||||
if (nextOpen) window.requestAnimationFrame(() => filterInputRef.current?.focus())
|
||||
return nextOpen
|
||||
})}
|
||||
/>
|
||||
{!embedded && (
|
||||
<ToolbarIconButton Icon={X} label={t('workspace.closePanel')} onClick={() => closePanel(sessionId)} />
|
||||
@ -1608,11 +1930,10 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
data-testid="workspace-file-navigator"
|
||||
className={`${hasPreviewTabs ? 'border-l border-[var(--color-text-primary)]/10' : ''} ${hasPreviewTabs && !forceVisible ? 'absolute inset-y-0 right-0 z-20 w-[min(280px,100%)] shadow-[-12px_0_28px_rgba(15,23,42,0.08)]' : ''} flex min-h-0 flex-col bg-[var(--color-surface)]`}
|
||||
>
|
||||
{!hasPreviewTabs && (
|
||||
<header
|
||||
data-testid="workspace-file-navigator-header"
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-text-primary)]/10 px-3"
|
||||
>
|
||||
<header
|
||||
data-testid="workspace-file-navigator-header"
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-text-primary)]/10 px-3"
|
||||
>
|
||||
<div className="relative min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
@ -1625,7 +1946,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
<span className="truncate">
|
||||
{activeView === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
|
||||
</span>
|
||||
<span className="material-symbols-outlined shrink-0 text-[15px] font-normal text-[var(--color-text-tertiary)]">expand_more</span>
|
||||
<span aria-hidden="true" className="material-symbols-outlined shrink-0 text-[15px] font-normal text-[var(--color-text-tertiary)]">expand_more</span>
|
||||
</button>
|
||||
{isViewMenuOpen && (
|
||||
<div
|
||||
@ -1648,7 +1969,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
{view === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">check</span>
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">check</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
@ -1664,13 +1985,16 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<WorkspaceFilterInput
|
||||
value={filterQuery}
|
||||
onChange={setFilterQuery}
|
||||
summary={normalizedFilterQuery ? filterSummary : undefined}
|
||||
mode={navigatorView}
|
||||
loading={hasWorkspaceSearch && workspaceSearchLoading}
|
||||
inputRef={filterInputRef}
|
||||
onFocusFirstResult={hasWorkspaceSearch ? focusFirstSearchResult : undefined}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto py-1.5">
|
||||
|
||||
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { parseWorkspaceDiff } from './workspaceDiffModel'
|
||||
import {
|
||||
buildWorkspaceDiffWordRanges,
|
||||
highlightWorkspaceCode,
|
||||
highlightWorkspaceDiff,
|
||||
} from './workspaceDiffHighlighter'
|
||||
|
||||
@ -13,6 +14,25 @@ function findRowId(diff: string, text: string) {
|
||||
}
|
||||
|
||||
describe('workspaceDiffHighlighter', () => {
|
||||
it('loads the Java grammar for regular workspace file previews', async () => {
|
||||
const result = await highlightWorkspaceCode({
|
||||
language: 'java',
|
||||
value: [
|
||||
'package com.example.campus;',
|
||||
'public final class MentalHealthTrendController {',
|
||||
' private String campusId;',
|
||||
' public int countVisibleOrganizations() { return 1; }',
|
||||
'}',
|
||||
].join('\n'),
|
||||
})
|
||||
const tokens = result.tokensByLine.flat()
|
||||
|
||||
expect(result.engine).toBe('shiki')
|
||||
expect(tokens.some((token) => token.content === 'package' && token.color === 'var(--color-code-keyword)')).toBe(true)
|
||||
expect(tokens.some((token) => token.content === 'MentalHealthTrendController' && token.color === 'var(--color-code-type)')).toBe(true)
|
||||
expect(tokens.some((token) => token.content === 'countVisibleOrganizations' && token.color === 'var(--color-code-function)')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps TextMate grammar state across the rows in one diff hunk', async () => {
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
|
||||
@ -24,6 +24,11 @@ export interface WorkspaceDiffHighlightResult {
|
||||
wordRangesByRowId: Record<string, WorkspaceDiffWordRange[]>
|
||||
}
|
||||
|
||||
export interface WorkspaceCodeHighlightResult {
|
||||
engine: 'shiki' | 'plain'
|
||||
tokensByLine: WorkspaceDiffHighlightToken[][]
|
||||
}
|
||||
|
||||
interface WordSegment extends WorkspaceDiffWordRange {
|
||||
text: string
|
||||
}
|
||||
@ -220,6 +225,31 @@ const workspaceDiffShikiTheme: ThemeRegistration = {
|
||||
],
|
||||
}
|
||||
|
||||
const workspaceCodeShikiTheme: ThemeRegistration = {
|
||||
name: 'codex-workspace-code',
|
||||
type: 'dark',
|
||||
fg: 'var(--color-code-fg)',
|
||||
bg: 'transparent',
|
||||
settings: [
|
||||
{ settings: { foreground: 'var(--color-code-fg)', background: 'transparent' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: 'var(--color-code-comment)', fontStyle: 'italic' } },
|
||||
{ scope: ['string', 'string.quoted', 'string.template', 'string.other.link'], settings: { foreground: 'var(--color-code-string)' } },
|
||||
{ scope: ['string.regexp'], settings: { foreground: 'var(--color-primary-container)' } },
|
||||
{ scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: 'var(--color-code-keyword)' } },
|
||||
{ scope: ['keyword.operator'], settings: { foreground: 'var(--color-code-keyword)' } },
|
||||
{ scope: ['entity.name.function', 'support.function', 'meta.function-call'], settings: { foreground: 'var(--color-code-function)' } },
|
||||
{ scope: ['entity.name.type', 'support.type', 'support.class', 'entity.name.class', 'entity.other.inherited-class'], settings: { foreground: 'var(--color-code-type)' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: 'var(--color-code-parameter)' } },
|
||||
{ scope: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key'], settings: { foreground: 'var(--color-code-property)' } },
|
||||
{ scope: ['variable.other.constant', 'variable.other.enummember'], settings: { foreground: 'var(--color-code-type)' } },
|
||||
{ scope: ['constant.numeric', 'constant.language'], settings: { foreground: 'var(--color-code-number)' } },
|
||||
{ scope: ['punctuation', 'meta.brace', 'meta.bracket'], settings: { foreground: 'var(--color-code-punctuation)' } },
|
||||
{ scope: ['entity.name.tag', 'punctuation.definition.tag'], settings: { foreground: 'var(--color-code-keyword)' } },
|
||||
{ scope: ['entity.other.attribute-name'], settings: { foreground: 'var(--color-code-property)' } },
|
||||
{ scope: ['meta.decorator', 'punctuation.decorator'], settings: { foreground: 'var(--color-code-type)' } },
|
||||
],
|
||||
}
|
||||
|
||||
const workspaceDiffLanguageLoaders: Record<string, () => Promise<LanguageRegistration[]>> = {
|
||||
bash: () => import('@shikijs/langs/bash').then((module) => module.default),
|
||||
c: () => import('@shikijs/langs/c').then((module) => module.default),
|
||||
@ -313,7 +343,7 @@ const workspaceDiffLanguagePromises = new Map<string, Promise<void>>()
|
||||
|
||||
function getWorkspaceDiffHighlighter() {
|
||||
workspaceDiffHighlighterPromise ??= createHighlighterCore({
|
||||
themes: [workspaceDiffShikiTheme],
|
||||
themes: [workspaceDiffShikiTheme, workspaceCodeShikiTheme],
|
||||
langs: [],
|
||||
engine: createOnigurumaEngine(import('shiki/wasm')),
|
||||
})
|
||||
@ -348,6 +378,12 @@ export function getWorkspaceDiffShikiLanguage(path: string) {
|
||||
return shikiLanguageAliases[extension] ?? 'text'
|
||||
}
|
||||
|
||||
export function getWorkspaceCodeShikiLanguage(language: string) {
|
||||
const normalized = language.trim().toLowerCase()
|
||||
if (workspaceDiffLanguageLoaders[normalized]) return normalized
|
||||
return shikiLanguageAliases[normalized] ?? 'text'
|
||||
}
|
||||
|
||||
function tokenizeWords(value: string): WordSegment[] {
|
||||
const segments: WordSegment[] = []
|
||||
const pattern = /\s+|[\p{L}\p{N}_$]+|[^\s\p{L}\p{N}_$]+/gu
|
||||
@ -536,3 +572,32 @@ export async function highlightWorkspaceDiff({
|
||||
return { engine: 'plain', tokensByRowId: {}, wordRangesByRowId }
|
||||
}
|
||||
}
|
||||
|
||||
export async function highlightWorkspaceCode({
|
||||
value,
|
||||
language,
|
||||
}: {
|
||||
value: string
|
||||
language: string
|
||||
}): Promise<WorkspaceCodeHighlightResult> {
|
||||
try {
|
||||
const highlighter = await getWorkspaceDiffHighlighter()
|
||||
const normalizedLanguage = getWorkspaceCodeShikiLanguage(language)
|
||||
await ensureWorkspaceDiffLanguage(highlighter, normalizedLanguage)
|
||||
const result = highlighter.codeToTokens(value, {
|
||||
lang: workspaceDiffLanguageLoaders[normalizedLanguage] ? normalizedLanguage : 'text',
|
||||
theme: workspaceCodeShikiTheme,
|
||||
tokenizeMaxLineLength: WORKSPACE_DIFF_TOKENIZE_MAX_LINE_LENGTH,
|
||||
})
|
||||
return {
|
||||
engine: 'shiki',
|
||||
tokensByLine: result.tokens.map((line) => line.map((token) => ({
|
||||
content: token.content,
|
||||
color: token.color,
|
||||
fontStyle: token.fontStyle,
|
||||
}))),
|
||||
}
|
||||
} catch {
|
||||
return { engine: 'plain', tokensByLine: [] }
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,7 +134,13 @@ export const en = {
|
||||
'workspace.viewTabs': 'Workspace views',
|
||||
'workspace.previewTabs': 'Preview tabs',
|
||||
'workspace.filterPlaceholder': 'Filter files...',
|
||||
'workspace.filterChangedPlaceholder': 'Filter changed files...',
|
||||
'workspace.searchAllPlaceholder': 'Search all files...',
|
||||
'workspace.clearFilter': 'Clear file filter',
|
||||
'workspace.searching': 'Searching workspace...',
|
||||
'workspace.searchResults': 'File search results',
|
||||
'workspace.searchResultsCount': '{count} search results',
|
||||
'workspace.searchResultsTruncated': 'Showing the first {count} results. Refine your search.',
|
||||
'workspace.reviewWorkspace': 'Review workspace',
|
||||
'workspace.filesCount': '{count} files',
|
||||
'workspace.filteredFilesCount': '{visible} of {total} files',
|
||||
|
||||
@ -136,7 +136,13 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'workspace.viewTabs': 'ワークスペースビュー',
|
||||
'workspace.previewTabs': 'プレビュータブ',
|
||||
'workspace.filterPlaceholder': 'ファイルを絞り込み...',
|
||||
'workspace.filterChangedPlaceholder': '変更されたファイルを絞り込み...',
|
||||
'workspace.searchAllPlaceholder': 'すべてのファイルを検索...',
|
||||
'workspace.clearFilter': 'ファイルフィルターをクリア',
|
||||
'workspace.searching': 'ワークスペースを検索中...',
|
||||
'workspace.searchResults': 'ファイル検索結果',
|
||||
'workspace.searchResultsCount': '{count} 件の検索結果',
|
||||
'workspace.searchResultsTruncated': '最初の {count} 件を表示しています。検索を絞り込んでください。',
|
||||
'workspace.reviewWorkspace': 'ワークスペースをレビュー',
|
||||
'workspace.filesCount': '{count} ファイル',
|
||||
'workspace.filteredFilesCount': '{total} 件中 {visible} ファイル',
|
||||
|
||||
@ -136,7 +136,13 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'workspace.viewTabs': '작업 공간 보기',
|
||||
'workspace.previewTabs': '미리 보기 탭',
|
||||
'workspace.filterPlaceholder': '파일 필터링...',
|
||||
'workspace.filterChangedPlaceholder': '변경된 파일 필터링...',
|
||||
'workspace.searchAllPlaceholder': '모든 파일 검색...',
|
||||
'workspace.clearFilter': '파일 필터 지우기',
|
||||
'workspace.searching': '작업 공간 검색 중...',
|
||||
'workspace.searchResults': '파일 검색 결과',
|
||||
'workspace.searchResultsCount': '검색 결과 {count}개',
|
||||
'workspace.searchResultsTruncated': '처음 {count}개 결과를 표시합니다. 검색어를 구체화하세요.',
|
||||
'workspace.reviewWorkspace': '작업 공간 검토',
|
||||
'workspace.filesCount': '파일 {count}개',
|
||||
'workspace.filteredFilesCount': '파일 {total}개 중 {visible}개',
|
||||
|
||||
@ -136,7 +136,13 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.viewTabs': '工作區檢視',
|
||||
'workspace.previewTabs': '預覽標籤',
|
||||
'workspace.filterPlaceholder': '篩選檔案...',
|
||||
'workspace.filterChangedPlaceholder': '篩選已更改檔案...',
|
||||
'workspace.searchAllPlaceholder': '搜尋所有檔案...',
|
||||
'workspace.clearFilter': '清除檔案篩選',
|
||||
'workspace.searching': '正在搜尋工作區...',
|
||||
'workspace.searchResults': '檔案搜尋結果',
|
||||
'workspace.searchResultsCount': '找到 {count} 個結果',
|
||||
'workspace.searchResultsTruncated': '顯示前 {count} 個結果,請縮小搜尋範圍。',
|
||||
'workspace.reviewWorkspace': '審閱工作區',
|
||||
'workspace.filesCount': '{count} 個檔案',
|
||||
'workspace.filteredFilesCount': '{visible} / {total} 個檔案',
|
||||
|
||||
@ -136,7 +136,13 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.viewTabs': '工作区视图',
|
||||
'workspace.previewTabs': '预览标签',
|
||||
'workspace.filterPlaceholder': '筛选文件...',
|
||||
'workspace.filterChangedPlaceholder': '筛选已更改文件...',
|
||||
'workspace.searchAllPlaceholder': '搜索所有文件...',
|
||||
'workspace.clearFilter': '清除文件筛选',
|
||||
'workspace.searching': '正在搜索工作区...',
|
||||
'workspace.searchResults': '文件搜索结果',
|
||||
'workspace.searchResultsCount': '找到 {count} 个结果',
|
||||
'workspace.searchResultsTruncated': '显示前 {count} 个结果,请缩小搜索范围。',
|
||||
'workspace.reviewWorkspace': '审阅工作区',
|
||||
'workspace.filesCount': '{count} 个文件',
|
||||
'workspace.filteredFilesCount': '{visible} / {total} 个文件',
|
||||
|
||||
@ -421,12 +421,16 @@ describe('TraceSession', () => {
|
||||
|
||||
it('refetches messages when only the trace message signature changes', async () => {
|
||||
const pendingMessages = baseMessages.filter((message) => message.type !== 'tool_result')
|
||||
let resolveRefreshedMessages!: (value: { messages: MessageEntry[] }) => void
|
||||
const refreshedMessages = new Promise<{ messages: MessageEntry[] }>((resolve) => {
|
||||
resolveRefreshedMessages = resolve
|
||||
})
|
||||
vi.mocked(sessionsApi.getTrace)
|
||||
.mockResolvedValueOnce({ ...baseTrace, messageSignature: '3:tool-use' })
|
||||
.mockResolvedValue({ ...baseTrace, messageSignature: '4:tool-result' })
|
||||
vi.mocked(sessionsApi.getMessages)
|
||||
.mockResolvedValueOnce({ messages: pendingMessages })
|
||||
.mockResolvedValue({ messages: baseMessages })
|
||||
.mockReturnValue(refreshedMessages)
|
||||
|
||||
await renderReady(20)
|
||||
|
||||
@ -435,7 +439,8 @@ describe('TraceSession', () => {
|
||||
expect(within(screen.getByTestId('trace-detail')).queryByText('file.txt')).not.toBeInTheDocument()
|
||||
|
||||
await waitFor(() => expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2))
|
||||
expect(within(screen.getByTestId('trace-detail')).getByText('file.txt')).toBeInTheDocument()
|
||||
resolveRefreshedMessages({ messages: baseMessages })
|
||||
expect(await within(screen.getByTestId('trace-detail')).findByText('file.txt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies poll updates when a call changes without changing row counts', async () => {
|
||||
|
||||
@ -143,14 +143,32 @@ async function createWorkspaceApiGitRepo(baseDir: string): Promise<string> {
|
||||
`workspace-api-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
)
|
||||
|
||||
const javaControllerDir = path.join(
|
||||
workDir,
|
||||
'services',
|
||||
'mental-health-service',
|
||||
'src',
|
||||
'main',
|
||||
'java',
|
||||
'com',
|
||||
'example',
|
||||
'campus',
|
||||
'mentalhealth',
|
||||
'controller',
|
||||
)
|
||||
await fs.mkdir(path.join(workDir, 'src'), { recursive: true })
|
||||
await fs.mkdir(javaControllerDir, { recursive: true })
|
||||
git(workDir, 'init')
|
||||
git(workDir, 'config', 'user.email', 'sessions-api@example.com')
|
||||
git(workDir, 'config', 'user.name', 'Sessions API')
|
||||
|
||||
await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\n')
|
||||
await fs.writeFile(path.join(workDir, 'src', 'app.ts'), 'export const answer = 42\n')
|
||||
git(workDir, 'add', 'tracked.txt', 'src/app.ts')
|
||||
await fs.writeFile(
|
||||
path.join(javaControllerDir, 'MentalHealthTrendController.java'),
|
||||
'package com.example.campus.mentalhealth.controller;\n\npublic final class MentalHealthTrendController {}\n',
|
||||
)
|
||||
git(workDir, 'add', 'tracked.txt', 'src/app.ts', 'services')
|
||||
git(workDir, 'commit', '-m', 'initial')
|
||||
|
||||
await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\nafter\n')
|
||||
@ -3370,7 +3388,7 @@ describe('Sessions API', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/workspace/status|tree|file|diff should return workspace data', async () => {
|
||||
it('GET /api/sessions/:id/workspace/status|tree|search|file|diff should return workspace data', async () => {
|
||||
const workDir = await createWorkspaceApiGitRepo(tmpDir)
|
||||
const { sessionId } = await service.createSession(workDir)
|
||||
|
||||
@ -3403,10 +3421,26 @@ describe('Sessions API', () => {
|
||||
path: '',
|
||||
})
|
||||
expect(treeBody.entries).toEqual([
|
||||
{ name: 'services', path: 'services', isDirectory: true },
|
||||
{ name: 'src', path: 'src', isDirectory: true },
|
||||
{ name: 'tracked.txt', path: 'tracked.txt', isDirectory: false },
|
||||
])
|
||||
|
||||
const searchRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/workspace/search?query=${encodeURIComponent('MentalHealthTrendController')}`,
|
||||
)
|
||||
expect(searchRes.status).toBe(200)
|
||||
expect(await searchRes.json()).toMatchObject({
|
||||
state: 'ok',
|
||||
query: 'MentalHealthTrendController',
|
||||
truncated: false,
|
||||
entries: [{
|
||||
name: 'MentalHealthTrendController.java',
|
||||
path: 'services/mental-health-service/src/main/java/com/example/campus/mentalhealth/controller/MentalHealthTrendController.java',
|
||||
isDirectory: false,
|
||||
}],
|
||||
})
|
||||
|
||||
const fileRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`,
|
||||
)
|
||||
@ -3714,6 +3748,17 @@ describe('Sessions API', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/workspace/search should require a non-empty query', async () => {
|
||||
const workDir = await createWorkspaceApiGitRepo(tmpDir)
|
||||
const { sessionId } = await service.createSession(workDir)
|
||||
|
||||
for (const suffix of ['', '?query=%20%20']) {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/search${suffix}`)
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toMatchObject({ error: 'BAD_REQUEST' })
|
||||
}
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/workspace/file and tree should reject traversal with 403', async () => {
|
||||
const workDir = await createWorkspaceApiGitRepo(tmpDir)
|
||||
const { sessionId } = await service.createSession(workDir)
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
normalizeDriveRootPathForPlatform,
|
||||
} from '../services/windowsDrivePath.js'
|
||||
|
||||
type FilesystemEntry = {
|
||||
export type FilesystemEntry = {
|
||||
name: string
|
||||
path: string
|
||||
isDirectory: boolean
|
||||
@ -189,15 +189,19 @@ async function handleBrowse(url: URL): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
async function searchFilesystemEntries(
|
||||
export async function searchFilesystemEntries(
|
||||
rootPath: string,
|
||||
searchQuery: string,
|
||||
options: { includeFiles: boolean; maxResults: number },
|
||||
options: { includeFiles: boolean; includeDirectories?: boolean; maxResults: number },
|
||||
): Promise<FilesystemEntry[]> {
|
||||
const normalizedQuery = normalizeSearchText(searchQuery)
|
||||
if (!normalizedQuery) return []
|
||||
|
||||
const candidates = await getSearchCandidates(rootPath, options.includeFiles)
|
||||
const candidates = await getSearchCandidates(
|
||||
rootPath,
|
||||
options.includeFiles,
|
||||
options.includeDirectories ?? true,
|
||||
)
|
||||
const results = candidates
|
||||
.map((entry): ScoredFilesystemEntry | null => {
|
||||
const relativePath = entry.relativePath ?? entry.name
|
||||
@ -221,7 +225,11 @@ async function searchFilesystemEntries(
|
||||
.map(({ score: _score, ...entry }) => entry)
|
||||
}
|
||||
|
||||
async function getSearchCandidates(rootPath: string, includeFiles: boolean): Promise<FilesystemEntry[]> {
|
||||
async function getSearchCandidates(
|
||||
rootPath: string,
|
||||
includeFiles: boolean,
|
||||
includeDirectories: boolean,
|
||||
): Promise<FilesystemEntry[]> {
|
||||
const files = await getProjectSearchFiles(rootPath)
|
||||
const entries = new Map<string, FilesystemEntry>()
|
||||
|
||||
@ -229,12 +237,14 @@ async function getSearchCandidates(rootPath: string, includeFiles: boolean): Pro
|
||||
const normalizedFile = normalizeRelativePath(filePath)
|
||||
if (!normalizedFile || !isRelativeInsideRoot(normalizedFile)) continue
|
||||
|
||||
let currentDir = path.posix.dirname(normalizedFile)
|
||||
while (currentDir !== '.') {
|
||||
addCandidate(entries, rootPath, currentDir, true)
|
||||
const parent = path.posix.dirname(currentDir)
|
||||
if (parent === currentDir) break
|
||||
currentDir = parent
|
||||
if (includeDirectories) {
|
||||
let currentDir = path.posix.dirname(normalizedFile)
|
||||
while (currentDir !== '.') {
|
||||
addCandidate(entries, rootPath, currentDir, true)
|
||||
const parent = path.posix.dirname(currentDir)
|
||||
if (parent === currentDir) break
|
||||
currentDir = parent
|
||||
}
|
||||
}
|
||||
|
||||
if (includeFiles) {
|
||||
|
||||
@ -46,6 +46,7 @@ import { findGitRoot } from '../../utils/git.js'
|
||||
import { traceCaptureService, trimTraceCallPreviews } from '../services/traceCaptureService.js'
|
||||
import { getSubagentRunByTool } from '../services/subagentRunService.js'
|
||||
import { isValidPermissionMode } from '../services/settingsService.js'
|
||||
import { handleWorkspaceSearchRoute } from './workspaceSearch.js'
|
||||
|
||||
const DEFAULT_GIT_INFO_COMMAND_TIMEOUT_MS = 3_000
|
||||
|
||||
@ -354,7 +355,7 @@ async function handleSessionWorkspaceRoute(
|
||||
url: URL,
|
||||
workspaceResource?: string,
|
||||
): Promise<Response> {
|
||||
await requireSessionWorkspace(sessionId)
|
||||
const workDir = await requireSessionWorkspace(sessionId)
|
||||
|
||||
switch (workspaceResource) {
|
||||
case 'status':
|
||||
@ -364,6 +365,8 @@ async function handleSessionWorkspaceRoute(
|
||||
sessionId,
|
||||
url.searchParams.get('path') || '',
|
||||
))
|
||||
case 'search':
|
||||
return handleWorkspaceSearchRoute(workDir, url)
|
||||
case 'file':
|
||||
return await runWorkspaceRequest(() => workspaceService.readFile(
|
||||
sessionId,
|
||||
|
||||
76
src/server/api/workspaceSearch.test.ts
Normal file
76
src/server/api/workspaceSearch.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import * as fsp from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { handleWorkspaceSearchRoute } from './workspaceSearch.js'
|
||||
|
||||
const cleanupDirs = new Set<string>()
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of cleanupDirs) {
|
||||
await fsp.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
cleanupDirs.clear()
|
||||
})
|
||||
|
||||
describe('workspace search API', () => {
|
||||
it('finds a deeply nested Java class and returns workspace-relative paths', async () => {
|
||||
const workDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'workspace-search-test-'))
|
||||
cleanupDirs.add(workDir)
|
||||
execFileSync('git', ['init'], { cwd: workDir })
|
||||
const relativePath = 'services/mental-health-service/src/main/java/com/example/campus/mentalhealth/controller/MentalHealthTrendController.java'
|
||||
await fsp.mkdir(path.dirname(path.join(workDir, relativePath)), { recursive: true })
|
||||
await fsp.writeFile(path.join(workDir, relativePath), 'final class MentalHealthTrendController {}')
|
||||
|
||||
const response = await handleWorkspaceSearchRoute(
|
||||
workDir,
|
||||
new URL('http://localhost/api/workspace/search?query=%20MentalHealthTrendController%20'),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({
|
||||
state: 'ok',
|
||||
query: 'MentalHealthTrendController',
|
||||
truncated: false,
|
||||
entries: [{
|
||||
name: 'MentalHealthTrendController.java',
|
||||
path: relativePath,
|
||||
isDirectory: false,
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects missing and blank queries', async () => {
|
||||
for (const suffix of ['', '?query=%20%20']) {
|
||||
expect(handleWorkspaceSearchRoute('/tmp/workspace', new URL(`http://localhost/search${suffix}`)))
|
||||
.rejects.toMatchObject({ statusCode: 400 })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns files in relevance order and reports when results are truncated', async () => {
|
||||
const workDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'workspace-search-limit-test-'))
|
||||
cleanupDirs.add(workDir)
|
||||
execFileSync('git', ['init'], { cwd: workDir })
|
||||
const matchingDirectory = path.join(workDir, 'MatchingClassDirectory')
|
||||
await fsp.mkdir(matchingDirectory, { recursive: true })
|
||||
await Promise.all(Array.from({ length: 201 }, (_, index) => {
|
||||
const fileName = `MatchingClass${String(index).padStart(3, '0')}.java`
|
||||
return fsp.writeFile(path.join(matchingDirectory, fileName), `final class MatchingClass${index} {}`)
|
||||
}))
|
||||
|
||||
const response = await handleWorkspaceSearchRoute(
|
||||
workDir,
|
||||
new URL('http://localhost/api/workspace/search?query=MatchingClass'),
|
||||
)
|
||||
const body = await response.json() as {
|
||||
truncated: boolean
|
||||
entries: Array<{ path: string; isDirectory: boolean }>
|
||||
}
|
||||
|
||||
expect(body.truncated).toBe(true)
|
||||
expect(body.entries).toHaveLength(200)
|
||||
expect(body.entries.every((entry) => !entry.isDirectory)).toBe(true)
|
||||
expect(body.entries[0]?.path).toBe('MatchingClassDirectory/MatchingClass000.java')
|
||||
})
|
||||
})
|
||||
30
src/server/api/workspaceSearch.ts
Normal file
30
src/server/api/workspaceSearch.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import * as path from 'node:path'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { searchFilesystemEntries } from './filesystem.js'
|
||||
|
||||
const WORKSPACE_SEARCH_RESULT_LIMIT = 200
|
||||
|
||||
export async function handleWorkspaceSearchRoute(workDir: string, url: URL): Promise<Response> {
|
||||
const query = url.searchParams.get('query')?.trim()
|
||||
if (!query) {
|
||||
throw ApiError.badRequest('query parameter is required for workspace search')
|
||||
}
|
||||
|
||||
const entries = await searchFilesystemEntries(workDir, query, {
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
maxResults: WORKSPACE_SEARCH_RESULT_LIMIT + 1,
|
||||
})
|
||||
const truncated = entries.length > WORKSPACE_SEARCH_RESULT_LIMIT
|
||||
|
||||
return Response.json({
|
||||
state: 'ok',
|
||||
query,
|
||||
truncated,
|
||||
entries: entries.slice(0, WORKSPACE_SEARCH_RESULT_LIMIT).map((entry) => ({
|
||||
name: entry.name,
|
||||
path: (entry.relativePath || path.relative(workDir, entry.path)).replace(/\\/g, '/'),
|
||||
isDirectory: entry.isDirectory,
|
||||
})),
|
||||
})
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user