workspace file search (#2002)

This commit is contained in:
Gabriel Gordon-Hall
2026-01-13 18:01:33 +00:00
committed by GitHub
parent 82a4e0fccf
commit 7bc8ece068
8 changed files with 102 additions and 17 deletions

View File

@@ -26,6 +26,7 @@ use db::models::{
coding_agent_turn::CodingAgentTurn,
execution_process::{ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus},
merge::{Merge, MergeStatus, PrMerge, PullRequestInfo},
project::SearchResult,
repo::{Repo, RepoError},
session::{CreateSession, Session},
task::{Task, TaskRelationships, TaskStatus},
@@ -45,6 +46,7 @@ use git2::BranchType;
use serde::{Deserialize, Serialize};
use services::services::{
container::ContainerService,
file_search_cache::SearchQuery,
git::{ConflictOp, GitCliError, GitServiceError},
workspace_manager::WorkspaceManager,
};
@@ -1568,6 +1570,43 @@ pub async fn get_task_attempt_repos(
Ok(ResponseJson(ApiResponse::success(repos)))
}
pub async fn search_workspace_files(
Extension(workspace): Extension<Workspace>,
State(deployment): State<DeploymentImpl>,
Query(search_query): Query<SearchQuery>,
) -> Result<ResponseJson<ApiResponse<Vec<SearchResult>>>, StatusCode> {
if search_query.q.trim().is_empty() {
return Ok(ResponseJson(ApiResponse::error(
"Query parameter 'q' is required and cannot be empty",
)));
}
let repos =
match WorkspaceRepo::find_repos_for_workspace(&deployment.db().pool, workspace.id).await {
Ok(r) => r,
Err(e) => {
tracing::error!("Failed to get workspace repos: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
match deployment
.project()
.search_files(
deployment.file_search_cache().as_ref(),
&repos,
&search_query,
)
.await
{
Ok(results) => Ok(ResponseJson(ApiResponse::success(results))),
Err(e) => {
tracing::error!("Failed to search files: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn get_first_user_message(
Extension(workspace): Extension<Workspace>,
State(deployment): State<DeploymentImpl>,
@@ -1724,6 +1763,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
.route("/change-target-branch", post(change_target_branch))
.route("/rename-branch", post(rename_branch))
.route("/repos", get(get_task_attempt_repos))
.route("/search", get(search_workspace_files))
.route("/first-message", get(get_first_user_message))
.route("/mark-seen", put(mark_seen))
.layer(from_fn_with_state(

View File

@@ -553,6 +553,7 @@ export function SessionChatBoxContainer({
return (
<SessionChatBox
status={status}
workspaceId={workspaceId}
projectId={projectId}
editor={{
value: editorValue,

View File

@@ -32,6 +32,7 @@ interface ChatBoxBaseProps {
placeholder: string;
onCmdEnter: () => void;
disabled?: boolean;
workspaceId?: string;
projectId?: string;
autoFocus?: boolean;
@@ -81,6 +82,7 @@ export function ChatBoxBase({
placeholder,
onCmdEnter,
disabled,
workspaceId,
projectId,
autoFocus,
variant,
@@ -142,6 +144,7 @@ export function ChatBoxBase({
onCmdEnter={onCmdEnter}
disabled={disabled}
className="min-h-0 max-h-[50vh] overflow-y-auto"
workspaceId={workspaceId}
projectId={projectId}
autoFocus={autoFocus}
onPasteFiles={onPasteFiles}

View File

@@ -129,6 +129,7 @@ interface SessionChatBoxProps {
reviewComments?: ReviewCommentsProps;
toolbarActions?: ToolbarActionsProps;
error?: string | null;
workspaceId?: string;
projectId?: string;
agent?: BaseCodingAgent | null;
executor?: ExecutorProps;
@@ -153,6 +154,7 @@ export function SessionChatBox({
reviewComments,
toolbarActions,
error,
workspaceId,
projectId,
agent,
executor,
@@ -488,6 +490,7 @@ export function SessionChatBox({
placeholder={placeholder}
onCmdEnter={handleCmdEnter}
disabled={isDisabled}
workspaceId={workspaceId}
projectId={projectId}
autoFocus={true}
focusKey={focusKey}

View File

@@ -56,7 +56,10 @@ type WysiwygProps = {
disabled?: boolean;
onPasteFiles?: (files: File[]) => void;
className?: string;
projectId?: string; // for file search in typeahead
/** Workspace ID for workspace-scoped file search (preferred over projectId) */
workspaceId?: string;
/** Project ID for file search in typeahead (fallback if workspaceId not provided) */
projectId?: string;
onCmdEnter?: () => void;
onShiftCmdEnter?: () => void;
/** Task attempt ID for resolving .vibe-images paths (preferred over taskId) */
@@ -85,6 +88,7 @@ function WYSIWYGEditor({
disabled = false,
onPasteFiles,
className,
workspaceId,
projectId,
onCmdEnter,
onShiftCmdEnter,
@@ -251,7 +255,10 @@ function WYSIWYGEditor({
{autoFocus && <AutoFocusPlugin />}
<HistoryPlugin />
<MarkdownShortcutPlugin transformers={extendedTransformers} />
<FileTagTypeaheadPlugin projectId={projectId} />
<FileTagTypeaheadPlugin
workspaceId={workspaceId}
projectId={projectId}
/>
<KeyboardCommandsPlugin
onCmdEnter={onCmdEnter}
onShiftCmdEnter={onShiftCmdEnter}

View File

@@ -62,7 +62,13 @@ function getMenuPosition(anchorEl: HTMLElement) {
return { top, bottom, left };
}
export function FileTagTypeaheadPlugin({ projectId }: { projectId?: string }) {
export function FileTagTypeaheadPlugin({
workspaceId,
projectId,
}: {
workspaceId?: string;
projectId?: string;
}) {
const [editor] = useLexicalComposerContext();
const [options, setOptions] = useState<FileTagOption[]>([]);
const lastMousePositionRef = useRef<{ x: number; y: number } | null>(null);
@@ -77,7 +83,7 @@ export function FileTagTypeaheadPlugin({ projectId }: { projectId?: string }) {
}
// Here query is a string, including possible empty string ''
searchTagsAndFiles(query, projectId)
searchTagsAndFiles(query, { workspaceId, projectId })
.then((results) => {
setOptions(results.map((r) => new FileTagOption(r)));
})
@@ -85,7 +91,7 @@ export function FileTagTypeaheadPlugin({ projectId }: { projectId?: string }) {
console.error('Failed to search tags/files', err);
});
},
[projectId]
[workspaceId, projectId]
);
return (

View File

@@ -565,6 +565,18 @@ export const attemptsApi = {
return handleApiResponse<void>(response);
},
searchFiles: async (
workspaceId: string,
query: string,
mode?: string
): Promise<SearchResult[]> => {
const modeParam = mode ? `&mode=${encodeURIComponent(mode)}` : '';
const response = await makeRequest(
`/api/task-attempts/${workspaceId}/search?q=${encodeURIComponent(query)}${modeParam}`
);
return handleApiResponse<SearchResult[]>(response);
},
runAgentSetup: async (
attemptId: string,
data: RunAgentSetupRequest

View File

@@ -1,4 +1,4 @@
import { projectsApi, tagsApi } from '@/lib/api';
import { attemptsApi, projectsApi, tagsApi } from '@/lib/api';
import type { SearchResult, Tag } from 'shared/types';
interface FileSearchResult extends SearchResult {
@@ -11,9 +11,14 @@ export interface SearchResultItem {
file?: FileSearchResult;
}
export interface SearchOptions {
workspaceId?: string;
projectId?: string;
}
export async function searchTagsAndFiles(
query: string,
projectId?: string
options?: SearchOptions
): Promise<SearchResultItem[]> {
const results: SearchResultItem[] = [];
@@ -24,16 +29,24 @@ export async function searchTagsAndFiles(
);
results.push(...filteredTags.map((tag) => ({ type: 'tag' as const, tag })));
// Fetch files (if projectId is available and query has content)
if (projectId && query.length > 0) {
const fileResults = await projectsApi.searchFiles(projectId, query);
const fileSearchResults: FileSearchResult[] = fileResults.map((item) => ({
...item,
name: item.path.split('/').pop() || item.path,
}));
results.push(
...fileSearchResults.map((file) => ({ type: 'file' as const, file }))
);
// Fetch files - prefer workspace-scoped if available
if (query.length > 0) {
let fileResults: SearchResult[] = [];
if (options?.workspaceId) {
fileResults = await attemptsApi.searchFiles(options.workspaceId, query);
} else if (options?.projectId) {
fileResults = await projectsApi.searchFiles(options.projectId, query);
}
if (fileResults.length > 0) {
const fileSearchResults: FileSearchResult[] = fileResults.map((item) => ({
...item,
name: item.path.split('/').pop() || item.path,
}));
results.push(
...fileSearchResults.map((file) => ({ type: 'file' as const, file }))
);
}
}
return results;