mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 00:03:09 +00:00
fix: address review findings
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user