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, coding_agent_turn::CodingAgentTurn,
execution_process::{ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus}, execution_process::{ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus},
merge::{Merge, MergeStatus, PrMerge, PullRequestInfo}, merge::{Merge, MergeStatus, PrMerge, PullRequestInfo},
project::SearchResult,
repo::{Repo, RepoError}, repo::{Repo, RepoError},
session::{CreateSession, Session}, session::{CreateSession, Session},
task::{Task, TaskRelationships, TaskStatus}, task::{Task, TaskRelationships, TaskStatus},
@@ -45,6 +46,7 @@ use git2::BranchType;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use services::services::{ use services::services::{
container::ContainerService, container::ContainerService,
file_search_cache::SearchQuery,
git::{ConflictOp, GitCliError, GitServiceError}, git::{ConflictOp, GitCliError, GitServiceError},
workspace_manager::WorkspaceManager, workspace_manager::WorkspaceManager,
}; };
@@ -1568,6 +1570,43 @@ pub async fn get_task_attempt_repos(
Ok(ResponseJson(ApiResponse::success(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( pub async fn get_first_user_message(
Extension(workspace): Extension<Workspace>, Extension(workspace): Extension<Workspace>,
State(deployment): State<DeploymentImpl>, State(deployment): State<DeploymentImpl>,
@@ -1724,6 +1763,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
.route("/change-target-branch", post(change_target_branch)) .route("/change-target-branch", post(change_target_branch))
.route("/rename-branch", post(rename_branch)) .route("/rename-branch", post(rename_branch))
.route("/repos", get(get_task_attempt_repos)) .route("/repos", get(get_task_attempt_repos))
.route("/search", get(search_workspace_files))
.route("/first-message", get(get_first_user_message)) .route("/first-message", get(get_first_user_message))
.route("/mark-seen", put(mark_seen)) .route("/mark-seen", put(mark_seen))
.layer(from_fn_with_state( .layer(from_fn_with_state(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -565,6 +565,18 @@ export const attemptsApi = {
return handleApiResponse<void>(response); 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 ( runAgentSetup: async (
attemptId: string, attemptId: string,
data: RunAgentSetupRequest 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'; import type { SearchResult, Tag } from 'shared/types';
interface FileSearchResult extends SearchResult { interface FileSearchResult extends SearchResult {
@@ -11,9 +11,14 @@ export interface SearchResultItem {
file?: FileSearchResult; file?: FileSearchResult;
} }
export interface SearchOptions {
workspaceId?: string;
projectId?: string;
}
export async function searchTagsAndFiles( export async function searchTagsAndFiles(
query: string, query: string,
projectId?: string options?: SearchOptions
): Promise<SearchResultItem[]> { ): Promise<SearchResultItem[]> {
const results: SearchResultItem[] = []; const results: SearchResultItem[] = [];
@@ -24,9 +29,16 @@ export async function searchTagsAndFiles(
); );
results.push(...filteredTags.map((tag) => ({ type: 'tag' as const, tag }))); results.push(...filteredTags.map((tag) => ({ type: 'tag' as const, tag })));
// Fetch files (if projectId is available and query has content) // Fetch files - prefer workspace-scoped if available
if (projectId && query.length > 0) { if (query.length > 0) {
const fileResults = await projectsApi.searchFiles(projectId, query); 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) => ({ const fileSearchResults: FileSearchResult[] = fileResults.map((item) => ({
...item, ...item,
name: item.path.split('/').pop() || item.path, name: item.path.split('/').pop() || item.path,
@@ -35,6 +47,7 @@ export async function searchTagsAndFiles(
...fileSearchResults.map((file) => ({ type: 'file' as const, file })) ...fileSearchResults.map((file) => ({ type: 'file' as const, file }))
); );
} }
}
return results; return results;
} }