diff --git a/desktop/src/api/sessions.test.ts b/desktop/src/api/sessions.test.ts index 5a6aee98..ab1fa64d 100644 --- a/desktop/src/api/sessions.test.ts +++ b/desktop/src/api/sessions.test.ts @@ -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' }) + }) }) diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 00f3d373..f45f28a1 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -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(buildWorkspacePath(sessionId, 'tree', workspacePath)) }, + searchWorkspace(sessionId: string, query: string) { + const params = new URLSearchParams({ query }) + return api.get(`/api/sessions/${sessionId}/workspace/search?${params}`) + }, + getWorkspaceFile(sessionId: string, workspacePath: string) { return api.get(buildWorkspacePath(sessionId, 'file', workspacePath)) }, diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 28cf93f2..9e67b626 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -31,6 +31,7 @@ if (typeof document === 'undefined') { type WorkspaceApiMocks = { getWorkspaceStatusMock: ReturnType getWorkspaceTreeMock: ReturnType + searchWorkspaceMock: ReturnType getWorkspaceFileMock: ReturnType getWorkspaceDiffMock: ReturnType } @@ -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 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('[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') diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx index 2f46919e..d514fee8 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -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 = { 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 (
{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 + onFocusFirstResult?: () => void }) { const t = useTranslation() + const placeholder = mode === 'all' + ? t('workspace.searchAllPlaceholder') + : t('workspace.filterChangedPlaceholder') return (
-