diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx index 68de13176..e263a0db9 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx @@ -69,6 +69,8 @@ vi.mock('@/lib/workspace-tab-palette-activation', () => ({ vi.mock('@/components/ui/command', async () => { const React = await import('react') return { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, // Why the commandProps passthrough: cmdk resolves Enter against its `value`, so the controlled // value is the only honest stand-in for "what would Enter activate" without mounting real cmdk. CommandDialog: ({ diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index cd1149f72..47215a8c9 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -2,6 +2,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { fireEvent } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactI18Next from 'react-i18next' import { useAppStore } from '@/store' @@ -74,25 +75,40 @@ vi.mock('@/components/ui/command', async () => { ) : null }, - CommandInput: ({ - value, - onValueChange, - placeholder - }: { - value?: string - onValueChange?: (next: string) => void - placeholder?: string - }) => { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandInput: React.forwardRef(function CommandInput( + { + value, + onValueChange, + placeholder, + onClick, + onSelect, + onKeyDown + }: { + value?: string + onValueChange?: (next: string) => void + placeholder?: string + onClick?: React.MouseEventHandler + onSelect?: React.ReactEventHandler + onKeyDown?: React.KeyboardEventHandler + }, + ref: React.ForwardedRef + ) { setCommandQuery = onValueChange ?? null return ( onValueChange?.(event.currentTarget.value)} + onClick={onClick} + onSelect={onSelect} + onKeyDown={onKeyDown} /> ) - }, + }), CommandList: React.forwardRef(function CommandList( { children }: { children: React.ReactNode }, ref: React.ForwardedRef @@ -337,4 +353,16 @@ describe('WorktreeJumpPalette', () => { expect(testContainer.textContent).toContain('Feature workspace') }) + + it('replaces a completed emoji shortcode in the search query', async () => { + await renderPalette({ worktreesByRepo: { 'repo-1': [] } }) + const input = testContainer.querySelector('[data-command-input="true"]') + expect(input).not.toBeNull() + + await act(async () => { + fireEvent.change(input!, { target: { value: ':wink:', selectionStart: 6 } }) + }) + + expect(input?.value).toBe('😉') + }) }) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 89068eb48..a8adbec77 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -169,6 +169,8 @@ import { resolvePaletteFocusRestoreTarget } from '@/components/cmd-j/palette-foc import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { buildPluginQuickActions } from '@/components/cmd-j/plugin-quick-actions' +import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover' +import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput' import { usePluginCommands } from '@/store/plugin-panels' import { getComposerEligibleRepos, @@ -1893,6 +1895,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { setSelectedItemId('') listRef.current?.scrollTo(0, 0) }, []) + const emojiInput = useWorkspaceEmojiShortcodeInput({ + inputRef, + onValueChange: handleQueryChange, + value: query + }) const cancelFallbackFocusFrames = useCallback((): void => { if (fallbackFocusOuterFrameRef.current !== null) { @@ -2455,7 +2462,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'Search chats, terminals, worktrees, settings, and actions...' )} value={query} - onValueChange={handleQueryChange} + onValueChange={emojiInput.handleValueChange} + onClick={(event) => emojiInput.syncCursor(event.currentTarget)} + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onKeyDown={(event) => emojiInput.handleKeyDown(event)} wrapperClassName="mx-3 mt-3 rounded-lg border border-border/55 bg-muted/28 px-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]" iconClassName="mr-2.5 h-4 w-4 text-muted-foreground/60" className="h-12 text-[14px] placeholder:text-muted-foreground/75" @@ -2471,6 +2481,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } /> + !open && emojiInput.close()} + portalContainer={dialogElement} + side="bottom" + contentClassName="w-80" + /> ['repos'][number] const EMPTY_REPO_SEARCH_REPOS: readonly RepoOption[] = [] diff --git a/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx b/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx new file mode 100644 index 000000000..715c9976e --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx @@ -0,0 +1,79 @@ +import { useId, type RefObject } from 'react' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover' +import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput' +import { translate } from '@/i18n/i18n' + +type WorktreeDisplayNameFieldProps = { + disabled: boolean + inputRef: RefObject + onEnter: () => void | Promise + onValueChange: (value: string) => void + portalContainer: HTMLElement | null + value: string +} + +export function WorktreeDisplayNameField({ + disabled, + inputRef, + onEnter, + onValueChange, + portalContainer, + value +}: WorktreeDisplayNameFieldProps): React.JSX.Element { + const inputId = useId() + const emojiInput = useWorkspaceEmojiShortcodeInput({ + disabled, + inputRef, + onValueChange, + value + }) + + return ( +
+ + + emojiInput.handleValueChange(event.target.value, event.target.selectionStart) + } + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onKeyDown={(event) => { + if (emojiInput.handleKeyDown(event) || event.key !== 'Enter') { + return + } + event.preventDefault() + void onEnter() + }} + placeholder={translate( + 'auto.components.sidebar.WorktreeMetaDialog.7f21e0464f', + 'Custom display name...' + )} + className="h-8 text-xs" + /> + !open && emojiInput.close()} + portalContainer={portalContainer} + side="bottom" + /> +

+ {translate( + 'auto.components.sidebar.WorktreeMetaDialog.459ad7f650', + 'Only changes the name shown in the sidebar — the folder on disk stays the same. Leave blank to use the branch or folder name.' + )} +

+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx index b45585a46..66de27601 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx @@ -225,6 +225,17 @@ describe('WorktreeMetaDialog issue link row', () => { expect(issueInput().value).toBe('42') }) + it('replaces a completed emoji shortcode in the display name', () => { + openDialog() + const displayNameInput = screen.getByRole('textbox', { name: 'Display Name' }) + + fireEvent.change(displayNameInput, { + target: { value: 'Feature :wink:', selectionStart: 14 } + }) + + expect((displayNameInput as HTMLInputElement).value).toBe('Feature 😉') + }) + it('seeds the chip and value from a Linear link', () => { openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 4445520fd..0482a6442 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -30,6 +30,7 @@ import { parseIssueLinkInput, type IssueLinkProvider } from '../../../../shared/issue-link-input' +import { WorktreeDisplayNameField } from './WorktreeDisplayNameField' function resizeCommentTextarea(textarea: HTMLTextAreaElement): void { textarea.style.height = 'auto' @@ -93,6 +94,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT) + const [dialogElement, setDialogElement] = useState(null) const { canOpenIssue, openingIssue, openIssueFailed, handleOpenIssue, resetOpeningIssue } = useWorktreeIssueLink({ worktreeId, @@ -282,6 +284,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { return ( { e.preventDefault() @@ -312,28 +315,14 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
-
- - setDisplayNameInput(e.target.value)} - onKeyDown={handleIssueKeyDown} - placeholder={translate( - 'auto.components.sidebar.WorktreeMetaDialog.7f21e0464f', - 'Custom display name...' - )} - className="h-8 text-xs" - /> -

- {translate( - 'auto.components.sidebar.WorktreeMetaDialog.459ad7f650', - 'Only changes the name shown in the sidebar — the folder on disk stays the same. Leave blank to use the branch or folder name.' - )} -

-
+ ({ } })) +vi.mock('@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover', () => ({ + WorkspaceEmojiSuggestionPopover: () => null +})) + +vi.mock('@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput', () => ({ + useWorkspaceEmojiShortcodeInput: ({ + onValueChange + }: { + onValueChange: (value: string) => void + }) => ({ + close: vi.fn(), + commandValue: '', + handleKeyDown: () => false, + handleValueChange: (value: string) => onValueChange(value), + onCommandValueChange: vi.fn(), + open: false, + selectSuggestion: vi.fn(), + suggestions: [], + syncCursor: vi.fn() + }) +})) + type ReactElementLike = { type: unknown props: Record diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx index 679460541..ec670c3c5 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx @@ -3,6 +3,8 @@ import { LoaderCircle } from 'lucide-react' import { toast } from 'sonner' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover' +import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput' import { cn } from '@/lib/utils' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' @@ -68,10 +70,17 @@ export function WorktreeTitleInlineRename({ const titleElementRef = useRef(null) const titleResizeObserverRef = useRef(null) const removeTitleResizeListenerRef = useRef<(() => void) | null>(null) + const inputElementRef = useRef(null) const [editing, setEditing] = useState(false) const [value, setValue] = useState(displayName) const [saving, setSaving] = useState(false) const [titleTruncated, setTitleTruncated] = useState(false) + const emojiInput = useWorkspaceEmojiShortcodeInput({ + disabled: saving, + inputRef: inputElementRef, + onValueChange: setValue, + value + }) const measureTitleTruncated = useCallback((element: HTMLSpanElement | null) => { const nextTruncated = element ? isWorktreeTitleTruncated(element) : false @@ -141,6 +150,7 @@ export function WorktreeTitleInlineRename({ ) const handleInputRef = useCallback((input: HTMLInputElement | null) => { + inputElementRef.current = input if (!input) { return } @@ -182,9 +192,10 @@ export function WorktreeTitleInlineRename({ ) const cancelRename = useCallback(() => { + emojiInput.close() setValue(displayName) setEditingMode(false) - }, [displayName, setEditingMode]) + }, [displayName, emojiInput, setEditingMode]) const commitRename = useCallback(async () => { if (savingRef.current) { @@ -226,6 +237,9 @@ export function WorktreeTitleInlineRename({ const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { event.stopPropagation() + if (emojiInput.handleKeyDown(event)) { + return + } // Why: an Enter that only confirms a CJK IME candidate must not commit the // rename; wait for a non-composition Enter. if (isImeCompositionKeyDown(event)) { @@ -239,61 +253,81 @@ export function WorktreeTitleInlineRename({ cancelRename() } }, - [cancelRename, commitRename] + [cancelRename, commitRename, emojiInput] ) if (editing) { return ( - + <> - setValue(event.target.value)} - onBlur={() => void commitRename()} - onClick={stopCardEvent} - onDoubleClick={stopCardEvent} - onPointerDown={stopCardEvent} - onKeyDown={handleKeyDown} + key={`editing:${titleElementKey}`} + ref={handleRootRef} className={cn( - 'col-start-1 row-start-1 min-w-0 select-text truncate text-foreground outline-none', - editingInputClassName, - saving && savingInputClassName, - inputClassName + 'relative grid min-w-0 truncate leading-tight text-foreground', + showUnreadEmphasis ? 'font-semibold' : 'font-normal', + className, + editingClassName )} - /> - {saving ? ( - + + + emojiInput.handleValueChange(event.target.value, event.target.selectionStart) + } + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onBlur={() => void commitRename()} + onClick={stopCardEvent} + onDoubleClick={stopCardEvent} + onPointerDown={stopCardEvent} + onKeyDown={handleKeyDown} className={cn( - 'pointer-events-none absolute top-1/2 size-3 -translate-y-1/2 animate-spin text-muted-foreground', - savingSpinnerClassName + 'col-start-1 row-start-1 min-w-0 select-text truncate text-foreground outline-none', + editingInputClassName, + saving && savingInputClassName, + inputClassName )} /> - ) : null} - + {saving ? ( + + ) : null} + + !open && emojiInput.close()} + side="right" + contentClassName="w-56" + /> + ) } diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts index 04d2857ed..67b614776 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { VirtualItem } from '@tanstack/react-virtual' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { HOST_STICKY_PINNED_HEIGHT, buildLineageRowRekeyMap, @@ -11,11 +12,11 @@ import { type RenderRow } from './worktree-list-virtual-rows' -function hostRow(hostId: string): RenderRow { +function hostRow(hostId: ExecutionHostId): RenderRow { return { type: 'host-header', key: `host:${hostId}`, - hostId: hostId as never, + hostId, kind: 'ssh', label: hostId, detail: 'SSH', @@ -25,8 +26,15 @@ function hostRow(hostId: string): RenderRow { } } -function groupRow(key: string): RenderRow { - return { type: 'header', key, label: key, count: 1, tone: 'text-foreground' } +function groupRow(key: string, hostId?: ExecutionHostId): RenderRow { + return { + type: 'header', + key, + label: key, + count: 1, + tone: 'text-foreground', + ...(hostId ? { hostId } : {}) + } } function itemStub(id: string): RenderRow { @@ -39,11 +47,11 @@ function virtualItem(index: number, start: number): VirtualItem { // rows: [host-a, group-a1, item, item, host-b, group-b1, item] const rows: RenderRow[] = [ - hostRow('a'), + hostRow('ssh:a'), groupRow('a1'), itemStub('wt-1'), itemStub('wt-2'), - hostRow('b'), + hostRow('ssh:b'), groupRow('b1'), itemStub('wt-3') ] @@ -51,6 +59,23 @@ const stickyHeaderIndexes = getStickyHeaderIndexes(rows) // Geometry: each row 100px tall for easy math. const virtualItems = rows.map((_, index) => virtualItem(index, index * 100)) +describe('getRenderRowKey', () => { + it('scopes repeated group headers to their host section', () => { + expect(getRenderRowKey(groupRow('workspace-status:in-progress', 'local'))).toBe( + 'hdr:local:workspace-status:in-progress' + ) + expect(getRenderRowKey(groupRow('workspace-status:in-progress', 'ssh:builder'))).toBe( + 'hdr:ssh:builder:workspace-status:in-progress' + ) + }) + + it('preserves unsectioned group header keys', () => { + expect(getRenderRowKey(groupRow('workspace-status:in-progress'))).toBe( + 'hdr:workspace-status:in-progress' + ) + }) +}) + describe('getActiveStickyIndexesForScroll', () => { it('pins the host and its inner group while scrolled inside a section', () => { expect( @@ -149,7 +174,7 @@ describe('getActiveStickyIndexesForScroll', () => { it('keeps the previous mounted Project sticky when the next group is unmounted', () => { const multiGroupRows: RenderRow[] = [ - hostRow('a'), + hostRow('ssh:a'), groupRow('a1'), itemStub('wt-1'), groupRow('a2'), @@ -263,7 +288,8 @@ describe('buildLineageRowRekeyMap', () => { it('contributes nothing for non-lineage row types', () => { expect( - buildLineageRowRekeyMap([hostRow('a'), groupRow('a1'), hostRow('b'), groupRow('b1')]).size + buildLineageRowRekeyMap([hostRow('ssh:a'), groupRow('a1'), hostRow('ssh:b'), groupRow('b1')]) + .size ).toBe(0) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index 1ec2e07a8..69b8c71c0 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -20,7 +20,7 @@ export function getRenderRowKey(row: RenderRow): string { return `host:${row.hostId}` } if (row.type === 'header') { - return `hdr:${row.key}` + return row.hostId ? `hdr:${row.hostId}:${row.key}` : `hdr:${row.key}` } if (row.type === 'lineage-group') { return `lineage-group:${row.key}` diff --git a/src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx b/src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.tsx similarity index 82% rename from src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx rename to src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.tsx index 209fd7231..7497f080a 100644 --- a/src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx +++ b/src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.tsx @@ -1,27 +1,34 @@ -import type { RefObject } from 'react' +import type { ComponentProps, RefObject } from 'react' import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { cn } from '@/lib/utils' import type { WorkspaceEmojiSuggestion } from '@/lib/workspace-emoji-shortcodes' type WorkspaceEmojiSuggestionPopoverProps = { anchorRef: RefObject commandValue: string + contentClassName?: string heading: string onCommandValueChange: (value: string) => void onOpenChange: (open: boolean) => void onSelect: (suggestion: WorkspaceEmojiSuggestion) => void open: boolean + portalContainer?: HTMLElement | null + side?: ComponentProps['side'] suggestions: readonly WorkspaceEmojiSuggestion[] } export function WorkspaceEmojiSuggestionPopover({ anchorRef, commandValue, + contentClassName, heading, onCommandValueChange, onOpenChange, onSelect, open, + portalContainer, + side = 'top', suggestions }: WorkspaceEmojiSuggestionPopoverProps): React.JSX.Element { return ( @@ -30,11 +37,16 @@ export function WorkspaceEmojiSuggestionPopover({ event.preventDefault()} + onPointerDown={(event) => event.preventDefault()} onPointerDownOutside={(event) => { if (anchorRef.current?.contains(event.target as Node)) { event.preventDefault() diff --git a/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx b/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx new file mode 100644 index 000000000..d750bd3df --- /dev/null +++ b/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom + +import { useRef, useState } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useWorkspaceEmojiShortcodeInput } from './useWorkspaceEmojiShortcodeInput' + +function EmojiInputHarness({ + initialValue, + onUnhandledKeyDown +}: { + initialValue: string + onUnhandledKeyDown?: (event: React.KeyboardEvent) => void +}): React.JSX.Element { + const [value, setValue] = useState(initialValue) + const inputRef = useRef(null) + const emojiInput = useWorkspaceEmojiShortcodeInput({ + inputRef, + onValueChange: setValue, + value + }) + + return ( +
+ + emojiInput.handleValueChange( + event.currentTarget.value, + event.currentTarget.selectionStart + ) + } + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onKeyDown={(event) => { + if (!emojiInput.handleKeyDown(event)) { + onUnhandledKeyDown?.(event) + } + }} + /> + {emojiInput.suggestions.map((suggestion) => ( + + ))} +
+ ) +} + +describe('useWorkspaceEmojiShortcodeInput', () => { + afterEach(cleanup) + + it('replaces a completed shortcode without discarding trailing text', () => { + render() + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + fireEvent.change(input, { + target: { value: 'Launch :wink: experiment', selectionStart: 13 } + }) + + expect((input as HTMLInputElement).value).toBe('Launch 😉 experiment') + }) + + it('opens suggestions and accepts the highlighted emoji with Enter', () => { + render() + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + fireEvent.change(input, { target: { value: 'Launch :wink', selectionStart: 12 } }) + + expect(screen.getByText(':wink:')).toBeTruthy() + expect(input.parentElement?.dataset.emojiMenuOpen).toBe('true') + fireEvent.keyDown(input, { key: 'Enter' }) + expect((input as HTMLInputElement).value).toBe('Launch 😉 ') + }) + + it('dismisses suggestions with Escape without clearing the query', () => { + render() + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + fireEvent.change(input, { target: { value: 'Launch :wink', selectionStart: 12 } }) + fireEvent.keyDown(input, { key: 'Escape' }) + + expect((input as HTMLInputElement).value).toBe('Launch :wink') + expect(input.parentElement?.dataset.emojiMenuOpen).toBe('false') + }) + + it('leaves composing arrow navigation to the IME', () => { + const onUnhandledKeyDown = vi.fn() + render( + + ) + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + ;(input as HTMLInputElement).setSelectionRange(12, 12) + fireEvent.select(input) + const handled = fireEvent.keyDown(input, { key: 'ArrowDown', isComposing: true }) + + expect(handled).toBe(true) + expect(onUnhandledKeyDown).not.toHaveBeenCalled() + }) + + it('does not accept or submit on a composing Enter', () => { + const onUnhandledKeyDown = vi.fn() + render( + + ) + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + ;(input as HTMLInputElement).setSelectionRange(12, 12) + fireEvent.select(input) + const handled = fireEvent.keyDown(input, { key: 'Enter', isComposing: true }) + + expect(handled).toBe(true) + expect(onUnhandledKeyDown).not.toHaveBeenCalled() + expect((input as HTMLInputElement).value).toBe('Launch :wink') + }) +}) diff --git a/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.ts b/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.ts new file mode 100644 index 000000000..567e3ed8b --- /dev/null +++ b/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.ts @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' +import { + applyWorkspaceEmojiSuggestion, + getActiveWorkspaceEmojiShortcode, + replaceCompletedWorkspaceEmojiShortcode, + searchWorkspaceEmojiShortcodes, + type WorkspaceEmojiReplacement, + type WorkspaceEmojiSuggestion +} from '@/lib/workspace-emoji-shortcodes' + +type WorkspaceEmojiShortcodeInputOptions = { + disabled?: boolean + inputRef: RefObject + onValueChange: (value: string) => void + value: string +} + +export function useWorkspaceEmojiShortcodeInput({ + disabled = false, + inputRef, + onValueChange, + value +}: WorkspaceEmojiShortcodeInputOptions) { + const [cursor, setCursor] = useState(null) + const [commandValue, setCommandValue] = useState('') + const focusFrameRef = useRef(null) + const activeShortcode = useMemo( + () => getActiveWorkspaceEmojiShortcode(value, cursor), + [cursor, value] + ) + const suggestions = useMemo( + () => (activeShortcode ? searchWorkspaceEmojiShortcodes(activeShortcode.query) : []), + [activeShortcode] + ) + const open = !disabled && activeShortcode !== null && suggestions.length > 0 + const resolvedCommandValue = suggestions.some( + (suggestion) => `emoji:${suggestion.shortcode}` === commandValue + ) + ? commandValue + : suggestions[0] + ? `emoji:${suggestions[0].shortcode}` + : '' + const selectedSuggestion = + suggestions.find((suggestion) => `emoji:${suggestion.shortcode}` === resolvedCommandValue) ?? + null + + const cancelFocusFrame = useCallback(() => { + if (focusFrameRef.current !== null) { + cancelAnimationFrame(focusFrameRef.current) + focusFrameRef.current = null + } + }, []) + + useEffect(() => cancelFocusFrame, [cancelFocusFrame]) + + const applyReplacement = useCallback( + (replacement: WorkspaceEmojiReplacement) => { + onValueChange(replacement.value) + setCursor(null) + cancelFocusFrame() + focusFrameRef.current = requestAnimationFrame(() => { + focusFrameRef.current = null + inputRef.current?.focus({ preventScroll: true }) + inputRef.current?.setSelectionRange(replacement.cursor, replacement.cursor) + }) + }, + [cancelFocusFrame, inputRef, onValueChange] + ) + + const handleValueChange = useCallback( + ( + nextValue: string, + nextCursor: number | null = inputRef.current?.selectionStart ?? nextValue.length + ) => { + const completedEmoji = replaceCompletedWorkspaceEmojiShortcode(nextValue, nextCursor) + if (completedEmoji) { + applyReplacement(completedEmoji) + return + } + onValueChange(nextValue) + setCursor(nextCursor) + }, + [applyReplacement, inputRef, onValueChange] + ) + + const syncCursor = useCallback( + (input = inputRef.current) => setCursor(input?.selectionStart ?? null), + [inputRef] + ) + + const close = useCallback(() => setCursor(null), []) + + const selectSuggestion = useCallback( + (suggestion: WorkspaceEmojiSuggestion) => { + if (!activeShortcode) { + return + } + applyReplacement(applyWorkspaceEmojiSuggestion(value, activeShortcode, suggestion)) + }, + [activeShortcode, applyReplacement, value] + ) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): boolean => { + if (!open) { + return false + } + if (isImeCompositionKeyDown(event)) { + event.stopPropagation() + return true + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + event.stopPropagation() + const selectedIndex = suggestions.findIndex( + (suggestion) => `emoji:${suggestion.shortcode}` === resolvedCommandValue + ) + const direction = event.key === 'ArrowDown' ? 1 : -1 + const nextIndex = (selectedIndex + direction + suggestions.length) % suggestions.length + setCommandValue(`emoji:${suggestions[nextIndex].shortcode}`) + return true + } + const acceptsSuggestion = + (event.key === 'Enter' && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey) || + (event.key === 'Tab' && !event.shiftKey) + if (acceptsSuggestion && selectedSuggestion) { + event.preventDefault() + event.stopPropagation() + selectSuggestion(selectedSuggestion) + return true + } + if (event.key === 'Escape') { + event.stopPropagation() + close() + return true + } + return false + }, + [close, open, resolvedCommandValue, selectSuggestion, selectedSuggestion, suggestions] + ) + + return { + close, + commandValue: resolvedCommandValue, + handleKeyDown, + handleValueChange, + onCommandValueChange: setCommandValue, + open, + selectSuggestion, + suggestions, + syncCursor + } +} diff --git a/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx b/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx index 95b1db5e5..aad685c56 100644 --- a/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx +++ b/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx @@ -52,6 +52,8 @@ vi.mock('@/components/cmd-j/palette-host-badge', () => ({ vi.mock('@/components/ui/command', async () => { const React = await import('react') return { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => open ?
{children}
: null, CommandInput: ({ diff --git a/tests/e2e/workspace-emoji-picker.spec.ts b/tests/e2e/workspace-emoji-picker.spec.ts new file mode 100644 index 000000000..f16499a22 --- /dev/null +++ b/tests/e2e/workspace-emoji-picker.spec.ts @@ -0,0 +1,88 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +async function captureProof(page: Page, testInfo: TestInfo, name: string): Promise { + if (process.env.ORCA_E2E_RECORD_VIDEO === '1') { + return + } + const screenshotPath = testInfo.outputPath(name) + await page.screenshot({ path: screenshotPath }) + await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }) +} + +test.describe('Workspace emoji picker', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await orcaPage.waitForTimeout(750) + }) + + test('inserts emoji in sidebar rename, worktree details, and Cmd+J', async ({ + orcaPage + }, testInfo) => { + const title = orcaPage.locator('[data-worktree-title-inline-rename=""]').first() + await expect(title).toBeVisible() + await title.dblclick() + + const inlineInput = orcaPage.locator('[data-worktree-title-rename-input="true"]') + await expect(inlineInput).toBeVisible() + await inlineInput.fill('Sidebar proof') + await captureProof(orcaPage, testInfo, 'sidebar-rename-before.png') + await inlineInput.pressSequentially(' :wink', { delay: 60 }) + const inlineSuggestions = orcaPage.locator('[data-workspace-emoji-suggestions="true"]') + await expect(inlineSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible() + await captureProof(orcaPage, testInfo, 'sidebar-rename-picker.png') + await inlineInput.press('Enter') + await expect(inlineInput).toHaveValue('Sidebar proof 😉 ') + await inlineInput.press('Enter') + await expect(orcaPage.getByText('Sidebar proof 😉', { exact: true }).first()).toBeVisible() + + await orcaPage.evaluate(() => { + const state = window.__store!.getState() + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((candidate) => candidate.id === state.activeWorktreeId) + if (!worktree) { + throw new Error('Active worktree not found') + } + state.openModal('edit-meta', { + worktreeId: worktree.id, + repoId: worktree.repoId, + currentDisplayName: worktree.displayName, + currentComment: worktree.comment, + focus: 'displayName' + }) + }) + + const detailsDialog = orcaPage.getByRole('dialog', { name: 'Edit Worktree Details' }) + const displayNameInput = detailsDialog.getByPlaceholder('Custom display name...') + await expect(displayNameInput).toBeFocused() + await displayNameInput.fill('Details proof') + await captureProof(orcaPage, testInfo, 'worktree-details-before.png') + await displayNameInput.pressSequentially(' :wink', { delay: 60 }) + const detailsSuggestions = detailsDialog.locator('[data-workspace-emoji-suggestions="true"]') + await expect(detailsSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible() + await captureProof(orcaPage, testInfo, 'worktree-details-picker.png') + await displayNameInput.press('Enter') + await expect(displayNameInput).toHaveValue('Details proof 😉 ') + await detailsDialog.getByRole('button', { name: 'Cancel' }).click() + + await orcaPage.evaluate(() => window.__store!.getState().openModal('worktree-palette')) + const palette = orcaPage.getByRole('dialog', { name: 'Jump to...' }) + const paletteInput = palette.getByPlaceholder( + 'Search chats, terminals, worktrees, settings, and actions...' + ) + await expect(paletteInput).toBeFocused() + await captureProof(orcaPage, testInfo, 'cmd-j-before.png') + await paletteInput.pressSequentially(':wink', { delay: 60 }) + const paletteSuggestions = palette.locator('[data-workspace-emoji-suggestions="true"]') + await expect(paletteSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible() + await captureProof(orcaPage, testInfo, 'cmd-j-picker.png') + await paletteInput.press('Enter') + await expect(paletteInput).toHaveValue('😉 ') + await expect(palette.getByText('Sidebar proof 😉', { exact: true }).first()).toBeVisible() + await orcaPage.waitForTimeout(750) + }) +})