diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 30caf9c60..eb21d4a03 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -202,11 +202,13 @@ Livestream history: the team agreed to build semantic search on [day two at 2:57 A **commit** is a post on /dev/null, not a Git commit in a repository. Open the composer or press **c**, write your update, and choose **Commit** or press **⌘/Ctrl+Enter**. Text-only commits accept 1–4,000 characters; as you type, the ring around your avatar in the composer fills toward that limit. On the web and in the app, an image attachment makes the text optional (terminal commits still need text), but the 4,000-character maximum still applies. Runs of blank lines outside code collapse to one before the length is checked. Lists fold posts longer than 280 characters or 8 lines behind **Show more**. When a commit starts with a code block too long to fit, its first lines fade out to show that the code continues. **Show more** reveals the full snippet, and the copy control copies the whole block even while folded. Commits can be created and deleted, but not edited after publication. There are no plans to add commit editing, and it will never be added: to change a commit, delete it and post a new one. +Typing `/` in the composer opens a slash command menu: `/image` attaches images (alias `/img`), `/poll` starts a poll, `/code` inserts a fenced code block, and `/bug` or `/feature` turn a fresh draft into a GitHub issue draft. Choose with ↑/↓ and Enter or Tab, or dismiss with Esc; an exact command followed by a space runs immediately. Commands a draft cannot use right now are hidden: `/poll`, `/bug`, and `/feature` are unavailable in replies and quotes, and issue commands are also hidden while scheduling. Commands that conflict with the current draft, such as images combined with polls, appear disabled with the reason. + Commits, replies, quotes, scheduling, terminal submissions, and app (signed API) commits share a limit of 10 attempts per minute per user. Posts go through [moderation](#moderation), so a newly submitted commit may be pending rather than public immediately. Approved top-level commits about technology, current affairs or substantive real-world events can receive brief, shared subject headlines. Technical and developer topics carry the most weight. /dev/null community activity also qualifies, including livestreams, Tom, app betas and feature updates. Quote commits include the directly quoted post's text when it is approved and still available, so a brief reaction can share the subject of the post it quotes. Replies, casual small talk and evergreen lifestyle or hobby advice are skipped. OpenAI's Decisions API assesses this text for suitability and checks the closest existing headline names, shortlisted by semantic similarity; when none match, DeepSeek through OpenCode generates a headline from the same text. Posts can be processed together, and similar newly generated headlines share the first headline rather than creating duplicates. No account metadata is sent. A headline expires after fourteen days without a new commit link. This categorization does not change the original post and does not yet add visible headline labels to the interface. -Sources: [composer](resources/js/components/commit-composer.tsx), [commit model](app/Models/Commit.php), [commit validation](app/Http/Requests/CreateCommitRequest.php), [text normalization](app/Services/CommitBodies.php), [submission throttle](app/Http/Middleware/ThrottleCommits.php). +Sources: [composer](resources/js/components/commit-composer.tsx), [slash commands](resources/js/lib/slash-commands.ts), [commit model](app/Models/Commit.php), [commit validation](app/Http/Requests/CreateCommitRequest.php), [text normalization](app/Services/CommitBodies.php), [submission throttle](app/Http/Middleware/ThrottleCommits.php). Livestream history: the team settled on calling posts **commits** and their list a **log** on [day one at 2:56:00](https://www.youtube.com/watch?v=6zCNQa3hNm0&t=10560s). At [3:02:19](https://www.youtube.com/watch?v=6zCNQa3hNm0&t=10939s), they agreed to rename the post model too, rather than changing only the UI. These are naming decisions, not timestamps proving the rename had finished. diff --git a/PRODUCT.md b/PRODUCT.md index 13dbe7437..17377af69 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -31,7 +31,7 @@ Developers who left X or Bluesky, or are thinking about it, and want a feed with What exists, one line each (details in the knowledge base): -- **Commits:** text with code, images, link cards and embedded players, or a poll; replies, quotes, threads, scheduling, translation. +- **Commits:** text with code, images, link cards and embedded players, or a poll; slash commands for images, polls, code, and GitHub issue drafts; replies, quotes, threads, scheduling, translation. - **Logs:** Following, Latest, and Trending. - **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries). - **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube. diff --git a/resources/js/components/commit-composer-dialog.tsx b/resources/js/components/commit-composer-dialog.tsx index 572f7c317..ec968cefb 100644 --- a/resources/js/components/commit-composer-dialog.tsx +++ b/resources/js/components/commit-composer-dialog.tsx @@ -77,7 +77,7 @@ export function CommitComposerDialog({ onEscapeKeyDown={(event) => { if ( document.querySelector( - '[data-mention-suggestions], [data-command-suggestions], [role="dialog"].yarl__root', + '[data-mention-suggestions], [data-slash-suggestions], [data-emoji-suggestions], [data-issue-suggestions], [role="dialog"].yarl__root', ) || (typeof Element !== 'undefined' && event.target instanceof Element && @@ -91,7 +91,7 @@ export function CommitComposerDialog({ typeof Element !== 'undefined' && event.target instanceof Element && event.target.closest( - '[data-mention-suggestions], [data-command-suggestions], [role="dialog"].yarl__root', + '[data-mention-suggestions], [data-slash-suggestions], [data-emoji-suggestions], [data-issue-suggestions], [role="dialog"].yarl__root', ) ) { event.preventDefault(); diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index a78973f89..f33526de3 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -27,7 +27,6 @@ import type { UseIssueCommandReturn } from '@/components/issue-command'; import { IssueCommandBadge, IssueCommandHint, - IssueCommandSuggestions, useIssueCommand, } from '@/components/issue-command'; import { LinkCardPicker } from '@/components/link-card-picker'; @@ -159,9 +158,17 @@ function CommitComposerBody({ : undefined); const textareaRef = useRef(null); const textBox = useRef(null); + const imageInputRef = useRef(null); const preview = useComposerPreview({ body, textareaRef, isSubmittable }); const isIssue = issue.command !== null; + const startPoll = () => { + setPoll(emptyPollDraft); + requestAnimationFrame(() => + document.getElementById(`${id}-poll-option-0`)?.focus(), + ); + }; + // When the dialog's text box shrinks, as the keyboard opens or an image is added, it keeps the caret in view. useEffect(() => { const box = textBox.current; @@ -296,17 +303,24 @@ function CommitComposerBody({ preview.handleShortcut(event) || onKeyDown(event) } - onFocus={(event) => { - issue.focus(); - - if (isRestored) { - moveCaretToEnd(event); - } - }} - onBlur={issue.blur} + onFocus={isRestored ? moveCaretToEnd : undefined} maxLength={ isIssue ? maxBodyLength : maxRawBodyLength } + onImage={ + isIssue + ? undefined + : () => imageInputRef.current?.click() + } + onPoll={isIssue ? undefined : startPoll} + onIssue={(kind, rest) => issue.start(kind, rest)} + commandContext={{ + surface: 'commit', + hasPoll: poll !== null, + imageCount: attachments.images.length, + isScheduling, + processing, + }} placeholder={ issue.command?.placeholder ?? (poll ? 'Ask a question' : 'What did you ship?') @@ -353,17 +367,6 @@ function CommitComposerBody({ /> )} - {issue.suggestions.length > 0 && ( - { - issue.start(kind, ''); - focusBody(id); - }} - /> - )} - { - setPoll(emptyPollDraft); - requestAnimationFrame(() => - document - .getElementById( - `${id}-poll-option-0`, - ) - ?.focus(), - ); - }} + onClick={startPoll} aria-label="Add poll" className="text-muted-foreground" data-test="add-poll-button" diff --git a/resources/js/components/commit-image-picker.tsx b/resources/js/components/commit-image-picker.tsx index 05ceebad3..996bed0c3 100644 --- a/resources/js/components/commit-image-picker.tsx +++ b/resources/js/components/commit-image-picker.tsx @@ -1,4 +1,5 @@ import { ImagePlus, X } from 'lucide-react'; +import type { RefObject } from 'react'; import { useId, useLayoutEffect, useRef, useState } from 'react'; import { ActionTooltip } from '@/components/action-tooltip'; import type { ShownImage } from '@/components/commit-images'; @@ -112,19 +113,22 @@ export function ImagePickerButton({ reading = false, onPick, className, + inputRef, }: { count: number; disabled?: boolean; reading?: boolean; onPick: (files: File[]) => Promise; className?: string; + inputRef?: RefObject; }) { - const inputRef = useRef(null); + const fallbackRef = useRef(null); + const ref = inputRef ?? fallbackRef; return ( <> = maxImages} - onClick={() => inputRef.current?.click()} + onClick={() => ref.current?.click()} aria-label={reading ? 'Reading images' : 'Attach images'} aria-busy={reading} className={cn('text-muted-foreground', className)} diff --git a/resources/js/components/commit-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index 17173604d..3743beae2 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -141,6 +141,7 @@ function QuoteComposer({ ); const linkCard = useLinkCard(body); const textareaRef = useRef(null); + const imageInputRef = useRef(null); const commitRules = useCommitRules(); const keys = useKeyLabels(); @@ -174,7 +175,7 @@ function QuoteComposer({ onEscapeKeyDown={(event) => { if ( document.querySelector( - '[data-mention-suggestions], [role="dialog"].yarl__root', + '[data-mention-suggestions], [data-slash-suggestions], [data-emoji-suggestions], [role="dialog"].yarl__root', ) || (typeof Element !== 'undefined' && event.target instanceof Element && @@ -188,7 +189,7 @@ function QuoteComposer({ typeof Element !== 'undefined' && event.target instanceof Element && event.target.closest( - '[data-mention-suggestions], [role="dialog"].yarl__root', + '[data-mention-suggestions], [data-slash-suggestions], [data-emoji-suggestions], [role="dialog"].yarl__root', ) ) { event.preventDefault(); @@ -265,6 +266,16 @@ function QuoteComposer({ value={body} onValueChange={updateBody} onKeyDown={submitOnModEnter} + onImage={() => + imageInputRef.current?.click() + } + commandContext={{ + surface: 'quote', + hasPoll: false, + imageCount: images.length, + isScheduling: false, + processing, + }} onPaste={attachments.onPaste} onFocus={ isRestored ? moveCaretToEnd : undefined @@ -333,6 +344,7 @@ function QuoteComposer({ disabled={processing} reading={attachments.reading} onPick={attachments.pick} + inputRef={imageInputRef} className="size-8" /> (null); + const imageInputRef = useRef(null); const commitRules = useCommitRules(); const isReplyable = (images.length > 0 || characterCount(body.trim()) >= minBodyLength) && @@ -1562,6 +1563,14 @@ function ReplyComposer({ value={body} onValueChange={updateBody} onKeyDown={onKeyDown} + onImage={() => imageInputRef.current?.click()} + commandContext={{ + surface: 'reply', + hasPoll: false, + imageCount: images.length, + isScheduling: false, + processing, + }} onPaste={attachments.onPaste} onFocus={isRestored ? moveCaretToEnd : undefined} maxLength={maxRawBodyLength} @@ -1640,6 +1649,7 @@ function ReplyComposer({ disabled={processing} reading={attachments.reading} onPick={attachments.pick} + inputRef={imageInputRef} className="size-8" /> diff --git a/resources/js/components/issue-command.tsx b/resources/js/components/issue-command.tsx index 56b0513c7..b176c64fd 100644 --- a/resources/js/components/issue-command.tsx +++ b/resources/js/components/issue-command.tsx @@ -12,11 +12,9 @@ import type { IssueCommand, IssueKind } from '@/lib/issue-commands'; import { hintedCommand, issueCommands, - suggestedCommands, typedCommand, } from '@/lib/issue-commands'; import { hasModKey } from '@/lib/keyboard'; -import { cn } from '@/lib/utils'; const hintDismissedKey = 'issue-command-hint-dismissed'; @@ -57,22 +55,15 @@ export function useIssueCommand({ >({ command: '', body: '' }); const command = issueCommands.find((candidate) => candidate.kind === kind) ?? null; - // A draft restored from the stash can be a lone slash: the menu only opens while the user is typing in it. - const suggestions = - enabled && kind === null && !dismissed && focused - ? suggestedCommands(body) - : []; const hint = enabled && kind === null && !hintDismissed ? hintedCommand(body) : null; const start = (next: IssueKind, rest: string) => { setKind(next); - setSelected(0); updateBody(rest); }; const change = (value: string) => { - setDismissed(false); const typed = enabled && kind === null ? typedCommand(value) : null; if (typed) { @@ -145,37 +136,6 @@ export function useIssueCommand({ return false; } - if (suggestions.length > 0) { - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault(); - setSelected( - (index) => - (index + - (event.key === 'ArrowDown' ? 1 : -1) + - suggestions.length) % - suggestions.length, - ); - return true; - } - - if ( - (event.key === 'Tab' || event.key === 'Enter') && - !event.shiftKey && - !hasModKey(event) - ) { - event.preventDefault(); - start(suggestions[selected % suggestions.length].kind, ''); - return true; - } - - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - setDismissed(true); - return true; - } - } - if (command === null) { return false; } @@ -201,7 +161,6 @@ export function useIssueCommand({ return { command, - suggestions, hint, dismissHint: () => { setHintDismissed(true); @@ -211,11 +170,8 @@ export function useIssueCommand({ // Without storage, the hint stays dismissed until the page reloads. } }, - selected: selected % Math.max(suggestions.length, 1), processing: request.processing, change, - focus: () => setFocused(true), - blur: () => setFocused(false), start, exit: () => setKind(null), submit, @@ -259,59 +215,6 @@ export function IssueCommandBadge({ ); } -export function IssueCommandSuggestions({ - suggestions, - selected, - onPick, -}: { - suggestions: IssueCommand[]; - selected: number; - onPick: (kind: IssueKind) => void; -}) { - return ( -
- {suggestions.map((command, index) => { - const Icon = icons[command.kind]; - - return ( - - ); - })} -

- ↑↓ select · Tab or Enter pick · Esc dismiss -

-
- ); -} - export function IssueCommandHint({ command, onUse, diff --git a/resources/js/components/mention-textarea.tsx b/resources/js/components/mention-textarea.tsx index 4f7e67b0a..2f261d90f 100644 --- a/resources/js/components/mention-textarea.tsx +++ b/resources/js/components/mention-textarea.tsx @@ -11,13 +11,22 @@ import { typedProse } from '@/lib/code'; import type { EmojiSuggestion } from '@/lib/emoji'; import { emojifyInput, searchEmoji } from '@/lib/emoji'; import { issueReferenceBeforeCaret } from '@/lib/issue-reference'; +import { + availableCommands, + codeFenceInsertion, + consumeSlashToken, + filterCommands, + matchCommand, + slashCommandInProse, +} from '@/lib/slash-commands'; +import type { ComposerSurface, SlashOption } from '@/lib/slash-commands'; import { suggestionPosition } from '@/lib/suggestion-position'; import { toastWhenThrottled } from '@/lib/throttle'; import { characterCount, cn, laneColor, limitCharacters } from '@/lib/utils'; import type { Organization, OrgMember, User } from '@/types'; type Trigger = { - kind: 'mention' | 'emoji' | 'issue'; + kind: 'mention' | 'emoji' | 'issue' | 'slash'; start: number; query: string; }; @@ -118,7 +127,15 @@ function triggerAt( ? issueReferenceBeforeCaret(textarea.value, caret) : null; - return issue ? { kind: 'issue', ...issue } : null; + if (issue) { + return { kind: 'issue', ...issue }; + } + + const slash = slashCommandInProse(typedProse(before).at(-1) ?? ''); + + return slash === null + ? null + : { kind: 'slash', start: caret - slash.length - 1, query: slash }; } /** @@ -263,6 +280,10 @@ export function MentionTextarea({ members, reply, quote, + commandContext, + onImage, + onPoll, + onIssue, ...props }: Omit, 'value' | 'onChange'> & { ref?: Ref; @@ -274,12 +295,17 @@ export function MentionTextarea({ members?: OrgMember[]; reply?: string; quote?: string; + commandContext?: ComposerSurface; + onImage?: () => void; + onPoll?: () => void; + onIssue?: (kind: 'bug' | 'feature', rest: string) => void; }) { const { repository } = usePage().props; const id = useId(); const textareaRef = useRef(null); const listRef = useRef(null); const dismissedIssue = useRef(null); + const dismissedSlash = useRef(null); const [trigger, setTrigger] = useState(null); const [position, setPosition] = useState(null); const [results, setResults] = useState(null); @@ -316,6 +342,10 @@ export function MentionTextarea({ const track = (textarea: HTMLTextAreaElement) => { const next = triggerAt(textarea, suggestsMentions, suggestsIssues); + if (next?.kind !== 'slash' || next.start !== dismissedSlash.current) { + dismissedSlash.current = null; + } + if ( next?.kind === 'issue' && dismissedIssue.current === `${next.start}:${next.query}` @@ -502,7 +532,43 @@ export function MentionTextarea({ ? issue !== null : trigger.kind === 'emoji' ? emojis.length > 0 - : suggestions.length + organizations.length > 0 || query !== ''); + : trigger.kind === 'slash' + ? false + : suggestions.length + organizations.length > 0 || + query !== ''); + const slash = trigger?.kind === 'slash' ? trigger : null; + const slashOptions = + slash !== null && commandContext !== undefined + ? filterCommands( + availableCommands(commandContext), + slash.query, + ).filter((option) => { + const id = option.command.id; + + if (id === 'image') { + return onImage !== undefined; + } + + if (id === 'poll') { + return onPoll !== undefined; + } + + if (id === 'bug' || id === 'feature') { + return onIssue !== undefined; + } + + return true; + }) + : []; + const isSlashOpen = + slash !== null && + position !== null && + slashOptions.length > 0 && + dismissedSlash.current !== slash.start; + const slashIndex = + slashOptions.length > 0 + ? Math.min(activeIndex, slashOptions.length - 1) + : 0; const insert = (text: string, separator: string) => { const textarea = textareaRef.current; @@ -558,7 +624,160 @@ export function MentionTextarea({ const selectEmoji = (emoji: EmojiSuggestion) => insert(emoji.emoji, ''); + const runSlash = (option: SlashOption): boolean => { + if (slash === null || !option.enabled) { + return false; + } + + const textarea = textareaRef.current; + const focus = (caret?: number) => + requestAnimationFrame(() => { + textarea?.focus(); + + if (caret !== undefined) { + textarea?.setSelectionRange(caret, caret); + } + }); + + if (option.command.id === 'code') { + const insertion = codeFenceInsertion( + value, + slash.start, + slash.start + slash.query.length + 1, + ); + + if ( + maxLength !== undefined && + characterCount(insertion.value) > maxLength + ) { + return false; + } + + onValueChange(insertion.value); + close(); + focus(insertion.caret); + + return true; + } + + if (option.command.id === 'bug' || option.command.id === 'feature') { + const rest = consumeSlashToken(value, slash.start, slash.query); + + close(); + focus(); + onIssue?.(option.command.id, rest); + + return true; + } + + onValueChange(consumeSlashToken(value, slash.start, slash.query)); + close(); + focus(); + + if (option.command.id === 'image') { + onImage?.(); + } + + if (option.command.id === 'poll') { + onPoll?.(); + } + + return true; + }; + + const slashOptionFits = (index: number): boolean => { + const option = slashOptions[index]; + + if (option === undefined || !option.enabled) { + return false; + } + + if ( + option.command.id !== 'code' || + maxLength === undefined || + slash === null + ) { + return true; + } + + const insertion = codeFenceInsertion( + value, + slash.start, + slash.start + slash.query.length + 1, + ); + + return characterCount(insertion.value) <= maxLength; + }; + + const handleSlashKeyDown = ( + event: KeyboardEvent, + ): boolean => { + if (!isSlashOpen || event.nativeEvent.isComposing || slash === null) { + return false; + } + + const hasModifier = event.metaKey || event.ctrlKey || event.altKey; + + if ( + (event.key === 'ArrowDown' || event.key === 'ArrowUp') && + slashOptions.length > 0 + ) { + event.preventDefault(); + const step = event.key === 'ArrowDown' ? 1 : -1; + setActiveIndex( + (slashIndex + step + slashOptions.length) % slashOptions.length, + ); + + return true; + } + + if ( + (event.key === 'Enter' || event.key === 'Tab') && + !hasModifier && + !event.shiftKey + ) { + if (!slashOptionFits(slashIndex)) { + return false; + } + + event.preventDefault(); + runSlash(slashOptions[slashIndex]); + + return true; + } + + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + dismissedSlash.current = slash.start; + close(); + + return true; + } + + if (event.key === ' ') { + const exact = matchCommand(slash.query); + const index = slashOptions.findIndex( + (option) => option.command.id === exact?.id, + ); + const option = slashOptions[index]; + + if (option !== undefined && slashOptionFits(index)) { + event.preventDefault(); + runSlash(option); + + return true; + } + } + + return false; + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (handleSlashKeyDown(event)) { + return; + } + if (isOpen && !event.nativeEvent.isComposing) { const hasModifier = event.metaKey || event.ctrlKey || event.altKey; @@ -615,25 +834,30 @@ export function MentionTextarea({ onKeyDown?.(event); }; - const activeId = !isOpen - ? undefined - : trigger?.kind === 'issue' - ? `${id}-issue-${issue?.number}` - : trigger?.kind === 'emoji' - ? emojis[selectedIndex] && - `${id}-emoji-${emojis[selectedIndex].name}` - : selectedIndex < suggestions.length - ? `${id}-mention-${suggestions[selectedIndex].id}` - : organizations[selectedIndex - suggestions.length] && - `${id}-mention-${organizations[selectedIndex - suggestions.length].handle}`; + const activeId = isSlashOpen + ? slashOptions[slashIndex] && + `${id}-slash-${slashOptions[slashIndex].command.id}` + : !isOpen + ? undefined + : trigger?.kind === 'issue' + ? `${id}-issue-${issue?.number}` + : trigger?.kind === 'emoji' + ? emojis[selectedIndex] && + `${id}-emoji-${emojis[selectedIndex].name}` + : selectedIndex < suggestions.length + ? `${id}-mention-${suggestions[selectedIndex].id}` + : organizations[selectedIndex - suggestions.length] && + `${id}-mention-${organizations[selectedIndex - suggestions.length].handle}`; const isSearching = current?.key !== key; const listId = - trigger?.kind === 'issue' - ? `${id}-issues` - : trigger?.kind === 'emoji' - ? `${id}-emojis` - : `${id}-mentions`; + trigger?.kind === 'slash' + ? `${id}-slash` + : trigger?.kind === 'issue' + ? `${id}-issues` + : trigger?.kind === 'emoji' + ? `${id}-emojis` + : `${id}-mentions`; return ( <> @@ -683,7 +907,7 @@ export function MentionTextarea({ }} onKeyDown={handleKeyDown} aria-autocomplete="list" - aria-controls={isOpen ? listId : undefined} + aria-controls={isOpen || isSlashOpen ? listId : undefined} aria-activedescendant={activeId} /> @@ -694,7 +918,13 @@ export function MentionTextarea({ style={position} onMouseDown={(event) => event.preventDefault()} data-mention-suggestions - data-test="mention-suggestions" + data-test={ + trigger?.kind === 'issue' + ? 'issue-suggestions' + : trigger?.kind === 'emoji' + ? 'emoji-suggestions' + : 'mention-suggestions' + } > {trigger?.kind === 'issue' && issue ? (
    , document.body, )} + + {isSlashOpen && + position && + createPortal( +
    event.preventDefault()} + data-slash-suggestions + data-test="slash-suggestions" + > +
      + {slashOptions.map((option, index) => { + const Icon = option.command.icon; + + return ( +
    • + setActiveIndex(index) + } + onClick={() => runSlash(option)} + className={cn( + 'flex cursor-pointer items-center gap-2.5 rounded-sm px-2.5 py-1.5', + { + 'bg-accent': + index === slashIndex, + 'cursor-not-allowed opacity-60': + !option.enabled, + }, + )} + data-test="slash-command" + > + + + + {`/${option.command.id}`} + + + {option.reason ?? + option.command.description} + + + + ↵ + +
    • + ); + })} +
    +

    + ↑↓ select · Tab or Enter pick · Esc dismiss +

    +
    , + document.body, + )} ); } diff --git a/resources/js/lib/issue-commands.ts b/resources/js/lib/issue-commands.ts index 81947133b..131f7ee2d 100644 --- a/resources/js/lib/issue-commands.ts +++ b/resources/js/lib/issue-commands.ts @@ -24,15 +24,6 @@ export const issueCommands: IssueCommand[] = [ }, ]; -/** - * The commands a draft could still become while it is only a slash and the start of a name. - */ -export function suggestedCommands(body: string): IssueCommand[] { - return /^\/[a-z]*$/.test(body) - ? issueCommands.filter((command) => command.name.startsWith(body)) - : []; -} - /** * A complete command followed by a space or a new line turns the draft into an issue, without the command. */ diff --git a/resources/js/lib/slash-commands.ts b/resources/js/lib/slash-commands.ts new file mode 100644 index 000000000..af07439d9 --- /dev/null +++ b/resources/js/lib/slash-commands.ts @@ -0,0 +1,217 @@ +import type { LucideIcon } from 'lucide-react'; +import { Braces, Bug, ChartBar, ImagePlus, Lightbulb } from 'lucide-react'; + +// Keep in sync with maxImages in resources/js/lib/read-picked-images.ts. +const maxImages = 3; + +export type SlashCommandId = 'image' | 'poll' | 'code' | 'bug' | 'feature'; + +export type SlashCommand = { + id: SlashCommandId; + label: string; + description: string; + aliases: string[]; + icon: LucideIcon; +}; + +export type ComposerSurface = { + surface: 'commit' | 'reply' | 'quote'; + hasPoll: boolean; + imageCount: number; + isScheduling: boolean; + processing: boolean; +}; + +export type SlashOption = { + command: SlashCommand; + enabled: boolean; + reason: string | null; +}; + +const commands: SlashCommand[] = [ + { + id: 'image', + label: 'Image', + description: 'Attach up to 3 images', + aliases: ['img'], + icon: ImagePlus, + }, + { + id: 'poll', + label: 'Poll', + description: 'Ask a question with up to 4 options', + aliases: [], + icon: ChartBar, + }, + { + id: 'code', + label: 'Code', + description: 'Insert a code block', + aliases: [], + icon: Braces, + }, + { + id: 'bug', + label: 'Bug', + description: 'Report a bug', + aliases: [], + icon: Bug, + }, + { + id: 'feature', + label: 'Feature', + description: 'Request a feature', + aliases: [], + icon: Lightbulb, + }, +]; + +/** + * The commands that turn the draft into a GitHub issue. + */ +export function isIssueCommand(command: SlashCommand): boolean { + return command.id === 'bug' || command.id === 'feature'; +} + +function disabledReason( + command: SlashCommand, + surface: ComposerSurface, +): string | null { + if (command.id === 'image') { + if (surface.hasPoll) { + return 'Remove the poll first'; + } + + if (surface.imageCount >= maxImages) { + return 'Maximum of 3 images'; + } + + return null; + } + + if (command.id === 'poll') { + if (surface.hasPoll) { + return 'A poll is already added'; + } + + if (surface.imageCount > 0) { + return 'Remove the images first'; + } + + return null; + } + + if (isIssueCommand(command)) { + if (surface.hasPoll) { + return 'Remove the poll first'; + } + + if (surface.imageCount > 0) { + return 'Remove the images first'; + } + + return null; + } + + return null; +} + +/** + * The commands a composer offers, in menu order, each with its availability or the reason it is disabled. + */ +export function availableCommands(surface: ComposerSurface): SlashOption[] { + return commands + .filter((command) => { + if ( + (command.id === 'poll' || isIssueCommand(command)) && + surface.surface !== 'commit' + ) { + return false; + } + + return !(isIssueCommand(command) && surface.isScheduling); + }) + .map((command) => { + if (surface.processing) { + return { command, enabled: false, reason: null }; + } + + const reason = disabledReason(command, surface); + + return { command, enabled: reason === null, reason }; + }); +} + +export function filterCommands( + options: SlashOption[], + query: string, +): SlashOption[] { + if (query === '') { + return options; + } + + return options.filter( + ({ command }) => + command.id.startsWith(query) || + command.aliases.some((alias) => alias.startsWith(query)), + ); +} + +export function matchCommand(query: string): SlashCommand | null { + return ( + commands.find( + (command) => + command.id === query || command.aliases.includes(query), + ) ?? null + ); +} + +const slashCommandBeforeCaretPattern = /(?:^|[\s([{>])\/([a-z]*)$/; + +/** + * The "/command" at the end of a prose segment, if any. + */ +export function slashCommandInProse(prose: string): string | null { + return slashCommandBeforeCaretPattern.exec(prose)?.[1] ?? null; +} + +/** + * The body without the "/command" token, dropping one adjacent space. + */ +export function consumeSlashToken( + value: string, + start: number, + query: string, +): string { + const before = value.slice(0, start); + const after = value.slice(start + query.length + 1); + + if (before.endsWith(' ')) { + return before.slice(0, -1) + after; + } + + if (after.startsWith(' ')) { + return before + after.slice(1); + } + + return before + after; +} + +/** + * The body with a fenced code block where the token was, and the caret index inside it. + */ +export function codeFenceInsertion( + value: string, + start: number, + end: number, +): { value: string; caret: number } { + const before = value.slice(0, start); + const after = value.slice(end); + const prefix = before === '' || before.endsWith('\n') ? '' : '\n'; + const suffix = after === '' || after.startsWith('\n') ? '' : '\n'; + + return { + value: `${before}${prefix}\`\`\`\n\n\`\`\`${suffix}${after}`, + caret: before.length + prefix.length + 4, + }; +} diff --git a/tests/Pest.php b/tests/Pest.php index 8b24a19dd..65493258c 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -30,6 +30,8 @@ Process::preventStrayProcesses(); Sleep::fake(); + config(['services.github.repository' => 'dev-null-sh/dev-null.sh']); + $this->freezeTime(); }) ->in('Feature', 'Unit'); diff --git a/tests/Unit/ComposerDraft.test.tsx b/tests/Unit/ComposerDraft.test.tsx index f786609a5..7faef67c5 100644 --- a/tests/Unit/ComposerDraft.test.tsx +++ b/tests/Unit/ComposerDraft.test.tsx @@ -8,11 +8,11 @@ const image = new File(['restored'], 'draft.png', { type: 'image/png' }); let state: unknown[] = []; let cursor = 0; const draft = { - key: 'stash:viewer:commit', body: 'A restored draft https://example.com', images: [] as File[], descriptions: [] as string[], isRestored: true, + key: 'stash:viewer:commit', updateBody: vi.fn(), updateImages: vi.fn((images: File[], descriptions: string[]) => { draft.images = images; @@ -76,7 +76,7 @@ vi.doMock('../../resources/js/hooks/use-stash', () => ({ const { CommitComposer } = await import('../../resources/js/components/commit-composer'); -const { IssueCommandBadge, IssueCommandHint, IssueCommandSuggestions } = +const { IssueCommandBadge, IssueCommandHint } = await import('../../resources/js/components/issue-command'); const { ImageDropOverlay, ImagePickerButton, ImagePickerPreviews } = await import('../../resources/js/components/commit-image-picker'); @@ -471,49 +471,14 @@ function keyDown( return event; } -function focus(view: ReturnType, handler = 'onFocus') { - const textarea = view.fields.find( - (element) => element.type === MentionTextarea, - )!; - (textarea.props[handler] as (event: unknown) => void)({ - currentTarget: { selectionStart: 0, selectionEnd: 0 }, - }); -} - -function hasSuggestions(): boolean { - return render().fields.some( - (element) => element.type === IssueCommandSuggestions, - ); -} - -test('a restored slash only opens the command menu while the composer is focused', () => { - draft.body = '/'; - assert.equal(hasSuggestions(), false); - - focus(render()); - assert.equal(hasSuggestions(), true); - - focus(render(), 'onBlur'); - assert.equal(hasSuggestions(), false); -}); - test('typing a command and a space turns the draft into an issue, and backspace turns it back into a commit', () => { draft.body = '/bu'; - focus(render()); let view = render(); - const suggestions = view.fields.find( - (element) => element.type === IssueCommandSuggestions, - )!; - assert.deepEqual( - (suggestions.props.suggestions as { name: string }[]).map( - (command) => command.name, - ), - ['/bug'], - ); - const textarea = view.fields.find( (element) => element.type === MentionTextarea, )!; + assert.equal(typeof textarea.props.onIssue, 'function'); + (textarea.props.onValueChange as (value: string) => void)( '/bug The timeline jumps', ); @@ -535,6 +500,11 @@ test('typing a command and a space turns the draft into an issue, and backspace false, ); assert.equal(find('data-test', 'add-poll-button'), undefined); + const issueTextarea = view.fields.find( + (element) => element.type === MentionTextarea, + )!; + assert.equal(issueTextarea.props.onImage, undefined); + assert.equal(issueTextarea.props.onPoll, undefined); assert.equal(view.form.props.onBefore(), false); keyDown(view, 'Backspace'); @@ -551,7 +521,7 @@ test('typing a command and a space turns the draft into an issue, and backspace assert.equal(view.form.props.onBefore(), true); }); -test('picking a suggestion with Enter starts an empty issue, and the shortcut opens it instead of committing', () => { +test('picking the issue command starts an empty issue, and the shortcut opens it instead of committing', () => { vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X) Chrome/140.0.0.0', language: 'en-US', @@ -565,11 +535,15 @@ test('picking a suggestion with Enter starts an empty issue, and the shortcut op }); try { - draft.body = '/'; - focus(render()); - keyDown(render(), 'ArrowDown'); - const enter = keyDown(render(), 'Enter'); - assert.equal(enter.preventDefault.mock.calls.length, 1); + const view = render(); + const textarea = view.fields.find( + (element) => element.type === MentionTextarea, + )!; + + (textarea.props.onIssue as (kind: string, rest: string) => void)( + 'bug', + '', + ); assert.deepEqual(draft.updateBody.mock.calls.at(-1), ['']); draft.body = @@ -673,12 +647,17 @@ test('switching a discounted commit to an issue enforces the literal limit for t test('commands are not offered while the draft has images, a poll or a schedule', () => { draft.images = [image]; draft.body = '/'; - focus(render()); const view = render(); - assert.equal( - view.fields.some((element) => element.type === IssueCommandSuggestions), - false, - ); + const textarea = view.fields.find( + (element) => element.type === MentionTextarea, + )!; + assert.deepEqual(textarea.props.commandContext, { + surface: 'commit', + hasPoll: false, + imageCount: 1, + isScheduling: false, + processing: false, + }); draft.body = 'Found a bug'; assert.equal( render().fields.some((element) => element.type === IssueCommandHint), @@ -686,9 +665,6 @@ test('commands are not offered while the draft has images, a poll or a schedule' ); draft.body = '/'; - const textarea = view.fields.find( - (element) => element.type === MentionTextarea, - )!; (textarea.props.onValueChange as (value: string) => void)('/bug '); assert.deepEqual(draft.updateBody.mock.calls.at(-1), ['/bug ']); assert.equal( @@ -728,6 +704,52 @@ test('turning scheduling off clears the chosen time and commits now', () => { assert.equal(find('id', 'body-scheduled-at')!.props.value, ''); }); +test('passes slash command context and callbacks to the composer textarea', () => { + let view = render(); + let textarea = view.fields.find( + (element) => element.type === MentionTextarea, + )!; + assert.deepEqual(textarea.props.commandContext, { + surface: 'commit', + hasPoll: false, + imageCount: 0, + isScheduling: false, + processing: false, + }); + assert.equal(typeof textarea.props.onImage, 'function'); + assert.equal(typeof textarea.props.onPoll, 'function'); + assert.equal(typeof textarea.props.onIssue, 'function'); + assert.equal( + ( + view.fields.find((element) => element.type === ImagePickerButton)! + .props.inputRef as { current: unknown } + ).current, + null, + ); + + vi.stubGlobal('requestAnimationFrame', vi.fn()); + try { + const pollButton = view.fields.find( + (element) => element.props['data-test'] === 'add-poll-button', + )!; + (pollButton.props.onClick as () => void)(); + view = render(); + textarea = view.fields.find( + (element) => element.type === MentionTextarea, + )!; + assert.equal( + (textarea.props.commandContext as { hasPoll: boolean }).hasPoll, + true, + ); + + (textarea.props.onPoll as () => void)(); + view = render(); + assert.ok(view.fields.some((element) => element.type === PollEditor)); + } finally { + vi.unstubAllGlobals(); + } +}); + test('picker clears its selection before a pending read finishes so the same file can be retried', async () => { const reads: Array<() => void> = []; const selections: File[][] = []; diff --git a/tests/Unit/IssueCommands.test.ts b/tests/Unit/IssueCommands.test.ts index 82f553b67..c1af2817f 100644 --- a/tests/Unit/IssueCommands.test.ts +++ b/tests/Unit/IssueCommands.test.ts @@ -2,24 +2,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { hintedCommand, - suggestedCommands, typedCommand, } from '../../resources/js/lib/issue-commands.ts'; -test('suggests commands only while the draft is a slash and the start of a name', () => { - const names = (body: string) => - suggestedCommands(body).map((command) => command.name); - - assert.deepEqual(names('/'), ['/feature', '/bug']); - assert.deepEqual(names('/f'), ['/feature']); - assert.deepEqual(names('/bug'), ['/bug']); - assert.deepEqual(names('/deploy'), []); - assert.deepEqual(names('/bug '), []); - assert.deepEqual(names('Shipped /bug fixes'), []); - assert.deepEqual(names(''), []); -}); - -test('a complete command and a space or new line becomes an issue without the command', () => { +void test('a complete command and a space or new line becomes an issue without the command', () => { assert.deepEqual(typedCommand('/bug '), { kind: 'bug', body: '' }); assert.deepEqual(typedCommand('/feature Dark mode'), { kind: 'feature', @@ -34,7 +20,7 @@ test('a complete command and a space or new line becomes an issue without the co assert.equal(typedCommand('Fixed a /bug today'), null); }); -test('hints the command a plain draft mentions', () => { +void test('hints the command a plain draft mentions', () => { const name = (body: string) => hintedCommand(body)?.name ?? null; assert.equal(name('The search has a bug'), '/bug'); diff --git a/tests/Unit/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index d05c689e0..22f28f516 100644 --- a/tests/Unit/QuoteDraft.test.tsx +++ b/tests/Unit/QuoteDraft.test.tsx @@ -59,6 +59,8 @@ const { CommitQuoteButton } = await import('../../resources/js/components/commit-quote-button'); const { ImagePickerButton, ImagePickerPreviews } = await import('../../resources/js/components/commit-image-picker'); +const { MentionTextarea } = + await import('../../resources/js/components/mention-textarea'); const { StashLabel } = await import('../../resources/js/components/stash-label'); const { LinkCardPicker } = @@ -192,6 +194,44 @@ test('quotes submit restored images and clear the complete draft only on success } }); +test('passes slash command context to the quote composer textarea', () => { + const commit = { + id: 'quoted', + author: { id: 'author', handle: 'author' }, + status: 'approved', + } as Commit; + const button = CommitQuoteButton({ commit })!; + const dialog = React.Children.toArray( + button.props.children, + )[1] as ReactElement<{ + children: ReactElement<{ commit: Commit; onClose: () => void }>; + }>; + const composer = dialog.props.children; + const renderComposer = composer.type as ( + props: typeof composer.props, + ) => ReactElement; + const form = elements(renderComposer(composer.props)).find( + (element) => element.type === Form, + )!; + const renderFields = form.props.children as (props: { + processing: boolean; + errors: Record; + }) => ReactNode; + const fields = elements(renderFields({ processing: false, errors: {} })); + const textarea = fields.find( + (element) => element.type === MentionTextarea, + )!; + + assert.deepEqual(textarea.props.commandContext, { + surface: 'quote', + hasPoll: false, + imageCount: 1, + isScheduling: false, + processing: false, + }); + assert.equal(textarea.props.onPoll, undefined); + assert.equal(typeof textarea.props.onImage, 'function'); +}); test('escape in the preview returns to writing without closing the quote', () => { const commit = { id: 'quoted', diff --git a/tests/Unit/SlashCommandMenu.test.tsx b/tests/Unit/SlashCommandMenu.test.tsx new file mode 100644 index 000000000..ef0ccc819 --- /dev/null +++ b/tests/Unit/SlashCommandMenu.test.tsx @@ -0,0 +1,535 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { beforeEach, test, vi } from 'vite-plus/test'; + +let state: unknown[] = []; +let cursor = 0; + +vi.doMock('react', () => ({ + ...React, + useEffect: () => {}, + useEffectEvent: (callback: () => void) => callback, + useMemo: (factory: () => unknown) => factory(), + useId: () => `menu-${cursor++}`, + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), + useRef: (initial: unknown) => { + const index = cursor++; + if (!(index in state)) { + state[index] = { current: initial }; + } + return state[index]; + }, + useState: (initial: unknown) => { + const index = cursor++; + if (!(index in state)) { + state[index] = typeof initial === 'function' ? initial() : initial; + } + return [ + state[index], + (value: unknown) => { + state[index] = + typeof value === 'function' ? value(state[index]) : value; + }, + ]; + }, +})); +vi.doMock('react-dom', () => ({ + createPortal: (children: unknown) => children, +})); +vi.doMock('@inertiajs/react', () => ({ + usePage: () => ({ props: { repository: 'nunomaduro/dev-null.sh' } }), +})); +vi.doMock('../../resources/js/lib/suggestion-position', () => ({ + suggestionPosition: () => ({ left: 0, top: 0 }), +})); + +const { MentionTextarea } = + await import('../../resources/js/components/mention-textarea'); + +type Element = ReactElement>; + +const surface = (overrides: Record = {}) => ({ + surface: 'commit', + hasPoll: false, + imageCount: 0, + isScheduling: false, + processing: false, + ...overrides, +}); + +let value = ''; +const onValueChange = vi.fn((next: string) => { + value = next; +}); +const onImage = vi.fn(); +const onPoll = vi.fn(); +const onIssue = vi.fn(); + +const renderer = MentionTextarea as unknown as ( + props: Record, +) => ReactElement; + +function render(overrides: Record = {}) { + cursor = 0; + + return renderer({ + id: 'body', + value, + onValueChange, + commandContext: surface(), + onImage, + onPoll, + onIssue, + maxLength: 4000, + ...overrides, + }); +} + +function elements(node: ReactNode): Element[] { + if (!React.isValidElement(node)) { + return []; + } + + const element = node as Element; + + return [ + element, + ...React.Children.toArray(element.props.children as ReactNode).flatMap( + elements, + ), + ]; +} + +function field(tree: ReactElement): Element { + return elements(tree).find((element) => element.type === 'textarea')!; +} + +function type(next: string, overrides: Record = {}) { + const tree = render(overrides); + (field(tree).props.onChange as (event: unknown) => void)({ + currentTarget: { + value: next, + selectionStart: next.length, + selectionEnd: next.length, + getBoundingClientRect: () => ({ + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + }), + scrollTop: 0, + scrollLeft: 0, + }, + nativeEvent: { isComposing: false }, + }); + + return render(overrides); +} + +function press(tree: ReactElement, key: string) { + const event = { + key, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + nativeEvent: { isComposing: false }, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + (field(tree).props.onKeyDown as (event: unknown) => void)(event); + + return event; +} + +function row(tree: ReactElement, command: string): Element | undefined { + return elements(tree).find( + (element) => + element.props['data-test'] === 'slash-command' && + elements(element).some( + (child) => child.props.children === `/${command}`, + ), + ); +} + +function menu(tree: ReactElement, testId: string): Element | undefined { + return elements(tree).find( + (element) => + element.props['data-test'] === testId || + element.props.testId === testId, + ); +} + +function mentionRows(tree: ReactElement): Element[] { + return elements(tree).filter( + (element) => element.props['data-test'] === 'mention-suggestion', + ); +} + +function emojiRows(tree: ReactElement): Element[] { + return elements(tree).filter( + (element) => element.props['data-test'] === 'emoji-suggestion', + ); +} + +function searching(tree: ReactElement): boolean { + return elements(tree).some((element) => + React.Children.toArray(element.props.children as ReactNode).includes( + 'Searching…', + ), + ); +} + +const alice = { id: 'alice', handle: 'alice', name: 'Alice', lane: 1 }; + +beforeEach(() => { + vi.clearAllMocks(); + state = []; + value = ''; + vi.stubGlobal('requestAnimationFrame', vi.fn()); + vi.stubGlobal('window', { + getComputedStyle: () => ({ + getPropertyValue: () => '0px', + lineHeight: '20px', + fontSize: '16px', + }), + visualViewport: { + offsetLeft: 0, + offsetTop: 0, + width: 100, + height: 100, + }, + innerWidth: 100, + innerHeight: 100, + }); + vi.stubGlobal('document', { + createElement: () => ({ + style: { setProperty: vi.fn() }, + textContent: '', + appendChild: vi.fn(), + offsetTop: 0, + offsetLeft: 0, + remove: vi.fn(), + }), + body: { appendChild: vi.fn() }, + }); +}); + +test('typing / opens the command menu with every available command', () => { + const tree = type('/'); + + assert.ok(row(tree, 'image')); + assert.ok(row(tree, 'poll')); + assert.ok(row(tree, 'code')); +}); + +test('shows the keyboard helper under the commands', () => { + const tree = type('/'); + + assert.ok( + elements(tree).some((element) => + String(element.props.children).includes('Tab or Enter pick'), + ), + ); +}); + +test('filters the menu by prefix, alias included', () => { + assert.ok(row(type('/po'), 'poll')); + assert.equal(row(type('/po'), 'image'), undefined); + assert.ok(row(type('/img'), 'image')); + assert.equal(row(type('/x'), 'image'), undefined); +}); + +test('selecting poll activates the poll editor and removes the command text', () => { + const tree = type('Ask /poll'); + + (row(tree, 'poll')!.props.onClick as () => void)(); + + assert.equal(onValueChange.mock.lastCall?.[0], 'Ask'); + assert.equal(onPoll.mock.calls.length, 1); + assert.equal(row(render(), 'poll'), undefined); +}); + +test('an exact command followed by space runs without picking', () => { + const tree = type('see /image'); + const event = press(tree, ' '); + + assert.equal(event.preventDefault.mock.calls.length, 1); + assert.equal(onImage.mock.calls.length, 1); + assert.equal(onValueChange.mock.lastCall?.[0], 'see'); +}); + +test('disabled commands stay listed with their reason and never run', () => { + const overrides = { commandContext: surface({ hasPoll: true }) }; + const tree = type('/image', overrides); + + const image = row(tree, 'image')!; + assert.equal(image.props['aria-disabled'], true); + assert.ok( + elements(image).some( + (element) => element.props.children === 'Remove the poll first', + ), + ); + + const event = press(tree, ' '); + + assert.equal(event.preventDefault.mock.calls.length, 0); + assert.equal(onImage.mock.calls.length, 0); + assert.ok(row(render(overrides), 'image')); +}); + +test('enter and tab on a disabled command are left to the caller', () => { + const overrides = { + commandContext: surface({ hasPoll: true }), + }; + const onKeyDown = vi.fn(); + + for (const key of ['Enter', 'Tab']) { + const tree = type('/image', { ...overrides, onKeyDown }); + const event = press(tree, key); + + assert.equal(event.preventDefault.mock.calls.length, 0, key); + assert.equal(onImage.mock.calls.length, 0, key); + } + + assert.equal(onKeyDown.mock.calls.length, 2); +}); + +test('escape keeps the text, and the menu stays dismissed until the token is re-formed', () => { + const tree = type('/po'); + press(tree, 'Escape'); + + assert.equal(row(render(), 'poll'), undefined); + assert.equal(row(type('/pol'), 'poll'), undefined); + type(''); + assert.ok(row(type('/po'), 'poll')); +}); + +test('code inserts a fenced block where the command was typed', () => { + const tree = type('see /code'); + press(tree, ' '); + + assert.equal(onValueChange.mock.lastCall?.[0], 'see \n```\n\n```'); +}); + +test('code at the start of the body inserts without padding', () => { + const tree = type('/code'); + press(tree, ' '); + + assert.equal(onValueChange.mock.lastCall?.[0], '```\n\n```'); +}); + +test('code does not insert when the block would exceed the character limit', () => { + const prefix = `${'a'.repeat(4000 - '/code'.length - 1)} `; + value = prefix; + const onKeyDown = vi.fn(); + const tree = type(`${prefix}/code`, { onKeyDown }); + const event = press(tree, ' '); + + assert.equal(event.preventDefault.mock.calls.length, 0); + assert.equal(onValueChange.mock.calls.length, 1); + assert.equal(onValueChange.mock.lastCall?.[0], `${prefix}/code`); + assert.equal(onKeyDown.mock.calls.length, 1); +}); + +test('enter runs the active command when the block fits', () => { + const tree = type('see /code'); + const event = press(tree, 'Enter'); + + assert.equal(event.preventDefault.mock.calls.length, 1); + assert.equal(onValueChange.mock.lastCall?.[0], 'see \n```\n\n```'); +}); + +test('enter at the character limit is left to the caller', () => { + const prefix = `${'a'.repeat(4000 - '/code'.length - 1)} `; + value = prefix; + const onKeyDown = vi.fn(); + const tree = type(`${prefix}/code`, { onKeyDown }); + const event = press(tree, 'Enter'); + + assert.equal(event.preventDefault.mock.calls.length, 0); + assert.equal(onValueChange.mock.calls.length, 1); + assert.equal(onKeyDown.mock.calls.length, 1); +}); + +test('the two menus do not open for each other', () => { + assert.equal(menu(type('/'), 'mention-suggestions'), undefined); + + const mention = type('@ali'); + + assert.ok(menu(mention, 'mention-suggestions')); + assert.equal(row(mention, 'image'), undefined); +}); + +test('typing @ opens the mention menu in its searching state', () => { + const tree = type('@ali'); + + assert.ok(menu(tree, 'mention-suggestions')); + assert.equal(mentionRows(tree).length, 0); + assert.ok(searching(tree)); +}); + +test('mention results for a stale query are not shown', () => { + state[7] = { + key: ':bob', + users: [{ id: 'bob', handle: 'bob', name: 'Bob', lane: 2 }], + organizations: [], + }; + const tree = type('@ali'); + + assert.ok(menu(tree, 'mention-suggestions')); + assert.equal(mentionRows(tree).length, 0); + assert.ok(searching(tree)); +}); + +test('selecting a mention inserts @handle and closes the menu', () => { + state[7] = { key: ':::ali', users: [alice], organizations: [] }; + const tree = type('@ali'); + const suggestion = mentionRows(tree)[0]!; + + assert.equal(suggestion.props.id, 'menu-0-mention-alice'); + + (field(tree).props.ref as (element: unknown) => void)({ + focus: vi.fn(), + setSelectionRange: vi.fn(), + }); + + (suggestion.props.onClick as () => void)(); + + assert.equal(onValueChange.mock.lastCall?.[0], '@alice '); + + const closed = render(); + + assert.equal(mentionRows(closed).length, 0); + assert.equal(menu(closed, 'mention-suggestions'), undefined); +}); + +test('poll is absent in replies and when no poll callback is given', () => { + assert.equal( + row( + type('/', { commandContext: surface({ surface: 'reply' }) }), + 'poll', + ), + undefined, + ); + assert.equal(row(type('/', { onPoll: undefined }), 'poll'), undefined); + assert.ok(row(type('/', { onPoll: undefined }), 'code')); +}); + +test('typing /b offers the issue commands and picking bug starts the flow', () => { + const tree = type('/b'); + const bug = row(tree, 'bug')!; + + assert.ok(bug); + assert.equal(row(tree, 'feature'), undefined); + + (bug.props.onClick as () => void)(); + + assert.deepEqual(onIssue.mock.lastCall, ['bug', '']); + assert.equal(row(render(), 'bug'), undefined); +}); + +test('an exact issue command followed by space runs without picking', () => { + const tree = type('/feature'); + const event = press(tree, ' '); + + assert.equal(event.preventDefault.mock.calls.length, 1); + assert.deepEqual(onIssue.mock.lastCall, ['feature', '']); +}); + +test('issue commands need the composer callback and stay out of replies and scheduling', () => { + assert.equal(row(type('/b', { onIssue: undefined }), 'bug'), undefined); + assert.equal( + row( + type('/', { commandContext: surface({ surface: 'reply' }) }), + 'bug', + ), + undefined, + ); + assert.equal( + row( + type('/', { commandContext: surface({ isScheduling: true }) }), + 'bug', + ), + undefined, + ); +}); + +test('the menu closes when the composer loses focus', () => { + const tree = type('/b'); + + assert.ok(row(tree, 'bug')); + + (field(tree).props.onBlur as (event: unknown) => void)({}); + + assert.equal(row(render(), 'bug'), undefined); +}); + +test('typing a shortcode opens the emoji menu', () => { + const tree = type('ship it :sm'); + + assert.ok(menu(tree, 'emoji-suggestions')); + assert.ok(emojiRows(tree).length > 0); + assert.equal(row(tree, 'image'), undefined); +}); + +test('selecting an emoji inserts it and closes the menu', () => { + const tree = type('ship it :sm'); + const suggestion = emojiRows(tree)[0]!; + + (field(tree).props.ref as (element: unknown) => void)({ + focus: vi.fn(), + setSelectionRange: vi.fn(), + }); + + (suggestion.props.onClick as () => void)(); + + const inserted = onValueChange.mock.lastCall?.[0] ?? ''; + + assert.ok(inserted.startsWith('ship it ')); + assert.ok(!inserted.includes(':sm')); + + const closed = render(); + + assert.equal(emojiRows(closed).length, 0); + assert.equal(menu(closed, 'emoji-suggestions'), undefined); +}); + +test('the emoji menu stays quiet inside code spans', () => { + const tree = type('`code :sm`'); + + assert.equal(menu(tree, 'emoji-suggestions'), undefined); +}); + +test('disabling mention suggestions keeps emoji and slash commands working', () => { + assert.equal( + menu(type('@ali', { suggestsMentions: false }), 'mention-suggestions'), + undefined, + ); + assert.ok( + menu(type(':sm', { suggestsMentions: false }), 'emoji-suggestions'), + ); + assert.ok( + menu(type('/', { suggestsMentions: false }), 'slash-suggestions'), + ); +}); + +test('submitting disables every command', () => { + const tree = type('/', { + commandContext: surface({ processing: true }), + }); + + const code = row(tree, 'code')!; + assert.equal(code.props['aria-disabled'], true); + assert.ok( + elements(code).some( + (element) => element.props.children === 'Insert a code block', + ), + ); +}); diff --git a/tests/Unit/SlashCommands.test.ts b/tests/Unit/SlashCommands.test.ts new file mode 100644 index 000000000..6ef3071fc --- /dev/null +++ b/tests/Unit/SlashCommands.test.ts @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { ComposerSurface } from '../../resources/js/lib/slash-commands.ts'; +import { + availableCommands, + codeFenceInsertion, + consumeSlashToken, + filterCommands, + matchCommand, + slashCommandInProse, +} from '../../resources/js/lib/slash-commands.ts'; + +const surface = ( + overrides: Partial = {}, +): ComposerSurface => ({ + surface: 'commit', + hasPoll: false, + imageCount: 0, + isScheduling: false, + processing: false, + ...overrides, +}); + +void test('lists image, poll, code, bug, and feature with stock copy', () => { + assert.deepEqual( + availableCommands(surface()).map((option) => [ + option.command.id, + option.enabled, + option.reason, + ]), + [ + ['image', true, null], + ['poll', true, null], + ['code', true, null], + ['bug', true, null], + ['feature', true, null], + ], + ); +}); + +void test('poll and the issue commands are only offered on top-level commits', () => { + for (const kind of ['reply', 'quote'] as const) { + assert.deepEqual( + availableCommands(surface({ surface: kind })).map( + (option) => option.command.id, + ), + ['image', 'code'], + ); + } +}); + +void test('issue commands are absent while scheduling and disabled by images and polls', () => { + const ids = (overrides: Partial) => + availableCommands(surface(overrides)).map( + (option) => option.command.id, + ); + const reason = (id: string, overrides: Partial) => + availableCommands(surface(overrides)).find( + (option) => option.command.id === id, + )!.reason; + + assert.deepEqual(ids({ isScheduling: true }), ['image', 'poll', 'code']); + assert.equal( + reason('bug', { imageCount: 1 }), + 'Remove the images first', + ); + assert.equal(reason('feature', { hasPoll: true }), 'Remove the poll first'); +}); + +void test('image is disabled with a reason by poll and the image limit', () => { + const reason = (overrides: Partial) => + availableCommands(surface(overrides)).find( + (option) => option.command.id === 'image', + )!.reason; + + assert.equal(reason({ hasPoll: true }), 'Remove the poll first'); + assert.equal(reason({ imageCount: 3 }), 'Maximum of 3 images'); +}); + +void test('poll is disabled with a reason by images and an existing poll', () => { + const reason = (overrides: Partial) => + availableCommands(surface(overrides)).find( + (option) => option.command.id === 'poll', + )!.reason; + + assert.equal(reason({ imageCount: 1 }), 'Remove the images first'); + assert.equal(reason({ hasPoll: true }), 'A poll is already added'); +}); + +void test('scheduling keeps images and polls available', () => { + const options = availableCommands(surface({ isScheduling: true })); + + assert.ok(options.every((option) => option.enabled)); +}); + +void test('every command is disabled without a reason while submitting', () => { + const options = availableCommands(surface({ processing: true })); + + assert.ok(options.every((option) => !option.enabled)); + assert.ok(options.every((option) => option.reason === null)); +}); + +void test('filters by prefix, aliases included', () => { + const options = availableCommands(surface()); + const ids = (query: string) => + filterCommands(options, query).map((option) => option.command.id); + + assert.deepEqual(ids(''), ['image', 'poll', 'code', 'bug', 'feature']); + assert.deepEqual(ids('i'), ['image']); + assert.deepEqual(ids('img'), ['image']); + assert.deepEqual(ids('po'), ['poll']); + assert.deepEqual(ids('c'), ['code']); + assert.deepEqual(ids('b'), ['bug']); + assert.deepEqual(ids('f'), ['feature']); + assert.deepEqual(ids('x'), []); +}); + +void test('resolves exact names and aliases for direct runs', () => { + assert.equal(matchCommand('image')?.id, 'image'); + assert.equal(matchCommand('img')?.id, 'image'); + assert.equal(matchCommand('poll')?.id, 'poll'); + assert.equal(matchCommand('code')?.id, 'code'); + assert.equal(matchCommand('bug')?.id, 'bug'); + assert.equal(matchCommand('feature')?.id, 'feature'); + assert.equal(matchCommand('imagex'), null); + assert.equal(matchCommand(''), null); +}); + +void test('consuming a token drops one adjacent space', () => { + assert.equal(consumeSlashToken('ask /poll', 4, 'poll'), 'ask'); + assert.equal(consumeSlashToken('/image tell us', 0, 'image'), 'tell us'); + assert.equal(consumeSlashToken('/code', 0, 'code'), ''); + assert.equal(consumeSlashToken('line\n/image', 5, 'image'), 'line\n'); +}); + +void test('a code insertion keeps the fences on their own lines', () => { + assert.deepEqual(codeFenceInsertion('see /code', 4, 9), { + value: 'see \n```\n\n```', + caret: 9, + }); + assert.deepEqual(codeFenceInsertion('/code', 0, 5), { + value: '```\n\n```', + caret: 4, + }); + assert.deepEqual(codeFenceInsertion('see /code here', 4, 9), { + value: 'see \n```\n\n```\n here', + caret: 9, + }); +}); + + +void test('finds the slash command being typed before the caret', () => { + assert.equal(slashCommandInProse('/'), ''); + assert.equal(slashCommandInProse('try /po'), 'po'); + assert.equal(slashCommandInProse('line\n/img'), 'img'); +}); + +void test('does not treat urls, dates, or mid-word slashes as commands', () => { + assert.equal(slashCommandInProse('https://example.com'), null); + assert.equal(slashCommandInProse('on 10/20'), null); + assert.equal(slashCommandInProse('foo/bar'), null); +});