fix: address review findings

This commit is contained in:
Jinjing
2026-06-09 13:42:54 -07:00
parent 5c31930e0e
commit 4da415dfd9
4 changed files with 543 additions and 89 deletions
+176 -74
View File
@@ -54,6 +54,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { TabCreateEntryArgs } from './tab-create-entry-action'
import { buildTabAgentLaunchOptions, orderTabLaunchAgents } from './tab-agent-launch-options'
import { buildTabCreateMenuOptions, type TabCreateMenuOption } from './tab-create-menu-options'
import { translate } from '@/i18n/i18n'
const isWindows = navigator.userAgent.includes('Windows')
@@ -304,6 +305,7 @@ function TabBarInner({
// clicks, so it misses webview clicks entirely. Listening for window blur
// catches the moment focus leaves the renderer (including into a webview).
const [newTabMenuOpen, setNewTabMenuOpen] = useState(false)
const [createMenuQuery, setCreateMenuQuery] = useState('')
const pendingNewTabMenuFocusRef = useRef<(() => void) | null>(null)
const pendingNewTabMenuFocusAnimationRef = useRef<number | null>(null)
const pendingNewTabMenuFocusRetryRef = useRef<number | null>(null)
@@ -357,6 +359,109 @@ function TabBarInner({
const queueTerminalTabFocusAfterNewTabMenuClose = (tabId: string): void => {
pendingNewTabMenuFocusRef.current = () => focusTerminalTabSurface(tabId)
}
const windowsShellEntries = useMemo(() => {
if (!shouldShowWindowsShellMenu || !onNewTerminalWithShell) {
return undefined
}
const allShells: {
label: string
shell: BuiltInWindowsTerminalShell
}[] = [
{
label: translate('auto.components.tab.bar.TabBar.2148f65e04', 'PowerShell'),
shell: 'powershell.exe'
},
{
label: translate('auto.components.tab.bar.TabBar.1a8af49530', 'CMD Prompt'),
shell: 'cmd.exe'
},
...(windowsTerminalCapabilities.gitBashAvailable
? ([
{
label: translate('auto.components.tab.bar.TabBar.efb33546ff', 'Git Bash'),
shell: WINDOWS_GIT_BASH_SHELL
}
] as const)
: []),
...(windowsTerminalCapabilities.wslAvailable
? ([
{
label: translate('auto.components.tab.bar.TabBar.d1afac112b', 'WSL'),
shell: 'wsl.exe'
}
] as const)
: [])
]
const defaultEntry =
allShells.find((shell) => shell.shell === defaultWindowsShell) ?? allShells[0]
const orderedShells = [
defaultEntry,
...allShells.filter((shell) => shell.shell !== defaultEntry.shell)
]
return orderedShells.map((entry) => ({ label: entry.label, shell: entry.shell }))
}, [
defaultWindowsShell,
onNewTerminalWithShell,
shouldShowWindowsShellMenu,
windowsTerminalCapabilities.gitBashAvailable,
windowsTerminalCapabilities.wslAvailable
])
const createMenuOptions = useMemo(
() =>
buildTabCreateMenuOptions({
terminalOnly,
windowsShellEntries,
hasNewBrowser: !terminalOnly,
hasNewMarkdown: !terminalOnly && Boolean(onNewFileTab),
hasOpenMarkdown: !terminalOnly && Boolean(onOpenFileTab),
hasSimulator:
!terminalOnly && isMacOs && mobileEmulatorEnabled && Boolean(onNewSimulatorTab),
simulatorIsGoTo: workspaceHasSimulatorTab
}),
[
mobileEmulatorEnabled,
onNewFileTab,
onNewSimulatorTab,
onOpenFileTab,
terminalOnly,
windowsShellEntries,
workspaceHasSimulatorTab
]
)
const handleSelectCreateMenuOption = (option: TabCreateMenuOption): void => {
switch (option.kind) {
case 'new-terminal':
queueNewActiveTerminalFocusAfterNewTabMenuClose()
onNewTerminalTab()
break
case 'new-terminal-shell':
if (!onNewTerminalWithShell || !option.shell) {
break
}
queueNewActiveTerminalFocusAfterNewTabMenuClose()
onNewTerminalWithShell(
resolveWindowsShellLaunchTarget(
option.shell,
defaultWindowsPowerShellImplementation,
windowsTerminalCapabilities.pwshAvailable
)
)
break
case 'new-browser':
onNewBrowserTab()
break
case 'new-markdown':
onNewFileTab?.()
break
case 'open-markdown':
onOpenFileTab?.()
break
case 'new-simulator':
case 'go-to-simulator':
onNewSimulatorTab?.()
break
}
}
const launchAgentFromNewTabEntry = (agent: TuiAgent): void => {
const option = agentLaunchOptions.find((candidate) => candidate.agent === agent)
const result = launchAgentInNewTab({
@@ -366,7 +471,13 @@ function TabBarInner({
launchSource: 'tab_bar_quick_launch'
})
if (!result) {
toast.error(translate("auto.components.tab.bar.TabBar.ab589350e5", "Could not build launch command for {{value0}}.", { value0: option?.label ?? agent }))
toast.error(
translate(
'auto.components.tab.bar.TabBar.ab589350e5',
'Could not build launch command for {{value0}}.',
{ value0: option?.label ?? agent }
)
)
return
}
if (result.tabId) {
@@ -406,68 +517,38 @@ function TabBarInner({
const clearPendingNewTabMenuFocusOnUnmount = clearPendingNewTabMenuFocusOnUnmountRef.current
const defaultTerminalMenuItems =
shouldShowWindowsShellMenu && onNewTerminalWithShell ? (
// Why: previously the Windows path nested shell choices under a
// Radix submenu. In practice the submenu frequently failed to open
// on hover/click, and even when it worked the two-step expansion
// hid the fact that multiple shells were available. Inlining all
// shells as flat items — default pinned to the top with the
// Ctrl+T hint — matches the "no popouts, show all options at
// once" rec. Each entry uses a shell-specific icon (ShellIcon)
// so PowerShell / CMD / Git Bash / WSL are distinguishable at a glance.
// Labels use "CMD Prompt" instead of "Command Prompt" to keep
// each row narrow enough that the shortcut hint fits without
// wrapping.
(() => {
const allShells: {
label: string
shell: BuiltInWindowsTerminalShell
}[] = [
{ label: translate("auto.components.tab.bar.TabBar.2148f65e04", "PowerShell"), shell: 'powershell.exe' },
{ label: translate("auto.components.tab.bar.TabBar.1a8af49530", "CMD Prompt"), shell: 'cmd.exe' },
...(windowsTerminalCapabilities.gitBashAvailable
? ([{ label: translate("auto.components.tab.bar.TabBar.efb33546ff", "Git Bash"), shell: WINDOWS_GIT_BASH_SHELL }] as const)
: []),
...(windowsTerminalCapabilities.wslAvailable
? ([{ label: translate("auto.components.tab.bar.TabBar.d1afac112b", "WSL"), shell: 'wsl.exe' }] as const)
: [])
]
const defaultEntry = allShells.find((s) => s.shell === defaultWindowsShell) ?? allShells[0]
const orderedShells = [
defaultEntry,
...allShells.filter((s) => s.shell !== defaultEntry.shell)
]
return orderedShells.map((entry, idx) => {
const isDefault = idx === 0
return (
<DropdownMenuItem
key={entry.shell}
onSelect={() => {
// Why: the top-level Windows shell menu models shell
// categories, not concrete executables. When the user
// picked PowerShell 7+ in advanced settings, launching the
// "PowerShell" menu item must preserve that implementation
// instead of forcing inbox powershell.exe.
queueNewActiveTerminalFocusAfterNewTabMenuClose()
onNewTerminalWithShell(
resolveWindowsShellLaunchTarget(
entry.shell,
defaultWindowsPowerShellImplementation,
windowsTerminalCapabilities.pwshAvailable
)
windowsShellEntries && onNewTerminalWithShell ? (
windowsShellEntries.map((entry, idx) => {
const isDefault = idx === 0
return (
<DropdownMenuItem
key={entry.shell}
onSelect={() => {
// Why: the top-level Windows shell menu models shell
// categories, not concrete executables. When the user
// picked PowerShell 7+ in advanced settings, launching the
// "PowerShell" menu item must preserve that implementation
// instead of forcing inbox powershell.exe.
queueNewActiveTerminalFocusAfterNewTabMenuClose()
onNewTerminalWithShell(
resolveWindowsShellLaunchTarget(
entry.shell,
defaultWindowsPowerShellImplementation,
windowsTerminalCapabilities.pwshAvailable
)
}}
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<ShellIcon shell={entry.shell} size={14} />
<span className="flex-1">{translate("auto.components.tab.bar.TabBar.7c1313d237", "New Terminal:")} {entry.label}</span>
{isDefault ? (
<DropdownMenuShortcut>{newTerminalShortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
})
})()
)
}}
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<ShellIcon shell={entry.shell} size={14} />
<span className="flex-1">
{translate('auto.components.tab.bar.TabBar.7c1313d237', 'New Terminal:')}{' '}
{entry.label}
</span>
{isDefault ? <DropdownMenuShortcut>{newTerminalShortcut}</DropdownMenuShortcut> : null}
</DropdownMenuItem>
)
})
) : (
<DropdownMenuItem
onSelect={() => {
@@ -477,7 +558,8 @@ function TabBarInner({
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<TerminalSquare className="size-4 text-muted-foreground" />
{translate("auto.components.tab.bar.TabBar.d364f3c8d4", "New Terminal")}<DropdownMenuShortcut>{newTerminalShortcut}</DropdownMenuShortcut>
{translate('auto.components.tab.bar.TabBar.d364f3c8d4', 'New Terminal')}
<DropdownMenuShortcut>{newTerminalShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
)
const newBrowserMenuItem = !terminalOnly ? (
@@ -486,7 +568,8 @@ function TabBarInner({
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<Globe className="size-4 text-muted-foreground" />
{translate("auto.components.tab.bar.TabBar.4833fb2cbe", "New Browser Tab")}<DropdownMenuShortcut>{newBrowserShortcut}</DropdownMenuShortcut>
{translate('auto.components.tab.bar.TabBar.4833fb2cbe', 'New Browser Tab')}
<DropdownMenuShortcut>{newBrowserShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
) : null
const newSimulatorMenuItem =
@@ -499,11 +582,16 @@ function TabBarInner({
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<Smartphone className="size-4 text-muted-foreground" />
{translate("auto.components.tab.bar.TabBar.b426bb2615", "Go to Mobile Emulator")}<DropdownMenuShortcut>{newSimulatorShortcut}</DropdownMenuShortcut>
{translate('auto.components.tab.bar.TabBar.b426bb2615', 'Go to Mobile Emulator')}
<DropdownMenuShortcut>{newSimulatorShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="z-[80]">
{translate("auto.components.tab.bar.TabBar.aea43b5748", "Open the existing emulator tab.")}</TooltipContent>
{translate(
'auto.components.tab.bar.TabBar.aea43b5748',
'Open the existing emulator tab.'
)}
</TooltipContent>
</Tooltip>
) : (
<DropdownMenuItem
@@ -511,7 +599,8 @@ function TabBarInner({
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<Smartphone className="size-4 text-muted-foreground" />
{translate("auto.components.tab.bar.TabBar.fd2b42aaa3", "New Mobile Emulator")}<DropdownMenuShortcut>{newSimulatorShortcut}</DropdownMenuShortcut>
{translate('auto.components.tab.bar.TabBar.fd2b42aaa3', 'New Mobile Emulator')}
<DropdownMenuShortcut>{newSimulatorShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
)
) : null
@@ -522,7 +611,8 @@ function TabBarInner({
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<FilePlus className="size-4 text-muted-foreground" />
{translate("auto.components.tab.bar.TabBar.3d5d6c960d", "New Markdown")}<DropdownMenuShortcut>{newFileShortcut}</DropdownMenuShortcut>
{translate('auto.components.tab.bar.TabBar.3d5d6c960d', 'New Markdown')}
<DropdownMenuShortcut>{newFileShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
) : null
const openMarkdownMenuItem =
@@ -532,7 +622,8 @@ function TabBarInner({
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<FileText className="size-4 text-muted-foreground" />
{translate("auto.components.tab.bar.TabBar.4f327c8b3d", "Open Markdown...")}</DropdownMenuItem>
{translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')}
</DropdownMenuItem>
) : null
const standardCreateMenuItems =
newTabMenuOrder === 'markdown-first' ? (
@@ -562,6 +653,14 @@ function TabBarInner({
return () => window.removeEventListener('blur', dismiss)
}, [newTabMenuOpen])
useEffect(() => {
if (!newTabMenuOpen) {
setCreateMenuQuery('')
}
}, [newTabMenuOpen])
const showStaticCreateMenuItems = createMenuQuery.trim().length === 0
const terminalMap = useMemo(() => new Map(tabs.map((t) => [t.id, t])), [tabs])
const editorMap = useMemo(
() => new Map((editorFiles ?? []).map((f) => [f.tabId ?? f.id, f])),
@@ -952,12 +1051,12 @@ function TabBarInner({
<button
className="ml-2 my-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
title={translate("auto.components.tab.bar.TabBar.b1a132357f", "New tab")}
title={translate('auto.components.tab.bar.TabBar.b1a132357f', 'New tab')}
// Why: aria-label matches the tooltip so E2E can locate the "+"
// affordance via getByRole('button', { name: 'New tab' }). The
// store-only createTab() round-trip that preceded this was a
// tautology — it would pass even if the + button had been deleted.
aria-label={translate("auto.components.tab.bar.TabBar.b1a132357f", "New tab")}
aria-label={translate('auto.components.tab.bar.TabBar.b1a132357f', 'New tab')}
>
<Plus className="w-3.5 h-3.5" />
</button>
@@ -980,6 +1079,7 @@ function TabBarInner({
worktreeId={worktreeId}
groupId={resolvedGroupId}
menuOpen={newTabMenuOpen}
menuOptions={createMenuOptions}
agentOptions={agentLaunchOptions}
onLaunchAgent={launchAgentFromNewTabEntry}
onOpenDefaultTerminal={() => {
@@ -987,13 +1087,15 @@ function TabBarInner({
onNewTerminalTab()
}}
onOpenEntry={onOpenEntry}
onQueryChange={setCreateMenuQuery}
onSelectMenuOption={handleSelectCreateMenuOption}
onDidOpenEntry={() => setNewTabMenuOpen(false)}
/>
<DropdownMenuSeparator />
{showStaticCreateMenuItems ? <DropdownMenuSeparator /> : null}
</>
) : null}
{standardCreateMenuItems}
{showAgentLaunchItems ? (
{showStaticCreateMenuItems ? standardCreateMenuItems : null}
{showStaticCreateMenuItems && showAgentLaunchItems ? (
<>
<DropdownMenuSeparator />
<QuickLaunchAgentMenuItems
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { FilePlus, FileText, Globe, Loader2 } from 'lucide-react'
import { FilePlus, FileText, Globe, Loader2, Smartphone, TerminalSquare } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
@@ -14,19 +14,27 @@ import {
findMatchingTabAgentLaunchOptions,
type TabAgentLaunchOption
} from './tab-agent-launch-options'
import {
findMatchingTabCreateMenuOptions,
type TabCreateMenuOption
} from './tab-create-menu-options'
import type { TuiAgent } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
const EMPTY_AGENT_OPTIONS: readonly TabAgentLaunchOption[] = []
const EMPTY_MENU_OPTIONS: readonly TabCreateMenuOption[] = []
type TabBarCreateEntryProps = {
agentOptions?: readonly TabAgentLaunchOption[]
groupId: string
menuOpen: boolean
menuOptions?: readonly TabCreateMenuOption[]
onDidOpenEntry?: () => void
onLaunchAgent?: (agent: TuiAgent) => void
onOpenDefaultTerminal?: () => void
onOpenEntry?: (args: TabCreateEntryArgs) => Promise<void>
onQueryChange?: (query: string) => void
onSelectMenuOption?: (option: TabCreateMenuOption) => void
worktreeId: string
}
@@ -34,10 +42,13 @@ export default function TabBarCreateEntry({
agentOptions = EMPTY_AGENT_OPTIONS,
groupId,
menuOpen,
menuOptions = EMPTY_MENU_OPTIONS,
onDidOpenEntry,
onLaunchAgent,
onOpenDefaultTerminal,
onOpenEntry,
onQueryChange,
onSelectMenuOption,
worktreeId
}: TabBarCreateEntryProps): React.JSX.Element {
const [query, setQuery] = useState('')
@@ -57,12 +68,27 @@ export default function TabBarCreateEntry({
return () => cancelAnimationFrame(focusFrame)
}, [menuOpen])
const options = useMemo(() => getTabEntryOptions(query, fileList), [fileList, query])
const matchingMenuOptions = useMemo(
() => findMatchingTabCreateMenuOptions(query, menuOptions),
[menuOptions, query]
)
const options = useMemo(() => {
const entryOptions = getTabEntryOptions(query, fileList)
if (matchingMenuOptions.length === 0) {
return entryOptions
}
// Why: a matched create-menu action should win over a generic new-file fallback.
return entryOptions.filter((option) => option.classification.kind !== 'new-file')
}, [fileList, matchingMenuOptions.length, query])
const matchingAgentOptions = useMemo(
() => findMatchingTabAgentLaunchOptions(query, agentOptions),
[agentOptions, query]
)
useEffect(() => {
onQueryChange?.(query)
}, [onQueryChange, query])
if (selectedIndexQuery !== query) {
setSelectedIndexQuery(query)
if (selectedIndex !== 0) {
@@ -84,6 +110,10 @@ export default function TabBarCreateEntry({
const disabled = !onOpenEntry
const hasQuery = query.trim().length > 0
const activeOptions: ActiveOption[] = [
...matchingMenuOptions.map((option) => ({
kind: 'menu' as const,
option
})),
...matchingAgentOptions.map((option) => ({
kind: 'agent' as const,
option
@@ -117,6 +147,11 @@ export default function TabBarCreateEntry({
setError(statusMessage)
return
}
if (selectedOption.kind === 'menu') {
onSelectMenuOption?.(selectedOption.option)
onDidOpenEntry?.()
return
}
if (selectedOption.kind === 'agent') {
onLaunchAgent?.(selectedOption.option.agent)
onDidOpenEntry?.()
@@ -165,7 +200,7 @@ export default function TabBarCreateEntry({
}}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="-mx-1 flex items-center border-b border-black/14 px-3 dark:border-white/14">
<div className="-mx-1 flex items-center px-3">
<Input
ref={inputRef}
value={query}
@@ -174,10 +209,16 @@ export default function TabBarCreateEntry({
setError(null)
}}
disabled={disabled}
aria-label={translate("auto.components.tab.bar.TabBarCreateEntry.39676a184c", "Open any file, URL, agent, ...")}
aria-label={translate(
'auto.components.tab.bar.TabBarCreateEntry.39676a184c',
'Open any file, URL, agent, ...'
)}
aria-invalid={error ? true : undefined}
placeholder={translate("auto.components.tab.bar.TabBarCreateEntry.39676a184c", "Open any file, URL, agent, ...")}
className="h-9 rounded-none border-0 bg-transparent px-0 text-[12px] shadow-none focus-visible:border-0 focus-visible:ring-0 aria-invalid:border-0 aria-invalid:ring-0 dark:bg-transparent"
placeholder={translate(
'auto.components.tab.bar.TabBarCreateEntry.39676a184c',
'Open any file, URL, agent, ...'
)}
className="h-9 rounded-none border-0 bg-transparent px-0 text-xs font-normal text-foreground shadow-none placeholder:font-normal placeholder:text-muted-foreground focus-visible:border-0 focus-visible:ring-0 aria-invalid:border-0 aria-invalid:ring-0 md:text-xs dark:bg-transparent"
/>
</div>
{error || activeOptions.length > 0 || hasQuery ? (
@@ -215,13 +256,23 @@ type ActiveOption =
kind: 'entry'
option: ActiveEntryOption
}
| {
kind: 'menu'
option: TabCreateMenuOption
}
function isActiveEntryOption(option: TabEntryOption): option is ActiveEntryOption {
return option.classification.kind !== 'empty' && option.classification.kind !== 'blocked'
}
function getActiveOptionId(option: ActiveOption): string {
return option.kind === 'agent' ? `agent:${option.option.agent}` : option.option.id
if (option.kind === 'agent') {
return `agent:${option.option.agent}`
}
if (option.kind === 'menu') {
return `menu:${option.option.id}`
}
return option.option.id
}
function EntryStatusRow({
@@ -262,11 +313,17 @@ function EntryActionRow({
onClick={onClick}
>
{presentation.icon}
<span className="shrink-0 font-medium">{presentation.label}</span>
<span className="text-muted-foreground/70" aria-hidden="true">
·
<span className={cn('min-w-0 truncate font-medium', presentation.showDetail && 'shrink-0')}>
{presentation.label}
</span>
<span className="min-w-0 truncate">{presentation.detail}</span>
{presentation.showDetail ? (
<>
<span className="text-muted-foreground/70" aria-hidden="true">
·
</span>
<span className="min-w-0 truncate">{presentation.detail}</span>
</>
) : null}
</button>
)
}
@@ -275,12 +332,34 @@ function getActionPresentation(option: ActiveOption): {
detail: string
icon: React.ReactNode
label: string
showDetail: boolean
} {
if (option.kind === 'menu') {
const icon =
option.option.kind === 'new-browser' ? (
<Globe className="size-3.5 shrink-0" aria-hidden="true" />
) : option.option.kind === 'new-markdown' ? (
<FilePlus className="size-3.5 shrink-0" aria-hidden="true" />
) : option.option.kind === 'open-markdown' ? (
<FileText className="size-3.5 shrink-0" aria-hidden="true" />
) : option.option.kind === 'new-simulator' || option.option.kind === 'go-to-simulator' ? (
<Smartphone className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<TerminalSquare className="size-3.5 shrink-0" aria-hidden="true" />
)
return {
detail: '',
icon,
label: option.option.label,
showDetail: false
}
}
if (option.kind === 'agent') {
return {
detail: option.option.label,
icon: <AgentIcon agent={option.option.agent} size={14} />,
label: translate("auto.components.tab.bar.TabBarCreateEntry.b27864279e", "Launch agent")
label: translate('auto.components.tab.bar.TabBarCreateEntry.b27864279e', 'Launch agent'),
showDetail: true
}
}
const { classification } = option.option
@@ -288,19 +367,22 @@ function getActionPresentation(option: ActiveOption): {
return {
detail: classification.url,
icon: <Globe className="size-3.5 shrink-0" aria-hidden="true" />,
label: translate("auto.components.tab.bar.TabBarCreateEntry.7cdf8ee0c8", "Open URL")
label: translate('auto.components.tab.bar.TabBarCreateEntry.7cdf8ee0c8', 'Open URL'),
showDetail: true
}
}
if (classification.kind === 'existing-file') {
return {
detail: classification.relativePath,
icon: <FileText className="size-3.5 shrink-0" aria-hidden="true" />,
label: translate("auto.components.tab.bar.TabBarCreateEntry.25dc1cd653", "Open file")
label: translate('auto.components.tab.bar.TabBarCreateEntry.25dc1cd653', 'Open file'),
showDetail: true
}
}
return {
detail: classification.relativePath,
icon: <FilePlus className="size-3.5 shrink-0" aria-hidden="true" />,
label: translate("auto.components.tab.bar.TabBarCreateEntry.d62d63b807", "Create file")
label: translate('auto.components.tab.bar.TabBarCreateEntry.d62d63b807', 'Create file'),
showDetail: true
}
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import {
buildTabCreateMenuOptions,
findMatchingTabCreateMenuOptions
} from './tab-create-menu-options'
describe('tab create menu options', () => {
const defaultOptions = buildTabCreateMenuOptions({
terminalOnly: false,
hasNewBrowser: true,
hasNewMarkdown: true,
hasOpenMarkdown: true,
hasSimulator: true,
simulatorIsGoTo: false
})
it('matches mobile emulator aliases to the simulator menu action', () => {
expect(
findMatchingTabCreateMenuOptions('mobile emulator', defaultOptions).map(
(option) => option.kind
)
).toEqual(['new-simulator'])
})
it('matches go-to simulator when the workspace already has one', () => {
const options = buildTabCreateMenuOptions({
terminalOnly: false,
hasNewBrowser: true,
hasNewMarkdown: true,
hasOpenMarkdown: false,
hasSimulator: true,
simulatorIsGoTo: true
})
expect(
findMatchingTabCreateMenuOptions('simulator', options).map((option) => option.kind)
).toEqual(['go-to-simulator'])
})
it('matches terminal and browser quick actions', () => {
expect(
findMatchingTabCreateMenuOptions('new terminal', defaultOptions).map((option) => option.kind)
).toEqual(['new-terminal'])
expect(
findMatchingTabCreateMenuOptions('browser', defaultOptions).map((option) => option.kind)
).toEqual(['new-browser'])
})
it('preserves default Windows shell order for tied terminal matches', () => {
const options = buildTabCreateMenuOptions({
terminalOnly: false,
hasNewBrowser: false,
hasNewMarkdown: false,
hasOpenMarkdown: false,
hasSimulator: false,
simulatorIsGoTo: false,
windowsShellEntries: [
{ label: 'PowerShell', shell: 'powershell.exe' },
{ label: 'CMD Prompt', shell: 'cmd.exe' }
]
})
expect(
findMatchingTabCreateMenuOptions('new terminal', options).map((option) => option.shell)
).toEqual(['powershell.exe', 'cmd.exe'])
})
it('returns no matches for an empty query', () => {
expect(findMatchingTabCreateMenuOptions('', defaultOptions)).toEqual([])
expect(findMatchingTabCreateMenuOptions(' ', defaultOptions)).toEqual([])
})
})
@@ -0,0 +1,198 @@
import { translate } from '@/i18n/i18n'
import type { BuiltInWindowsTerminalShell } from '../../../../shared/windows-terminal-shell'
export type TabCreateMenuOptionKind =
| 'go-to-simulator'
| 'new-browser'
| 'new-markdown'
| 'new-simulator'
| 'new-terminal'
| 'new-terminal-shell'
| 'open-markdown'
export type TabCreateMenuOption = {
id: string
kind: TabCreateMenuOptionKind
keywords: readonly string[]
label: string
shell?: BuiltInWindowsTerminalShell
}
export type TabCreateMenuOptionsContext = {
hasNewBrowser: boolean
hasNewMarkdown: boolean
hasOpenMarkdown: boolean
hasSimulator: boolean
simulatorIsGoTo: boolean
terminalOnly: boolean
windowsShellEntries?: readonly { label: string; shell: BuiltInWindowsTerminalShell }[]
}
function normalizeQuery(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, ' ')
}
function tokenize(value: string): string[] {
return normalizeQuery(value)
.split(/[^a-z0-9]+/)
.filter(Boolean)
}
function scoreQueryTokens(
query: string,
values: readonly string[]
): { allTokensMatched: boolean; score: number } {
const candidateTokens = values.flatMap(tokenize)
if (candidateTokens.length === 0) {
return { allTokensMatched: false, score: 0 }
}
const queryTokens = tokenize(query)
if (queryTokens.length === 0) {
return { allTokensMatched: false, score: 0 }
}
let score = 0
let allTokensMatched = true
for (const queryToken of queryTokens) {
let best = 0
for (const candidateToken of candidateTokens) {
if (candidateToken === queryToken) {
best = Math.max(best, 3)
} else if (candidateToken.startsWith(queryToken)) {
best = Math.max(best, 2)
} else if (candidateToken.includes(queryToken)) {
best = Math.max(best, 1)
}
}
if (best === 0) {
allTokensMatched = false
}
score += best
}
return { allTokensMatched, score }
}
function scoreMenuOption(query: string, option: TabCreateMenuOption): number {
const normalizedQuery = normalizeQuery(query)
if (!normalizedQuery) {
return 0
}
const values = [option.label, ...option.keywords]
const normalizedLabel = normalizeQuery(option.label)
if (normalizedQuery === normalizedLabel) {
return 100
}
const tokenMatch = scoreQueryTokens(normalizedQuery, values)
if (!tokenMatch.allTokensMatched) {
return 0
}
if (normalizedLabel.includes(normalizedQuery) || normalizedQuery.includes(normalizedLabel)) {
return 80 + tokenMatch.score
}
return tokenMatch.score
}
export function buildTabCreateMenuOptions(
context: TabCreateMenuOptionsContext
): TabCreateMenuOption[] {
if (context.terminalOnly) {
return []
}
const options: TabCreateMenuOption[] = []
if (context.windowsShellEntries && context.windowsShellEntries.length > 0) {
for (const entry of context.windowsShellEntries) {
const label = `${translate('auto.components.tab.bar.TabBar.7c1313d237', 'New Terminal:')} ${entry.label}`
options.push({
id: `new-terminal-shell:${entry.shell}`,
kind: 'new-terminal-shell',
label,
shell: entry.shell,
keywords: ['terminal', 'shell', 'new terminal', entry.label, label]
})
}
} else {
const label = translate('auto.components.tab.bar.TabBar.d364f3c8d4', 'New Terminal')
options.push({
id: 'new-terminal',
kind: 'new-terminal',
label,
keywords: ['terminal', 'shell', 'new terminal', 'new shell']
})
}
if (context.hasNewBrowser) {
const label = translate('auto.components.tab.bar.TabBar.4833fb2cbe', 'New Browser Tab')
options.push({
id: 'new-browser',
kind: 'new-browser',
label,
keywords: ['browser', 'new browser', 'browser tab', 'web']
})
}
if (context.hasNewMarkdown) {
const label = translate('auto.components.tab.bar.TabBar.3d5d6c960d', 'New Markdown')
options.push({
id: 'new-markdown',
kind: 'new-markdown',
label,
keywords: ['markdown', 'md', 'new markdown', 'new file', 'mark']
})
}
if (context.hasOpenMarkdown) {
const label = translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')
options.push({
id: 'open-markdown',
kind: 'open-markdown',
label,
keywords: ['open markdown', 'markdown', 'md', 'open file']
})
}
if (context.hasSimulator) {
const label = context.simulatorIsGoTo
? translate('auto.components.tab.bar.TabBar.b426bb2615', 'Go to Mobile Emulator')
: translate('auto.components.tab.bar.TabBar.fd2b42aaa3', 'New Mobile Emulator')
options.push({
id: context.simulatorIsGoTo ? 'go-to-simulator' : 'new-simulator',
kind: context.simulatorIsGoTo ? 'go-to-simulator' : 'new-simulator',
label,
keywords: [
'mobile emulator',
'emulator',
'simulator',
'ios simulator',
'iphone',
'ipad',
'mobile'
]
})
}
return options
}
export function findMatchingTabCreateMenuOptions(
query: string,
options: readonly TabCreateMenuOption[]
): TabCreateMenuOption[] {
const normalizedQuery = normalizeQuery(query)
if (!normalizedQuery) {
return []
}
return options
.map((option, index) => ({ index, option, score: scoreMenuOption(normalizedQuery, option) }))
.filter((entry) => entry.score > 0)
.sort((left, right) => {
if (left.score !== right.score) {
return right.score - left.score
}
return left.index - right.index
})
.map((entry) => entry.option)
}