Add editor search-in-files action (#3360)

* Add editor search-in-files action

* Resolve Monaco editor cleanup conflict

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-05-30 11:55:59 -07:00 committed by GitHub
parent b8868023d9
commit a3ca79b67e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 123 additions and 1 deletions

View File

@ -14,6 +14,7 @@ import { registerFileSearchSelectedTextProvider } from '@/lib/file-search-select
import { useContextualCopySetup } from './useContextualCopySetup'
import { MAX_REVEAL_CONTENT_WAIT_FRAMES, performReveal } from './monaco-reveal'
import { syncContentOnMount, syncContentUpdate } from './monaco-content-sync'
import { getMonacoCodebaseSearchQuery } from './monaco-codebase-search'
import {
beginProgrammaticContentSync,
endProgrammaticContentSync,
@ -371,6 +372,29 @@ export default function MonacoEditor({
propsRef.current.onSave(value)
}
)
const searchInFilesAction = editorInstance.addAction({
id: 'orca.searchInFiles',
label: 'Search in Files',
contextMenuGroupId: 'navigation',
contextMenuOrder: 2,
run: () => {
if (!worktreeId) {
return
}
const query = getMonacoCodebaseSearchQuery(
editorInstance.getModel(),
editorInstance.getSelection(),
editorInstance.getPosition()
)
if (!query) {
return
}
const state = useAppStore.getState()
state.seedFileSearchQuery(worktreeId, query)
state.setRightSidebarTab('search')
state.setRightSidebarOpen(true)
}
})
// Track cursor line for "copy path to line" feature
const pos = editorInstance.getPosition()
@ -423,6 +447,7 @@ export default function MonacoEditor({
scrollStateSub.dispose()
gutterMouseDownSub.dispose()
cleanupSaveShortcut()
searchInFilesAction.dispose()
autoHeightSub?.dispose()
if (autoHeightFrame !== null) {
window.cancelAnimationFrame(autoHeightFrame)
@ -478,7 +503,8 @@ export default function MonacoEditor({
setEditorCursorLine,
updateMarkdownCompletionDocuments,
viewStateKey,
autoHeight
autoHeight,
worktreeId
]
)

View File

@ -0,0 +1,60 @@
import type { IPosition, IRange } from 'monaco-editor'
import { describe, expect, it, vi } from 'vitest'
import { getMonacoCodebaseSearchQuery } from './monaco-codebase-search'
type FakeSelection = IRange & {
isEmpty: () => boolean
}
function selection(empty: boolean): FakeSelection {
return {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: 1,
isEmpty: () => empty
}
}
function position(): IPosition {
return { lineNumber: 1, column: 5 }
}
function model(args: { selectedText?: string; word?: string }) {
return {
getValueInRange: vi.fn((_range: IRange) => args.selectedText ?? ''),
getWordAtPosition: vi.fn((_position: IPosition) =>
args.word === undefined ? null : { word: args.word }
)
}
}
describe('getMonacoCodebaseSearchQuery', () => {
it('prefers normalized selected text over the cursor word', () => {
const fakeModel = model({ selectedText: ' foo\r\n bar ', word: 'fallback' })
expect(getMonacoCodebaseSearchQuery(fakeModel, selection(false), position())).toBe('foo bar')
expect(fakeModel.getWordAtPosition).not.toHaveBeenCalled()
})
it('falls back to the cursor word when there is no selection', () => {
expect(
getMonacoCodebaseSearchQuery(model({ word: 'needle' }), selection(true), position())
).toBe('needle')
})
it('falls back to the cursor word when the selection normalizes to empty', () => {
expect(
getMonacoCodebaseSearchQuery(
model({ selectedText: ' \n\t ', word: 'cursorWord' }),
selection(false),
position()
)
).toBe('cursorWord')
})
it('returns null when neither selection nor cursor word yields a query', () => {
expect(getMonacoCodebaseSearchQuery(model({}), selection(true), position())).toBeNull()
expect(getMonacoCodebaseSearchQuery(null, selection(true), position())).toBeNull()
})
})

View File

@ -0,0 +1,36 @@
import type { IPosition, IRange } from 'monaco-editor'
import { normalizeSelectedTextForFileSearch } from '@/lib/file-search-selection'
type MonacoCodebaseSearchModel = {
getValueInRange: (range: IRange) => string
getWordAtPosition: (position: IPosition) => { word: string } | null
}
type MonacoCodebaseSearchSelection = IRange & {
isEmpty: () => boolean
}
export function getMonacoCodebaseSearchQuery(
model: MonacoCodebaseSearchModel | null,
selection: MonacoCodebaseSearchSelection | null,
position: IPosition | null
): string | null {
if (!model) {
return null
}
if (selection && !selection.isEmpty()) {
const selectedQuery = normalizeSelectedTextForFileSearch(model.getValueInRange(selection))
if (selectedQuery) {
return selectedQuery
}
}
if (!position) {
return null
}
// Why: until Orca has semantic LSP references, the editor affordance should
// still work from a cursor by searching the visible symbol text in files.
return normalizeSelectedTextForFileSearch(model.getWordAtPosition(position)?.word)
}