Include workspace diff files in file typeahead results (Vibe Kanban) (#2083)

* The implementation is complete. Here's a summary of the changes made to `file-tag-typeahead-plugin.tsx`:

1. **Added import** for `useWorkspaceContext` from `@/contexts/WorkspaceContext`

2. **Created helper function** `getMatchingDiffFiles()` that:
   - Takes a query and the `diffPaths` Set
   - Filters paths that match the query (checking both filename and full path)
   - Returns properly typed `DiffFileResult` objects with all required fields (`is_file`, `match_type`, `score`)
   - Uses a high score value to ensure diff files rank above server results

3. **Modified `onQueryChange`** to:
   - Extract `diffPaths` from WorkspaceContext
   - Get matching local files from diffPaths first
   - Fetch server results via `searchTagsAndFiles()`
   - Deduplicate server file results by filtering out any paths already in local files
   - Merge results with order: tags → local diff files → server files

The changes ensure that:
- Files from the current workspace diffs appear in typeahead results
- Diff files are ranked higher than server-sourced files
- No duplicate entries appear when a file exists in both sources

* Done. The fix involved two changes:

1. **`WorkspaceContext.tsx`** (line 78-81): Exported the `WorkspaceContext` so it can be used directly with `useContext`

2. **`file-tag-typeahead-plugin.tsx`**:
   - Changed import from `useWorkspaceContext` to `WorkspaceContext`
   - Added `useContext` to React imports
   - Changed usage from `const { diffPaths } = useWorkspaceContext()` to:
     ```typescript
     const workspaceContext = useContext(WorkspaceContext);
     const diffPaths = workspaceContext?.diffPaths ?? new Set<string>();
     ```

Now when the WYSIWYG is used in the old UI (without a `WorkspaceProvider`), `workspaceContext` will be `null`, and `diffPaths` will default to an empty Set, meaning only server results will be shown. In the new UI with `WorkspaceProvider`, diff files will be included as expected.

* Done. The changes:

1. Added `MAX_FILE_RESULTS = 10` constant
2. Updated the result merging logic to:
   - Limit local diff files to max 10
   - Fill remaining slots with server files
   - This ensures diffPaths files always appear first (prioritized)

File results are now capped at 10 total, preventing the menu from scrolling off the page.

* Cleanup script changes for workspace bda801c1-f48c-4ae8-948b-f0db6269d807

* Lint passes now. The fix was to wrap the `diffPaths` fallback in `useMemo` to ensure a stable reference when the context is not available.
This commit is contained in:
Louis Knight-Webb
2026-01-15 23:11:02 +00:00
committed by GitHub
parent daa2690500
commit 010b03aafd
2 changed files with 77 additions and 5 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef, useContext, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { import {
@@ -13,6 +13,7 @@ import {
} from 'lexical'; } from 'lexical';
import { Tag as TagIcon, FileText } from 'lucide-react'; import { Tag as TagIcon, FileText } from 'lucide-react';
import { usePortalContainer } from '@/contexts/PortalContainerContext'; import { usePortalContainer } from '@/contexts/PortalContainerContext';
import { WorkspaceContext } from '@/contexts/WorkspaceContext';
import { import {
searchTagsAndFiles, searchTagsAndFiles,
type SearchResultItem, type SearchResultItem,
@@ -33,6 +34,43 @@ const VIEWPORT_MARGIN = 8;
const VERTICAL_GAP = 4; const VERTICAL_GAP = 4;
const VERTICAL_GAP_ABOVE = 24; const VERTICAL_GAP_ABOVE = 24;
const MIN_WIDTH = 320; const MIN_WIDTH = 320;
const MAX_FILE_RESULTS = 10;
interface DiffFileResult {
path: string;
name: string;
is_file: boolean;
match_type: 'FileName' | 'DirectoryName' | 'FullPath';
score: bigint;
}
function getMatchingDiffFiles(
query: string,
diffPaths: Set<string>
): DiffFileResult[] {
if (!query) return [];
const lowerQuery = query.toLowerCase();
return Array.from(diffPaths)
.filter((path) => {
const name = path.split('/').pop() || path;
return (
name.toLowerCase().includes(lowerQuery) ||
path.toLowerCase().includes(lowerQuery)
);
})
.map((path) => {
const name = path.split('/').pop() || path;
const nameMatches = name.toLowerCase().includes(lowerQuery);
return {
path,
name,
is_file: true,
match_type: nameMatches ? ('FileName' as const) : ('FullPath' as const),
// High score to rank diff files above server results
score: BigInt(Number.MAX_SAFE_INTEGER),
};
});
}
function getMenuPosition(anchorEl: HTMLElement) { function getMenuPosition(anchorEl: HTMLElement) {
const rect = anchorEl.getBoundingClientRect(); const rect = anchorEl.getBoundingClientRect();
@@ -73,6 +111,12 @@ export function FileTagTypeaheadPlugin({
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);
const portalContainer = usePortalContainer(); const portalContainer = usePortalContainer();
// Use context directly to gracefully handle missing WorkspaceProvider (old UI)
const workspaceContext = useContext(WorkspaceContext);
const diffPaths = useMemo(
() => workspaceContext?.diffPaths ?? new Set<string>(),
[workspaceContext?.diffPaths]
);
const onQueryChange = useCallback( const onQueryChange = useCallback(
(query: string | null) => { (query: string | null) => {
@@ -82,16 +126,41 @@ export function FileTagTypeaheadPlugin({
return; return;
} }
// Get local diff files first (files from current workspace changes)
const localFiles = getMatchingDiffFiles(query, diffPaths);
const localFilePaths = new Set(localFiles.map((f) => f.path));
// Here query is a string, including possible empty string '' // Here query is a string, including possible empty string ''
searchTagsAndFiles(query, { workspaceId, projectId }) searchTagsAndFiles(query, { workspaceId, projectId })
.then((results) => { .then((serverResults) => {
setOptions(results.map((r) => new FileTagOption(r))); // Separate tags and files from server results
const tagResults = serverResults.filter((r) => r.type === 'tag');
const serverFileResults = serverResults
.filter((r) => r.type === 'file')
.filter((r) => !localFilePaths.has(r.file!.path)); // Dedupe
// Limit total file results: prioritize local diff files
const limitedLocalFiles = localFiles.slice(0, MAX_FILE_RESULTS);
const remainingSlots = MAX_FILE_RESULTS - limitedLocalFiles.length;
const limitedServerFiles = serverFileResults.slice(0, remainingSlots);
// Build merged results: tags, then local files (ranked higher), then server files
const mergedResults: SearchResultItem[] = [
...tagResults,
...limitedLocalFiles.map((file) => ({
type: 'file' as const,
file,
})),
...limitedServerFiles,
];
setOptions(mergedResults.map((r) => new FileTagOption(r)));
}) })
.catch((err) => { .catch((err) => {
console.error('Failed to search tags/files', err); console.error('Failed to search tags/files', err);
}); });
}, },
[workspaceId, projectId] [workspaceId, projectId, diffPaths]
); );
return ( return (

View File

@@ -75,7 +75,10 @@ interface WorkspaceContextValue {
diffStats: DiffStats; diffStats: DiffStats;
} }
const WorkspaceContext = createContext<WorkspaceContextValue | null>(null); // Exported for optional usage outside WorkspaceProvider (e.g., old UI)
export const WorkspaceContext = createContext<WorkspaceContextValue | null>(
null
);
interface WorkspaceProviderProps { interface WorkspaceProviderProps {
children: ReactNode; children: ReactNode;