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_SET_WATCHED_FILES,
|
||||||
EDITOR_WATCH_DIR,
|
EDITOR_WATCH_DIR,
|
||||||
EDITOR_WRITE_FILE,
|
EDITOR_WRITE_FILE,
|
||||||
|
PROJECT_LIST_FILES,
|
||||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
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).
|
* 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_WATCH_DIR, handleEditorWatchDir);
|
||||||
ipcMain.handle(EDITOR_SET_WATCHED_FILES, handleEditorSetWatchedFiles);
|
ipcMain.handle(EDITOR_SET_WATCHED_FILES, handleEditorSetWatchedFiles);
|
||||||
ipcMain.handle(EDITOR_SET_WATCHED_DIRS, handleEditorSetWatchedDirs);
|
ipcMain.handle(EDITOR_SET_WATCHED_DIRS, handleEditorSetWatchedDirs);
|
||||||
|
ipcMain.handle(PROJECT_LIST_FILES, handleProjectListFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeEditorHandlers(ipcMain: IpcMain): void {
|
export function removeEditorHandlers(ipcMain: IpcMain): void {
|
||||||
|
|
@ -435,6 +455,7 @@ export function removeEditorHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(EDITOR_WATCH_DIR);
|
ipcMain.removeHandler(EDITOR_WATCH_DIR);
|
||||||
ipcMain.removeHandler(EDITOR_SET_WATCHED_FILES);
|
ipcMain.removeHandler(EDITOR_SET_WATCHED_FILES);
|
||||||
ipcMain.removeHandler(EDITOR_SET_WATCHED_DIRS);
|
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 {
|
export class GitStatusService {
|
||||||
private git: SimpleGit | null = null;
|
private git: SimpleGit | null = null;
|
||||||
private projectRoot: string | 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
|
// Cache
|
||||||
private cachedResult: GitStatusResult | null = null;
|
private cachedResult: GitStatusResult | null = null;
|
||||||
|
|
@ -42,6 +44,7 @@ export class GitStatusService {
|
||||||
*/
|
*/
|
||||||
init(projectRoot: string): void {
|
init(projectRoot: string): void {
|
||||||
this.projectRoot = projectRoot;
|
this.projectRoot = projectRoot;
|
||||||
|
this.notAGitRepo = false;
|
||||||
this.git = simpleGit({
|
this.git = simpleGit({
|
||||||
baseDir: projectRoot,
|
baseDir: projectRoot,
|
||||||
timeout: { block: GIT_TIMEOUT_MS },
|
timeout: { block: GIT_TIMEOUT_MS },
|
||||||
|
|
@ -56,6 +59,7 @@ export class GitStatusService {
|
||||||
this.clearDebounceTimer();
|
this.clearDebounceTimer();
|
||||||
this.git = null;
|
this.git = null;
|
||||||
this.projectRoot = null;
|
this.projectRoot = null;
|
||||||
|
this.notAGitRepo = false;
|
||||||
this.cachedResult = null;
|
this.cachedResult = null;
|
||||||
this.cacheTimestamp = 0;
|
this.cacheTimestamp = 0;
|
||||||
}
|
}
|
||||||
|
|
@ -89,8 +93,10 @@ export class GitStatusService {
|
||||||
* Returns cached result if within TTL.
|
* Returns cached result if within TTL.
|
||||||
*/
|
*/
|
||||||
async getStatus(): Promise<GitStatusResult> {
|
async getStatus(): Promise<GitStatusResult> {
|
||||||
if (!this.git || !this.projectRoot) {
|
const notGitResult: GitStatusResult = { files: [], isGitRepo: false, branch: null };
|
||||||
return { files: [], isGitRepo: false, branch: null };
|
|
||||||
|
if (!this.git || !this.projectRoot || this.notAGitRepo) {
|
||||||
|
return notGitResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush pending debounced invalidation — when data is actually requested,
|
// Flush pending debounced invalidation — when data is actually requested,
|
||||||
|
|
@ -121,11 +127,20 @@ export class GitStatusService {
|
||||||
this.setCacheResult(result);
|
this.setCacheResult(result);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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);
|
log.error('Failed to get git status:', error);
|
||||||
// Graceful degradation: cache negative result to avoid repeated git calls
|
// Transient error — cache negative result to avoid hammering git, but allow retry after TTL
|
||||||
const result: GitStatusResult = { files: [], isGitRepo: false, branch: null };
|
this.setCacheResult(notGitResult);
|
||||||
this.setCacheResult(result);
|
return notGitResult;
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -469,3 +469,6 @@ export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview';
|
||||||
|
|
||||||
/** File change event from watcher (main -> renderer) */
|
/** File change event from watcher (main -> renderer) */
|
||||||
export const EDITOR_CHANGE = 'editor:change';
|
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_GET_STATUS,
|
||||||
HTTP_SERVER_START,
|
HTTP_SERVER_START,
|
||||||
HTTP_SERVER_STOP,
|
HTTP_SERVER_STOP,
|
||||||
|
PROJECT_LIST_FILES,
|
||||||
REVIEW_APPLY_DECISIONS,
|
REVIEW_APPLY_DECISIONS,
|
||||||
REVIEW_CHECK_CONFLICT,
|
REVIEW_CHECK_CONFLICT,
|
||||||
REVIEW_CLEAR_DECISIONS,
|
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 API =====
|
||||||
editor: {
|
editor: {
|
||||||
open: (projectPath: string) => invokeIpcWithResult<void>(EDITOR_OPEN, projectPath),
|
open: (projectPath: string) => invokeIpcWithResult<void>(EDITOR_OPEN, projectPath),
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ import type {
|
||||||
WslClaudeRootCandidate,
|
WslClaudeRootCandidate,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
import type { AgentConfig } from '@shared/types/api';
|
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';
|
import type { TerminalAPI } from '@shared/types/terminal';
|
||||||
|
|
||||||
export class HttpAPIClient implements ElectronAPI {
|
export class HttpAPIClient implements ElectronAPI {
|
||||||
|
|
@ -938,6 +938,16 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
onExit: (): (() => void) => () => {},
|
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)
|
// Editor (not available in browser mode)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,7 @@ export const ActivityTimeline = ({
|
||||||
}
|
}
|
||||||
return newKeys;
|
return newKeys;
|
||||||
}, [visibleMessages, visibleCount]);
|
}, [visibleMessages, visibleCount]);
|
||||||
/* eslint-enable react-hooks/refs */
|
/* eslint-enable react-hooks/refs -- end animation tracking block */
|
||||||
|
|
||||||
const handleShowMore = (): void => {
|
const handleShowMore = (): void => {
|
||||||
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
|
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
|
@ -74,6 +75,7 @@ export const CreateTaskDialog = ({
|
||||||
submitting = false,
|
submitting = false,
|
||||||
}: CreateTaskDialogProps): React.JSX.Element => {
|
}: CreateTaskDialogProps): React.JSX.Element => {
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||||
const [subject, setSubject] = useState(defaultSubject);
|
const [subject, setSubject] = useState(defaultSubject);
|
||||||
const descriptionDraft = useDraftPersistence({
|
const descriptionDraft = useDraftPersistence({
|
||||||
key: `createTask:${teamName}:description`,
|
key: `createTask:${teamName}:description`,
|
||||||
|
|
@ -265,6 +267,8 @@ export const CreateTaskDialog = ({
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
chips={descChipDraft.chips}
|
chips={descChipDraft.chips}
|
||||||
onChipRemove={handleDescChipRemove}
|
onChipRemove={handleDescChipRemove}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onFileChipInsert={(chip) => descChipDraft.setChips([...descChipDraft.chips, chip])}
|
||||||
minRows={3}
|
minRows={3}
|
||||||
maxRows={12}
|
maxRows={12}
|
||||||
footerRight={
|
footerRight={
|
||||||
|
|
@ -285,6 +289,7 @@ export const CreateTaskDialog = ({
|
||||||
value={promptDraft.value}
|
value={promptDraft.value}
|
||||||
onValueChange={promptDraft.setValue}
|
onValueChange={promptDraft.setValue}
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
|
projectPath={projectPath}
|
||||||
minRows={3}
|
minRows={3}
|
||||||
maxRows={12}
|
maxRows={12}
|
||||||
footerRight={
|
footerRight={
|
||||||
|
|
|
||||||
|
|
@ -925,6 +925,7 @@ export const CreateTeamDialog = ({
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onValueChange={promptDraft.setValue}
|
onValueChange={promptDraft.setValue}
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
|
projectPath={effectiveCwd || null}
|
||||||
placeholder="Instructions for the team lead during provisioning..."
|
placeholder="Instructions for the team lead during provisioning..."
|
||||||
footerRight={
|
footerRight={
|
||||||
promptDraft.isSaved ? (
|
promptDraft.isSaved ? (
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,7 @@ export const LaunchTeamDialog = ({
|
||||||
value={promptDraft.value}
|
value={promptDraft.value}
|
||||||
onValueChange={promptDraft.setValue}
|
onValueChange={promptDraft.setValue}
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
|
projectPath={effectiveCwd || null}
|
||||||
placeholder="Instructions for team lead... Use @ to mention team members."
|
placeholder="Instructions for team lead... Use @ to mention team members."
|
||||||
footerRight={
|
footerRight={
|
||||||
promptDraft.isSaved ? (
|
promptDraft.isSaved ? (
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ export const ReviewDialog = ({
|
||||||
onCancel,
|
onCancel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: ReviewDialogProps): React.JSX.Element => {
|
}: ReviewDialogProps): React.JSX.Element => {
|
||||||
|
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||||
const draft = useDraftPersistence({
|
const draft = useDraftPersistence({
|
||||||
key: `requestChanges:${teamName}:${taskId ?? ''}`,
|
key: `requestChanges:${teamName}:${taskId ?? ''}`,
|
||||||
enabled: Boolean(teamName && taskId),
|
enabled: Boolean(teamName && taskId),
|
||||||
|
|
@ -88,7 +90,7 @@ export const ReviewDialog = ({
|
||||||
onValueChange={draft.setValue}
|
onValueChange={draft.setValue}
|
||||||
placeholder="Describe what needs to change..."
|
placeholder="Describe what needs to change..."
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
hintText="Use @ to mention team members"
|
projectPath={projectPath}
|
||||||
footerRight={
|
footerRight={
|
||||||
draft.isSaved ? (
|
draft.isSaved ? (
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
<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 { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||||
|
|
@ -74,6 +75,7 @@ export const SendMessageDialog = ({
|
||||||
onClose,
|
onClose,
|
||||||
}: SendMessageDialogProps): React.JSX.Element => {
|
}: SendMessageDialogProps): React.JSX.Element => {
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||||
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
||||||
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
||||||
const [member, setMember] = useState('');
|
const [member, setMember] = useState('');
|
||||||
|
|
@ -268,6 +270,8 @@ export const SendMessageDialog = ({
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
chips={chipDraft.chips}
|
chips={chipDraft.chips}
|
||||||
onChipRemove={handleChipRemove}
|
onChipRemove={handleChipRemove}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])}
|
||||||
minRows={4}
|
minRows={4}
|
||||||
maxRows={12}
|
maxRows={12}
|
||||||
footerRight={
|
footerRight={
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export const TaskCommentInput = ({
|
||||||
}: TaskCommentInputProps): React.JSX.Element => {
|
}: TaskCommentInputProps): React.JSX.Element => {
|
||||||
const addTaskComment = useStore((s) => s.addTaskComment);
|
const addTaskComment = useStore((s) => s.addTaskComment);
|
||||||
const addingComment = useStore((s) => s.addingComment);
|
const addingComment = useStore((s) => s.addingComment);
|
||||||
|
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||||
|
|
||||||
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
@ -109,6 +110,7 @@ export const TaskCommentInput = ({
|
||||||
value={draft.value}
|
value={draft.value}
|
||||||
onValueChange={draft.setValue}
|
onValueChange={draft.setValue}
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
|
projectPath={projectPath}
|
||||||
minRows={2}
|
minRows={2}
|
||||||
maxRows={8}
|
maxRows={8}
|
||||||
maxLength={MAX_COMMENT_LENGTH}
|
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 { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
@ -14,16 +14,7 @@ import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import {
|
import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react';
|
||||||
CheckCircle2,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
MessageCircleWarning,
|
|
||||||
MessageSquare,
|
|
||||||
Reply,
|
|
||||||
Send,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
|
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
|
||||||
|
|
@ -63,23 +54,15 @@ export const TaskCommentsSection = ({
|
||||||
const [expandedCommentIds, setExpandedCommentIds] = useState<Set<string>>(new Set());
|
const [expandedCommentIds, setExpandedCommentIds] = useState<Set<string>>(new Set());
|
||||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
|
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
|
||||||
|
|
||||||
// Reset state when task changes (React-approved setState-during-render pattern)
|
// Reset local state when team/task changes (React-recommended pattern for
|
||||||
const resetKey = teamIdKey(teamName, taskId);
|
// adjusting state based on props without using effects or refs during render)
|
||||||
const [prevResetKey, setPrevResetKey] = useState(resetKey);
|
const currentKey = teamIdKey(teamName, taskId);
|
||||||
|
const [prevKey, setPrevKey] = useState(currentKey);
|
||||||
// --- New-comment animation tracking (refs only, useMemo is after visibleComments) ---
|
if (prevKey !== currentKey) {
|
||||||
const knownCommentIdsRef = useRef<Set<string>>(new Set());
|
setPrevKey(currentKey);
|
||||||
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);
|
|
||||||
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
|
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
|
||||||
setExpandedCommentIds(new Set());
|
setExpandedCommentIds(new Set());
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
knownCommentIdsRef.current.clear();
|
|
||||||
isCommentsInitializedRef.current = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleCommentExpanded = useCallback((commentId: string) => {
|
const toggleCommentExpanded = useCallback((commentId: string) => {
|
||||||
|
|
@ -112,46 +95,6 @@ export const TaskCommentsSection = ({
|
||||||
[sortedComments, visibleCount]
|
[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[]>(
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
() =>
|
() =>
|
||||||
members.map((m) => ({
|
members.map((m) => ({
|
||||||
|
|
@ -203,34 +146,19 @@ export const TaskCommentsSection = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{visibleComments.map((comment) => (
|
{visibleComments.map((comment) => (
|
||||||
<div
|
<div key={comment.id} className="group p-2.5">
|
||||||
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 className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
{comment.type === 'review_approved' && (
|
<span
|
||||||
<CheckCircle2 size={12} className="shrink-0 text-emerald-400" />
|
className="font-medium"
|
||||||
)}
|
style={{
|
||||||
{comment.type === 'review_request' && (
|
color: (() => {
|
||||||
<MessageCircleWarning size={12} className="shrink-0 text-amber-400" />
|
const rc = colorMap.get(comment.author);
|
||||||
)}
|
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
|
||||||
<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
|
{comment.author}
|
||||||
</span>
|
</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>
|
<span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const date = new Date(comment.createdAt);
|
const date = new Date(comment.createdAt);
|
||||||
|
|
@ -362,9 +290,19 @@ export const TaskCommentsSection = ({
|
||||||
{replyTo ? (
|
{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="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="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)]">
|
<div className="mb-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||||
Replying to
|
Replying to{' '}
|
||||||
<MemberBadge name={replyTo.author} color={colorMap.get(replyTo.author)} />
|
<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>
|
||||||
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]">
|
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]">
|
||||||
{replyTo.text}
|
{replyTo.text}
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,13 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { getQuickOpenCache, setQuickOpenCache } from '@renderer/utils/quickOpenCache';
|
|
||||||
import { Command } from 'cmdk';
|
import { Command } from 'cmdk';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { FileIcon } from './FileIcon';
|
import { getFileIcon } from './fileIcons';
|
||||||
|
|
||||||
import type { QuickOpenFile } from '@shared/types/editor';
|
import type { QuickOpenFile } from '@shared/types/editor';
|
||||||
|
|
||||||
const MAX_RENDERED = 100;
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -40,46 +37,29 @@ export const QuickOpenDialog = ({
|
||||||
const [allFiles, setAllFiles] = useState<QuickOpenFile[]>([]);
|
const [allFiles, setAllFiles] = useState<QuickOpenFile[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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);
|
const [prevProjectPath, setPrevProjectPath] = useState(projectPath);
|
||||||
if (prevProjectPath !== projectPath) {
|
if (prevProjectPath !== projectPath) {
|
||||||
setPrevProjectPath(projectPath);
|
setPrevProjectPath(projectPath);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setAllFiles([]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all project files via backend API (with module-level cache)
|
// Load all project files on mount via backend API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
// Use cache if fresh and for the same project
|
window.electronAPI.editor
|
||||||
const cached = projectPath ? getQuickOpenCache(projectPath) : null;
|
.listFiles()
|
||||||
if (cached) {
|
.then((files) => {
|
||||||
// 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();
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
if (projectPath) setQuickOpenCache(projectPath, files);
|
|
||||||
setAllFiles(files);
|
setAllFiles(files);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch {
|
})
|
||||||
|
.catch(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
void fetchFiles();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|
@ -115,24 +95,15 @@ export const QuickOpenDialog = ({
|
||||||
[allFiles, onSelectFile, onClose]
|
[allFiles, onSelectFile, onClose]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
// Memoize file icon lookups
|
||||||
|
const fileItems = useMemo(
|
||||||
const filteredFiles = useMemo(() => {
|
() =>
|
||||||
if (!search.trim()) return allFiles.slice(0, MAX_RENDERED);
|
allFiles.map((file) => ({
|
||||||
const q = search.toLowerCase();
|
...file,
|
||||||
const matches: QuickOpenFile[] = [];
|
iconInfo: getFileIcon(file.name),
|
||||||
for (const file of allFiles) {
|
})),
|
||||||
if (file.relativePath.toLowerCase().includes(q)) {
|
[allFiles]
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]">
|
<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"
|
aria-label="Quick Open"
|
||||||
className="relative z-10 w-[520px] overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl"
|
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
|
<Command.Input
|
||||||
value={search}
|
|
||||||
onValueChange={setSearch}
|
|
||||||
placeholder="Search files by name..."
|
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"
|
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm text-text outline-none placeholder:text-text-muted"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|
@ -169,10 +138,13 @@ export const QuickOpenDialog = ({
|
||||||
<span>Loading files...</span>
|
<span>Loading files...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loading && filteredFiles.length === 0 && (
|
{!loading && (
|
||||||
<div className="p-6 text-center text-sm text-text-muted">No files found</div>
|
<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 (
|
return (
|
||||||
<Command.Item
|
<Command.Item
|
||||||
key={file.path}
|
key={file.path}
|
||||||
|
|
@ -180,7 +152,7 @@ export const QuickOpenDialog = ({
|
||||||
onSelect={() => handleSelect(file.relativePath)}
|
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"
|
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="truncate font-medium">{file.name}</span>
|
||||||
<span className="ml-auto truncate text-xs text-text-muted">
|
<span className="ml-auto truncate text-xs text-text-muted">
|
||||||
{file.relativePath}
|
{file.relativePath}
|
||||||
|
|
@ -188,13 +160,6 @@ export const QuickOpenDialog = ({
|
||||||
</Command.Item>
|
</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.List>
|
||||||
</Command>
|
</Command>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { useAttachments } from '@renderer/hooks/useAttachments';
|
||||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
|
@ -53,6 +54,7 @@ export const MessageComposer = ({
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||||
const draft = useDraftPersistence({ key: `compose:${teamName}` });
|
const draft = useDraftPersistence({ key: `compose:${teamName}` });
|
||||||
const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`);
|
const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`);
|
||||||
const {
|
const {
|
||||||
|
|
@ -324,6 +326,8 @@ export const MessageComposer = ({
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
chips={chipDraft.chips}
|
chips={chipDraft.chips}
|
||||||
onChipRemove={handleChipRemove}
|
onChipRemove={handleChipRemove}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])}
|
||||||
minRows={2}
|
minRows={2}
|
||||||
maxRows={6}
|
maxRows={6}
|
||||||
maxLength={MAX_MESSAGE_LENGTH}
|
maxLength={MAX_MESSAGE_LENGTH}
|
||||||
|
|
|
||||||
|
|
@ -449,11 +449,18 @@ export const ChangeReviewDialog = ({
|
||||||
});
|
});
|
||||||
}, [activeChangeSet, initialFilePath, scrollToFile]);
|
}, [activeChangeSet, initialFilePath, scrollToFile]);
|
||||||
|
|
||||||
// Clear selection state on close + cleanup timer.
|
// Clear selection state on close (React-approved setState-during-render pattern)
|
||||||
// setState here is intentional: reset transient UI state when dialog closes.
|
const [prevOpen, setPrevOpen] = useState(open);
|
||||||
useEffect(() => {
|
if (prevOpen !== open) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSelectionInfo(null);
|
setSelectionInfo(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup refs/timers on close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
activeSelectionFileRef.current = null;
|
activeSelectionFileRef.current = null;
|
||||||
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
|
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
||||||
|
|
@ -9,6 +10,8 @@ interface MentionSuggestionListProps {
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelect: (s: MentionSuggestion) => void;
|
onSelect: (s: MentionSuggestion) => void;
|
||||||
query: string;
|
query: string;
|
||||||
|
/** When true, adjusts empty state text to mention files */
|
||||||
|
hasFileSearch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HighlightedName = ({ name, query }: { name: string; query: string }): React.JSX.Element => {
|
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 = ({
|
export const MentionSuggestionList = ({
|
||||||
suggestions,
|
suggestions,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
onSelect,
|
onSelect,
|
||||||
query,
|
query,
|
||||||
|
hasFileSearch,
|
||||||
}: MentionSuggestionListProps): React.JSX.Element => {
|
}: MentionSuggestionListProps): React.JSX.Element => {
|
||||||
const listRef = useRef<HTMLUListElement>(null);
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const list = listRef.current;
|
const list = listRef.current;
|
||||||
if (!list) return;
|
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' });
|
selected?.scrollIntoView({ block: 'nearest' });
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
|
|
||||||
if (suggestions.length === 0) {
|
if (suggestions.length === 0) {
|
||||||
return (
|
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)]">
|
<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>
|
</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 (
|
return (
|
||||||
<ul
|
<ul
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
role="listbox"
|
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) => {
|
{items}
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
import { useFileSuggestions } from '@renderer/hooks/useFileSuggestions';
|
||||||
import { useMentionDetection } from '@renderer/hooks/useMentionDetection';
|
import { useMentionDetection } from '@renderer/hooks/useMentionDetection';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { chipToken } from '@renderer/types/inlineChip';
|
import { chipToken } from '@renderer/types/inlineChip';
|
||||||
import {
|
import {
|
||||||
|
createChipFromSelection,
|
||||||
findChipBoundary,
|
findChipBoundary,
|
||||||
reconcileChips,
|
reconcileChips,
|
||||||
removeChipTokenFromText,
|
removeChipTokenFromText,
|
||||||
|
|
@ -198,6 +200,10 @@ interface MentionableTextareaProps extends Omit<
|
||||||
chips?: InlineChip[];
|
chips?: InlineChip[];
|
||||||
/** Called when a chip is removed (by X button, backspace, or reconciliation) */
|
/** Called when a chip is removed (by X button, backspace, or reconciliation) */
|
||||||
onChipRemove?: (chipId: string) => void;
|
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>(
|
export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, MentionableTextareaProps>(
|
||||||
|
|
@ -206,12 +212,14 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
suggestions,
|
suggestions,
|
||||||
hintText = 'Use @ to mention team members',
|
hintText,
|
||||||
showHint = true,
|
showHint = true,
|
||||||
footerRight,
|
footerRight,
|
||||||
cornerAction,
|
cornerAction,
|
||||||
chips = [],
|
chips = [],
|
||||||
onChipRemove,
|
onChipRemove,
|
||||||
|
projectPath,
|
||||||
|
onFileChipInsert,
|
||||||
style,
|
style,
|
||||||
className,
|
className,
|
||||||
...textareaProps
|
...textareaProps
|
||||||
|
|
@ -222,6 +230,9 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
const backdropRef = React.useRef<HTMLDivElement>(null);
|
const backdropRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [scrollTop, setScrollTop] = React.useState(0);
|
const [scrollTop, setScrollTop] = React.useState(0);
|
||||||
|
|
||||||
|
// --- File search activation ---
|
||||||
|
const enableFiles = !!projectPath;
|
||||||
|
|
||||||
const setRefs = React.useCallback(
|
const setRefs = React.useCallback(
|
||||||
(node: HTMLTextAreaElement | null) => {
|
(node: HTMLTextAreaElement | null) => {
|
||||||
internalRef.current = node;
|
internalRef.current = node;
|
||||||
|
|
@ -238,10 +249,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
query,
|
query,
|
||||||
filteredSuggestions,
|
filteredSuggestions: memberSuggestions,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
dropdownPosition,
|
dropdownPosition,
|
||||||
selectSuggestion,
|
selectSuggestion,
|
||||||
|
dismiss,
|
||||||
|
triggerIndex,
|
||||||
handleKeyDown: mentionHandleKeyDown,
|
handleKeyDown: mentionHandleKeyDown,
|
||||||
handleChange: mentionHandleChange,
|
handleChange: mentionHandleChange,
|
||||||
handleSelect: mentionHandleSelect,
|
handleSelect: mentionHandleSelect,
|
||||||
|
|
@ -250,8 +263,105 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
textareaRef: internalRef,
|
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.
|
// Sync backdrop font with textarea computed font to prevent caret drift.
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const textarea = internalRef.current;
|
const textarea = internalRef.current;
|
||||||
|
|
@ -357,15 +467,48 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
[chips, onChipRemove, value, onValueChange]
|
[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(
|
const composedHandleKeyDown = React.useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
handleChipKeyDown(e);
|
handleChipKeyDown(e);
|
||||||
if (!e.defaultPrevented) {
|
if (!e.defaultPrevented) {
|
||||||
mentionHandleKeyDown(e);
|
if (enableFiles) {
|
||||||
|
fileMentionHandleKeyDown(e);
|
||||||
|
} else {
|
||||||
|
mentionHandleKeyDown(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleChipKeyDown, mentionHandleKeyDown]
|
[handleChipKeyDown, enableFiles, fileMentionHandleKeyDown, mentionHandleKeyDown]
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Chip reconciliation on text change ---
|
// --- Chip reconciliation on text change ---
|
||||||
|
|
@ -442,7 +585,13 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
}
|
}
|
||||||
: style;
|
: 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 (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -523,8 +672,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
|
|
||||||
{showFooter ? (
|
{showFooter ? (
|
||||||
<div className="mt-1 flex items-center justify-between">
|
<div className="mt-1 flex items-center justify-between">
|
||||||
{showHint && suggestions.length > 0 ? (
|
{showHintRow ? (
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">{hintText}</span>
|
<span className="text-[10px] text-[var(--color-text-muted)]">{resolvedHintText}</span>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
|
|
@ -534,10 +683,11 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
{isOpen && dropdownPosition ? (
|
{isOpen && dropdownPosition ? (
|
||||||
<div className="absolute left-0 z-50 w-full" style={{ top: `${dropdownPosition.top}px` }}>
|
<div className="absolute left-0 z-50 w-full" style={{ top: `${dropdownPosition.top}px` }}>
|
||||||
<MentionSuggestionList
|
<MentionSuggestionList
|
||||||
suggestions={filteredSuggestions}
|
suggestions={effectiveSuggestions}
|
||||||
selectedIndex={selectedIndex}
|
selectedIndex={effectiveIndex}
|
||||||
onSelect={selectSuggestion}
|
onSelect={enableFiles ? handleMergedSelect : selectSuggestion}
|
||||||
query={query}
|
query={query}
|
||||||
|
hasFileSearch={enableFiles}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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;
|
value: string;
|
||||||
onValueChange: (v: string) => void;
|
onValueChange: (v: string) => void;
|
||||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
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 {
|
export interface DropdownPosition {
|
||||||
|
|
@ -25,6 +27,8 @@ interface UseMentionDetectionResult {
|
||||||
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
handleSelect: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void;
|
handleSelect: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void;
|
||||||
|
/** Current @-trigger character position in text (-1 if no active trigger) */
|
||||||
|
triggerIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MentionTrigger {
|
interface MentionTrigger {
|
||||||
|
|
@ -154,6 +158,7 @@ export function useMentionDetection({
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
textareaRef,
|
textareaRef,
|
||||||
|
enableTriggerAlways,
|
||||||
}: UseMentionDetectionOptions): UseMentionDetectionResult {
|
}: UseMentionDetectionOptions): UseMentionDetectionResult {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
|
@ -215,7 +220,7 @@ export function useMentionDetection({
|
||||||
const detectTrigger = useCallback(
|
const detectTrigger = useCallback(
|
||||||
(cursorPos: number) => {
|
(cursorPos: number) => {
|
||||||
const trigger = findMentionTrigger(value, cursorPos);
|
const trigger = findMentionTrigger(value, cursorPos);
|
||||||
if (trigger && suggestions.length > 0) {
|
if (trigger && (suggestions.length > 0 || enableTriggerAlways)) {
|
||||||
triggerIndexRef.current = trigger.triggerIndex;
|
triggerIndexRef.current = trigger.triggerIndex;
|
||||||
setQuery(trigger.query);
|
setQuery(trigger.query);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
|
|
@ -225,7 +230,7 @@ export function useMentionDetection({
|
||||||
dismiss();
|
dismiss();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[value, suggestions.length, dismiss, computeDropdownPosition]
|
[value, suggestions.length, enableTriggerAlways, dismiss, computeDropdownPosition]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
|
|
@ -236,7 +241,7 @@ export function useMentionDetection({
|
||||||
// Detect trigger based on cursor position after the change
|
// Detect trigger based on cursor position after the change
|
||||||
const cursorPos = e.target.selectionStart;
|
const cursorPos = e.target.selectionStart;
|
||||||
const trigger = findMentionTrigger(newValue, cursorPos);
|
const trigger = findMentionTrigger(newValue, cursorPos);
|
||||||
if (trigger && suggestions.length > 0) {
|
if (trigger && (suggestions.length > 0 || enableTriggerAlways)) {
|
||||||
triggerIndexRef.current = trigger.triggerIndex;
|
triggerIndexRef.current = trigger.triggerIndex;
|
||||||
setQuery(trigger.query);
|
setQuery(trigger.query);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
|
|
@ -246,7 +251,7 @@ export function useMentionDetection({
|
||||||
dismiss();
|
dismiss();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onValueChange, suggestions.length, dismiss, computeDropdownPosition]
|
[onValueChange, suggestions.length, enableTriggerAlways, dismiss, computeDropdownPosition]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
|
|
@ -296,5 +301,7 @@ export function useMentionDetection({
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handleChange,
|
handleChange,
|
||||||
handleSelect,
|
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;
|
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) */
|
/** Invalidate file list cache (call on file watcher create/delete events) */
|
||||||
export function invalidateQuickOpenCache(): void {
|
export function invalidateQuickOpenCache(): void {
|
||||||
fileListCache = null;
|
fileListCache = null;
|
||||||
|
invalidationListeners.forEach((fn) => fn());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get cached file list if fresh and for the same project */
|
/** Get cached file list if fresh and for the same project */
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CliInstallerAPI } from './cliInstaller';
|
import type { CliInstallerAPI } from './cliInstaller';
|
||||||
import type { EditorAPI } from './editor';
|
import type { EditorAPI, ProjectAPI } from './editor';
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
DetectedError,
|
DetectedError,
|
||||||
|
|
@ -678,6 +678,9 @@ export interface ElectronAPI {
|
||||||
// Embedded Terminal API (xterm.js + node-pty)
|
// Embedded Terminal API (xterm.js + node-pty)
|
||||||
terminal: TerminalAPI;
|
terminal: TerminalAPI;
|
||||||
|
|
||||||
|
// Project file operations (editor-independent)
|
||||||
|
project: ProjectAPI;
|
||||||
|
|
||||||
// Project Editor API (file browser + CodeMirror)
|
// Project Editor API (file browser + CodeMirror)
|
||||||
editor: EditorAPI;
|
editor: EditorAPI;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,11 @@ export interface EditorAPI {
|
||||||
onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void;
|
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
|
// Binary Preview
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
EDITOR_SET_WATCHED_FILES: 'editor:setWatchedFiles',
|
EDITOR_SET_WATCHED_FILES: 'editor:setWatchedFiles',
|
||||||
EDITOR_SET_WATCHED_DIRS: 'editor:setWatchedDirs',
|
EDITOR_SET_WATCHED_DIRS: 'editor:setWatchedDirs',
|
||||||
EDITOR_CHANGE: 'editor:change',
|
EDITOR_CHANGE: 'editor:change',
|
||||||
|
PROJECT_LIST_FILES: 'project:listFiles',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock atomicWrite used by ProjectFileService
|
// Mock atomicWrite used by ProjectFileService
|
||||||
|
|
@ -149,8 +150,8 @@ describe('Editor IPC handlers', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('registration', () => {
|
describe('registration', () => {
|
||||||
it('registers all 17 editor channels', () => {
|
it('registers all 18 editor channels', () => {
|
||||||
expect(mockIpc.handle).toHaveBeenCalledTimes(17);
|
expect(mockIpc.handle).toHaveBeenCalledTimes(18);
|
||||||
expect(mockIpc._handlers.has('editor:open')).toBe(true);
|
expect(mockIpc._handlers.has('editor:open')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:close')).toBe(true);
|
expect(mockIpc._handlers.has('editor:close')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:readDir')).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:watchDir')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:setWatchedFiles')).toBe(true);
|
expect(mockIpc._handlers.has('editor:setWatchedFiles')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:setWatchedDirs')).toBe(true);
|
expect(mockIpc._handlers.has('editor:setWatchedDirs')).toBe(true);
|
||||||
|
expect(mockIpc._handlers.has('project:listFiles')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removeEditorHandlers clears all channels', () => {
|
it('removeEditorHandlers clears all channels', () => {
|
||||||
removeEditorHandlers(mockIpc as unknown as IpcMain);
|
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