diff --git a/package.json b/package.json index e21ee6be8..921d0e59e 100644 --- a/package.json +++ b/package.json @@ -195,6 +195,7 @@ "electron-builder-squirrel-windows": "^26.15.3", "electron-vite": "^5.0.0", "emoji-picker-react": "^4.19.1", + "emojibase-data": "17.0.0", "happy-dom": "^20.9.0", "html-to-image": "^1.11.13", "husky": "^9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b1a656bd..64dd0cb34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,6 +252,9 @@ importers: emoji-picker-react: specifier: ^4.19.1 version: 4.19.1(react@19.2.7) + emojibase-data: + specifier: 17.0.0 + version: 17.0.0(emojibase@17.0.0) happy-dom: specifier: ^20.9.0 version: 20.9.0 @@ -3986,6 +3989,15 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emojibase-data@17.0.0: + resolution: {integrity: sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==} + peerDependencies: + emojibase: '*' + + emojibase@17.0.0: + resolution: {integrity: sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==} + engines: {node: '>=18.12.0'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -10031,6 +10043,12 @@ snapshots: emoji-regex@8.0.0: {} + emojibase-data@17.0.0(emojibase@17.0.0): + dependencies: + emojibase: 17.0.0 + + emojibase@17.0.0: {} + encodeurl@2.0.0: {} end-of-stream@1.4.5: diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 952d97c86..5ae35f651 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -74,15 +74,19 @@ describe('sanitizeWorktreeName', () => { expect(sanitizeWorktreeName('feat: δΈ­ζ–‡ (v2)')).toBe('feat-δΈ­ζ–‡-v2') }) - it('uses a git-safe fallback when a name contains only emoji', () => { - expect(sanitizeWorktreeName('πŸš€')).toBe('workspace') - expect(sanitizeWorktreeName('πŸ‘©β€πŸ’»βœ¨')).toBe('workspace') - expect(sanitizeWorktreeName('πŸ‡―πŸ‡΅')).toBe('workspace') - expect(sanitizeWorktreeName('1️⃣')).toBe('1') + it('uses readable git-safe shortcodes for known emoji', () => { + expect(sanitizeWorktreeName('πŸš€')).toBe('rocket') + expect(sanitizeWorktreeName('πŸ‘©β€πŸ’»βœ¨')).toBe('woman-technologist-sparkles') + expect(sanitizeWorktreeName('πŸ‡―πŸ‡΅')).toBe('jp') + expect(sanitizeWorktreeName('1️⃣')).toBe('one') }) - it('keeps readable text while removing emoji from branch and path names', () => { - expect(sanitizeWorktreeName('Ship it πŸš€')).toBe('Ship-it') + it('keeps readable text and emoji shortcodes in branch and path names', () => { + expect(sanitizeWorktreeName('Ship it πŸš€')).toBe('Ship-it-rocket') + }) + + it('uses a git-safe fallback for emoji newer than the shortcode catalog', () => { + expect(sanitizeWorktreeName('\u{1fae9}')).toBe('workspace') }) it('does not treat arbitrary punctuation as a workspace name', () => { diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 6a37af457..a056d657f 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -3,6 +3,7 @@ import type { GlobalSettings, OrcaWorkspaceLayout, Repo } from '../../shared/typ import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree-id' +import { replaceKnownEmojiWithShortcodes } from '../../shared/emoji-shortcode-catalog' import { getWslHome, parseWslPath } from '../wsl' type WorktreePathSettings = Pick @@ -25,7 +26,7 @@ export function sanitizeWorktreeName(input: string): string { // name workspaces in their own language. Git ref-format permits non-ASCII // bytes, and modern filesystems handle UTF-8 paths. Only strip characters // git or the filesystem actually rejects. - const sanitized = input + const sanitized = replaceKnownEmojiWithShortcodes(input) .trim() .replace(/[^\p{L}\p{N}._-]+/gu, '-') .replace(/-+/g, '-') diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index dacc12925..b5295570a 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -899,9 +899,9 @@ describe('registerWorktreeHandlers', () => { it('keeps an emoji-only display name while using safe branch and path names', async () => { listWorktreesMock.mockResolvedValue([ { - path: '/workspace/workspace', + path: '/workspace/rocket', head: 'abc123', - branch: 'workspace', + branch: 'rocket', isBare: false, isMainWorktree: false } @@ -914,13 +914,13 @@ describe('registerWorktreeHandlers', () => { expect(addWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', - '/workspace/workspace', - 'workspace', + '/workspace/rocket', + 'rocket', 'origin/main', false ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/workspace', + 'repo-1::/workspace/rocket', expect.objectContaining({ displayName: 'πŸš€' }) ) }) diff --git a/src/renderer/src/lib/worktree-palette-search.test.ts b/src/renderer/src/lib/worktree-palette-search.test.ts index 7059ee4ad..4cb69b75e 100644 --- a/src/renderer/src/lib/worktree-palette-search.test.ts +++ b/src/renderer/src/lib/worktree-palette-search.test.ts @@ -86,6 +86,23 @@ describe('worktree-palette-search', () => { ]) }) + it('finds an emoji-named workspace by its readable branch shortcode', () => { + const results = searchWorktrees( + [makeWorktree({ displayName: 'πŸš€', branch: 'refs/heads/rocket' })], + 'rocket', + repoMap, + null, + null + ) + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + worktreeId: 'wt-1', + matchedField: 'branch', + branchRange: { start: 0, end: 6 } + }) + }) + it('rejects oversized pasted queries before reading worktree metadata', () => { const oversizedQuery = 'secret-worktree-palette-search'.repeat(WORKTREE_PALETTE_QUERY_MAX_BYTES) const worktree = { diff --git a/src/shared/emoji-shortcode-catalog.ts b/src/shared/emoji-shortcode-catalog.ts new file mode 100644 index 000000000..fba621b7a --- /dev/null +++ b/src/shared/emoji-shortcode-catalog.ts @@ -0,0 +1,49 @@ +import emojiShortcodes from 'emojibase-data/en/shortcodes/github.json' + +export type StandardEmojiShortcodeEntry = { + emoji: string + shortcode: string +} + +export const STANDARD_EMOJI_SHORTCODE_ENTRIES: readonly StandardEmojiShortcodeEntry[] = + Object.entries(emojiShortcodes).flatMap(([hexcode, value]) => { + const shortcodes = typeof value === 'string' ? [value] : value + const emoji = hexcodeToEmoji(hexcode) + return shortcodes.map((shortcode) => ({ emoji, shortcode })) + }) + +const PRIMARY_SHORTCODE_BY_EMOJI = new Map( + Object.entries(emojiShortcodes).map(([hexcode, value]) => { + const shortcodes = typeof value === 'string' ? [value] : value + const shortcode = shortcodes.find((candidate) => /^[a-z]/i.test(candidate)) ?? shortcodes[0] + return [normalizeEmojiLookup(hexcodeToEmoji(hexcode)), shortcode] + }) +) + +const EMOJI_SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' }) + +export function replaceKnownEmojiWithShortcodes(input: string): string { + return Array.from(EMOJI_SEGMENTER.segment(input), ({ segment }) => { + const shortcode = PRIMARY_SHORTCODE_BY_EMOJI.get(normalizeEmojiLookup(segment)) + return shortcode ? ` ${shortcode.replaceAll('_', '-')} ` : segment + }).join('') +} + +function normalizeEmojiLookup(emoji: string): string { + return Array.from(emoji) + .filter((character) => { + const codepoint = character.codePointAt(0) + return ( + character !== '\ufe0f' && + (codepoint === undefined || codepoint < 0x1f3fb || codepoint > 0x1f3ff) + ) + }) + .join('') +} + +function hexcodeToEmoji(hexcode: string): string { + return hexcode + .split('-') + .map((codepoint) => String.fromCodePoint(Number.parseInt(codepoint, 16))) + .join('') +} diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index 14967d126..d290bea08 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -178,8 +178,7 @@ test.describe('Create Workspace', () => { const worktrees = Object.values(window.__store!.getState().worktreesByRepo).flat() return worktrees.find((worktree) => worktree.displayName === displayName)?.branch ?? null }, workspaceName) - expect(branch).not.toBeNull() - expect(branch).not.toMatch(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/u) + expect(branch).toBe('refs/heads/rocket-test-tube-sparkles') } finally { await orcaPage .evaluate(() => {