diff --git a/packages/core/issues/queries.test.ts b/packages/core/issues/queries.test.ts index fff921c594..27dd7b6fc6 100644 --- a/packages/core/issues/queries.test.ts +++ b/packages/core/issues/queries.test.ts @@ -3,12 +3,18 @@ import { QueryClient } from "@tanstack/react-query"; import { setApiInstance } from "../api"; import type { ApiClient } from "../api/client"; -import type { Issue, ListIssuesParams, ListIssuesResponse } from "../types"; +import type { + Issue, + ListIssuesParams, + ListIssuesResponse, + SearchIssuesResponse, +} from "../types"; import { CHILDREN_BY_PARENTS_CHUNK_SIZE, PROJECT_GANTT_MAX_ISSUES, PROJECT_GANTT_PAGE_LIMIT, childrenByParentsOptions, + issueIdentifierOptions, issueKeys, projectGanttIssuesOptions, } from "./queries"; @@ -54,6 +60,16 @@ function installFakeChildrenApi( setApiInstance({ listChildrenByParents } as unknown as ApiClient); } +function installFakeSearchApi( + searchIssues: (params: { q: string }) => Promise, +) { + setApiInstance({ searchIssues } as unknown as ApiClient); +} + +function makeSearchResult(idx: number, identifier: string) { + return { ...makeIssue(idx), identifier, match_source: "title" as const }; +} + describe("projectGanttIssuesOptions", () => { let qc: QueryClient; @@ -214,3 +230,68 @@ describe("childrenByParentsOptions chunking", () => { expect(grouped.get(lastId)).toHaveLength(1); }); }); + +describe("issueIdentifierOptions", () => { + let qc: QueryClient; + + beforeEach(() => { + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + + afterEach(() => { + qc.clear(); + vi.restoreAllMocks(); + }); + + it("returns the issue whose identifier exactly matches the query", async () => { + const searchIssues = vi + .fn<(params: { q: string }) => Promise>() + .mockResolvedValue({ + issues: [makeSearchResult(7, "MUL-7")], + total: 1, + }); + installFakeSearchApi(searchIssues); + + const data = await qc.fetchQuery(issueIdentifierOptions(WS_ID, "MUL-7")); + + expect(data?.id).toBe("issue-7"); + expect(searchIssues).toHaveBeenCalledWith( + expect.objectContaining({ q: "MUL-7" }), + ); + }); + + it("returns null when no result's identifier matches (wrong prefix / number-only hit)", async () => { + // Backend number-match returns MUL-7 for a TES-7 query; exact filter rejects it. + const searchIssues = vi + .fn<(params: { q: string }) => Promise>() + .mockResolvedValue({ + issues: [makeSearchResult(7, "MUL-7")], + total: 1, + }); + installFakeSearchApi(searchIssues); + + const data = await qc.fetchQuery(issueIdentifierOptions(WS_ID, "TES-7")); + + expect(data).toBeNull(); + }); + + it("returns null on an empty (or malformed→empty) search response", async () => { + const searchIssues = vi + .fn<(params: { q: string }) => Promise>() + .mockResolvedValue({ issues: [], total: 0 }); + installFakeSearchApi(searchIssues); + + const data = await qc.fetchQuery(issueIdentifierOptions(WS_ID, "MUL-999")); + + expect(data).toBeNull(); + }); + + it("keys the query by workspace and identifier", () => { + expect(issueKeys.identifier(WS_ID, "MUL-7")).toEqual([ + "issues", + WS_ID, + "identifier", + "MUL-7", + ]); + }); +}); diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index 3e416a05af..ef19cf4b45 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -57,6 +57,9 @@ export const issueKeys = { [...issueKeys.projectGanttAll(wsId), projectId] as const, detail: (wsId: string, id: string) => [...issueKeys.all(wsId), "detail", id] as const, + /** Resolve a bare issue identifier (e.g. "MUL-123") to an issue. */ + identifier: (wsId: string, identifier: string) => + [...issueKeys.all(wsId), "identifier", identifier] as const, children: (wsId: string, id: string) => [...issueKeys.all(wsId), "children", id] as const, /** Prefix for invalidating all batched-children queries in a workspace. */ @@ -410,6 +413,37 @@ export function issueDetailOptions(wsId: string, id: string) { }); } +/** + * Resolve a bare issue identifier ("MUL-123") to its issue, or `null`. + * + * Backs the Linear-style autolink: the backend `q` search matches an + * identifier on issue NUMBER only (prefix-agnostic — `MUL-123` and `TES-123` + * both hit number 123), so the exact `identifier === value` filter here is + * what enforces the workspace prefix. A non-existent or wrong-prefix + * identifier resolves to `null` and renders as plain text. + * + * Server state → TanStack Query; the key includes `wsId` and the identifier, + * so identical identifiers across the app share one request. Caller gates + * `enabled` (identifier shape + workspace prefix). + */ +export function issueIdentifierOptions(wsId: string, identifier: string) { + return queryOptions({ + queryKey: issueKeys.identifier(wsId, identifier), + queryFn: async ({ signal }) => { + const res = await api.searchIssues({ + q: identifier, + limit: 10, + include_closed: true, + signal, + }); + return res.issues.find((i) => i.identifier === identifier) ?? null; + }, + // Identifier→issue mapping is effectively immutable; avoid refetch churn + // when the same key renders across many comments/messages. + staleTime: 5 * 60_000, + }); +} + export function childIssueProgressOptions(wsId: string) { return queryOptions({ queryKey: issueKeys.childProgress(wsId), diff --git a/packages/ui/markdown/Markdown.tsx b/packages/ui/markdown/Markdown.tsx index cff5c923f2..8c9651e545 100644 --- a/packages/ui/markdown/Markdown.tsx +++ b/packages/ui/markdown/Markdown.tsx @@ -12,6 +12,7 @@ import { CODE_LIGATURE_CLASS } from '@multica/ui/lib/code-style' import { CodeBlock, InlineCode } from './CodeBlock' import { isAllowedFileCardHref, preprocessFileCards } from './file-cards' import { preprocessLinks } from './linkify' +import { preprocessIssueIdentifiers } from './issue-identifiers' import { preprocessMentionShortcodes } from './mentions' import 'katex/dist/katex.min.css' import './markdown.css' @@ -75,6 +76,15 @@ export interface MarkdownProps { * the views-package `` component. */ renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode + /** + * When true, bare issue identifiers (e.g. `MUL-123`, `TES-1`) are rewritten + * to `mention://issue/` links so `renderMention` can resolve them + * to a navigable issue chip. Off by default — enable only on surfaces whose + * `renderMention` knows how to resolve an identifier (see the app wrapper in + * packages/views/common/markdown.tsx). Detection is markdown-aware: code, + * existing links, URLs, and file/path tokens are skipped. + */ + autolinkIssueIdentifiers?: boolean } // Sanitization schema — extends GitHub defaults to allow code highlighting classes @@ -442,22 +452,27 @@ export function Markdown({ renderMention, renderImage, renderFileCard, - cdnDomain + cdnDomain, + autolinkIssueIdentifiers }: MarkdownProps): React.JSX.Element { const components = React.useMemo( () => createComponents(mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard), [mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard] ) - // Preprocess: convert mention shortcodes, raw URLs, and file cards to renderable content + // Preprocess: convert mention shortcodes, bare issue identifiers, raw URLs, + // and file cards to renderable content. Issue-identifier autolinking runs + // BEFORE linkify/file-card so those passes treat the rewritten spans as + // existing markdown links and skip them. const processedContent = React.useMemo( () => { let result = preprocessMentionShortcodes(children) + if (autolinkIssueIdentifiers) result = preprocessIssueIdentifiers(result) result = preprocessLinks(result) result = preprocessFileCards(result, cdnDomain ?? '') return result }, - [children, cdnDomain] + [children, cdnDomain, autolinkIssueIdentifiers] ) return ( diff --git a/packages/ui/markdown/index.ts b/packages/ui/markdown/index.ts index d11121fb27..795872a030 100644 --- a/packages/ui/markdown/index.ts +++ b/packages/ui/markdown/index.ts @@ -2,6 +2,11 @@ export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock' export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown' export { preprocessLinks, detectLinks, hasLinks } from './linkify' +export { + preprocessIssueIdentifiers, + isIssueIdentifier, + ISSUE_IDENTIFIER_PATTERN, +} from './issue-identifiers' export { preprocessMentionShortcodes } from './mentions' export { preprocessFileCards, diff --git a/packages/ui/markdown/issue-identifiers.ts b/packages/ui/markdown/issue-identifiers.ts new file mode 100644 index 0000000000..889a1bf434 --- /dev/null +++ b/packages/ui/markdown/issue-identifiers.ts @@ -0,0 +1,108 @@ +import { + detectLinks, + findCodeRanges, + findMarkdownLinkRanges, + isInsideCode, + rangesOverlap, +} from './linkify' + +/** + * Linear-style bare issue identifier autolinking for markdown preprocessing. + * + * Rewrites bare issue identifiers like `MUL-123` / `TES-1` into canonical + * mention links `[MUL-123](mention://issue/MUL-123)`, so the shared markdown + * `a`/mention renderers can route them to a navigable issue chip. + * + * This module is intentionally PURE (packages/ui): it has no workspace or API + * access. It only DETECTS candidate identifiers — whether one resolves to a + * real issue in the current workspace is decided later, in the views layer, + * via an exact-match lookup. A candidate that resolves to nothing renders as + * plain text, so over-detection here is harmless (at most one deduped lookup). + * We still skip code, existing links, URLs, and file/path tokens so non-prose + * content is never rewritten. + * + * NOTE: the `mention://issue/` transport is reused for both real UUID + * mentions and bare identifiers. Render sites tell them apart by the shape of + * the id segment (`isIssueIdentifier`) — a UUID never matches the identifier + * pattern, so the dispatch is unambiguous. + */ + +// Detection form (global). Prefix is one uppercase letter followed by uppercase +// alphanumerics, then `-`. Case-sensitive on purpose: lowercase +// `abc-1` in prose is almost never an issue reference, and matching it would +// linkify ordinary hyphenated words. The look-around excludes alphanumerics, +// `_`, and `-` on both sides so we only match standalone tokens. +const IDENTIFIER_RE = /(?`) from a real UUID. +export const ISSUE_IDENTIFIER_PATTERN = /^[A-Z][A-Z0-9]*-\d+$/ + +/** + * True when `value` is a bare issue identifier (e.g. "MUL-123"). A UUID never + * matches (lowercase hex, four dashes), so this cleanly separates autolinked + * identifiers from real mention UUIDs at render time. + */ +export function isIssueIdentifier(value: string): boolean { + return ISSUE_IDENTIFIER_PATTERN.test(value) +} + +/** + * Rewrite bare issue identifiers into canonical `mention://issue/` + * markdown links. Runs BEFORE `preprocessLinks`/`preprocessFileCards` so those + * passes see the rewritten spans as existing markdown links and skip them. + * + * Skipped contexts (never rewritten): + * - fenced code blocks, inline code, and math (findCodeRanges) + * - existing markdown links / images (findMarkdownLinkRanges) + * - detected URLs / emails / file paths (detectLinks) + * - dotted filename tails like `ABC-123.ts` and path segments like `FOO-1/bar` + */ +export function preprocessIssueIdentifiers(text: string): string { + // Cheap early-out: no `…-` shape at all. + if (!/[A-Z][A-Z0-9]*-\d/.test(text)) return text + + const codeRanges = findCodeRanges(text) + const linkRanges = findMarkdownLinkRanges(text) + const detectedLinks = detectLinks(text) + + IDENTIFIER_RE.lastIndex = 0 + let result = '' + let lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = IDENTIFIER_RE.exec(text)) !== null) { + const identifier = match[1] + if (!identifier) continue + const start = match.index + const end = start + identifier.length + const range = { start, end } + + // Inside fenced/inline code or math. + if (isInsideCode(start, codeRanges)) continue + // Inside an existing markdown link/image (label OR destination). + if (linkRanges.some((r) => rangesOverlap(range, r))) continue + // Inside a detected URL / email / file path. + if (detectedLinks.some((l) => rangesOverlap(range, l))) continue + + // Dotted continuation such as `ABC-123.ts` (filename) or `ABC-123.tar.gz`: + // a `.` immediately followed by an alphanumeric means the token is part of + // a larger dotted name. A trailing `.` before whitespace/EOL is a sentence + // end ("see MUL-1.") and stays linkable. + const after = text[end] + if (after === '.' && /[A-Za-z0-9]/.test(text[end + 1] ?? '')) continue + // Path segment such as `FOO-1/bar` or `foo/BAR-1` — a `/` on either side + // signals a path rather than a standalone reference. + if (after === '/' || text[start - 1] === '/') continue + // Embedded in a dotted name on the left (`file.MUL-1`). + if (text[start - 1] === '.') continue + + result += text.slice(lastIndex, start) + result += `[${identifier}](mention://issue/${identifier})` + lastIndex = end + } + + if (lastIndex === 0) return text + result += text.slice(lastIndex) + return result +} diff --git a/packages/ui/markdown/linkify.ts b/packages/ui/markdown/linkify.ts index c62bae6274..88e29de118 100644 --- a/packages/ui/markdown/linkify.ts +++ b/packages/ui/markdown/linkify.ts @@ -55,7 +55,7 @@ interface DetectedLink { end: number } -interface CodeRange { +export interface CodeRange { start: number end: number } @@ -64,7 +64,7 @@ interface CodeRange { * Find all code block and inline code ranges in text * These ranges should be excluded from link detection */ -function findCodeRanges(text: string): CodeRange[] { +export function findCodeRanges(text: string): CodeRange[] { const ranges: CodeRange[] = [] // Find fenced code blocks (```...```) @@ -112,7 +112,7 @@ function findCodeRanges(text: string): CodeRange[] { /** * Check if a position is inside any code range */ -function isInsideCode(pos: number, ranges: CodeRange[]): boolean { +export function isInsideCode(pos: number, ranges: CodeRange[]): boolean { return ranges.some((r) => pos >= r.start && pos < r.end) } @@ -164,7 +164,7 @@ function findInlineLinkEnd(text: string, openParenIndex: number): number { * Find existing markdown link/image spans so auto-linkification does not create * nested links inside their labels or destinations. */ -function findMarkdownLinkRanges(text: string): CodeRange[] { +export function findMarkdownLinkRanges(text: string): CodeRange[] { const ranges: CodeRange[] = [] for (let i = 0; i < text.length; i++) { @@ -223,7 +223,7 @@ function isAlreadyLinked(text: string, linkStart: number, linkEnd: number): bool /** * Check if ranges overlap */ -function rangesOverlap( +export function rangesOverlap( a: { start: number; end: number }, b: { start: number; end: number } ): boolean { diff --git a/packages/views/common/markdown.test.tsx b/packages/views/common/markdown.test.tsx index f752b35181..0e3450e33f 100644 --- a/packages/views/common/markdown.test.tsx +++ b/packages/views/common/markdown.test.tsx @@ -1,9 +1,15 @@ import type { ReactNode } from "react"; import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Markdown as MarkdownBase } from "@multica/ui/markdown"; import { Markdown } from "./markdown"; +const { resolveMock } = vi.hoisted(() => ({ resolveMock: vi.fn() })); + +vi.mock("../issues/hooks", () => ({ + useResolveIssueIdentifier: (identifier: string) => resolveMock(identifier), +})); + vi.mock("@multica/core/config", () => ({ useConfigStore: (selector: (state: { cdnDomain: string }) => unknown) => selector({ cdnDomain: "" }), @@ -92,6 +98,54 @@ describe("Markdown", () => { }); }); +describe("Markdown bare issue identifier autolink", () => { + afterEach(() => { + resolveMock.mockReset(); + }); + + it("renders a resolved bare identifier as an issue chip", () => { + resolveMock.mockImplementation((id: string) => + id === "MUL-1" ? { id: "issue-1", identifier: "MUL-1" } : null, + ); + + render({"Related to MUL-1 today"}); + + expect(screen.getByTestId("issue-mention-card")).toHaveTextContent("issue-1"); + expect(resolveMock).toHaveBeenCalledWith("MUL-1"); + }); + + it("leaves an unresolved bare identifier as plain text", () => { + resolveMock.mockReturnValue(null); + + render({"Related to MUL-999 today"}); + + expect(screen.queryByTestId("issue-mention-card")).toBeNull(); + expect(screen.getByText(/Related to/)).toHaveTextContent("MUL-999"); + }); + + it("does not autolink identifiers inside inline code", () => { + resolveMock.mockReturnValue(null); + + render({"use `MUL-1` here"}); + + expect(resolveMock).not.toHaveBeenCalled(); + expect(screen.queryByTestId("issue-mention-card")).toBeNull(); + }); + + it("routes a canonical UUID mention straight to the chip (no identifier resolve)", () => { + render( + + {"[MUL-2](mention://issue/00000000-0000-0000-0000-000000000002)"} + , + ); + + expect(screen.getByTestId("issue-mention-card")).toHaveTextContent( + "00000000-0000-0000-0000-000000000002", + ); + expect(resolveMock).not.toHaveBeenCalled(); + }); +}); + // The base renderer uses a plain ; exercising it here (instead of the // views wrapper, which swaps in ) lets us assert the sanitized // `src` directly. Covers the two gates that used to blank data-URI images: diff --git a/packages/views/common/markdown.tsx b/packages/views/common/markdown.tsx index 21337606a2..920efda33c 100644 --- a/packages/views/common/markdown.tsx +++ b/packages/views/common/markdown.tsx @@ -5,11 +5,13 @@ import { Markdown as MarkdownBase, type MarkdownProps as MarkdownBaseProps, type RenderMode, + isIssueIdentifier, } from "@multica/ui/markdown"; import { useConfigStore } from "@multica/core/config"; import type { Attachment as AttachmentRecord } from "@multica/core/types"; import { useWorkspacePaths } from "@multica/core/paths"; import { IssueMentionCard } from "../issues/components/issue-mention-card"; +import { useResolveIssueIdentifier } from "../issues/hooks"; import { ProjectChip } from "../projects/components/project-chip"; import { AppLink } from "../navigation"; import { @@ -46,6 +48,18 @@ function ProjectMentionCard({ projectId }: { projectId: string }): React.ReactNo ); } +/** + * Autolinked bare identifier (e.g. `MUL-123`) routed through + * `mention://issue/`. Resolves the identifier to a real issue in + * the current workspace; renders a navigable chip on a hit, plain text on a + * miss / while loading / cross-workspace. + */ +function AutolinkedIssueMention({ identifier }: { identifier: string }): React.ReactNode { + const issue = useResolveIssueIdentifier(identifier); + if (!issue) return identifier; + return ; +} + function defaultRenderMention({ type, id, @@ -54,6 +68,11 @@ function defaultRenderMention({ id: string; }): React.ReactNode { if (type === "issue") { + // A bare identifier (from the autolink preprocessor) is carried as the id + // segment; a real mention carries a UUID. Dispatch on the id shape. + if (isIssueIdentifier(id)) { + return ; + } return ; } if (type === "project") { @@ -110,6 +129,7 @@ export function Markdown(props: MarkdownProps): React.JSX.Element { renderImage={renderImage} renderFileCard={renderFileCard} cdnDomain={cdnDomain} + autolinkIssueIdentifiers {...rest} /> diff --git a/packages/views/editor/content-editor.tsx b/packages/views/editor/content-editor.tsx index 87206c5f86..11390cf063 100644 --- a/packages/views/editor/content-editor.tsx +++ b/packages/views/editor/content-editor.tsx @@ -44,6 +44,9 @@ import { cn } from "@multica/ui/lib/utils"; import type { UploadResult } from "@multica/core/hooks/use-file-upload"; import { useWorkspaceSlug } from "@multica/core/paths"; import { useQueryClient } from "@tanstack/react-query"; +import { issueIdentifierOptions } from "@multica/core/issues/queries"; +import { workspaceListOptions } from "@multica/core/workspace/queries"; +import { isIssueIdentifier } from "@multica/ui/markdown"; import type { Attachment } from "@multica/core/types"; import { parseMarkdownChunked, @@ -51,6 +54,7 @@ import { type MarkdownManagerLike, } from "./utils/parse-markdown-chunked"; import type { MentionItem } from "./extensions/mention-suggestion"; +import type { IssueIdentifierResolver } from "./extensions/issue-identifier-autolink"; import { createEditorExtensions } from "./extensions"; import { uploadAndInsertFile } from "./extensions/file-upload"; import { preprocessMarkdown } from "./utils/preprocess"; @@ -304,6 +308,35 @@ const ContentEditor = forwardRef( const queryClient = useQueryClient(); + // Linear-style bare identifier autolink resolver. Fully lazy — it runs only + // on user input, never on render, so it adds no query hook to this widely + // used component. It reads the current workspace from the query cache (via + // the slug ref) and returns null outside a workspace, for non-identifier + // tokens, or when the prefix can't match this workspace, so no network call + // happens for those; the exact-match filter enforces correctness. + const resolveIssueIdentifierRef = useRef( + undefined, + ); + resolveIssueIdentifierRef.current = async (identifier) => { + if (!isIssueIdentifier(identifier)) return null; + const slug = workspaceSlugRef.current; + if (!slug) return null; + const workspaces = await queryClient.fetchQuery(workspaceListOptions()); + const ws = workspaces.find((w) => w.slug === slug); + if (!ws) return null; + const prefix = ws.issue_prefix; + if ( + prefix && + !identifier.toUpperCase().startsWith(`${prefix.toUpperCase()}-`) + ) { + return null; + } + const issue = await queryClient.fetchQuery( + issueIdentifierOptions(ws.id, identifier), + ); + return issue ? { id: issue.id, identifier: issue.identifier } : null; + }; + const initialContent = defaultValue ? preprocessMarkdown(defaultValue) : ""; // With `immediatelyRender: false` the Tiptap instance is created after // mount, so an imperative `focus()` fired on the same tick (e.g. chat @@ -364,6 +397,7 @@ const ContentEditor = forwardRef( getMentionContextItems: () => mentionContextItemsRef.current, enableSlashCommands, slashCommandMode, + resolveIssueIdentifierRef, }), onUpdate: ({ editor: ed }) => { if (!onUpdateRef.current) return; diff --git a/packages/views/editor/extensions/index.ts b/packages/views/editor/extensions/index.ts index aff21fda31..cd4c5d4556 100644 --- a/packages/views/editor/extensions/index.ts +++ b/packages/views/editor/extensions/index.ts @@ -39,6 +39,10 @@ import type { UploadResult } from "@multica/core/hooks/use-file-upload"; import { escapeMarkdownLabel } from "../utils/escape-markdown-label"; import { BaseMentionExtension } from "./mention-extension"; import { createMentionSuggestion, type MentionItem } from "./mention-suggestion"; +import { + createIssueIdentifierAutolinkExtension, + type IssueIdentifierResolver, +} from "./issue-identifier-autolink"; import { SlashCommandExtension } from "./slash-command-extension"; import { createSlashCommandSuggestion, createBuiltinCommandSuggestion } from "./slash-command-suggestion"; import { CodeBlockView } from "./code-block-view"; @@ -152,6 +156,14 @@ export interface EditorExtensionsOptions { * - "command" — the fixed built-in command menu (issue comments), e.g. /note. */ slashCommandMode?: "skill" | "command"; + /** + * Resolver for Linear-style bare issue-identifier autolinking. When present + * (and mentions are enabled), typing a boundary after `MUL-123` or pasting + * text with identifiers resolves them and swaps in real issue mentions. A + * ref so the editor is created once while the resolver reads live workspace + * context; the setup layer owns React Query + workspace access. + */ + resolveIssueIdentifierRef?: RefObject; } export function createEditorExtensions( @@ -215,6 +227,16 @@ export function createEditorExtensions( ? { suggestion: createMentionSuggestion(options.queryClient, { mode: options.mentionMode, getContextItems: options.getMentionContextItems }) } : {}), }), + // Linear-style bare identifier → issue mention. Attached only when a + // resolver is provided AND mention creation is enabled (an editor that + // suppresses new mentions should not synthesise them from identifiers). + ...(!options.disableMentions && options.resolveIssueIdentifierRef + ? [ + createIssueIdentifierAutolinkExtension({ + resolveRef: options.resolveIssueIdentifierRef, + }), + ] + : []), SlashCommandExtension.configure({ HTMLAttributes: { class: "slash-command" }, suggestion: !options.enableSlashCommands diff --git a/packages/views/editor/extensions/issue-identifier-autolink.test.ts b/packages/views/editor/extensions/issue-identifier-autolink.test.ts new file mode 100644 index 0000000000..3fba4ff4f6 --- /dev/null +++ b/packages/views/editor/extensions/issue-identifier-autolink.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import Link from "@tiptap/extension-link"; +import { Markdown } from "@tiptap/markdown"; +import type { RefObject } from "react"; +import { BaseMentionExtension } from "./mention-extension"; +import { + createIssueIdentifierAutolinkExtension, + type IssueIdentifierResolver, +} from "./issue-identifier-autolink"; + +const LinkExtension = Link.extend({ inclusive: false }).configure({ + openOnClick: false, + autolink: false, +}); + +/** Count canonical issue mentions in serialised markdown. */ +function mentionCount(markdown: string): number { + return markdown.match(/mention:\/\/issue\//g)?.length ?? 0; +} + +// The real mention extension renders via a React NodeView (IssueChip → workspace +// hooks) that needs providers we don't mount here. Swap in a plain-DOM NodeView +// so the node still serialises through the inherited renderMarkdown, without +// pulling React into the jsdom editor. +const TestMention = BaseMentionExtension.extend({ + addNodeView() { + return () => ({ dom: document.createElement("span") }); + }, +}); + +const resolveMock = vi.fn(); +const resolveRef: RefObject = { + current: (identifier) => resolveMock(identifier), +}; + +let editor: Editor | null = null; + +function makeEditor(): Editor { + const element = document.createElement("div"); + document.body.appendChild(element); + return new Editor({ + element, + extensions: [ + StarterKit.configure({ link: false }), + LinkExtension, + TestMention, + createIssueIdentifierAutolinkExtension({ resolveRef }), + Markdown.configure({ indentation: { style: "space", size: 3 } }), + ], + }); +} + +/** Flush the resolver microtask + the follow-up replacement dispatch. */ +function flush(): Promise { + return new Promise((r) => setTimeout(r, 0)); +} + +/** Simulate user typing `text` at position 1 (empty paragraph start). */ +function typeAt1(ed: Editor, text: string): void { + const tr = ed.state.tr.insertText(text, 1); + ed.view.dispatch(tr); +} + +beforeEach(() => { + resolveMock.mockReset(); +}); + +afterEach(() => { + editor?.destroy(); + editor = null; + document.body.innerHTML = ""; +}); + +describe("createIssueIdentifierAutolinkExtension", () => { + it("converts a completed identifier into a canonical issue mention", async () => { + resolveMock.mockResolvedValue({ id: "uuid-1", identifier: "MUL-1" }); + editor = makeEditor(); + + // Typing "MUL-1 " leaves the caret right after the boundary space, which + // completes the previous token. + typeAt1(editor, "MUL-1 "); + await flush(); + + expect(resolveMock).toHaveBeenCalledWith("MUL-1"); + expect(editor.getMarkdown().trim()).toBe( + "[MUL-1](mention://issue/uuid-1)", + ); + }); + + it("leaves an unresolvable identifier as plain text", async () => { + resolveMock.mockResolvedValue(null); + editor = makeEditor(); + + typeAt1(editor, "MUL-9 "); + await flush(); + + expect(resolveMock).toHaveBeenCalledWith("MUL-9"); + const md = editor.getMarkdown(); + expect(md).toContain("MUL-9"); + expect(md).not.toContain("mention://issue"); + }); + + it("converts identifiers found inside pasted text", async () => { + resolveMock.mockResolvedValue({ id: "uuid-2", identifier: "MUL-2" }); + editor = makeEditor(); + + const tr = editor.state.tr.insertText("See MUL-2 now", 1); + tr.setMeta("paste", true); + editor.view.dispatch(tr); + await flush(); + + expect(resolveMock).toHaveBeenCalledWith("MUL-2"); + expect(editor.getMarkdown().trim()).toBe( + "See [MUL-2](mention://issue/uuid-2) now", + ); + }); + + it("does not convert content set programmatically (open ≠ rewrite)", async () => { + resolveMock.mockResolvedValue({ id: "uuid-3", identifier: "MUL-3" }); + editor = makeEditor(); + + // setContent uses emitUpdate:false (preventUpdate) — the same path the real + // editor uses on mount and WS-driven resets. + editor.commands.setContent("MUL-3 stays", { + emitUpdate: false, + contentType: "markdown", + }); + await flush(); + + expect(resolveMock).not.toHaveBeenCalled(); + expect(editor.getMarkdown()).toContain("MUL-3"); + expect(editor.getMarkdown()).not.toContain("mention://issue"); + }); + + it("does not convert an identifier inside inline code", async () => { + resolveMock.mockResolvedValue({ id: "uuid-4", identifier: "MUL-4" }); + editor = makeEditor(); + + const codeMark = editor.schema.marks.code!.create(); + const tr = editor.state.tr.insert( + 1, + editor.schema.text("MUL-4 ", [codeMark]), + ); + editor.view.dispatch(tr); + await flush(); + + expect(resolveMock).not.toHaveBeenCalled(); + expect(editor.getMarkdown()).not.toContain("mention://issue"); + }); + + it("does not fire while the identifier is still being typed (no boundary yet)", async () => { + resolveMock.mockResolvedValue({ id: "uuid-5", identifier: "MUL-5" }); + editor = makeEditor(); + + typeAt1(editor, "MUL-5"); + await flush(); + + expect(resolveMock).not.toHaveBeenCalled(); + expect(editor.getMarkdown()).not.toContain("mention://issue"); + }); + + // --- blocker regressions: replace ONLY the captured range --------------- + + it("converts only the newly-typed occurrence, not a pre-existing identical one", async () => { + resolveMock.mockResolvedValue({ id: "uuid-1", identifier: "MUL-1" }); + editor = makeEditor(); + + // Pre-existing MUL-1 arrives via a programmatic (preventUpdate) set — the + // path the real editor uses on mount / WS resets — so it is not captured. + editor.commands.setContent("old MUL-1 here", { + emitUpdate: false, + contentType: "markdown", + }); + await flush(); + expect(resolveMock).not.toHaveBeenCalled(); + + // User now types a brand-new MUL-1 at the end of the paragraph. + const endOfPara = editor.state.doc.content.size - 1; + editor.view.dispatch(editor.state.tr.insertText(" MUL-1 ", endOfPara)); + await flush(); + + const md = editor.getMarkdown(); + // Exactly the new occurrence became a mention; the old one stays text. + expect(mentionCount(md)).toBe(1); + expect(md).toContain("old MUL-1 here"); + expect(md).toContain("mention://issue/uuid-1"); + }); + + it("paste converts identifiers inside the paste range but not identical ones outside it", async () => { + resolveMock.mockImplementation(async (identifier) => { + const map: Record = { + "MUL-2": "uuid-2", + "MUL-3": "uuid-3", + "MUL-9": "uuid-9", + }; + const id = map[identifier]; + return id ? { id, identifier } : null; + }); + editor = makeEditor(); + + // Pre-existing (programmatic) MUL-9 outside any future paste range. + editor.commands.setContent("keep MUL-9 outside", { + emitUpdate: false, + contentType: "markdown", + }); + await flush(); + + // Paste two identifiers at the very start of the doc. + const tr = editor.state.tr.insertText("MUL-2 plus MUL-3 x ", 1); + tr.setMeta("paste", true); + editor.view.dispatch(tr); + await flush(); + + const md = editor.getMarkdown(); + // Both pasted identifiers converted; the outside MUL-9 did NOT. + expect(md).toContain("mention://issue/uuid-2"); + expect(md).toContain("mention://issue/uuid-3"); + expect(md).not.toContain("mention://issue/uuid-9"); + expect(md).toContain("MUL-9"); + expect(mentionCount(md)).toBe(2); + expect(resolveMock).not.toHaveBeenCalledWith("MUL-9"); + }); + + it("does not replace an identifier that already carries an explicit link mark", async () => { + resolveMock.mockResolvedValue({ id: "uuid-1", identifier: "MUL-1" }); + editor = makeEditor(); + + // A link-marked "MUL-1" (e.g. an existing markdown link label) followed by + // plain text, then the user types a boundary after it. + const linkMark = editor.schema.marks.link!.create({ href: "https://x.test" }); + const linked = editor.schema.text("MUL-1", [linkMark]); + const trailing = editor.schema.text(" "); + const paragraph = editor.schema.nodes.paragraph!.create(null, [ + linked, + trailing, + ]); + editor.view.dispatch( + editor.state.tr.replaceWith(0, editor.state.doc.content.size, paragraph), + ); + await flush(); + + expect(resolveMock).not.toHaveBeenCalled(); + const md = editor.getMarkdown(); + expect(md).not.toContain("mention://issue"); + expect(md).toContain("https://x.test"); + }); +}); diff --git a/packages/views/editor/extensions/issue-identifier-autolink.ts b/packages/views/editor/extensions/issue-identifier-autolink.ts new file mode 100644 index 0000000000..6013dd9a9e --- /dev/null +++ b/packages/views/editor/extensions/issue-identifier-autolink.ts @@ -0,0 +1,348 @@ +/** + * IssueIdentifierAutolink — Linear-style live autolinking of bare issue + * identifiers (e.g. `MUL-123`) in the editable editor. + * + * When the user finishes a bare identifier by typing a boundary character + * (space / punctuation) after it, or pastes text containing identifiers, the + * completed token is resolved against the workspace and — on an exact match — + * replaced with a real `issue` mention node. On save the mention serialises to + * the canonical `[MUL-123](mention://issue/)`. + * + * Resolution is async, so this is NOT a synchronous Tiptap InputRule/PasteRule. + * A ProseMirror plugin captures the SPECIFIC candidate range(s) introduced by a + * user transaction — the token before the caret when typing, or the tokens + * inside the pasted slice — into plugin state, mapping each range forward on + * every subsequent transaction so it stays current across the async gap. A + * plugin `view` resolves the captured identifiers and replaces ONLY those + * mapped ranges (after re-verifying the range still holds exactly that + * identifier, with intact boundaries and no code/link mark). It never rescans + * the document by identifier, so a pre-existing or out-of-paste occurrence of + * the same identifier that the user did not touch is left untouched. Misses and + * errors are cached so a token is resolved at most once per editing session. + * + * Only genuine user edits seed candidates (never programmatic setContent), so + * merely opening existing content does not rewrite it. The resolver is injected + * (a ref) from the editor setup layer, which owns React Query + workspace + * context; this extension never touches React hooks. + */ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import type { EditorState, Transaction } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; +import type { Mark, Node as PMNode, NodeType } from "@tiptap/pm/model"; +import type { RefObject } from "react"; + +export interface ResolvedIssueRef { + /** Issue UUID. */ + id: string; + /** Canonical identifier as returned by the server, e.g. "MUL-123". */ + identifier: string; +} + +export type IssueIdentifierResolver = ( + identifier: string, +) => Promise; + +export interface IssueIdentifierAutolinkOptions { + /** + * Ref to the resolver. A ref (not a bare function) so the editor is created + * once while the resolver always reads the latest workspace context. + */ + resolveRef: RefObject; +} + +// Boundary-delimited, case-sensitive identifier — same shape as the readonly +// detector in @multica/ui/markdown. +const IDENTIFIER_RE = /(?("issueIdentifierAutolink"); + +/** Text nodes that must never be autolinked (code and explicit-link contexts). */ +function isSkippedTextNode( + marks: readonly Mark[], + parent: PMNode | null, +): boolean { + if (marks.some((m) => m.type.name === "code" || m.type.name === "link")) { + return true; + } + if (parent && parent.type.name === "codeBlock") return true; + return false; +} + +/** + * Complete, standalone identifier tokens whose range intersects + * [rangeFrom, rangeTo). "Complete" means a trailing boundary char exists within + * the same text node — a token still being typed at the very end is skipped. + */ +function collectCandidates( + state: EditorState, + rangeFrom = 0, + rangeTo = Number.POSITIVE_INFINITY, +): Candidate[] { + const out: Candidate[] = []; + state.doc.descendants((node, pos, parent) => { + if (!node.isText || !node.text) return; + if (isSkippedTextNode(node.marks, parent)) return; + const text = node.text; + IDENTIFIER_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = IDENTIFIER_RE.exec(text)) !== null) { + const identifier = m[1]; + if (!identifier) continue; + const localEnd = m.index + identifier.length; + if (localEnd >= text.length) continue; // no trailing boundary in-node + const from = pos + m.index; + const to = pos + localEnd; + if (to <= rangeFrom || from >= rangeTo) continue; // outside window + out.push({ identifier, from, to }); + } + }); + return out; +} + +/** Absolute [from,to) range spanned by a transaction's changes, or null. */ +function changedRange(tr: Transaction): { from: number; to: number } | null { + let from = Number.POSITIVE_INFINITY; + let to = -1; + tr.mapping.maps.forEach((map) => { + map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + from = Math.min(from, newStart); + to = Math.max(to, newEnd); + }); + }); + if (to < 0) return null; + return { from, to }; +} + +/** + * Candidates introduced by a single user transaction: + * - paste: every complete identifier INSIDE the pasted range only + * - typing: the token immediately before the cursor, i.e. the "previous + * token" completed by the boundary char that was just typed + */ +function candidatesFromUserTransaction( + tr: Transaction, + state: EditorState, +): Candidate[] { + const isPaste = + tr.getMeta("paste") === true || tr.getMeta("uiEvent") === "paste"; + + if (isPaste) { + const range = changedRange(tr); + if (!range) return []; + return collectCandidates(state, range.from, range.to); + } + + const caret = state.selection.from; + const target = collectCandidates(state).find((c) => c.to === caret - 1); + return target ? [target] : []; +} + +/** + * Verify a mapped candidate range still holds exactly `identifier` as plain + * text with intact boundaries — the async safety net before replacing. + */ +function rangeStillPlainIdentifier( + state: EditorState, + from: number, + to: number, + identifier: string, +): boolean { + const size = state.doc.content.size; + if (from < 0 || to > size || from >= to) return false; + if (state.doc.textBetween(from, to) !== identifier) return false; + + let ok = true; + state.doc.nodesBetween(from, to, (node, _pos, parent) => { + if (node.isText && isSkippedTextNode(node.marks, parent)) ok = false; + }); + if (!ok) return false; + + const before = from > 0 ? state.doc.textBetween(from - 1, from) : ""; + const after = to < size ? state.doc.textBetween(to, to + 1) : ""; + if (before && BOUNDARY_RE.test(before)) return false; + if (after && BOUNDARY_RE.test(after)) return false; + return true; +} + +export function createIssueIdentifierAutolinkExtension( + options: IssueIdentifierAutolinkOptions, +): Extension { + return Extension.create({ + name: "issueIdentifierAutolink", + + addProseMirrorPlugins() { + const maybeMentionType = this.editor.schema.nodes.mention; + if (!maybeMentionType) return []; + // Alias to a definitely-defined const so the narrowing survives into the + // nested plugin `view`/`applyReady` closures. + const mentionType: NodeType = maybeMentionType; + const resolveRef = options.resolveRef; + + return [ + new Plugin({ + key: pluginKey, + state: { + init: () => ({ pending: [], seq: 0 }), + apply(tr, value, _oldState, newState): AutolinkPluginState { + let pending = value.pending; + let seq = value.seq; + + // 1. Keep captured ranges current across every doc change, and + // drop ranges the edit invalidated (collapsed to empty). + if (tr.docChanged && pending.length > 0) { + pending = pending + .map((c) => ({ + ...c, + from: tr.mapping.map(c.from, 1), + to: tr.mapping.map(c.to, -1), + })) + .filter((c) => c.from < c.to); + } + + // 2. Drop candidates consumed by our own replacement transaction. + const remove = tr.getMeta(META_REMOVE) as number[] | undefined; + if (remove && remove.length > 0) { + const rm = new Set(remove); + pending = pending.filter((c) => !rm.has(c.key)); + } + + // 3. Capture new candidates from a genuine user edit only (never + // programmatic setContent or our own replacement). + const isUserEdit = + tr.docChanged && !tr.getMeta("preventUpdate") && !remove; + if (isUserEdit) { + const fresh = candidatesFromUserTransaction(tr, newState); + if (fresh.length > 0) { + pending = pending.concat( + fresh.map((c) => ({ key: seq++, ...c })), + ); + } + } + + if (pending === value.pending && seq === value.seq) return value; + return { pending, seq }; + }, + }, + view(view) { + // identifier → resolved ref (null = miss); resolved at most once. + const resultCache = new Map(); + const inFlight = new Set(); + let destroyed = false; + let scheduled = false; + + // Apply all ready (resolved) pending candidates on a microtask, so + // we never dispatch synchronously from inside `update`. + function scheduleApply(): void { + if (scheduled || destroyed) return; + scheduled = true; + void Promise.resolve().then(() => { + scheduled = false; + if (!destroyed) applyReady(view); + }); + } + + function applyReady(v: EditorView): void { + const st = pluginKey.getState(v.state); + if (!st || st.pending.length === 0) return; + + const removeKeys: number[] = []; + const replacements: { + from: number; + to: number; + ref: ResolvedIssueRef; + }[] = []; + + for (const c of st.pending) { + if (!resultCache.has(c.identifier)) continue; + const ref = resultCache.get(c.identifier); + removeKeys.push(c.key); // processed either way + if ( + ref && + rangeStillPlainIdentifier(v.state, c.from, c.to, c.identifier) + ) { + replacements.push({ from: c.from, to: c.to, ref }); + } + } + if (removeKeys.length === 0) return; + + const { tr } = v.state; + // Right-to-left so earlier ranges stay valid as later ones change. + replacements.sort((a, b) => b.from - a.from); + for (const r of replacements) { + tr.replaceWith( + r.from, + r.to, + mentionType.create({ + id: r.ref.id, + label: r.ref.identifier, + type: "issue", + }), + ); + } + tr.setMeta(META_REMOVE, removeKeys); + v.dispatch(tr); + } + + return { + update() { + if (destroyed) return; + const resolve = resolveRef.current; + if (!resolve) return; + const st = pluginKey.getState(view.state); + if (!st || st.pending.length === 0) return; + + let anyReady = false; + for (const c of st.pending) { + if (resultCache.has(c.identifier)) { + anyReady = true; + continue; + } + if (inFlight.has(c.identifier)) continue; + inFlight.add(c.identifier); + Promise.resolve(resolve(c.identifier)) + .then((ref) => { + resultCache.set(c.identifier, ref ?? null); + inFlight.delete(c.identifier); + scheduleApply(); + }) + .catch(() => { + // Treat resolve failures as a miss so we don't spin. + resultCache.set(c.identifier, null); + inFlight.delete(c.identifier); + scheduleApply(); + }); + } + if (anyReady) scheduleApply(); + }, + destroy() { + destroyed = true; + }, + }; + }, + }), + ]; + }, + }); +} diff --git a/packages/views/editor/readonly-content.test.tsx b/packages/views/editor/readonly-content.test.tsx index da39939613..6c0932825e 100644 --- a/packages/views/editor/readonly-content.test.tsx +++ b/packages/views/editor/readonly-content.test.tsx @@ -4,8 +4,16 @@ import type { ReactElement } from "react"; import { readFileSync } from "node:fs"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -const { getAttachmentTextContentMock } = vi.hoisted(() => ({ - getAttachmentTextContentMock: vi.fn(), +const { getAttachmentTextContentMock, resolveIssueIdentifierMock } = vi.hoisted( + () => ({ + getAttachmentTextContentMock: vi.fn(), + resolveIssueIdentifierMock: vi.fn(), + }), +); + +vi.mock("../issues/hooks", () => ({ + useResolveIssueIdentifier: (identifier: string) => + resolveIssueIdentifierMock(identifier), })); vi.mock("@multica/core/api", () => ({ @@ -221,6 +229,41 @@ describe("ReadonlyContent issue mention Markdown", () => { expect(getByTestId("issue-mention-card").textContent).toBe("MUL-123"); }); + it("autolinks a resolved bare identifier as an issue mention card", () => { + resolveIssueIdentifierMock.mockImplementation((id: string) => + id === "MUL-7" ? { id: "issue-7", identifier: "MUL-7" } : null, + ); + + const { getByTestId } = render( + , + ); + + expect(getByTestId("issue-mention-card").textContent).toBe("MUL-7"); + expect(resolveIssueIdentifierMock).toHaveBeenCalledWith("MUL-7"); + }); + + it("leaves an unresolved bare identifier as plain text", () => { + resolveIssueIdentifierMock.mockReturnValue(null); + + const { container, queryByTestId } = render( + , + ); + + expect(queryByTestId("issue-mention-card")).toBeNull(); + expect(container.textContent).toContain("MUL-999"); + }); + + it("does not autolink a bare identifier inside inline code", () => { + resolveIssueIdentifierMock.mockReturnValue(null); + + const { queryByTestId } = render( + , + ); + + expect(resolveIssueIdentifierMock).not.toHaveBeenCalled(); + expect(queryByTestId("issue-mention-card")).toBeNull(); + }); + it("documents the CommonMark quoted-emphasis edge case before Korean particles", () => { const unsafe = render( , diff --git a/packages/views/editor/readonly-content.tsx b/packages/views/editor/readonly-content.tsx index 8952e9959b..58d71476a8 100644 --- a/packages/views/editor/readonly-content.tsx +++ b/packages/views/editor/readonly-content.tsx @@ -38,10 +38,11 @@ import type { Attachment } from "@multica/core/types"; import { useT } from "../i18n"; import { useNavigation } from "../navigation"; import { IssueMentionCard } from "../issues/components/issue-mention-card"; +import { useResolveIssueIdentifier } from "../issues/hooks"; import { ProjectChip } from "../projects/components/project-chip"; import { useLinkHover, LinkHoverCard } from "./link-hover-card"; import { openLink, isMentionHref } from "./utils/link-handler"; -import { isAllowedFileCardHref } from "@multica/ui/markdown"; +import { isAllowedFileCardHref, isIssueIdentifier } from "@multica/ui/markdown"; import { preprocessMarkdown } from "./utils/preprocess"; import { highlightToHtml } from "./utils/highlight-markdown"; import { MermaidDiagram } from "./mermaid-diagram"; @@ -154,6 +155,18 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string ); } +/** + * Autolinked bare identifier (e.g. `MUL-123`) routed through + * `mention://issue/` by the readonly preprocessor. Resolves to a + * real issue in the current workspace; renders a navigable mention on a hit, + * plain text on a miss / while loading / cross-workspace. + */ +function AutolinkedIssueMentionLink({ identifier }: { identifier: string }) { + const issue = useResolveIssueIdentifier(identifier); + if (!issue) return <>{identifier}; + return ; +} + function ProjectMentionLink({ projectId, label }: { projectId: string; label?: string }) { const { push, openInNewTab } = useNavigation(); const p = useWorkspacePaths(); @@ -247,6 +260,11 @@ function ReadonlyLink({ if (isMentionHref(href)) { const match = href.match(/^mention:\/\/(member|agent|issue|project|all)\/(.+)$/); if (match?.[1] === "issue" && match[2]) { + // A bare identifier (from the autolink preprocessor) is carried as the id + // segment; a real mention carries a UUID. Dispatch on the id shape. + if (isIssueIdentifier(match[2])) { + return ; + } const label = typeof children === "string" ? children @@ -433,7 +451,10 @@ export const ReadonlyContent = memo(function ReadonlyContent({ attachments, }: ReadonlyContentProps) { const processed = useMemo( - () => highlightToHtml(preprocessMarkdown(content)), + () => + highlightToHtml( + preprocessMarkdown(content, { autolinkIssueIdentifiers: true }), + ), [content], ); const wrapperRef = useRef(null); diff --git a/packages/views/editor/utils/issue-identifiers.test.ts b/packages/views/editor/utils/issue-identifiers.test.ts new file mode 100644 index 0000000000..c9c52de4c6 --- /dev/null +++ b/packages/views/editor/utils/issue-identifiers.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + preprocessIssueIdentifiers, + isIssueIdentifier, +} from "@multica/ui/markdown"; + +/** + * Pure detector for the Linear-style issue-identifier autolink. Lives in + * @multica/ui/markdown (no test runner there), exercised here where views' + * vitest can reach it. + */ +describe("preprocessIssueIdentifiers", () => { + it("rewrites a bare identifier into a canonical mention link", () => { + expect(preprocessIssueIdentifiers("Related to MUL-1745")).toBe( + "Related to [MUL-1745](mention://issue/MUL-1745)", + ); + }); + + it("rewrites multiple identifiers in one string", () => { + expect(preprocessIssueIdentifiers("Created TES-1 and MUL-2")).toBe( + "Created [TES-1](mention://issue/TES-1) and [MUL-2](mention://issue/MUL-2)", + ); + }); + + it("links an identifier at a sentence end (trailing dot + space)", () => { + expect(preprocessIssueIdentifiers("See MUL-1. Done.")).toBe( + "See [MUL-1](mention://issue/MUL-1). Done.", + ); + }); + + it("links identifiers wrapped in prose punctuation", () => { + expect(preprocessIssueIdentifiers("(MUL-1) and [MUL-2]")).toContain( + "([MUL-1](mention://issue/MUL-1))", + ); + }); + + // --- skip: code ------------------------------------------------------- + it("skips identifiers inside inline code", () => { + expect(preprocessIssueIdentifiers("use `MUL-1` here")).toBe( + "use `MUL-1` here", + ); + }); + + it("skips identifiers inside fenced code blocks", () => { + const input = "```\nMUL-1 in code\n```"; + expect(preprocessIssueIdentifiers(input)).toBe(input); + }); + + // --- skip: existing links / mentions ---------------------------------- + it("does not double-process an existing mention link", () => { + const input = "[MUL-1](mention://issue/00000000-0000-0000-0000-000000000001)"; + expect(preprocessIssueIdentifiers(input)).toBe(input); + }); + + it("skips an identifier used as a markdown link label", () => { + const input = "[MUL-1](https://example.com/x)"; + expect(preprocessIssueIdentifiers(input)).toBe(input); + }); + + // --- skip: urls / filenames / paths ----------------------------------- + it("skips an identifier inside a URL", () => { + const input = "https://example.com/board/MUL-1"; + expect(preprocessIssueIdentifiers(input)).toBe(input); + }); + + it("skips a filename token like ABC-123.ts", () => { + const input = "open ABC-123.ts now"; + expect(preprocessIssueIdentifiers(input)).toBe(input); + }); + + it("skips a path segment like FOO-1/bar", () => { + const input = "path FOO-1/bar/baz"; + expect(preprocessIssueIdentifiers(input)).toBe(input); + }); + + // --- non-matches ------------------------------------------------------ + it("ignores lowercase tokens", () => { + const input = "some-word-1 and mul-1"; + expect(preprocessIssueIdentifiers(input)).toBe(input); + }); + + it("ignores a token embedded in a larger word", () => { + const input = "XMUL-1A stays"; + expect(preprocessIssueIdentifiers(input)).toBe(input); + }); + + it("returns input unchanged when no candidates exist", () => { + const input = "plain text with no identifiers"; + expect(preprocessIssueIdentifiers(input)).toBe(input); + }); +}); + +describe("isIssueIdentifier", () => { + it("accepts a bare identifier", () => { + expect(isIssueIdentifier("MUL-1745")).toBe(true); + expect(isIssueIdentifier("TES-1")).toBe(true); + }); + + it("rejects a UUID (so real mentions are not treated as identifiers)", () => { + expect(isIssueIdentifier("00000000-0000-0000-0000-000000000001")).toBe( + false, + ); + }); + + it("rejects lowercase and malformed tokens", () => { + expect(isIssueIdentifier("mul-1")).toBe(false); + expect(isIssueIdentifier("MUL-")).toBe(false); + expect(isIssueIdentifier("MUL1")).toBe(false); + }); +}); diff --git a/packages/views/editor/utils/preprocess.ts b/packages/views/editor/utils/preprocess.ts index b41391a9c3..203aaf0a7e 100644 --- a/packages/views/editor/utils/preprocess.ts +++ b/packages/views/editor/utils/preprocess.ts @@ -1,4 +1,9 @@ -import { preprocessLinks, preprocessMentionShortcodes, preprocessFileCards } from "@multica/ui/markdown"; +import { + preprocessLinks, + preprocessMentionShortcodes, + preprocessFileCards, + preprocessIssueIdentifiers, +} from "@multica/ui/markdown"; import { configStore } from "@multica/core/config"; /** @@ -8,21 +13,33 @@ import { configStore } from "@multica/core/config"; * It does NOT convert to HTML — that was the old markdownToHtml.ts pipeline which * was deleted in the April 2026 refactor. * - * Three string→string transforms on raw Markdown: + * String→string transforms on raw Markdown: * 1. Legacy mention shortcodes [@ id="..." label="..."] → [@Label](mention://member/id) * (old serialization format in database, migrated on read) - * 2. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes) - * 3. File card syntax (new !file[name](url) + legacy [name](cdnUrl)) → HTML div for + * 2. (readonly only) Bare issue identifiers MUL-123 → [MUL-123](mention://issue/MUL-123) + * 3. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes) + * 4. File card syntax (new !file[name](url) + legacy [name](cdnUrl)) → HTML div for * fileCard node parsing * * Shared by the Tiptap editor and the read-only react-markdown renderer so both - * linkify identically. + * linkify identically. `autolinkIssueIdentifiers` is the one deliberate + * asymmetry: it is OPT-IN and MUST stay off for the editable Tiptap path, since + * rewriting a bare identifier there would create a mention node whose id is the + * identifier string (not a real UUID) and corrupt the saved markdown. Only the + * readonly renderer (which resolves the identifier to a UUID at render time) + * passes it. */ -export function preprocessMarkdown(markdown: string): string { +export function preprocessMarkdown( + markdown: string, + opts?: { autolinkIssueIdentifiers?: boolean }, +): string { if (!markdown) return ""; const cdnDomain = configStore.getState().cdnDomain; const step1 = preprocessMentionShortcodes(markdown); - const step2 = preprocessLinks(step1); - const step3 = preprocessFileCards(step2, cdnDomain); - return step3; + const step2 = opts?.autolinkIssueIdentifiers + ? preprocessIssueIdentifiers(step1) + : step1; + const step3 = preprocessLinks(step2); + const step4 = preprocessFileCards(step3, cdnDomain); + return step4; } diff --git a/packages/views/issues/hooks/index.ts b/packages/views/issues/hooks/index.ts index 52a39c8ea1..39b4c6757e 100644 --- a/packages/views/issues/hooks/index.ts +++ b/packages/views/issues/hooks/index.ts @@ -3,3 +3,4 @@ export { useIssueReactions } from "./use-issue-reactions"; export { useIssueSubscribers } from "./use-issue-subscribers"; export { useIssueDetailScrollRestore } from "./use-issue-detail-scroll-restore"; export { useInPageFind } from "./use-in-page-find"; +export { useResolveIssueIdentifier } from "./use-resolve-issue-identifier"; diff --git a/packages/views/issues/hooks/use-resolve-issue-identifier.ts b/packages/views/issues/hooks/use-resolve-issue-identifier.ts new file mode 100644 index 0000000000..5a319b5885 --- /dev/null +++ b/packages/views/issues/hooks/use-resolve-issue-identifier.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { issueIdentifierOptions } from "@multica/core/issues/queries"; +import { useCurrentWorkspace } from "@multica/core/paths"; +import { isIssueIdentifier } from "@multica/ui/markdown"; +import type { Issue } from "@multica/core/types"; + +/** + * Resolve a bare issue identifier ("MUL-123") to a real issue in the current + * workspace, or `null`. Backs the Linear-style autolink render path. + * + * Server state → TanStack Query (key includes wsId + identifier, so identical + * identifiers across many comments/messages share one request). + * + * The query is short-circuited (no network) when: + * - there is no current workspace, or + * - the token is not identifier-shaped, or + * - the identifier's prefix cannot match the workspace's `issue_prefix`. + * + * When the prefix is unknown (workspace list still loading) we fall through to + * the query and let the exact-match filter in `issueIdentifierOptions` decide. + */ +export function useResolveIssueIdentifier(identifier: string): Issue | null { + const workspace = useCurrentWorkspace(); + const wsId = workspace?.id ?? ""; + const prefix = workspace?.issue_prefix; + const prefixMatches = + !prefix || + identifier.toUpperCase().startsWith(`${prefix.toUpperCase()}-`); + + const { data } = useQuery({ + ...issueIdentifierOptions(wsId, identifier), + enabled: Boolean(wsId) && isIssueIdentifier(identifier) && prefixMatches, + }); + + return data ?? null; +}