feat(FN-4735): complete Step 4 — add useIssueMention hook

Fusion-Task-Id: FN-4735
Fusion-Task-Lineage: e171d3ca-d415-4f4d-8ec0-d94ca97f1aab
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 08:57:26 -07:00
committed by gsxdsm
parent 0f67447fcc
commit aa3b2cfb4c
2 changed files with 325 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useIssueMention } from "../useIssueMention";
vi.mock("../../api", () => ({
fetchRecentIssues: vi.fn(),
}));
import { fetchRecentIssues } from "../../api";
const mockFetchRecentIssues = fetchRecentIssues as unknown as ReturnType<typeof vi.fn>;
describe("useIssueMention", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mockFetchRecentIssues.mockResolvedValue([]);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("detects trigger at start, after space, and punctuation", () => {
const { result } = renderHook(() => useIssueMention());
act(() => result.current.detectMention("#", 1));
expect(result.current.mentionActive).toBe(true);
act(() => result.current.detectMention("hey #1", 6));
expect(result.current.mentionActive).toBe(true);
act(() => result.current.detectMention("hey, #2", 7));
expect(result.current.mentionActive).toBe(true);
});
it("deactivates on path-like query and stays active on digits", () => {
const { result } = renderHook(() => useIssueMention());
act(() => result.current.detectMention("#123", 4));
expect(result.current.mentionActive).toBe(true);
act(() => result.current.detectMention("#src/foo.ts", 11));
expect(result.current.mentionActive).toBe(false);
});
it("debounces fetch", async () => {
const { result } = renderHook(() => useIssueMention({ projectId: "p1" }));
act(() => {
result.current.detectMention("#12", 3);
});
act(() => {
vi.advanceTimersByTime(199);
});
expect(mockFetchRecentIssues).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
await Promise.resolve();
await Promise.resolve();
});
expect(mockFetchRecentIssues).toHaveBeenCalledWith("p1", "12");
});
it("selectIssue inserts trailing space and cursor position", () => {
const { result } = renderHook(() => useIssueMention());
act(() => result.current.detectMention("Fix #4 now", 6));
const selection = result.current.selectIssue(
{ number: 42, title: "Bug", state: "open", htmlUrl: "https://x", repository: "o/r" },
"Fix #4 now",
);
expect(selection.text).toBe("Fix #42 now");
expect(selection.cursorPosition).toBe(8);
});
it("handles keyboard navigation and dismiss", async () => {
mockFetchRecentIssues.mockResolvedValueOnce([
{ number: 1, title: "a", state: "open", htmlUrl: "https://x/1", repository: "o/r" },
{ number: 2, title: "b", state: "open", htmlUrl: "https://x/2", repository: "o/r" },
]);
const { result } = renderHook(() => useIssueMention());
act(() => {
result.current.detectMention("#", 1);
});
await act(async () => {
vi.advanceTimersByTime(200);
await Promise.resolve();
await Promise.resolve();
});
const preventDefault = vi.fn();
act(() => {
result.current.handleKeyDown({ key: "ArrowDown", preventDefault } as unknown as React.KeyboardEvent<HTMLElement>);
});
expect(result.current.selectedIndex).toBe(1);
act(() => {
result.current.handleKeyDown({ key: "Escape", preventDefault } as unknown as React.KeyboardEvent<HTMLElement>);
});
expect(result.current.mentionActive).toBe(false);
});
});

View File

@@ -0,0 +1,211 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { fetchRecentIssues, type IssueMentionItem } from "../api";
export interface UseIssueMentionOptions {
projectId?: string;
}
export interface IssueSelectResult {
text: string;
cursorPosition: number;
}
export interface UseIssueMentionReturn {
mentionActive: boolean;
issues: IssueMentionItem[];
loading: boolean;
mentionQuery: string;
selectedIndex: number;
setSelectedIndex: (index: number) => void;
detectMention: (text: string, cursorPosition: number) => boolean;
dismissMention: () => void;
selectIssue: (issue: IssueMentionItem, currentText: string) => IssueSelectResult;
handleKeyDown: (event: React.KeyboardEvent<HTMLElement>) => boolean;
}
const DEBOUNCE_MS = 200;
/**
* Hook to manage # issue mention interactions.
*
* Disambiguation contract:
* - treat `#` as issue mention when query is empty, only digits, or has neither `/` nor `.`
* - if query contains `/` or `.`, issue mention deactivates so file mention can take over
*/
export function useIssueMention(options: UseIssueMentionOptions = {}): UseIssueMentionReturn {
const { projectId } = options;
const [mentionActive, setMentionActive] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
const [issues, setIssues] = useState<IssueMentionItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortController = useRef<AbortController | null>(null);
useEffect(() => {
return () => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
abortController.current?.abort();
};
}, []);
const dismissMention = useCallback(() => {
setMentionActive(false);
setMentionQuery("");
setMentionStartIndex(-1);
setIssues([]);
setSelectedIndex(0);
setLoading(false);
}, []);
const detectMention = useCallback((text: string, cursorPosition: number): boolean => {
if (cursorPosition < 0 || cursorPosition > text.length) {
setMentionActive(false);
return false;
}
const isPathChar = (char: string): boolean => /[a-zA-Z0-9/_.-]/.test(char);
for (let i = cursorPosition - 1; i >= 0; i--) {
if (text[i] === "#") {
const validTrigger = i === 0 || /[\s,.;:!?'"()[\]{}]/.test(text[i - 1] ?? "");
if (!validTrigger) {
setMentionActive(false);
return false;
}
const query = text.slice(i + 1, cursorPosition);
if (query.includes("/") || query.includes(".")) {
setMentionActive(false);
return false;
}
if (query.length > 0 && !/^\d*$/.test(query)) {
// active for textual title search when not path-like
}
setMentionStartIndex(i);
setMentionQuery(query);
setSelectedIndex(0);
setMentionActive(true);
return true;
}
if (!isPathChar(text[i])) {
setMentionActive(false);
return false;
}
}
setMentionActive(false);
return false;
}, []);
const performSearch = useCallback(
async (query: string) => {
abortController.current?.abort();
const controller = new AbortController();
abortController.current = controller;
try {
setLoading(true);
const items = await fetchRecentIssues(projectId, query.trim() ? query : undefined);
if (!controller.signal.aborted) {
setIssues(items);
setSelectedIndex(0);
}
} catch {
if (!controller.signal.aborted) {
setIssues([]);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
},
[projectId],
);
useEffect(() => {
if (!mentionActive) return;
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
void performSearch(mentionQuery);
}, DEBOUNCE_MS);
return () => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
};
}, [mentionActive, mentionQuery, performSearch]);
/**
* Replaces `#<partial>` with `#<number> ` (trailing space), so users can continue typing naturally.
*/
const selectIssue = useCallback(
(issue: IssueMentionItem, currentText: string): IssueSelectResult => {
if (!mentionActive || mentionStartIndex < 0) {
return { text: currentText, cursorPosition: currentText.length };
}
const beforeMention = currentText.slice(0, mentionStartIndex);
const afterMention = currentText.slice(mentionStartIndex + 1);
const mentionEndMatch = afterMention.match(/[\s]|$/);
const mentionEndIndex = mentionEndMatch ? mentionEndMatch.index ?? afterMention.length : afterMention.length;
const afterCurrentMention = afterMention.slice(mentionEndIndex);
const replacement = `#${issue.number} `;
const text = `${beforeMention}${replacement}${afterCurrentMention}`;
const cursorPosition = beforeMention.length + replacement.length;
return { text, cursorPosition };
},
[mentionActive, mentionStartIndex],
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>): boolean => {
if (!mentionActive || issues.length === 0) return false;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, issues.length - 1));
return true;
case "ArrowUp":
event.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
return true;
case "Enter":
case "Tab":
if (issues[selectedIndex]) {
event.preventDefault();
return true;
}
return false;
case "Escape":
event.preventDefault();
dismissMention();
return true;
default:
return false;
}
},
[dismissMention, mentionActive, issues, selectedIndex],
);
return {
mentionActive,
issues,
loading,
mentionQuery,
selectedIndex,
setSelectedIndex,
detectMention,
dismissMention,
selectIssue,
handleKeyDown,
};
}