From 171773acf14d7d54cbedf045a440b2cea217f8f4 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 2 Mar 2026 21:14:49 +0200 Subject: [PATCH] feat: add project file listing functionality and enhance mention support - Introduced a new IPC handler for listing project files, enabling file mentions independent of editor state. - Enhanced the MentionableTextarea component to support file suggestions based on project path, improving user experience when mentioning files. - Implemented a custom hook for loading and filtering project files as mention suggestions, optimizing performance with caching. - Updated relevant components to integrate project path handling, ensuring seamless file mention functionality across dialogs and message composers. --- src/main/ipc/editor.ts | 21 +++ src/main/services/editor/GitStatusService.ts | 27 ++- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 7 + src/renderer/api/httpClient.ts | 12 +- .../team/activity/ActivityTimeline.tsx | 2 +- .../team/dialogs/CreateTaskDialog.tsx | 5 + .../team/dialogs/CreateTeamDialog.tsx | 1 + .../team/dialogs/LaunchTeamDialog.tsx | 1 + .../components/team/dialogs/ReviewDialog.tsx | 4 +- .../team/dialogs/SendMessageDialog.tsx | 4 + .../team/dialogs/TaskCommentInput.tsx | 2 + .../team/dialogs/TaskCommentsSection.tsx | 130 ++++--------- .../team/editor/QuickOpenDialog.tsx | 89 +++------ .../team/messages/MessageComposer.tsx | 4 + .../team/review/ChangeReviewDialog.tsx | 13 +- .../components/ui/MentionSuggestionList.tsx | 114 ++++++++---- .../components/ui/MentionableTextarea.tsx | 172 ++++++++++++++++-- src/renderer/hooks/useFileSuggestions.ts | 128 +++++++++++++ src/renderer/hooks/useMentionDetection.ts | 15 +- src/renderer/utils/quickOpenCache.ts | 9 + src/shared/types/api.ts | 5 +- src/shared/types/editor.ts | 5 + test/main/ipc/editor.test.ts | 8 +- .../renderer/hooks/useFileSuggestions.test.ts | 97 ++++++++++ 25 files changed, 654 insertions(+), 224 deletions(-) create mode 100644 src/renderer/hooks/useFileSuggestions.ts create mode 100644 test/renderer/hooks/useFileSuggestions.test.ts diff --git a/src/main/ipc/editor.ts b/src/main/ipc/editor.ts index 6447d4bb..9ae47eab 100644 --- a/src/main/ipc/editor.ts +++ b/src/main/ipc/editor.ts @@ -26,6 +26,7 @@ import { EDITOR_SET_WATCHED_FILES, EDITOR_WATCH_DIR, EDITOR_WRITE_FILE, + PROJECT_LIST_FILES, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; @@ -301,6 +302,24 @@ async function handleEditorListFiles(): Promise> { }); } +/** + * List project files by explicit path (for @file mentions). + * Independent of editor state — works without editor:open. + */ +async function handleProjectListFiles( + _event: IpcMainInvokeEvent, + projectPath: string +): Promise> { + return wrapHandler('project:listFiles', async () => { + if (typeof projectPath !== 'string' || projectPath.length === 0) { + throw new Error('projectPath is required'); + } + const normalized = path.resolve(projectPath); + await fs.access(normalized); + return fileSearchService.listFiles(normalized); + }); +} + /** * Get git status for current project (cached 5s). */ @@ -415,6 +434,7 @@ export function registerEditorHandlers(ipcMain: IpcMain): void { ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir); ipcMain.handle(EDITOR_SET_WATCHED_FILES, handleEditorSetWatchedFiles); ipcMain.handle(EDITOR_SET_WATCHED_DIRS, handleEditorSetWatchedDirs); + ipcMain.handle(PROJECT_LIST_FILES, handleProjectListFiles); } export function removeEditorHandlers(ipcMain: IpcMain): void { @@ -435,6 +455,7 @@ export function removeEditorHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(EDITOR_WATCH_DIR); ipcMain.removeHandler(EDITOR_SET_WATCHED_FILES); ipcMain.removeHandler(EDITOR_SET_WATCHED_DIRS); + ipcMain.removeHandler(PROJECT_LIST_FILES); } /** diff --git a/src/main/services/editor/GitStatusService.ts b/src/main/services/editor/GitStatusService.ts index 3b65b017..1758b278 100644 --- a/src/main/services/editor/GitStatusService.ts +++ b/src/main/services/editor/GitStatusService.ts @@ -30,6 +30,8 @@ const GIT_STATUS_ARGS: string[] = ['--untracked-files=no']; export class GitStatusService { private git: SimpleGit | null = null; private projectRoot: string | null = null; + /** Set to true when we confirm the project is not a git repo — skip all future git calls. */ + private notAGitRepo = false; // Cache private cachedResult: GitStatusResult | null = null; @@ -42,6 +44,7 @@ export class GitStatusService { */ init(projectRoot: string): void { this.projectRoot = projectRoot; + this.notAGitRepo = false; this.git = simpleGit({ baseDir: projectRoot, timeout: { block: GIT_TIMEOUT_MS }, @@ -56,6 +59,7 @@ export class GitStatusService { this.clearDebounceTimer(); this.git = null; this.projectRoot = null; + this.notAGitRepo = false; this.cachedResult = null; this.cacheTimestamp = 0; } @@ -89,8 +93,10 @@ export class GitStatusService { * Returns cached result if within TTL. */ async getStatus(): Promise { - if (!this.git || !this.projectRoot) { - return { files: [], isGitRepo: false, branch: null }; + const notGitResult: GitStatusResult = { files: [], isGitRepo: false, branch: null }; + + if (!this.git || !this.projectRoot || this.notAGitRepo) { + return notGitResult; } // Flush pending debounced invalidation — when data is actually requested, @@ -121,11 +127,20 @@ export class GitStatusService { this.setCacheResult(result); return result; } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + if (message.includes('not a git repository')) { + // Expected condition — project is not a git repo. Stop all future git calls + // until init() is called again with a different project. + log.info('Project is not a git repository, disabling git status'); + this.notAGitRepo = true; + return notGitResult; + } + log.error('Failed to get git status:', error); - // Graceful degradation: cache negative result to avoid repeated git calls - const result: GitStatusResult = { files: [], isGitRepo: false, branch: null }; - this.setCacheResult(result); - return result; + // Transient error — cache negative result to avoid hammering git, but allow retry after TTL + this.setCacheResult(notGitResult); + return notGitResult; } } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index da2d26cf..a24a6284 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -469,3 +469,6 @@ export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview'; /** File change event from watcher (main -> renderer) */ export const EDITOR_CHANGE = 'editor:change'; + +/** List project files by path (for @file mentions, independent of editor state) */ +export const PROJECT_LIST_FILES = 'project:listFiles'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 42863099..91decfa3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -31,6 +31,7 @@ import { HTTP_SERVER_GET_STATUS, HTTP_SERVER_START, HTTP_SERVER_STOP, + PROJECT_LIST_FILES, REVIEW_APPLY_DECISIONS, REVIEW_CHECK_CONFLICT, REVIEW_CLEAR_DECISIONS, @@ -1013,6 +1014,12 @@ const electronAPI: ElectronAPI = { }, }, + // ===== Project API (editor-independent) ===== + project: { + listFiles: (projectPath: string) => + invokeIpcWithResult(PROJECT_LIST_FILES, projectPath), + }, + // ===== Editor API ===== editor: { open: (projectPath: string) => invokeIpcWithResult(EDITOR_OPEN, projectPath), diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index e68c646a..90628029 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -63,7 +63,7 @@ import type { WslClaudeRootCandidate, } from '@shared/types'; import type { AgentConfig } from '@shared/types/api'; -import type { EditorAPI } from '@shared/types/editor'; +import type { EditorAPI, ProjectAPI } from '@shared/types/editor'; import type { TerminalAPI } from '@shared/types/terminal'; export class HttpAPIClient implements ElectronAPI { @@ -938,6 +938,16 @@ export class HttpAPIClient implements ElectronAPI { onExit: (): (() => void) => () => {}, }; + // --------------------------------------------------------------------------- + // Project (not available in browser mode) + // --------------------------------------------------------------------------- + + project: ProjectAPI = { + listFiles: async () => { + throw new Error('Project API not available in browser mode'); + }, + }; + // --------------------------------------------------------------------------- // Editor (not available in browser mode) // --------------------------------------------------------------------------- diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 08b10417..5ae3bf92 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -210,7 +210,7 @@ export const ActivityTimeline = ({ } return newKeys; }, [visibleMessages, visibleCount]); - /* eslint-enable react-hooks/refs */ + /* eslint-enable react-hooks/refs -- end animation tracking block */ const handleShowMore = (): void => { setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE); diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index c15114bd..8e5bbc09 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -24,6 +24,7 @@ import { import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useStore } from '@renderer/store'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; @@ -74,6 +75,7 @@ export const CreateTaskDialog = ({ submitting = false, }: CreateTaskDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const [subject, setSubject] = useState(defaultSubject); const descriptionDraft = useDraftPersistence({ key: `createTask:${teamName}:description`, @@ -265,6 +267,8 @@ export const CreateTaskDialog = ({ suggestions={mentionSuggestions} chips={descChipDraft.chips} onChipRemove={handleDescChipRemove} + projectPath={projectPath} + onFileChipInsert={(chip) => descChipDraft.setChips([...descChipDraft.chips, chip])} minRows={3} maxRows={12} footerRight={ @@ -285,6 +289,7 @@ export const CreateTaskDialog = ({ value={promptDraft.value} onValueChange={promptDraft.setValue} suggestions={mentionSuggestions} + projectPath={projectPath} minRows={3} maxRows={12} footerRight={ diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index b0dc3852..a5023b59 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -925,6 +925,7 @@ export const CreateTeamDialog = ({ value={prompt} onValueChange={promptDraft.setValue} suggestions={mentionSuggestions} + projectPath={effectiveCwd || null} placeholder="Instructions for the team lead during provisioning..." footerRight={ promptDraft.isSaved ? ( diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 83c8b338..03793207 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -364,6 +364,7 @@ export const LaunchTeamDialog = ({ value={promptDraft.value} onValueChange={promptDraft.setValue} suggestions={mentionSuggestions} + projectPath={effectiveCwd || null} placeholder="Instructions for team lead... Use @ to mention team members." footerRight={ promptDraft.isSaved ? ( diff --git a/src/renderer/components/team/dialogs/ReviewDialog.tsx b/src/renderer/components/team/dialogs/ReviewDialog.tsx index 45725ad0..83a5a50f 100644 --- a/src/renderer/components/team/dialogs/ReviewDialog.tsx +++ b/src/renderer/components/team/dialogs/ReviewDialog.tsx @@ -12,6 +12,7 @@ import { import { Label } from '@renderer/components/ui/label'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -35,6 +36,7 @@ export const ReviewDialog = ({ onCancel, onSubmit, }: ReviewDialogProps): React.JSX.Element => { + const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const draft = useDraftPersistence({ key: `requestChanges:${teamName}:${taskId ?? ''}`, enabled: Boolean(teamName && taskId), @@ -88,7 +90,7 @@ export const ReviewDialog = ({ onValueChange={draft.setValue} placeholder="Describe what needs to change..." suggestions={mentionSuggestions} - hintText="Use @ to mention team members" + projectPath={projectPath} footerRight={ draft.isSaved ? ( Draft saved diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 4897eec1..2e40d154 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -24,6 +24,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useStore } from '@renderer/store'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; @@ -74,6 +75,7 @@ export const SendMessageDialog = ({ onClose, }: SendMessageDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const [quote, setQuote] = useState(undefined); const [quoteExpanded, setQuoteExpanded] = useState(false); const [member, setMember] = useState(''); @@ -268,6 +270,8 @@ export const SendMessageDialog = ({ suggestions={mentionSuggestions} chips={chipDraft.chips} onChipRemove={handleChipRemove} + projectPath={projectPath} + onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])} minRows={4} maxRows={12} footerRight={ diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index d44615eb..602253a4 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -33,6 +33,7 @@ export const TaskCommentInput = ({ }: TaskCommentInputProps): React.JSX.Element => { const addTaskComment = useStore((s) => s.addTaskComment); const addingComment = useStore((s) => s.addingComment); + const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); @@ -109,6 +110,7 @@ export const TaskCommentInput = ({ value={draft.value} onValueChange={draft.setValue} suggestions={mentionSuggestions} + projectPath={projectPath} minRows={2} maxRows={8} maxLength={MAX_COMMENT_LENGTH} diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 055df8ee..b5be0f98 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -1,10 +1,10 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; -import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; import { useStore } from '@renderer/store'; @@ -14,16 +14,7 @@ import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { formatDistanceToNow } from 'date-fns'; -import { - CheckCircle2, - ChevronDown, - ChevronUp, - MessageCircleWarning, - MessageSquare, - Reply, - Send, - X, -} from 'lucide-react'; +import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { ResolvedTeamMember, TaskComment } from '@shared/types'; @@ -63,23 +54,15 @@ export const TaskCommentsSection = ({ const [expandedCommentIds, setExpandedCommentIds] = useState>(new Set()); const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS); - // Reset state when task changes (React-approved setState-during-render pattern) - const resetKey = teamIdKey(teamName, taskId); - const [prevResetKey, setPrevResetKey] = useState(resetKey); - - // --- New-comment animation tracking (refs only, useMemo is after visibleComments) --- - const knownCommentIdsRef = useRef>(new Set()); - const isCommentsInitializedRef = useRef(false); - const prevVisibleCountRef = useRef(visibleCount); - - /* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */ - if (prevResetKey !== resetKey) { - setPrevResetKey(resetKey); + // Reset local state when team/task changes (React-recommended pattern for + // adjusting state based on props without using effects or refs during render) + const currentKey = teamIdKey(teamName, taskId); + const [prevKey, setPrevKey] = useState(currentKey); + if (prevKey !== currentKey) { + setPrevKey(currentKey); setVisibleCount(INITIAL_VISIBLE_COMMENTS); setExpandedCommentIds(new Set()); setReplyTo(null); - knownCommentIdsRef.current.clear(); - isCommentsInitializedRef.current = false; } const toggleCommentExpanded = useCallback((commentId: string) => { @@ -112,46 +95,6 @@ export const TaskCommentsSection = ({ [sortedComments, visibleCount] ); - const newCommentIds = useMemo(() => { - if (visibleComments.length === 0) { - knownCommentIdsRef.current.clear(); - isCommentsInitializedRef.current = false; - return new Set(); - } - - // First render: seed all known IDs, no animations - if (!isCommentsInitializedRef.current) { - isCommentsInitializedRef.current = true; - for (const c of visibleComments) { - knownCommentIdsRef.current.add(c.id); - } - prevVisibleCountRef.current = visibleCount; - return new Set(); - } - - // Pagination expansion ("Show more"): add IDs silently, no animations - const isPaginationExpansion = visibleCount > prevVisibleCountRef.current; - prevVisibleCountRef.current = visibleCount; - - if (isPaginationExpansion) { - for (const c of visibleComments) { - knownCommentIdsRef.current.add(c.id); - } - return new Set(); - } - - // Normal update: unknown IDs are new comments - const newIds = new Set(); - for (const c of visibleComments) { - if (!knownCommentIdsRef.current.has(c.id)) { - newIds.add(c.id); - knownCommentIdsRef.current.add(c.id); - } - } - return newIds; - }, [visibleComments, visibleCount]); - /* eslint-enable react-hooks/refs */ - const mentionSuggestions = useMemo( () => members.map((m) => ({ @@ -203,34 +146,19 @@ export const TaskCommentsSection = ({ ) : null} {visibleComments.map((comment) => ( -
+
- {comment.type === 'review_approved' && ( - - )} - {comment.type === 'review_request' && ( - - )} - - {comment.type === 'review_approved' && ( - - Approved - - )} - {comment.type === 'review_request' && ( - - Changes requested - - )} + { + const rc = colorMap.get(comment.author); + return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)'; + })(), + }} + > + {comment.author} + {(() => { const date = new Date(comment.createdAt); @@ -362,9 +290,19 @@ export const TaskCommentsSection = ({ {replyTo ? (
-
- Replying to - +
+ Replying to{' '} + { + const rc = colorMap.get(replyTo.author); + return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)'; + })(), + }} + > + @{replyTo.author} +
{replyTo.text} diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx index fec5986d..eaca7c3e 100644 --- a/src/renderer/components/team/editor/QuickOpenDialog.tsx +++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx @@ -8,16 +8,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; -import { getQuickOpenCache, setQuickOpenCache } from '@renderer/utils/quickOpenCache'; import { Command } from 'cmdk'; import { Loader2 } from 'lucide-react'; -import { FileIcon } from './FileIcon'; +import { getFileIcon } from './fileIcons'; import type { QuickOpenFile } from '@shared/types/editor'; -const MAX_RENDERED = 100; - // ============================================================================= // Types // ============================================================================= @@ -40,46 +37,29 @@ export const QuickOpenDialog = ({ const [allFiles, setAllFiles] = useState([]); const [loading, setLoading] = useState(true); - // Reset state when projectPath changes (React-approved setState-during-render pattern) + // Reset loading state when projectPath changes (React-recommended + // "adjusting state when props change" pattern without effects or refs) const [prevProjectPath, setPrevProjectPath] = useState(projectPath); if (prevProjectPath !== projectPath) { setPrevProjectPath(projectPath); setLoading(true); - setAllFiles([]); } - // Load all project files via backend API (with module-level cache) + // Load all project files on mount via backend API useEffect(() => { let cancelled = false; - // Use cache if fresh and for the same project - const cached = projectPath ? getQuickOpenCache(projectPath) : null; - if (cached) { - // Defer setState to avoid cascading render within the same effect cycle - queueMicrotask(() => { - if (cancelled) return; - setAllFiles(cached.files); - setLoading(false); - }); - return () => { - cancelled = true; - }; - } - - const fetchFiles = async (): Promise => { - try { - const files = await window.electronAPI.editor.listFiles(); + window.electronAPI.editor + .listFiles() + .then((files) => { if (!cancelled) { - if (projectPath) setQuickOpenCache(projectPath, files); setAllFiles(files); setLoading(false); } - } catch { + }) + .catch(() => { if (!cancelled) setLoading(false); - } - }; - - void fetchFiles(); + }); return () => { cancelled = true; @@ -115,24 +95,15 @@ export const QuickOpenDialog = ({ [allFiles, onSelectFile, onClose] ); - const [search, setSearch] = useState(''); - - const filteredFiles = useMemo(() => { - if (!search.trim()) return allFiles.slice(0, MAX_RENDERED); - const q = search.toLowerCase(); - const matches: QuickOpenFile[] = []; - for (const file of allFiles) { - if (file.relativePath.toLowerCase().includes(q)) { - matches.push(file); - if (matches.length >= MAX_RENDERED) break; - } - } - return matches; - }, [allFiles, search]); - - const hasMore = !search.trim() - ? allFiles.length > MAX_RENDERED - : filteredFiles.length >= MAX_RENDERED; + // Memoize file icon lookups + const fileItems = useMemo( + () => + allFiles.map((file) => ({ + ...file, + iconInfo: getFileIcon(file.name), + })), + [allFiles] + ); return (
@@ -154,10 +125,8 @@ export const QuickOpenDialog = ({ aria-label="Quick Open" className="relative z-10 w-[520px] overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl" > - + Loading files...
)} - {!loading && filteredFiles.length === 0 && ( -
No files found
+ {!loading && ( + + No files found + )} - {filteredFiles.map((file) => { + {fileItems.map((file) => { + const Icon = file.iconInfo.icon; return ( handleSelect(file.relativePath)} className="flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-sm text-text-secondary aria-selected:bg-surface-raised aria-selected:text-text" > - + {file.name} {file.relativePath} @@ -188,13 +160,6 @@ export const QuickOpenDialog = ({ ); })} - {hasMore && ( -
- {search - ? 'Refine search to see more...' - : `Type to search ${allFiles.length} files...`} -
- )}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 9a3134b9..774f358a 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -10,6 +10,7 @@ import { useAttachments } from '@renderer/hooks/useAttachments'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { cn } from '@renderer/lib/utils'; +import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; @@ -53,6 +54,7 @@ export const MessageComposer = ({ const dragCounterRef = useRef(0); const fileInputRef = useRef(null); + const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const draft = useDraftPersistence({ key: `compose:${teamName}` }); const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`); const { @@ -324,6 +326,8 @@ export const MessageComposer = ({ suggestions={mentionSuggestions} chips={chipDraft.chips} onChipRemove={handleChipRemove} + projectPath={projectPath} + onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])} minRows={2} maxRows={6} maxLength={MAX_MESSAGE_LENGTH} diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 89c3f149..7757a8d7 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -449,11 +449,18 @@ export const ChangeReviewDialog = ({ }); }, [activeChangeSet, initialFilePath, scrollToFile]); - // Clear selection state on close + cleanup timer. - // setState here is intentional: reset transient UI state when dialog closes. - useEffect(() => { + // Clear selection state on close (React-approved setState-during-render pattern) + const [prevOpen, setPrevOpen] = useState(open); + if (prevOpen !== open) { + setPrevOpen(open); if (!open) { setSelectionInfo(null); + } + } + + // Cleanup refs/timers on close + useEffect(() => { + if (!open) { activeSelectionFileRef.current = null; if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current); } diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx index df200374..84dbb352 100644 --- a/src/renderer/components/ui/MentionSuggestionList.tsx +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { FileText } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -9,6 +10,8 @@ interface MentionSuggestionListProps { selectedIndex: number; onSelect: (s: MentionSuggestion) => void; query: string; + /** When true, adjusts empty state text to mention files */ + hasFileSearch?: boolean; } const HighlightedName = ({ name, query }: { name: string; query: string }): React.JSX.Element => { @@ -33,67 +36,108 @@ const HighlightedName = ({ name, query }: { name: string; query: string }): Reac ); }; +/** Section header for grouped suggestion lists */ +const SectionHeader = ({ label }: { label: string }): React.JSX.Element => ( +
  • + {label} +
  • +); + export const MentionSuggestionList = ({ suggestions, selectedIndex, onSelect, query, + hasFileSearch, }: MentionSuggestionListProps): React.JSX.Element => { const listRef = useRef(null); useEffect(() => { const list = listRef.current; if (!list) return; - const selected = list.children[selectedIndex] as HTMLElement | undefined; + // Query by role=option to skip section headers + const options = list.querySelectorAll('[role="option"]'); + const selected = options[selectedIndex] as HTMLElement | undefined; selected?.scrollIntoView({ block: 'nearest' }); }, [selectedIndex]); if (suggestions.length === 0) { return (
    - No matching members + {hasFileSearch ? 'No matching members or files' : 'No matching members'}
    ); } + // Determine if we need grouped sections + const hasMemberItems = suggestions.some((s) => s.type !== 'file'); + const hasFileItems = suggestions.some((s) => s.type === 'file'); + const showSections = hasMemberItems && hasFileItems; + + // Build items with section headers inserted + const items: React.JSX.Element[] = []; + let currentSection: 'member' | 'file' | null = null; + let optionIndex = 0; + + for (const s of suggestions) { + const isFile = s.type === 'file'; + const section = isFile ? 'file' : 'member'; + + // Insert section header on transition + if (showSections && section !== currentSection) { + items.push(); + currentSection = section; + } + + const isSelected = optionIndex === selectedIndex; + const colorSet = !isFile && s.color ? getTeamColorSet(s.color) : null; + const idx = optionIndex; + optionIndex++; + + items.push( +
  • { + e.preventDefault(); + onSelect(s); + }} + > + {isFile ? ( + + ) : ( + + )} + + + + {s.subtitle ? ( + {s.subtitle} + ) : null} +
  • + ); + } + return (
      - {suggestions.map((s, i) => { - const colorSet = s.color ? getTeamColorSet(s.color) : null; - const isSelected = i === selectedIndex; - - return ( -
    • { - e.preventDefault(); - onSelect(s); - }} - > - - - - - {s.subtitle ? ( - {s.subtitle} - ) : null} -
    • - ); - })} + {items}
    ); }; diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index d6b59039..0b54f7f8 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useFileSuggestions } from '@renderer/hooks/useFileSuggestions'; import { useMentionDetection } from '@renderer/hooks/useMentionDetection'; import { cn } from '@renderer/lib/utils'; import { chipToken } from '@renderer/types/inlineChip'; import { + createChipFromSelection, findChipBoundary, reconcileChips, removeChipTokenFromText, @@ -198,6 +200,10 @@ interface MentionableTextareaProps extends Omit< chips?: InlineChip[]; /** Called when a chip is removed (by X button, backspace, or reconciliation) */ onChipRemove?: (chipId: string) => void; + /** Project path for @file search. When provided, enables file suggestions alongside members. */ + projectPath?: string | null; + /** Called when a file chip is created via @ selection. Parent must add chip to state. */ + onFileChipInsert?: (chip: InlineChip) => void; } export const MentionableTextarea = React.forwardRef( @@ -206,12 +212,14 @@ export const MentionableTextarea = React.forwardRef(null); const [scrollTop, setScrollTop] = React.useState(0); + // --- File search activation --- + const enableFiles = !!projectPath; + const setRefs = React.useCallback( (node: HTMLTextAreaElement | null) => { internalRef.current = node; @@ -238,10 +249,12 @@ export const MentionableTextarea = React.forwardRef { + if (!enableFiles) return memberSuggestions; + if (fileSuggestions.length === 0) return memberSuggestions; + return [...memberSuggestions, ...fileSuggestions]; + }, [enableFiles, memberSuggestions, fileSuggestions]); + + // When files are enabled, manage our own selectedIndex for the merged list + const [mergedIndex, setMergedIndex] = React.useState(0); + + // Reset merged index when suggestions change or query changes + React.useEffect(() => { + setMergedIndex(0); + }, [query, allSuggestions.length]); + + // Effective index: use merged when files enabled, hook's index otherwise + const effectiveIndex = enableFiles ? mergedIndex : selectedIndex; + const effectiveSuggestions = enableFiles ? allSuggestions : memberSuggestions; + + // --- File selection handler --- + const handleFileSelect = React.useCallback( + (s: MentionSuggestion) => { + const textarea = internalRef.current; + if (!textarea || triggerIndex < 0 || !s.filePath) return; + + const replaceStart = triggerIndex; + const replaceEnd = triggerIndex + 1 + query.length; + const before = value.slice(0, replaceStart); + const after = value.slice(replaceEnd); + + if (onFileChipInsert && onChipRemove) { + // Chip mode: create InlineChip and insert chip token + const chip = createChipFromSelection( + { + type: 'sendMessage', + filePath: s.filePath, + fromLine: null, + toLine: null, + selectedText: '', + formattedContext: '', + displayPath: s.relativePath, + }, + chips + ); + + if (chip) { + const token = chipToken(chip); + const newValue = before + token + after; + onValueChange(newValue); + onFileChipInsert(chip); + dismiss(); + + requestAnimationFrame(() => { + const cursor = before.length + token.length; + textarea.setSelectionRange(cursor, cursor); + }); + } else { + // Duplicate chip — just dismiss + dismiss(); + } + } else { + // Text mode: insert backtick-wrapped relative path + const displayPath = s.relativePath ?? s.name; + const insertion = `\`${displayPath}\` `; + const newValue = before + insertion + after; + onValueChange(newValue); + dismiss(); + + requestAnimationFrame(() => { + const cursor = before.length + insertion.length; + textarea.setSelectionRange(cursor, cursor); + }); + } + }, + [triggerIndex, query, value, chips, onValueChange, onFileChipInsert, onChipRemove, dismiss] + ); + + // --- Merged selection handler --- + const handleMergedSelect = React.useCallback( + (s: MentionSuggestion) => { + if (s.type === 'file') { + handleFileSelect(s); + } else { + selectSuggestion(s); + } + }, + [handleFileSelect, selectSuggestion] + ); + // Sync backdrop font with textarea computed font to prevent caret drift. React.useLayoutEffect(() => { const textarea = internalRef.current; @@ -357,15 +467,48 @@ export const MentionableTextarea = React.forwardRef) => { + if (!isOpen || allSuggestions.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setMergedIndex((prev) => (prev + 1) % allSuggestions.length); + break; + case 'ArrowUp': + e.preventDefault(); + setMergedIndex((prev) => (prev - 1 + allSuggestions.length) % allSuggestions.length); + break; + case 'Enter': + e.preventDefault(); + if (allSuggestions[mergedIndex]) { + handleMergedSelect(allSuggestions[mergedIndex]); + } + break; + case 'Escape': + e.preventDefault(); + dismiss(); + break; + } + }, + [isOpen, allSuggestions, mergedIndex, handleMergedSelect, dismiss] + ); + + // Composed key handler: chip logic → (file-aware OR original) mention logic const composedHandleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { handleChipKeyDown(e); if (!e.defaultPrevented) { - mentionHandleKeyDown(e); + if (enableFiles) { + fileMentionHandleKeyDown(e); + } else { + mentionHandleKeyDown(e); + } } }, - [handleChipKeyDown, mentionHandleKeyDown] + [handleChipKeyDown, enableFiles, fileMentionHandleKeyDown, mentionHandleKeyDown] ); // --- Chip reconciliation on text change --- @@ -442,7 +585,13 @@ export const MentionableTextarea = React.forwardRef 0) || footerRight; + // --- Hint text --- + const defaultHintText = enableFiles + ? 'Use @ to mention team members or search files' + : 'Use @ to mention team members'; + const resolvedHintText = hintText ?? defaultHintText; + const showHintRow = showHint && (suggestions.length > 0 || enableFiles); + const showFooter = showHintRow || footerRight; return (
    @@ -523,8 +672,8 @@ export const MentionableTextarea = React.forwardRef - {showHint && suggestions.length > 0 ? ( - {hintText} + {showHintRow ? ( + {resolvedHintText} ) : ( )} @@ -534,10 +683,11 @@ export const MentionableTextarea = React.forwardRef
    ) : null} diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts new file mode 100644 index 00000000..02ac5703 --- /dev/null +++ b/src/renderer/hooks/useFileSuggestions.ts @@ -0,0 +1,128 @@ +/** + * Hook for loading and filtering project files as @-mention suggestions. + * + * Uses the Quick Open file list API with a 10s TTL cache. + * Returns up to 8 matching files filtered by name or relative path. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + getQuickOpenCache, + onQuickOpenCacheInvalidated, + setQuickOpenCache, +} from '@renderer/utils/quickOpenCache'; + +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { QuickOpenFile } from '@shared/types/editor'; + +const MAX_FILE_SUGGESTIONS = 8; + +/** + * Filters files by query (name or relative path) and converts to MentionSuggestion[]. + * Exported for testing. + */ +export function filterFileSuggestions(files: QuickOpenFile[], query: string): MentionSuggestion[] { + if (!query || files.length === 0) return []; + + const lower = query.toLowerCase(); + const results: MentionSuggestion[] = []; + + for (const f of files) { + if (results.length >= MAX_FILE_SUGGESTIONS) break; + + if (f.name.toLowerCase().includes(lower) || f.relativePath.toLowerCase().includes(lower)) { + results.push({ + id: `file:${f.path}`, + name: f.name, + subtitle: f.relativePath, + type: 'file', + filePath: f.path, + relativePath: f.relativePath, + }); + } + } + + return results; +} + +/** + * Loads project files and returns filtered MentionSuggestion[] with type: 'file'. + * + * @param projectPath - Project root path (null disables) + * @param query - Current @-mention query string + * @param enabled - Whether file suggestions are active (isOpen && enableFiles) + */ +export function useFileSuggestions( + projectPath: string | null, + query: string, + enabled: boolean +): MentionSuggestion[] { + const [allFiles, setAllFiles] = useState([]); + // Bumped on cache invalidation (file create/delete) to trigger refetch + const [fetchTrigger, setFetchTrigger] = useState(0); + + // Seed from cache immediately when projectPath changes (setState-during-render pattern) + const [prevPath, setPrevPath] = useState(projectPath); + if (prevPath !== projectPath) { + setPrevPath(projectPath); + const cached = projectPath ? getQuickOpenCache(projectPath) : null; + if (cached) { + setAllFiles(cached.files); + } else { + setAllFiles([]); + } + } + + // React to cache invalidation from EditorFileWatcher (create/delete events) + useEffect(() => { + return onQuickOpenCacheInvalidated(() => setFetchTrigger((n) => n + 1)); + }, []); + + // Lazy refetch: when dropdown opens and cache is stale, trigger a reload + const [prevEnabled, setPrevEnabled] = useState(enabled); + if (enabled && !prevEnabled && projectPath && !getQuickOpenCache(projectPath)) { + setFetchTrigger((n) => n + 1); + } + if (prevEnabled !== enabled) { + setPrevEnabled(enabled); + } + + // Load files from API when cache is empty. + // Uses project:listFiles (not editor:listFiles) — works without editor being open. + const fetchFiles = useCallback( + (projectRoot: string) => { + let cancelled = false; + window.electronAPI.project + .listFiles(projectRoot) + .then((files) => { + if (cancelled) return; + setQuickOpenCache(projectRoot, files); + setAllFiles(files); + }) + .catch(() => { + // Project path may be invalid — will retry on next trigger + }); + return () => { + cancelled = true; + }; + }, + [] // listFiles API is stable + ); + + useEffect(() => { + if (!projectPath) return; + + // Cache already seeded during render — only fetch if missing + const cached = getQuickOpenCache(projectPath); + if (cached) return; + + return fetchFiles(projectPath); + }, [projectPath, fetchTrigger, fetchFiles]); + + // Filter by query and convert to MentionSuggestion[] + return useMemo( + () => (enabled ? filterFileSuggestions(allFiles, query) : []), + [enabled, query, allFiles] + ); +} diff --git a/src/renderer/hooks/useMentionDetection.ts b/src/renderer/hooks/useMentionDetection.ts index 0230dab6..fca66b0f 100644 --- a/src/renderer/hooks/useMentionDetection.ts +++ b/src/renderer/hooks/useMentionDetection.ts @@ -7,6 +7,8 @@ interface UseMentionDetectionOptions { value: string; onValueChange: (v: string) => void; textareaRef: React.RefObject; + /** When true, detect @-trigger even if suggestions list is empty (e.g. for file-only search) */ + enableTriggerAlways?: boolean; } export interface DropdownPosition { @@ -25,6 +27,8 @@ interface UseMentionDetectionResult { handleKeyDown: (e: React.KeyboardEvent) => void; handleChange: (e: React.ChangeEvent) => void; handleSelect: (e: React.SyntheticEvent) => void; + /** Current @-trigger character position in text (-1 if no active trigger) */ + triggerIndex: number; } interface MentionTrigger { @@ -154,6 +158,7 @@ export function useMentionDetection({ value, onValueChange, textareaRef, + enableTriggerAlways, }: UseMentionDetectionOptions): UseMentionDetectionResult { const [isOpen, setIsOpen] = useState(false); const [query, setQuery] = useState(''); @@ -215,7 +220,7 @@ export function useMentionDetection({ const detectTrigger = useCallback( (cursorPos: number) => { const trigger = findMentionTrigger(value, cursorPos); - if (trigger && suggestions.length > 0) { + if (trigger && (suggestions.length > 0 || enableTriggerAlways)) { triggerIndexRef.current = trigger.triggerIndex; setQuery(trigger.query); setIsOpen(true); @@ -225,7 +230,7 @@ export function useMentionDetection({ dismiss(); } }, - [value, suggestions.length, dismiss, computeDropdownPosition] + [value, suggestions.length, enableTriggerAlways, dismiss, computeDropdownPosition] ); const handleChange = useCallback( @@ -236,7 +241,7 @@ export function useMentionDetection({ // Detect trigger based on cursor position after the change const cursorPos = e.target.selectionStart; const trigger = findMentionTrigger(newValue, cursorPos); - if (trigger && suggestions.length > 0) { + if (trigger && (suggestions.length > 0 || enableTriggerAlways)) { triggerIndexRef.current = trigger.triggerIndex; setQuery(trigger.query); setIsOpen(true); @@ -246,7 +251,7 @@ export function useMentionDetection({ dismiss(); } }, - [onValueChange, suggestions.length, dismiss, computeDropdownPosition] + [onValueChange, suggestions.length, enableTriggerAlways, dismiss, computeDropdownPosition] ); const handleSelect = useCallback( @@ -296,5 +301,7 @@ export function useMentionDetection({ handleKeyDown, handleChange, handleSelect, + // eslint-disable-next-line react-hooks/refs -- expose current trigger position to caller + triggerIndex: triggerIndexRef.current, }; } diff --git a/src/renderer/utils/quickOpenCache.ts b/src/renderer/utils/quickOpenCache.ts index 6853939f..a3026f07 100644 --- a/src/renderer/utils/quickOpenCache.ts +++ b/src/renderer/utils/quickOpenCache.ts @@ -9,9 +9,18 @@ const FILE_LIST_CACHE_TTL = 10_000; // 10 seconds let fileListCache: { files: QuickOpenFile[]; projectPath: string; timestamp: number } | null = null; +const invalidationListeners = new Set<() => void>(); + +/** Subscribe to cache invalidation events. Returns unsubscribe function. */ +export function onQuickOpenCacheInvalidated(listener: () => void): () => void { + invalidationListeners.add(listener); + return () => invalidationListeners.delete(listener); +} + /** Invalidate file list cache (call on file watcher create/delete events) */ export function invalidateQuickOpenCache(): void { fileListCache = null; + invalidationListeners.forEach((fn) => fn()); } /** Get cached file list if fresh and for the same project */ diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index b3991b75..04d4ee69 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -8,7 +8,7 @@ */ import type { CliInstallerAPI } from './cliInstaller'; -import type { EditorAPI } from './editor'; +import type { EditorAPI, ProjectAPI } from './editor'; import type { AppConfig, DetectedError, @@ -678,6 +678,9 @@ export interface ElectronAPI { // Embedded Terminal API (xterm.js + node-pty) terminal: TerminalAPI; + // Project file operations (editor-independent) + project: ProjectAPI; + // Project Editor API (file browser + CodeMirror) editor: EditorAPI; } diff --git a/src/shared/types/editor.ts b/src/shared/types/editor.ts index 9e3ab46d..a8c9638d 100644 --- a/src/shared/types/editor.ts +++ b/src/shared/types/editor.ts @@ -211,6 +211,11 @@ export interface EditorAPI { onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void; } +/** Editor-independent project file operations (e.g. @file mentions). */ +export interface ProjectAPI { + listFiles: (projectPath: string) => Promise; +} + // ============================================================================= // Binary Preview // ============================================================================= diff --git a/test/main/ipc/editor.test.ts b/test/main/ipc/editor.test.ts index 7a21dd01..3917a7c0 100644 --- a/test/main/ipc/editor.test.ts +++ b/test/main/ipc/editor.test.ts @@ -47,6 +47,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({ EDITOR_SET_WATCHED_FILES: 'editor:setWatchedFiles', EDITOR_SET_WATCHED_DIRS: 'editor:setWatchedDirs', EDITOR_CHANGE: 'editor:change', + PROJECT_LIST_FILES: 'project:listFiles', })); // Mock atomicWrite used by ProjectFileService @@ -149,8 +150,8 @@ describe('Editor IPC handlers', () => { }); describe('registration', () => { - it('registers all 17 editor channels', () => { - expect(mockIpc.handle).toHaveBeenCalledTimes(17); + it('registers all 18 editor channels', () => { + expect(mockIpc.handle).toHaveBeenCalledTimes(18); expect(mockIpc._handlers.has('editor:open')).toBe(true); expect(mockIpc._handlers.has('editor:close')).toBe(true); expect(mockIpc._handlers.has('editor:readDir')).toBe(true); @@ -168,11 +169,12 @@ describe('Editor IPC handlers', () => { expect(mockIpc._handlers.has('editor:watchDir')).toBe(true); expect(mockIpc._handlers.has('editor:setWatchedFiles')).toBe(true); expect(mockIpc._handlers.has('editor:setWatchedDirs')).toBe(true); + expect(mockIpc._handlers.has('project:listFiles')).toBe(true); }); it('removeEditorHandlers clears all channels', () => { removeEditorHandlers(mockIpc as unknown as IpcMain); - expect(mockIpc.removeHandler).toHaveBeenCalledTimes(17); + expect(mockIpc.removeHandler).toHaveBeenCalledTimes(18); }); }); diff --git a/test/renderer/hooks/useFileSuggestions.test.ts b/test/renderer/hooks/useFileSuggestions.test.ts new file mode 100644 index 00000000..d6966a8e --- /dev/null +++ b/test/renderer/hooks/useFileSuggestions.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { filterFileSuggestions } from '@renderer/hooks/useFileSuggestions'; + +import type { QuickOpenFile } from '@shared/types/editor'; + +function file(name: string, relativePath: string, path?: string): QuickOpenFile { + return { + name, + relativePath, + path: path ?? `/project/${relativePath}`, + }; +} + +const FILES: QuickOpenFile[] = [ + file('index.ts', 'src/index.ts'), + file('App.tsx', 'src/App.tsx'), + file('test.ts', 'src/test.ts'), + file('telemetry.ts', 'src/utils/telemetry.ts'), + file('auth.ts', 'src/services/auth.ts'), + file('authMiddleware.ts', 'src/middleware/authMiddleware.ts'), + file('package.json', 'package.json'), + file('README.md', 'README.md'), + file('config.ts', 'src/config.ts'), + file('database.ts', 'src/services/database.ts'), + file('router.ts', 'src/router.ts'), + file('types.ts', 'src/types.ts'), +]; + +describe('filterFileSuggestions', () => { + it('returns empty array for empty query', () => { + expect(filterFileSuggestions(FILES, '')).toEqual([]); + }); + + it('returns empty array for empty file list', () => { + expect(filterFileSuggestions([], 'test')).toEqual([]); + }); + + it('filters by file name', () => { + const results = filterFileSuggestions(FILES, 'test'); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('test.ts'); + expect(results[0].type).toBe('file'); + expect(results[0].filePath).toBe('/project/src/test.ts'); + expect(results[0].relativePath).toBe('src/test.ts'); + }); + + it('filters by relative path', () => { + const results = filterFileSuggestions(FILES, 'middleware'); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('authMiddleware.ts'); + }); + + it('is case-insensitive', () => { + const results = filterFileSuggestions(FILES, 'APP'); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('App.tsx'); + }); + + it('returns multiple matches', () => { + const results = filterFileSuggestions(FILES, 'auth'); + expect(results).toHaveLength(2); + expect(results.map((r) => r.name)).toEqual(['auth.ts', 'authMiddleware.ts']); + }); + + it('matches on name substring', () => { + const results = filterFileSuggestions(FILES, 'te'); + // 'te' matches: test.ts, telemetry.ts, and router.ts (rou-te-r) + expect(results.map((r) => r.name)).toEqual(['test.ts', 'telemetry.ts', 'router.ts']); + }); + + it('limits results to 8', () => { + const results = filterFileSuggestions(FILES, 'ts'); + expect(results.length).toBeLessThanOrEqual(8); + }); + + it('sets id with file: prefix', () => { + const results = filterFileSuggestions(FILES, 'config'); + expect(results[0].id).toBe('file:/project/src/config.ts'); + }); + + it('sets subtitle to relativePath', () => { + const results = filterFileSuggestions(FILES, 'config'); + expect(results[0].subtitle).toBe('src/config.ts'); + }); + + it('matches partial path segments', () => { + const results = filterFileSuggestions(FILES, 'services/'); + expect(results).toHaveLength(2); + expect(results.map((r) => r.name)).toEqual(['auth.ts', 'database.ts']); + }); + + it('returns results in file list order', () => { + const results = filterFileSuggestions(FILES, '.ts'); + expect(results[0].name).toBe('index.ts'); + }); +});