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.
This commit is contained in:
iliya 2026-03-02 21:14:49 +02:00
parent 80034542ec
commit 171773acf1
25 changed files with 654 additions and 224 deletions

View file

@ -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<IpcResult<QuickOpenFile[]>> {
});
}
/**
* 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<IpcResult<QuickOpenFile[]>> {
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);
}
/**

View file

@ -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<GitStatusResult> {
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;
}
}

View file

@ -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';

View file

@ -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<QuickOpenFile[]>(PROJECT_LIST_FILES, projectPath),
},
// ===== Editor API =====
editor: {
open: (projectPath: string) => invokeIpcWithResult<void>(EDITOR_OPEN, projectPath),

View file

@ -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)
// ---------------------------------------------------------------------------

View file

@ -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);

View file

@ -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={

View file

@ -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 ? (

View file

@ -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 ? (

View file

@ -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 ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>

View file

@ -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<QuotedMessage | undefined>(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={

View file

@ -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}

View file

@ -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<Set<string>>(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<Set<string>>(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<string>();
}
// 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<string>();
}
// 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<string>();
}
// Normal update: unknown IDs are new comments
const newIds = new Set<string>();
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<MentionSuggestion[]>(
() =>
members.map((m) => ({
@ -203,34 +146,19 @@ export const TaskCommentsSection = ({
) : null}
{visibleComments.map((comment) => (
<div
key={comment.id}
className={`group rounded-md p-2.5 ${newCommentIds.has(comment.id) ? 'message-enter-animate' : ''} ${
comment.type === 'review_approved'
? 'bg-emerald-500/8 border border-emerald-500/15'
: comment.type === 'review_request'
? 'bg-amber-500/8 border border-amber-500/15'
: ''
}`}
>
<div key={comment.id} className="group p-2.5">
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{comment.type === 'review_approved' && (
<CheckCircle2 size={12} className="shrink-0 text-emerald-400" />
)}
{comment.type === 'review_request' && (
<MessageCircleWarning size={12} className="shrink-0 text-amber-400" />
)}
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
{comment.type === 'review_approved' && (
<span className="rounded-full bg-emerald-500/15 px-1.5 py-px text-[9px] font-medium text-emerald-400">
Approved
</span>
)}
{comment.type === 'review_request' && (
<span className="rounded-full bg-amber-500/15 px-1.5 py-px text-[9px] font-medium text-amber-400">
Changes requested
</span>
)}
<span
className="font-medium"
style={{
color: (() => {
const rc = colorMap.get(comment.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
{comment.author}
</span>
<span>
{(() => {
const date = new Date(comment.createdAt);
@ -362,9 +290,19 @@ export const TaskCommentsSection = ({
{replyTo ? (
<div className="mb-2 flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2">
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-center gap-1.5 text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to
<MemberBadge name={replyTo.author} color={colorMap.get(replyTo.author)} />
<div className="mb-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to{' '}
<span
className="font-semibold"
style={{
color: (() => {
const rc = colorMap.get(replyTo.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
@{replyTo.author}
</span>
</div>
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]">
{replyTo.text}

View file

@ -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<QuickOpenFile[]>([]);
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<void> => {
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 (
<div className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]">
@ -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"
>
<Command label="Quick Open" shouldFilter={false}>
<Command label="Quick Open" shouldFilter={true}>
<Command.Input
value={search}
onValueChange={setSearch}
placeholder="Search files by name..."
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm text-text outline-none placeholder:text-text-muted"
autoFocus
@ -169,10 +138,13 @@ export const QuickOpenDialog = ({
<span>Loading files...</span>
</div>
)}
{!loading && filteredFiles.length === 0 && (
<div className="p-6 text-center text-sm text-text-muted">No files found</div>
{!loading && (
<Command.Empty className="p-6 text-center text-sm text-text-muted">
No files found
</Command.Empty>
)}
{filteredFiles.map((file) => {
{fileItems.map((file) => {
const Icon = file.iconInfo.icon;
return (
<Command.Item
key={file.path}
@ -180,7 +152,7 @@ export const QuickOpenDialog = ({
onSelect={() => 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"
>
<FileIcon fileName={file.name} className="size-4" />
<Icon className="size-4 shrink-0" style={{ color: file.iconInfo.color }} />
<span className="truncate font-medium">{file.name}</span>
<span className="ml-auto truncate text-xs text-text-muted">
{file.relativePath}
@ -188,13 +160,6 @@ export const QuickOpenDialog = ({
</Command.Item>
);
})}
{hasMore && (
<div className="p-2 text-center text-xs text-text-muted">
{search
? 'Refine search to see more...'
: `Type to search ${allFiles.length} files...`}
</div>
)}
</Command.List>
</Command>
</div>

View file

@ -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<HTMLInputElement>(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}

View file

@ -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);
}

View file

@ -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 => (
<li className="px-3 pb-0.5 pt-1.5 text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">
{label}
</li>
);
export const MentionSuggestionList = ({
suggestions,
selectedIndex,
onSelect,
query,
hasFileSearch,
}: MentionSuggestionListProps): React.JSX.Element => {
const listRef = useRef<HTMLUListElement>(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 (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] px-3 py-2 text-xs text-[var(--color-text-muted)]">
No matching members
{hasFileSearch ? 'No matching members or files' : 'No matching members'}
</div>
);
}
// 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(<SectionHeader key={`section-${section}`} label={isFile ? 'Files' : 'Members'} />);
currentSection = section;
}
const isSelected = optionIndex === selectedIndex;
const colorSet = !isFile && s.color ? getTeamColorSet(s.color) : null;
const idx = optionIndex;
optionIndex++;
items.push(
<li
key={s.id}
role="option"
aria-selected={isSelected}
data-index={idx}
className={`flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs transition-colors ${
isSelected
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onMouseDown={(e) => {
e.preventDefault();
onSelect(s);
}}
>
{isFile ? (
<FileText size={10} className="shrink-0 text-[var(--color-text-muted)]" />
) : (
<span
className="inline-block size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: colorSet?.border ?? 'var(--color-text-muted)' }}
/>
)}
<span
className={isFile ? 'truncate' : 'font-medium'}
style={colorSet ? { color: colorSet.text } : undefined}
>
<HighlightedName name={s.name} query={query} />
</span>
{s.subtitle ? (
<span className="truncate text-[var(--color-text-muted)]">{s.subtitle}</span>
) : null}
</li>
);
}
return (
<ul
ref={listRef}
role="listbox"
className="max-h-40 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] py-1"
className="max-h-48 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] py-1"
>
{suggestions.map((s, i) => {
const colorSet = s.color ? getTeamColorSet(s.color) : null;
const isSelected = i === selectedIndex;
return (
<li
key={s.id}
role="option"
aria-selected={isSelected}
className={`flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs transition-colors ${
isSelected
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onMouseDown={(e) => {
e.preventDefault();
onSelect(s);
}}
>
<span
className="inline-block size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: colorSet?.border ?? 'var(--color-text-muted)' }}
/>
<span className="font-medium" style={colorSet ? { color: colorSet.text } : undefined}>
<HighlightedName name={s.name} query={query} />
</span>
{s.subtitle ? (
<span className="text-[var(--color-text-muted)]">{s.subtitle}</span>
) : null}
</li>
);
})}
{items}
</ul>
);
};

View file

@ -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<HTMLTextAreaElement, MentionableTextareaProps>(
@ -206,12 +212,14 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
value,
onValueChange,
suggestions,
hintText = 'Use @ to mention team members',
hintText,
showHint = true,
footerRight,
cornerAction,
chips = [],
onChipRemove,
projectPath,
onFileChipInsert,
style,
className,
...textareaProps
@ -222,6 +230,9 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
const backdropRef = React.useRef<HTMLDivElement>(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<HTMLTextAreaElement, Mention
const {
isOpen,
query,
filteredSuggestions,
filteredSuggestions: memberSuggestions,
selectedIndex,
dropdownPosition,
selectSuggestion,
dismiss,
triggerIndex,
handleKeyDown: mentionHandleKeyDown,
handleChange: mentionHandleChange,
handleSelect: mentionHandleSelect,
@ -250,8 +263,105 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
value,
onValueChange,
textareaRef: internalRef,
enableTriggerAlways: enableFiles,
});
// --- File suggestions ---
const fileSuggestions = useFileSuggestions(
enableFiles ? projectPath : null,
query,
isOpen && enableFiles
);
// Merged suggestion list: members first, then files
const allSuggestions = React.useMemo(() => {
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<HTMLTextAreaElement, Mention
[chips, onChipRemove, value, onValueChange]
);
// Composed key handler: chip logic → mention logic
// --- File-aware keyboard handler (replaces mention handler when files enabled) ---
const fileMentionHandleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement, Mention
}
: style;
const showFooter = (showHint && suggestions.length > 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 (
<div className="relative">
@ -523,8 +672,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
{showFooter ? (
<div className="mt-1 flex items-center justify-between">
{showHint && suggestions.length > 0 ? (
<span className="text-[10px] text-[var(--color-text-muted)]">{hintText}</span>
{showHintRow ? (
<span className="text-[10px] text-[var(--color-text-muted)]">{resolvedHintText}</span>
) : (
<span />
)}
@ -534,10 +683,11 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
{isOpen && dropdownPosition ? (
<div className="absolute left-0 z-50 w-full" style={{ top: `${dropdownPosition.top}px` }}>
<MentionSuggestionList
suggestions={filteredSuggestions}
selectedIndex={selectedIndex}
onSelect={selectSuggestion}
suggestions={effectiveSuggestions}
selectedIndex={effectiveIndex}
onSelect={enableFiles ? handleMergedSelect : selectSuggestion}
query={query}
hasFileSearch={enableFiles}
/>
</div>
) : null}

View file

@ -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<QuickOpenFile[]>([]);
// 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]
);
}

View file

@ -7,6 +7,8 @@ interface UseMentionDetectionOptions {
value: string;
onValueChange: (v: string) => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
/** 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<HTMLTextAreaElement>) => void;
handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleSelect: (e: React.SyntheticEvent<HTMLTextAreaElement>) => 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,
};
}

View file

@ -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 */

View file

@ -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;
}

View file

@ -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<QuickOpenFile[]>;
}
// =============================================================================
// Binary Preview
// =============================================================================

View file

@ -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);
});
});

View file

@ -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');
});
});