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:
parent
80034542ec
commit
171773acf1
25 changed files with 654 additions and 224 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
128
src/renderer/hooks/useFileSuggestions.ts
Normal file
128
src/renderer/hooks/useFileSuggestions.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
97
test/renderer/hooks/useFileSuggestions.test.ts
Normal file
97
test/renderer/hooks/useFileSuggestions.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue