mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Allow directly search in google (#13863)
* Allow direct search with configured search engines
Users can now search directly from the tab creation menu using their
configured search engine. Forced search mode (`?` prefix) skips file and tab
matching for guaranteed search. Refactored tab-entry operations into focused
modules for clarity: forced-search parsing, network-safe selection, keyboard
focus, copy strings, empty options, and props types. Search routes through
the same workspace browser tab opening mechanism used for URLs, with safe
title and query presentation that doesn't retain sensitive details.
* Allow direct search with configured search engines
- Permit search and URL navigation while file index loads; require explicit
selection only when needed, not automatic opening
- Block malformed IPv6 addresses in bracket notation to prevent misclassification
- Support Kagi private-session links via searchUrlOptions
- Improve error handling with accessibility: show error messages in status region,
disable input during submission, display loading state
- Fall back to local browser tab creation when remote creation fails instead of
throwing; avoids remote availability blocking local search/navigation
- Add error translations for all supported locales (es, ja, ko, zh)
* Allow direct search with configured search engines
- Extract tab create entry lifecycle to key-driven component remounting, replacing conditional state reset with useEffect cleanup
- Consolidate network tab entry classification and request building into reusable helpers, eliminating duplicate logic
- Simplify owner resolution by inlining logic directly into openWorkspaceBrowserTab
- Replace custom surrogate-pair handling with native String.toWellFormed() for search queries
- Disable explicit URL classification to prioritize search-engine queries over raw URLs
* Allow direct search from quick-open tab bar entry
- Single-token queries keep file matches ranked above search (quick-open intent)
- Multi-word phrases promote search to top, since they cannot be file paths
- Arm network actions once file index fails or text is unambiguous search
- Cache prepared file index to avoid re-processing per keystroke
- Generate specific tab titles (e.g. 'example.com/docs') instead of generic 'Open URL'
- Surface opening workspace when launching browser tabs remotely
* Use readOnly instead of disabled for pending search input
Maintain keyboard focus during submission so arrow/Escape navigation continues to work. Use aria-busy to indicate loading state accessibly. Also fixes button hover styling when disabled and cleans up error message handling in the classifier.
* Treat bare searches as prompts; refine path and IP classification
- Bare search queries (e.g., "?") no longer display as error rows
- Path prefixes with existing matches are no longer blocked mid-keystroke
- Private IPv4 addresses now use http, public addresses use https
- Ambiguous inputs with non-numeric ports fall through to search instead of blocking
- Improve diagnostics by logging failure reasons in openFailure
(cherry picked from commit 55beeebd25)
This commit is contained in:
@@ -29,6 +29,22 @@ export function prepareQuickOpenFiles(files: readonly string[]): QuickOpenIndexe
|
||||
})
|
||||
}
|
||||
|
||||
const preparedQuickOpenFiles = new WeakMap<readonly string[], QuickOpenIndexedFile[]>()
|
||||
|
||||
// Why: keystroke-driven callers re-classify on every character, but the index
|
||||
// only changes when the file list array itself is replaced.
|
||||
export function getPreparedQuickOpenFiles(
|
||||
files: readonly string[]
|
||||
): readonly QuickOpenIndexedFile[] {
|
||||
const cached = preparedQuickOpenFiles.get(files)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const prepared = prepareQuickOpenFiles(files)
|
||||
preparedQuickOpenFiles.set(files, prepared)
|
||||
return prepared
|
||||
}
|
||||
|
||||
export function isQuickOpenQueryTooLarge(
|
||||
query: string,
|
||||
maxBytes = QUICK_OPEN_QUERY_MAX_BYTES
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { TabAgentLaunchOption } from './tab-agent-launch-options'
|
||||
import type { TabCreateMenuOption } from './tab-create-menu-options'
|
||||
import { QUICK_OPEN_QUERY_MAX_BYTES } from '../quick-open-search'
|
||||
|
||||
const fileListMock = vi.hoisted(() => ({
|
||||
current: { files: [] as string[], loading: false, loadError: null as string | null }
|
||||
}))
|
||||
vi.mock('../quick-open-file-list', () => ({
|
||||
useRuntimeFileListForWorktree: () => fileListMock.current
|
||||
}))
|
||||
|
||||
const tabSearchMock = vi.hoisted(() => ({
|
||||
calls: [] as { enabled: boolean; query: string }[],
|
||||
results: [] as unknown[]
|
||||
}))
|
||||
vi.mock('./use-open-tab-search', () => ({
|
||||
useOpenTabSearch: ({ enabled, query }: { enabled: boolean; query: string }) => {
|
||||
tabSearchMock.calls.push({ enabled, query })
|
||||
return { query, results: tabSearchMock.results }
|
||||
}
|
||||
}))
|
||||
|
||||
import TabBarCreateEntry from './TabBarCreateEntry'
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
function renderEntry(props: Record<string, unknown> = {}): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<TabBarCreateEntry
|
||||
worktreeId="wt"
|
||||
groupId="g"
|
||||
menuOpen
|
||||
onOpenEntry={vi.fn().mockResolvedValue(undefined)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function setQuery(value: string): void {
|
||||
const input = container.querySelector('input')!
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
)?.set
|
||||
act(() => {
|
||||
nativeSetter?.call(input, value)
|
||||
input.dispatchEvent(new window.Event('input', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
function submit(): void {
|
||||
act(() => {
|
||||
container
|
||||
.querySelector('form')!
|
||||
.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true }))
|
||||
})
|
||||
}
|
||||
|
||||
function press(key: string): void {
|
||||
act(() => {
|
||||
container
|
||||
.querySelector('input')!
|
||||
.dispatchEvent(new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fileListMock.current = { files: [], loading: false, loadError: null }
|
||||
tabSearchMock.calls = []
|
||||
tabSearchMock.results = []
|
||||
useAppStore.setState(
|
||||
{ ...useAppStore.getInitialState(), browserDefaultSearchEngine: null } as AppState,
|
||||
true
|
||||
)
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('TabBarCreateEntry search behavior', () => {
|
||||
it('tracks the configured provider and submits its exact classification', () => {
|
||||
const onOpenEntry = vi.fn().mockResolvedValue(undefined)
|
||||
renderEntry({ onOpenEntry })
|
||||
setQuery('react hooks')
|
||||
expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google')
|
||||
|
||||
act(() => useAppStore.setState({ browserDefaultSearchEngine: 'bing' }))
|
||||
expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Bing')
|
||||
submit()
|
||||
|
||||
expect(onOpenEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
classification: { kind: 'search', engine: 'bing', query: 'react hooks' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('searches a phrase on Enter while the file index is still loading', () => {
|
||||
fileListMock.current = { files: [], loading: true, loadError: null }
|
||||
const onOpenEntry = vi.fn().mockResolvedValue(undefined)
|
||||
renderEntry({ onOpenEntry })
|
||||
setQuery('natural language')
|
||||
|
||||
expect(container.querySelectorAll('[role="option"]')).toHaveLength(1)
|
||||
expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google')
|
||||
expect(container.querySelector('[role="status"]')?.textContent).toContain('Loading files...')
|
||||
expect(container.querySelector('[aria-selected="true"]')).not.toBeNull()
|
||||
|
||||
submit()
|
||||
expect(onOpenEntry).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('requires an explicit choice for text the loading index could still match', () => {
|
||||
fileListMock.current = { files: [], loading: true, loadError: null }
|
||||
const onOpenEntry = vi.fn().mockResolvedValue(undefined)
|
||||
renderEntry({ onOpenEntry })
|
||||
setQuery('notes.md draft')
|
||||
|
||||
expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google')
|
||||
expect(container.querySelector('[aria-selected="true"]')).toBeNull()
|
||||
|
||||
submit()
|
||||
expect(onOpenEntry).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('[role="status"]')?.textContent).toContain('Choose an action.')
|
||||
|
||||
press('ArrowDown')
|
||||
submit()
|
||||
expect(onOpenEntry).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('arms network actions once the index fails, since no file match can arrive', () => {
|
||||
fileListMock.current = { files: [], loading: false, loadError: 'scan failed' }
|
||||
const onOpenEntry = vi.fn().mockResolvedValue(undefined)
|
||||
renderEntry({ onOpenEntry })
|
||||
setQuery('example.com')
|
||||
|
||||
expect(container.querySelector('[aria-selected="true"]')?.textContent).toContain('Open URL')
|
||||
submit()
|
||||
expect(onOpenEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ classification: expect.objectContaining({ kind: 'host-url' }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not auto-open a host-like filename while the file index is unresolved', () => {
|
||||
fileListMock.current = { files: [], loading: true, loadError: null }
|
||||
const onOpenEntry = vi.fn().mockResolvedValue(undefined)
|
||||
renderEntry({ onOpenEntry })
|
||||
setQuery('example.com')
|
||||
|
||||
expect(container.querySelector('[role="option"]')?.textContent).toContain('Open URL')
|
||||
expect(container.querySelector('[aria-selected="true"]')).toBeNull()
|
||||
submit()
|
||||
expect(onOpenEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the active submission and exposes failures to assistive technology', async () => {
|
||||
let rejectOpen: ((error: Error) => void) | undefined
|
||||
const onOpenEntry = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectOpen = reject
|
||||
})
|
||||
)
|
||||
renderEntry({ onOpenEntry })
|
||||
setQuery('react hooks')
|
||||
submit()
|
||||
|
||||
// Read-only rather than disabled so the pending input keeps keyboard focus.
|
||||
expect(container.querySelector('input')?.disabled).toBe(false)
|
||||
expect(container.querySelector('input')?.readOnly).toBe(true)
|
||||
expect(container.querySelector('input')?.getAttribute('aria-busy')).toBe('true')
|
||||
expect(container.querySelector<HTMLButtonElement>('[role="option"]')?.disabled).toBe(true)
|
||||
expect(container.querySelector('[role="option"] .animate-spin')).not.toBeNull()
|
||||
|
||||
await act(async () => rejectOpen?.(new Error('Search failed safely.')))
|
||||
|
||||
const input = container.querySelector('input')!
|
||||
expect(input.readOnly).toBe(false)
|
||||
expect(input.getAttribute('aria-busy')).toBeNull()
|
||||
expect(input.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(input.getAttribute('aria-errormessage')).toBe('tab-create-entry-error')
|
||||
expect(container.querySelector('#tab-create-entry-error')?.textContent).toContain(
|
||||
'Search failed safely.'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores completion from a previous menu session', async () => {
|
||||
let resolveOpen: (() => void) | undefined
|
||||
const onDidOpenEntry = vi.fn()
|
||||
const onOpenEntry = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveOpen = resolve
|
||||
})
|
||||
)
|
||||
renderEntry({ onDidOpenEntry, onOpenEntry })
|
||||
setQuery('react hooks')
|
||||
submit()
|
||||
|
||||
renderEntry({ menuOpen: false, onDidOpenEntry, onOpenEntry })
|
||||
renderEntry({ menuOpen: true, onDidOpenEntry, onOpenEntry })
|
||||
await act(async () => resolveOpen?.())
|
||||
|
||||
expect(onDidOpenEntry).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('input')?.readOnly).toBe(false)
|
||||
})
|
||||
|
||||
it('does not arm ordinary search when a ranked file disappears asynchronously', () => {
|
||||
fileListMock.current = { files: ['project codename'], loading: false, loadError: null }
|
||||
const onOpenEntry = vi.fn().mockResolvedValue(undefined)
|
||||
renderEntry({ onOpenEntry })
|
||||
setQuery('project codename')
|
||||
expect(container.querySelector('[aria-selected="true"]')?.textContent).toContain('Open file')
|
||||
|
||||
fileListMock.current = { files: [], loading: true, loadError: null }
|
||||
renderEntry({ onOpenEntry })
|
||||
expect(container.querySelector('[role="option"]')?.textContent).toContain('Search Google')
|
||||
expect(container.querySelector('[aria-selected="true"]')).toBeNull()
|
||||
expect(container.querySelector('input')?.getAttribute('aria-activedescendant')).toBeNull()
|
||||
|
||||
submit()
|
||||
expect(onOpenEntry).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('[role="status"]')?.textContent).toContain('Choose an action.')
|
||||
|
||||
press('ArrowDown')
|
||||
submit()
|
||||
expect(onOpenEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ classification: expect.objectContaining({ kind: 'search' }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('makes forced search the only category and disables open-tab matching', () => {
|
||||
fileListMock.current = { files: ['gem'], loading: false, loadError: null }
|
||||
tabSearchMock.results = [
|
||||
{
|
||||
id: 'open-tab:workspace:tab-1',
|
||||
title: 'gem tab',
|
||||
contentType: 'terminal',
|
||||
worktreeId: 'wt',
|
||||
tabId: 'tab-1',
|
||||
entityId: 'term-1',
|
||||
groupId: 'g',
|
||||
relativePath: null
|
||||
}
|
||||
]
|
||||
const menuOptions: TabCreateMenuOption[] = [
|
||||
{ id: 'new-browser', kind: 'new-browser', keywords: ['gem'], label: 'Gem action' }
|
||||
]
|
||||
const agentOptions: TabAgentLaunchOption[] = [
|
||||
{ agent: 'gemini', aliases: ['gem'], label: 'Gemini' }
|
||||
]
|
||||
const onOpenEntry = vi.fn().mockResolvedValue(undefined)
|
||||
renderEntry({ agentOptions, menuOptions, onOpenEntry })
|
||||
setQuery('?gem')
|
||||
|
||||
const rows = container.querySelectorAll('[role="option"]')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].textContent).toContain('Search Google')
|
||||
expect(tabSearchMock.calls.at(-1)).toEqual({ enabled: false, query: '' })
|
||||
submit()
|
||||
expect(onOpenEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
classification: { kind: 'search', engine: 'google', query: 'gem' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a bare search prefix as a prompt rather than a submission error', () => {
|
||||
const onOpenEntry = vi.fn().mockResolvedValue(undefined)
|
||||
renderEntry({ onOpenEntry })
|
||||
setQuery('?')
|
||||
|
||||
expect(container.querySelectorAll('[role="option"]')).toHaveLength(0)
|
||||
submit()
|
||||
|
||||
expect(onOpenEntry).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('#tab-create-entry-error')).toBeNull()
|
||||
expect(container.querySelector('input')?.getAttribute('aria-errormessage')).toBeNull()
|
||||
})
|
||||
|
||||
it('blocks oversized forced input without surfacing global actions', () => {
|
||||
renderEntry({
|
||||
menuOptions: [
|
||||
{ id: 'new-browser', kind: 'new-browser', keywords: ['x'], label: 'New Browser' }
|
||||
],
|
||||
agentOptions: [{ agent: 'gemini', aliases: ['x'], label: 'Gemini' }]
|
||||
})
|
||||
setQuery(`?${'x'.repeat(QUICK_OPEN_QUERY_MAX_BYTES)}`)
|
||||
|
||||
expect(container.querySelectorAll('[role="option"]')).toHaveLength(0)
|
||||
expect(container.textContent).toContain('Search text is too large.')
|
||||
expect(tabSearchMock.calls.at(-1)).toEqual({ enabled: false, query: '' })
|
||||
})
|
||||
})
|
||||
@@ -4,17 +4,10 @@ import { useRuntimeFileListForWorktree } from '../quick-open-file-list'
|
||||
import {
|
||||
createTabEntryAllowAbsolutePathsSelector,
|
||||
getTabEntryOptions,
|
||||
isTabEntryAbsolutePathLike,
|
||||
type TabCreateEntryArgs
|
||||
isTabEntryAbsolutePathLike
|
||||
} from './tab-create-entry-action'
|
||||
import {
|
||||
findMatchingTabAgentLaunchOptions,
|
||||
type TabAgentLaunchOption
|
||||
} from './tab-agent-launch-options'
|
||||
import {
|
||||
findMatchingTabCreateMenuOptions,
|
||||
type TabCreateMenuOption
|
||||
} from './tab-create-menu-options'
|
||||
import { findMatchingTabAgentLaunchOptions } from './tab-agent-launch-options'
|
||||
import { findMatchingTabCreateMenuOptions } from './tab-create-menu-options'
|
||||
import {
|
||||
getActiveOptionId,
|
||||
isActiveEntryOption,
|
||||
@@ -28,42 +21,30 @@ import {
|
||||
} from './TabBarCreateEntryRow'
|
||||
import { dropFileEntriesCoveredByTabResults } from './open-tab-entry-dedupe'
|
||||
import { activateOpenTabSearchResult } from './open-tab-selection-routing'
|
||||
import type { OpenTabSearchResult } from './open-tab-search'
|
||||
import { useOpenTabSearch } from './use-open-tab-search'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { DEFAULT_SEARCH_ENGINE } from '../../../../shared/browser-url'
|
||||
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isQuickOpenQueryTooLarge } from '../quick-open-search'
|
||||
import { parseForcedSearchQuery } from './tab-create-entry-forced-search'
|
||||
import { useNetworkSafeTabEntrySelection } from './tab-create-entry-network-selection'
|
||||
import { focusTabEntryMenuItemAtEdge } from './tab-create-entry-keyboard-focus'
|
||||
import {
|
||||
getTabEntryChooseActionMessage,
|
||||
getTabEntryOmniboxPlaceholder
|
||||
} from './tab-create-entry-copy'
|
||||
import {
|
||||
EMPTY_AGENT_OPTIONS,
|
||||
EMPTY_MENU_OPTIONS,
|
||||
EMPTY_TAB_RESULTS
|
||||
} from './tab-create-entry-empty-options'
|
||||
import type { TabBarCreateEntryProps } from './tab-create-entry-props'
|
||||
|
||||
// Leads with tabs: the omnibox now jumps to open tabs before it creates anything.
|
||||
function omniboxPlaceholder(): string {
|
||||
return translate(
|
||||
'auto.components.tab.bar.TabBarCreateEntry.0e5b7a3f16',
|
||||
'Search open tabs, files, URLs, agents…'
|
||||
)
|
||||
export default function TabBarCreateEntry(props: TabBarCreateEntryProps): React.JSX.Element {
|
||||
return <TabBarCreateEntrySession key={String(props.menuOpen)} {...props} />
|
||||
}
|
||||
|
||||
const EMPTY_AGENT_OPTIONS: readonly TabAgentLaunchOption[] = []
|
||||
const EMPTY_MENU_OPTIONS: readonly TabCreateMenuOption[] = []
|
||||
const EMPTY_TAB_RESULTS: readonly OpenTabSearchResult[] = []
|
||||
|
||||
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
|
||||
/** Runs after the menu closes, so the tab jumped to actually takes focus. */
|
||||
onQueueSwitchFocus?: (focus: () => void) => void
|
||||
onSelectMenuOption?: (option: TabCreateMenuOption) => void
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
export default function TabBarCreateEntry({
|
||||
function TabBarCreateEntrySession({
|
||||
agentOptions = EMPTY_AGENT_OPTIONS,
|
||||
groupId,
|
||||
menuOpen,
|
||||
@@ -81,16 +62,33 @@ export default function TabBarCreateEntry({
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [switchError, setSwitchError] = useState<string | null>(null)
|
||||
const [selectionGuidance, setSelectionGuidance] = useState<string | null>(null)
|
||||
// null = follow ranking (deferred tabs can prepend); set on arrow keys only.
|
||||
const [pinnedOptionId, setPinnedOptionId] = useState<string | null>(null)
|
||||
const [lastMenuOpen, setLastMenuOpen] = useState(menuOpen)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const submissionIdRef = useRef(0)
|
||||
useEffect(
|
||||
() => () => {
|
||||
submissionIdRef.current += 1
|
||||
},
|
||||
[]
|
||||
)
|
||||
const fileList = useRuntimeFileListForWorktree({ enabled: menuOpen, worktreeId })
|
||||
const tabSearch = useOpenTabSearch({ enabled: menuOpen, query, worktreeId })
|
||||
const rawQueryOversized = isQuickOpenQueryTooLarge(query)
|
||||
const forcedSearch = parseForcedSearchQuery(query)
|
||||
const terminalQueryMode = rawQueryOversized || forcedSearch.forced
|
||||
const tabSearchQuery = terminalQueryMode ? '' : query
|
||||
const tabSearch = useOpenTabSearch({
|
||||
enabled: menuOpen && !terminalQueryMode,
|
||||
query: tabSearchQuery,
|
||||
worktreeId
|
||||
})
|
||||
// Why gate on the query: the search defers, so its rows can still describe an
|
||||
// earlier query — Enter must never submit a tab the current query never matched.
|
||||
const tabResults = tabSearch.query === query ? tabSearch.results : EMPTY_TAB_RESULTS
|
||||
const shouldResolveAbsolutePaths = menuOpen && isTabEntryAbsolutePathLike(query.trim())
|
||||
const tabResults =
|
||||
!terminalQueryMode && tabSearch.query === query ? tabSearch.results : EMPTY_TAB_RESULTS
|
||||
const shouldResolveAbsolutePaths =
|
||||
menuOpen && !terminalQueryMode && isTabEntryAbsolutePathLike(query.trim())
|
||||
const allowAbsolutePathsSelector = useMemo(
|
||||
() =>
|
||||
createTabEntryAllowAbsolutePathsSelector(worktreeId, {
|
||||
@@ -105,6 +103,9 @@ export default function TabBarCreateEntry({
|
||||
menuOpen ? (state.getKnownWorktreeById(worktreeId)?.path ?? null) : null
|
||||
)
|
||||
const localPlatform = getRendererAppPlatform() === 'win32' ? 'windows' : 'posix'
|
||||
const searchEngine = useAppStore(
|
||||
(state) => state.browserDefaultSearchEngine ?? DEFAULT_SEARCH_ENGINE
|
||||
)
|
||||
|
||||
// Why: once ArrowDown moves focus into the static menu list, ArrowUp on the
|
||||
// first item should return to the search box so the keyboard trip isn't
|
||||
@@ -144,14 +145,16 @@ export default function TabBarCreateEntry({
|
||||
}, [menuOpen])
|
||||
|
||||
const matchingMenuOptions = useMemo(
|
||||
() => findMatchingTabCreateMenuOptions(query, menuOptions),
|
||||
[menuOptions, query]
|
||||
() =>
|
||||
terminalQueryMode ? EMPTY_MENU_OPTIONS : findMatchingTabCreateMenuOptions(query, menuOptions),
|
||||
[menuOptions, query, terminalQueryMode]
|
||||
)
|
||||
const options = useMemo(() => {
|
||||
const entryOptions = dropFileEntriesCoveredByTabResults(
|
||||
getTabEntryOptions(query, fileList, 4, {
|
||||
allowAbsolutePaths,
|
||||
localPlatform
|
||||
localPlatform,
|
||||
searchEngine
|
||||
}),
|
||||
tabResults,
|
||||
worktreePath
|
||||
@@ -167,25 +170,18 @@ export default function TabBarCreateEntry({
|
||||
localPlatform,
|
||||
matchingMenuOptions.length,
|
||||
query,
|
||||
searchEngine,
|
||||
tabResults,
|
||||
worktreePath
|
||||
])
|
||||
const matchingAgentOptions = useMemo(
|
||||
() => findMatchingTabAgentLaunchOptions(query, agentOptions),
|
||||
[agentOptions, query]
|
||||
() =>
|
||||
terminalQueryMode
|
||||
? EMPTY_AGENT_OPTIONS
|
||||
: findMatchingTabAgentLaunchOptions(query, agentOptions),
|
||||
[agentOptions, query, terminalQueryMode]
|
||||
)
|
||||
|
||||
if (lastMenuOpen !== menuOpen) {
|
||||
setLastMenuOpen(menuOpen)
|
||||
if (!menuOpen) {
|
||||
setQuery('')
|
||||
setPending(false)
|
||||
setError(null)
|
||||
setSwitchError(null)
|
||||
setPinnedOptionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = !onOpenEntry
|
||||
const hasQuery = query.trim().length > 0
|
||||
const activeOptions: ActiveOption[] = [
|
||||
@@ -206,13 +202,15 @@ export default function TabBarCreateEntry({
|
||||
option
|
||||
}))
|
||||
]
|
||||
// Why pin by id (not index): deferred tab rows prepend and would steal a
|
||||
// user-moved highlight if we kept a raw index. Null pin follows top rank.
|
||||
const pinnedOptionIndex = pinnedOptionId
|
||||
? activeOptions.findIndex((option) => getActiveOptionId(option) === pinnedOptionId)
|
||||
: -1
|
||||
const activeSelectedIndex = Math.max(pinnedOptionIndex, 0)
|
||||
const selectedActiveOption = activeOptions[activeSelectedIndex]
|
||||
const { activeSelectedIndex, selectedActiveOption } = useNetworkSafeTabEntrySelection({
|
||||
activeOptions,
|
||||
fileIndexFailed: Boolean(fileList.loadError),
|
||||
fileIndexReady: !fileList.loading && !fileList.loadError,
|
||||
forcedSearch: forcedSearch.forced,
|
||||
menuOpen,
|
||||
pinnedOptionId,
|
||||
query
|
||||
})
|
||||
const statusOption = options.find(
|
||||
(option) => option.classification.kind === 'empty' || option.classification.kind === 'blocked'
|
||||
)
|
||||
@@ -220,7 +218,7 @@ export default function TabBarCreateEntry({
|
||||
statusOption != null &&
|
||||
(statusOption.classification.kind === 'empty' || statusOption.classification.kind === 'blocked')
|
||||
? statusOption.classification.message
|
||||
: omniboxPlaceholder()
|
||||
: getTabEntryOmniboxPlaceholder()
|
||||
|
||||
const submitOption = (option?: ActiveOption) => {
|
||||
if (disabled || pending) {
|
||||
@@ -233,7 +231,15 @@ export default function TabBarCreateEntry({
|
||||
onDidOpenEntry?.()
|
||||
return
|
||||
}
|
||||
setError(statusMessage)
|
||||
if (activeOptions.length > 0) {
|
||||
setSelectionGuidance(getTabEntryChooseActionMessage())
|
||||
return
|
||||
}
|
||||
// Why: an 'empty' status is the placeholder prompt, not a failure — showing
|
||||
// it as an error turns a bare "?" into a red row that reads like a bug.
|
||||
if (statusOption?.classification.kind !== 'empty') {
|
||||
setError(statusMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (selectedOption.kind === 'tab') {
|
||||
@@ -260,6 +266,7 @@ export default function TabBarCreateEntry({
|
||||
}
|
||||
setPending(true)
|
||||
setError(null)
|
||||
const submissionId = ++submissionIdRef.current
|
||||
void onOpenEntry({
|
||||
query,
|
||||
worktreeId,
|
||||
@@ -268,13 +275,19 @@ export default function TabBarCreateEntry({
|
||||
classification: selectedOption.option.classification
|
||||
})
|
||||
.then(() => {
|
||||
onDidOpenEntry?.()
|
||||
if (submissionIdRef.current === submissionId) {
|
||||
onDidOpenEntry?.()
|
||||
}
|
||||
})
|
||||
.catch((caught) => {
|
||||
setError(caught instanceof Error ? caught.message : String(caught))
|
||||
if (submissionIdRef.current === submissionId) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught))
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setPending(false)
|
||||
if (submissionIdRef.current === submissionId) {
|
||||
setPending(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -292,15 +305,23 @@ export default function TabBarCreateEntry({
|
||||
event.stopPropagation()
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
const nextIndex =
|
||||
(activeSelectedIndex + delta + activeOptions.length) % activeOptions.length
|
||||
activeSelectedIndex === null
|
||||
? event.key === 'ArrowDown'
|
||||
? 0
|
||||
: activeOptions.length - 1
|
||||
: (activeSelectedIndex + delta + activeOptions.length) % activeOptions.length
|
||||
setPinnedOptionId(getActiveOptionId(activeOptions[nextIndex]))
|
||||
setSelectionGuidance(null)
|
||||
return
|
||||
}
|
||||
// Why: with no result rows the static create/agent items render below;
|
||||
// move focus into that Radix menu list so it stays keyboard-navigable
|
||||
// from the search box instead of trapping focus in the input.
|
||||
if (
|
||||
focusMenuItemAtEdge(event.currentTarget, event.key === 'ArrowDown' ? 'first' : 'last')
|
||||
focusTabEntryMenuItemAtEdge(
|
||||
event.currentTarget,
|
||||
event.key === 'ArrowDown' ? 'first' : 'last'
|
||||
)
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -326,18 +347,26 @@ export default function TabBarCreateEntry({
|
||||
setPinnedOptionId(null)
|
||||
setError(null)
|
||||
setSwitchError(null)
|
||||
setSelectionGuidance(null)
|
||||
}}
|
||||
// Why: disabling the focused input would hand focus to the body and
|
||||
// break arrow/Escape navigation until the submission settles.
|
||||
disabled={disabled}
|
||||
readOnly={pending}
|
||||
aria-busy={pending || undefined}
|
||||
role="combobox"
|
||||
aria-expanded={activeOptions.length > 0}
|
||||
aria-controls={RESULT_LISTBOX_ID}
|
||||
aria-expanded={!error && activeOptions.length > 0}
|
||||
aria-controls={!error && activeOptions.length > 0 ? RESULT_LISTBOX_ID : undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
activeOptions.length > 0 && !error ? resultOptionDomId(activeSelectedIndex) : undefined
|
||||
activeSelectedIndex !== null && !error
|
||||
? resultOptionDomId(activeSelectedIndex)
|
||||
: undefined
|
||||
}
|
||||
aria-label={omniboxPlaceholder()}
|
||||
aria-label={getTabEntryOmniboxPlaceholder()}
|
||||
aria-invalid={error ? true : undefined}
|
||||
placeholder={omniboxPlaceholder()}
|
||||
aria-errormessage={error ? 'tab-create-entry-error' : undefined}
|
||||
placeholder={getTabEntryOmniboxPlaceholder()}
|
||||
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>
|
||||
@@ -345,28 +374,46 @@ export default function TabBarCreateEntry({
|
||||
the rows the user can still act on. The live region stays mounted so a
|
||||
screen reader announces the failure instead of missing the insertion. */}
|
||||
<div role="status">
|
||||
{error ? (
|
||||
<div className="mt-1 px-1" id="tab-create-entry-error">
|
||||
<EntryStatusRow message={error} />
|
||||
</div>
|
||||
) : null}
|
||||
{switchError ? (
|
||||
<div className="mt-1 px-1">
|
||||
<EntryStatusRow message={switchError} />
|
||||
</div>
|
||||
) : null}
|
||||
{selectionGuidance ? (
|
||||
<div className="mt-1 px-1">
|
||||
<EntryStatusRow message={selectionGuidance} />
|
||||
</div>
|
||||
) : null}
|
||||
{activeOptions.length > 0 && statusOption ? (
|
||||
<div className="mt-1 px-1">
|
||||
<EntryStatusRow loading={fileList.loading} message={statusMessage} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{error || activeOptions.length > 0 || hasQuery ? (
|
||||
{!error && (activeOptions.length > 0 || hasQuery) ? (
|
||||
<div
|
||||
className="mt-1 space-y-0.5 px-1"
|
||||
id={RESULT_LISTBOX_ID}
|
||||
role={activeOptions.length > 0 && !error ? 'listbox' : undefined}
|
||||
role={activeOptions.length > 0 ? 'listbox' : undefined}
|
||||
>
|
||||
{error ? (
|
||||
<EntryStatusRow message={error} />
|
||||
) : activeOptions.length > 0 ? (
|
||||
{activeOptions.length > 0 ? (
|
||||
activeOptions.map((option, index) => (
|
||||
<EntryActionRow
|
||||
key={getActiveOptionId(option)}
|
||||
id={resultOptionDomId(index)}
|
||||
option={option}
|
||||
selected={index === activeSelectedIndex}
|
||||
onClick={() => submitOption(option)}
|
||||
disabled={disabled || pending}
|
||||
loading={pending && index === activeSelectedIndex}
|
||||
onClick={() => {
|
||||
setSelectionGuidance(null)
|
||||
submitOption(option)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
@@ -377,21 +424,3 @@ export default function TabBarCreateEntry({
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// Moves keyboard focus to the first/last enabled item of the enclosing Radix
|
||||
// menu so the static create/agent list stays navigable from the search input.
|
||||
function focusMenuItemAtEdge(fromElement: HTMLElement, edge: 'first' | 'last'): boolean {
|
||||
const menu = fromElement.closest('[role="menu"]')
|
||||
if (!menu) {
|
||||
return false
|
||||
}
|
||||
const items = menu.querySelectorAll<HTMLElement>(
|
||||
'[role="menuitem"]:not([data-disabled]):not([aria-disabled="true"])'
|
||||
)
|
||||
const target = edge === 'first' ? items[0] : items.item(items.length - 1)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
target.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { getByRole } from '@testing-library/dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { ActiveOption } from './tab-create-entry-active-option'
|
||||
@@ -88,4 +89,18 @@ describe('EntryActionRow', () => {
|
||||
expect(renderRow(makeFileOption('README.md')).textContent).toContain('README.md')
|
||||
expect(container.textContent).not.toContain('/')
|
||||
})
|
||||
|
||||
it('names the provider and query without retaining a built URL', () => {
|
||||
renderRow({
|
||||
kind: 'entry',
|
||||
option: {
|
||||
id: 'search:private project',
|
||||
classification: { kind: 'search', engine: 'kagi', query: 'private project' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(getByRole(container, 'option', { name: 'Search Kagi private project' })).toBeTruthy()
|
||||
expect(container.textContent).toBe('Search Kagi·private project')
|
||||
expect(container.textContent).not.toContain('https://')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GitCompare,
|
||||
Globe,
|
||||
Loader2,
|
||||
Search,
|
||||
Smartphone,
|
||||
TerminalSquare
|
||||
} from 'lucide-react'
|
||||
@@ -12,6 +13,7 @@ import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FilePathCursorTooltip, splitTrailingSegment } from '@/components/file-path-cursor-tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SEARCH_ENGINE_LABELS } from '../../../../shared/browser-url'
|
||||
import type { ActiveOption } from './tab-create-entry-active-option'
|
||||
|
||||
export const RESULT_LISTBOX_ID = 'tab-create-entry-results'
|
||||
@@ -38,12 +40,16 @@ export function EntryStatusRow({
|
||||
}
|
||||
|
||||
export function EntryActionRow({
|
||||
disabled = false,
|
||||
id,
|
||||
loading = false,
|
||||
onClick,
|
||||
option,
|
||||
selected
|
||||
}: {
|
||||
disabled?: boolean
|
||||
id: string
|
||||
loading?: boolean
|
||||
onClick: () => void
|
||||
option: ActiveOption
|
||||
selected: boolean
|
||||
@@ -56,15 +62,22 @@ export function EntryActionRow({
|
||||
id={id}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex h-6 w-full items-center gap-1.5 rounded-[7px] px-1 text-left text-[11px] leading-5 outline-none',
|
||||
'flex h-6 w-full items-center gap-1.5 rounded-[7px] px-1 text-left text-[11px] leading-5 outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
selected
|
||||
? 'bg-black/8 text-accent-foreground dark:bg-white/14'
|
||||
: 'text-muted-foreground hover:bg-black/8 hover:text-accent-foreground dark:hover:bg-white/14'
|
||||
: // Why: CSS :hover still matches a disabled button, so a pending row would
|
||||
// light up under the pointer despite being unactivatable.
|
||||
'text-muted-foreground hover:bg-black/8 hover:text-accent-foreground disabled:hover:bg-transparent disabled:hover:text-muted-foreground dark:hover:bg-white/14 dark:disabled:hover:bg-transparent'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{presentation.icon}
|
||||
{loading ? (
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
presentation.icon
|
||||
)}
|
||||
<span className={cn('min-w-0 truncate font-medium', presentation.showDetail && 'shrink-0')}>
|
||||
{presentation.label}
|
||||
</span>
|
||||
@@ -169,6 +182,19 @@ function getActionPresentation(option: ActiveOption): {
|
||||
}
|
||||
}
|
||||
const { classification } = option.option
|
||||
if (classification.kind === 'search') {
|
||||
return {
|
||||
detail: classification.query,
|
||||
icon: <Search className="size-3.5 shrink-0" aria-hidden="true" />,
|
||||
label: translate(
|
||||
'auto.components.tab.bar.TabBarCreateEntry.searchProvider',
|
||||
'Search {{value0}}',
|
||||
{ value0: SEARCH_ENGINE_LABELS[classification.engine] }
|
||||
),
|
||||
prioritizeFilename: false,
|
||||
showDetail: true
|
||||
}
|
||||
}
|
||||
if (classification.kind === 'explicit-url' || classification.kind === 'host-url') {
|
||||
return {
|
||||
detail: classification.url,
|
||||
|
||||
@@ -10,10 +10,8 @@ const readyFiles = (files: string[]) => ({ files, loading: false, loadError: nul
|
||||
describe('openTabEntryWithOperations', () => {
|
||||
function makeOperations(overrides: Partial<TabEntryOperations> = {}): TabEntryOperations {
|
||||
return {
|
||||
createBrowserTab: vi.fn() as TabEntryOperations['createBrowserTab'],
|
||||
createRuntimePath: vi.fn().mockResolvedValue(undefined),
|
||||
createWebRuntimeSessionBrowserTab: vi.fn().mockResolvedValue(true),
|
||||
isWebRuntimeSessionActive: vi.fn().mockReturnValue(false),
|
||||
openWorkspaceBrowserTab: vi.fn().mockResolvedValue(undefined),
|
||||
openFile: vi.fn(),
|
||||
statRuntimePath: vi.fn().mockResolvedValue({ size: 1, isDirectory: false, mtime: 1 }),
|
||||
authorizeExternalPath: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -32,9 +30,9 @@ describe('openTabEntryWithOperations', () => {
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
},
|
||||
activeRuntimeEnvironmentId: null,
|
||||
allowAbsolutePaths: true,
|
||||
localPlatform: 'posix' as const
|
||||
localPlatform: 'posix' as const,
|
||||
searchEngine: 'google' as const
|
||||
}
|
||||
|
||||
it('stats existing files before opening and rejects directories', async () => {
|
||||
@@ -182,51 +180,77 @@ describe('openTabEntryWithOperations', () => {
|
||||
expect(operations.openFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes paired runtime browser creation through the web session API', async () => {
|
||||
const operations = makeOperations({
|
||||
isWebRuntimeSessionActive: vi.fn().mockReturnValue(true)
|
||||
})
|
||||
it('routes URL classifications through the workspace browser opener', async () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
await openTabEntryWithOperations({
|
||||
...baseArgs,
|
||||
query: 'https://example.com',
|
||||
activeRuntimeEnvironmentId: 'runtime-1',
|
||||
operations
|
||||
})
|
||||
|
||||
expect(operations.createWebRuntimeSessionBrowserTab).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
environmentId: 'runtime-1',
|
||||
expect(operations.openWorkspaceBrowserTab).toHaveBeenCalledWith({
|
||||
workspaceId: 'wt-1',
|
||||
url: 'https://example.com/',
|
||||
targetGroupId: 'group-1'
|
||||
targetGroupId: 'group-1',
|
||||
intent: { kind: 'url' }
|
||||
})
|
||||
expect(operations.createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to a local browser tab when paired runtime browser creation fails', async () => {
|
||||
const operations = makeOperations({
|
||||
createWebRuntimeSessionBrowserTab: vi.fn().mockResolvedValue(false),
|
||||
isWebRuntimeSessionActive: vi.fn().mockReturnValue(true)
|
||||
})
|
||||
it('builds a search URL from the selected immutable classification', async () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
await openTabEntryWithOperations({
|
||||
...baseArgs,
|
||||
query: 'https://example.com',
|
||||
activeRuntimeEnvironmentId: 'runtime-1',
|
||||
query: 'different query',
|
||||
classification: { kind: 'search', engine: 'duckduckgo', query: 'react hooks' },
|
||||
operations
|
||||
})
|
||||
|
||||
expect(operations.createWebRuntimeSessionBrowserTab).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
environmentId: 'runtime-1',
|
||||
url: 'https://example.com/',
|
||||
targetGroupId: 'group-1'
|
||||
})
|
||||
expect(operations.createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
|
||||
activate: true,
|
||||
browserRuntimeEnvironmentId: null,
|
||||
expect(operations.openWorkspaceBrowserTab).toHaveBeenCalledWith({
|
||||
workspaceId: 'wt-1',
|
||||
url: 'https://duckduckgo.com/?q=react%20hooks',
|
||||
targetGroupId: 'group-1',
|
||||
title: 'https://example.com/'
|
||||
intent: { kind: 'search', engine: 'duckduckgo' }
|
||||
})
|
||||
})
|
||||
|
||||
it('uses plain Kagi search URLs and the fallback classifier engine', async () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
await openTabEntryWithOperations({
|
||||
...baseArgs,
|
||||
query: 'private project',
|
||||
searchEngine: 'kagi',
|
||||
operations
|
||||
})
|
||||
|
||||
expect(operations.openWorkspaceBrowserTab).toHaveBeenCalledWith({
|
||||
workspaceId: 'wt-1',
|
||||
url: 'https://kagi.com/search?q=private%20project',
|
||||
targetGroupId: 'group-1',
|
||||
intent: { kind: 'search', engine: 'kagi' }
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a configured Kagi private-session link', async () => {
|
||||
const operations = makeOperations()
|
||||
|
||||
await openTabEntryWithOperations({
|
||||
...baseArgs,
|
||||
query: 'private project',
|
||||
searchEngine: 'kagi',
|
||||
searchUrlOptions: {
|
||||
kagiSessionLink: 'https://kagi.com/search?token=secret'
|
||||
},
|
||||
operations
|
||||
})
|
||||
|
||||
expect(operations.openWorkspaceBrowserTab).toHaveBeenCalledWith({
|
||||
workspaceId: 'wt-1',
|
||||
url: 'https://kagi.com/search?token=secret&q=private+project',
|
||||
targetGroupId: 'group-1',
|
||||
intent: { kind: 'search', engine: 'kagi' }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,14 +6,19 @@ import {
|
||||
statRuntimePath,
|
||||
type RuntimeFileOperationArgs
|
||||
} from '@/runtime/runtime-file-client'
|
||||
import {
|
||||
createWebRuntimeSessionBrowserTab,
|
||||
isWebRuntimeSessionActive
|
||||
} from '@/runtime/web-runtime-session'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { BrowserTab as BrowserTabState } from '../../../../shared/types'
|
||||
import {
|
||||
buildSearchUrl,
|
||||
DEFAULT_SEARCH_ENGINE,
|
||||
type SearchEngine,
|
||||
type SearchUrlOptions
|
||||
} from '../../../../shared/browser-url'
|
||||
import type { RuntimeFileListState } from '../quick-open-file-list'
|
||||
import {
|
||||
openWorkspaceBrowserTab,
|
||||
type OpenWorkspaceBrowserTabRequest
|
||||
} from '@/lib/workspace-browser-tab-open'
|
||||
import {
|
||||
classifyTabEntryQuery,
|
||||
TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE,
|
||||
@@ -54,19 +59,8 @@ export type TabCreateEntryArgs = {
|
||||
}
|
||||
|
||||
export type TabEntryOperations = {
|
||||
createBrowserTab: (
|
||||
worktreeId: string,
|
||||
url: string,
|
||||
options?: {
|
||||
activate?: boolean
|
||||
browserRuntimeEnvironmentId?: string | null
|
||||
targetGroupId?: string
|
||||
title?: string
|
||||
}
|
||||
) => BrowserTabState
|
||||
createRuntimePath: typeof createRuntimePath
|
||||
createWebRuntimeSessionBrowserTab: typeof createWebRuntimeSessionBrowserTab
|
||||
isWebRuntimeSessionActive: typeof isWebRuntimeSessionActive
|
||||
openWorkspaceBrowserTab: typeof openWorkspaceBrowserTab
|
||||
openFile: (
|
||||
file: Omit<OpenFile, 'id' | 'isDirty'>,
|
||||
options?: { preview?: boolean; targetGroupId?: string }
|
||||
@@ -83,9 +77,10 @@ type OpenTabEntryWithOperationsArgs = {
|
||||
groupId: string
|
||||
worktreePath: string
|
||||
runtimeContext: RuntimeFileOperationArgs
|
||||
activeRuntimeEnvironmentId: string | null
|
||||
allowAbsolutePaths: boolean
|
||||
localPlatform: TabEntryLocalPlatform
|
||||
searchEngine: SearchEngine
|
||||
searchUrlOptions?: SearchUrlOptions
|
||||
classification?: TabEntryActionClassification
|
||||
operations: TabEntryOperations
|
||||
}
|
||||
@@ -152,8 +147,38 @@ async function openExistingFile(args: {
|
||||
)
|
||||
}
|
||||
|
||||
type NetworkTabEntryClassification = Extract<
|
||||
TabEntryActionClassification,
|
||||
{ kind: 'explicit-url' | 'host-url' | 'search' }
|
||||
>
|
||||
|
||||
function isNetworkTabEntry(
|
||||
classification: TabEntryActionClassification
|
||||
): classification is NetworkTabEntryClassification {
|
||||
return (
|
||||
classification.kind === 'explicit-url' ||
|
||||
classification.kind === 'host-url' ||
|
||||
classification.kind === 'search'
|
||||
)
|
||||
}
|
||||
|
||||
function getNetworkTabRequest(
|
||||
classification: NetworkTabEntryClassification,
|
||||
{ worktreeId, groupId }: Pick<TabCreateEntryArgs, 'worktreeId' | 'groupId'>,
|
||||
searchUrlOptions?: SearchUrlOptions
|
||||
): OpenWorkspaceBrowserTabRequest {
|
||||
const target = { workspaceId: worktreeId, targetGroupId: groupId }
|
||||
if (classification.kind === 'search') {
|
||||
return {
|
||||
...target,
|
||||
url: buildSearchUrl(classification.query, classification.engine, searchUrlOptions),
|
||||
intent: { kind: 'search', engine: classification.engine }
|
||||
}
|
||||
}
|
||||
return { ...target, url: classification.url, intent: { kind: 'url' } }
|
||||
}
|
||||
|
||||
export async function openTabEntryWithOperations({
|
||||
activeRuntimeEnvironmentId,
|
||||
allowAbsolutePaths,
|
||||
classification: selectedClassification,
|
||||
fileList,
|
||||
@@ -162,43 +187,22 @@ export async function openTabEntryWithOperations({
|
||||
operations,
|
||||
query,
|
||||
runtimeContext,
|
||||
searchEngine,
|
||||
searchUrlOptions,
|
||||
worktreeId,
|
||||
worktreePath
|
||||
}: OpenTabEntryWithOperationsArgs): Promise<void> {
|
||||
const entryContext: TabEntryOptionsContext = { allowAbsolutePaths, localPlatform }
|
||||
const entryContext: TabEntryOptionsContext = { allowAbsolutePaths, localPlatform, searchEngine }
|
||||
const classification =
|
||||
selectedClassification ?? classifyTabEntryQuery(query, fileList, entryContext)
|
||||
if (classification.kind === 'empty' || classification.kind === 'blocked') {
|
||||
throw new Error(classification.message)
|
||||
}
|
||||
|
||||
if (classification.kind === 'explicit-url' || classification.kind === 'host-url') {
|
||||
const runtimeSessionActive = operations.isWebRuntimeSessionActive(activeRuntimeEnvironmentId)
|
||||
if (runtimeSessionActive) {
|
||||
const created = await operations.createWebRuntimeSessionBrowserTab({
|
||||
worktreeId,
|
||||
environmentId: activeRuntimeEnvironmentId,
|
||||
url: classification.url,
|
||||
targetGroupId: groupId
|
||||
})
|
||||
if (created) {
|
||||
return
|
||||
}
|
||||
// Why: headless remote runtimes cannot host browser panes yet; a URL open
|
||||
// should still give the user a usable client-local browser tab.
|
||||
operations.createBrowserTab(worktreeId, classification.url, {
|
||||
activate: true,
|
||||
browserRuntimeEnvironmentId: null,
|
||||
targetGroupId: groupId,
|
||||
title: classification.url
|
||||
})
|
||||
} else {
|
||||
operations.createBrowserTab(worktreeId, classification.url, {
|
||||
activate: true,
|
||||
targetGroupId: groupId,
|
||||
title: classification.url
|
||||
})
|
||||
}
|
||||
if (isNetworkTabEntry(classification)) {
|
||||
await operations.openWorkspaceBrowserTab(
|
||||
getNetworkTabRequest(classification, { worktreeId, groupId }, searchUrlOptions)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -256,6 +260,12 @@ export async function openTabEntryWithOperations({
|
||||
|
||||
export async function openTabBarEntry(args: TabCreateEntryArgs): Promise<void> {
|
||||
const state = useAppStore.getState()
|
||||
const searchUrlOptions = { kagiSessionLink: state.browserKagiSessionLink }
|
||||
if (args.classification && isNetworkTabEntry(args.classification)) {
|
||||
await openWorkspaceBrowserTab(getNetworkTabRequest(args.classification, args, searchUrlOptions))
|
||||
return
|
||||
}
|
||||
const searchEngine = state.browserDefaultSearchEngine ?? DEFAULT_SEARCH_ENGINE
|
||||
const worktree = state.getKnownWorktreeById(args.worktreeId)
|
||||
if (!worktree) {
|
||||
throw new Error('No active worktree.')
|
||||
@@ -270,15 +280,14 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise<void> {
|
||||
groupId: args.groupId,
|
||||
worktreePath: worktree.path,
|
||||
runtimeContext,
|
||||
activeRuntimeEnvironmentId: runtimeContext.settings?.activeRuntimeEnvironmentId?.trim() ?? null,
|
||||
allowAbsolutePaths,
|
||||
localPlatform,
|
||||
searchEngine,
|
||||
searchUrlOptions,
|
||||
classification: args.classification,
|
||||
operations: {
|
||||
createBrowserTab: state.createBrowserTab,
|
||||
createRuntimePath,
|
||||
createWebRuntimeSessionBrowserTab,
|
||||
isWebRuntimeSessionActive,
|
||||
openWorkspaceBrowserTab,
|
||||
openFile: state.openFile,
|
||||
statRuntimePath,
|
||||
authorizeExternalPath: window.api.fs.authorizeExternalPath,
|
||||
|
||||
@@ -70,6 +70,27 @@ describe('tab create entry classification', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('picks http for private IPv4 hosts and https for public ones', () => {
|
||||
for (const input of ['192.168.1.5:5173', '10.0.0.2', '172.16.4.1:8080', '100.64.0.1']) {
|
||||
expect(classifyTabEntryQuery(input, readyFiles([])), input).toMatchObject({
|
||||
kind: 'host-url',
|
||||
url: expect.stringMatching(/^http:\/\//)
|
||||
})
|
||||
}
|
||||
for (const input of ['8.8.8.8', '1.1.1.1:8080']) {
|
||||
expect(classifyTabEntryQuery(input, readyFiles([])), input).toMatchObject({
|
||||
kind: 'host-url',
|
||||
url: expect.stringMatching(/^https:\/\//)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('blocks malformed bracketed IPv6 URL attempts', () => {
|
||||
for (const input of ['[::1]:abc', '[::1]:99999', '[2001:db8::1]:nope/path']) {
|
||||
expect(classifyTabEntryQuery(input, readyFiles([])), input).toMatchObject({ kind: 'blocked' })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not classify invalid numeric hosts as URLs', () => {
|
||||
expect(classifyTabEntryQuery('999.999.999.999', readyFiles([]))).toEqual({
|
||||
kind: 'new-file',
|
||||
@@ -92,13 +113,10 @@ describe('tab create entry classification', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks non-explicit URLs and file paths while list state is not ready', () => {
|
||||
it('keeps safe URL actions available while the file list is not ready', () => {
|
||||
expect(
|
||||
classifyTabEntryQuery('example.com', { files: [], loading: true, loadError: null })
|
||||
).toEqual({
|
||||
kind: 'blocked',
|
||||
message: 'Loading files...'
|
||||
})
|
||||
).toMatchObject({ kind: 'host-url', url: 'https://example.com/' })
|
||||
expect(
|
||||
classifyTabEntryQuery('https://example.com', { files: [], loading: true, loadError: null })
|
||||
).toMatchObject({ kind: 'explicit-url' })
|
||||
@@ -108,7 +126,7 @@ describe('tab create entry classification', () => {
|
||||
loading: false,
|
||||
loadError: 'scan failed'
|
||||
})
|
||||
).toEqual({ kind: 'blocked', message: 'scan failed' })
|
||||
).toMatchObject({ kind: 'host-url', url: 'https://example.com/' })
|
||||
})
|
||||
|
||||
it('matches exact relative path before basename and fuzzy results', () => {
|
||||
@@ -137,7 +155,8 @@ describe('tab create entry classification', () => {
|
||||
)
|
||||
).toEqual([
|
||||
{ kind: 'existing-file', matchKind: 'exact-basename', relativePath: 'src/index.ts' },
|
||||
{ kind: 'existing-file', matchKind: 'exact-basename', relativePath: 'docs/index.ts' }
|
||||
{ kind: 'existing-file', matchKind: 'exact-basename', relativePath: 'docs/index.ts' },
|
||||
{ kind: 'search', engine: 'google', query: 'index.ts' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -148,10 +167,208 @@ describe('tab create entry classification', () => {
|
||||
)
|
||||
).toEqual([
|
||||
{ kind: 'new-file', relativePath: 'read.md' },
|
||||
{ kind: 'search', engine: 'google', query: 'read.md' },
|
||||
{ kind: 'existing-file', matchKind: 'fuzzy', relativePath: 'README.md' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps single-token quick-open matches ahead of search, but not phrases', () => {
|
||||
expect(
|
||||
getTabEntryOptions('typescript', readyFiles(['docs/typescript-guide.md'])).map(
|
||||
(option) => option.classification
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
kind: 'existing-file',
|
||||
matchKind: 'fuzzy',
|
||||
relativePath: 'docs/typescript-guide.md'
|
||||
},
|
||||
{ kind: 'search', engine: 'google', query: 'typescript' },
|
||||
{ kind: 'new-file', relativePath: 'typescript' }
|
||||
])
|
||||
expect(
|
||||
getTabEntryOptions('type script', readyFiles(['docs/typescript-guide.md'])).map(
|
||||
(option) => option.classification.kind
|
||||
)
|
||||
).toEqual(['search', 'new-file'])
|
||||
})
|
||||
|
||||
// Fuzzy matching is a subsequence scan, so a short token matches broadly.
|
||||
it('keeps a search slot when fuzzy matches would fill the whole list', () => {
|
||||
const files = [
|
||||
'src/components/Button.tsx',
|
||||
'src/lib/bootstrap-nav.ts',
|
||||
'docs/build-notes.md',
|
||||
'src/base/tone.ts',
|
||||
'src/bin/tune.ts'
|
||||
]
|
||||
expect(getTabEntryOptions('btn', readyFiles(files)).map((o) => o.classification.kind)).toEqual([
|
||||
'existing-file',
|
||||
'existing-file',
|
||||
'existing-file',
|
||||
'search'
|
||||
])
|
||||
// A one-slot list still answers with the file, so Enter keeps quick-open.
|
||||
expect(
|
||||
getTabEntryOptions('btn', readyFiles(files), 1).map((o) => o.classification.kind)
|
||||
).toEqual(['existing-file'])
|
||||
})
|
||||
|
||||
it('ranks search before ordinary create-file actions', () => {
|
||||
expect(
|
||||
getTabEntryOptions('typescript', readyFiles([])).map((option) => option.classification)
|
||||
).toEqual([
|
||||
{ kind: 'search', engine: 'google', query: 'typescript' },
|
||||
{ kind: 'new-file', relativePath: 'typescript' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps strong file syntax ahead of search without treating dotted phrases as files', () => {
|
||||
expect(
|
||||
getTabEntryOptions('README.md', readyFiles(['docs/README-old.md'])).map(
|
||||
(option) => option.classification.kind
|
||||
)
|
||||
).toEqual(['new-file', 'search', 'existing-file'])
|
||||
for (const query of ['node.js tutorial', 'package.json docs', 'what is foo.bar']) {
|
||||
expect(classifyTabEntryQuery(query, readyFiles([]))).toMatchObject({
|
||||
kind: 'search',
|
||||
query
|
||||
})
|
||||
}
|
||||
for (const query of ['.env', '.gitignore']) {
|
||||
expect(
|
||||
getTabEntryOptions(query, readyFiles([])).map((option) => option.classification.kind)
|
||||
).toEqual(['new-file', 'search'])
|
||||
}
|
||||
})
|
||||
|
||||
it('searches natural-language colons while blocking explicit unsupported schemes', () => {
|
||||
for (const query of ['error: cannot connect', 'node:fs docs', 'site:github.com react']) {
|
||||
expect(classifyTabEntryQuery(query, readyFiles([]))).toMatchObject({ kind: 'search', query })
|
||||
}
|
||||
for (const query of ['ftp:example.com', 'ftp://example.com', 'custom:// bad input']) {
|
||||
expect(classifyTabEntryQuery(query, readyFiles([]))).toMatchObject({ kind: 'blocked' })
|
||||
}
|
||||
expect(classifyTabEntryQuery('foo.ts:123', readyFiles([]))).toMatchObject({
|
||||
kind: 'new-file',
|
||||
relativePath: 'foo.ts:123'
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks malformed explicit and host-like URLs unless an exact file exists', () => {
|
||||
for (const query of ['https://', 'example.com:99999']) {
|
||||
expect(classifyTabEntryQuery(query, readyFiles([]))).toMatchObject({ kind: 'blocked' })
|
||||
}
|
||||
expect(classifyTabEntryQuery('example.com:99999', readyFiles(['example.com:99999']))).toEqual({
|
||||
kind: 'existing-file',
|
||||
matchKind: 'exact-path',
|
||||
relativePath: 'example.com:99999'
|
||||
})
|
||||
})
|
||||
|
||||
it('offers network actions alongside file-index status only when safe', () => {
|
||||
const loading = { files: [], loading: true, loadError: null }
|
||||
expect(
|
||||
getTabEntryOptions('natural language', loading).map((option) => option.classification.kind)
|
||||
).toEqual(['search', 'blocked'])
|
||||
expect(
|
||||
getTabEntryOptions('example.com', loading).map((option) => option.classification.kind)
|
||||
).toEqual(['host-url', 'blocked'])
|
||||
expect(
|
||||
getTabEntryOptions('README.md', loading).map((option) => option.classification.kind)
|
||||
).toEqual(['blocked'])
|
||||
expect(
|
||||
getTabEntryOptions('example.com:99999', loading).map((option) => option.classification.kind)
|
||||
).toEqual(['blocked'])
|
||||
})
|
||||
|
||||
it('keeps file matches for path prefixes that cannot be created', () => {
|
||||
const files = ['src/main/index.ts', 'src/renderer/App.tsx']
|
||||
// A trailing separator is an ordinary keystroke on the way to a nested path,
|
||||
// so the matches it finds must survive the unusable-path verdict.
|
||||
for (const query of ['src/', 'src/renderer/']) {
|
||||
expect(
|
||||
getTabEntryOptions(query, readyFiles(files)).map((option) => option.classification.kind),
|
||||
query
|
||||
).toEqual(expect.arrayContaining(['existing-file']))
|
||||
}
|
||||
expect(getTabEntryOptions('src/', readyFiles([]))).toMatchObject([
|
||||
{ classification: { kind: 'blocked' } }
|
||||
])
|
||||
})
|
||||
|
||||
it('reports the running scan for path-shaped text it cannot create yet', () => {
|
||||
expect(
|
||||
getTabEntryOptions('src/', { files: [], loading: true, loadError: null }).map(
|
||||
(option) => option.id
|
||||
)
|
||||
).toEqual(['loading'])
|
||||
})
|
||||
|
||||
it('falls through to file and search actions for host-like text with a non-numeric port', () => {
|
||||
expect(
|
||||
getTabEntryOptions('docker.io:latest', readyFiles([])).map(
|
||||
(option) => option.classification.kind
|
||||
)
|
||||
).toEqual(['new-file', 'search'])
|
||||
expect(
|
||||
getTabEntryOptions('example.com:', readyFiles([])).map((option) => option.classification.kind)
|
||||
).toEqual(['new-file', 'search'])
|
||||
})
|
||||
|
||||
it('never turns invalid paths into search actions', () => {
|
||||
for (const query of ['../.env', 'foo//bar', 'foo/', 'C:relative.txt', 'src/\u0000file.ts']) {
|
||||
expect(classifyTabEntryQuery(query, readyFiles([])), query).toMatchObject({ kind: 'blocked' })
|
||||
expect(
|
||||
classifyTabEntryQuery(query, { files: [], loading: true, loadError: null }),
|
||||
query
|
||||
).toMatchObject({ kind: 'blocked' })
|
||||
}
|
||||
})
|
||||
|
||||
it('applies the action limit once and leaves index status outside it', () => {
|
||||
expect(getTabEntryOptions('query', readyFiles([]), 0)).toEqual([])
|
||||
expect(
|
||||
getTabEntryOptions('query', { files: [], loading: true, loadError: null }, 0).map(
|
||||
(option) => option.id
|
||||
)
|
||||
).toEqual(['loading'])
|
||||
expect(
|
||||
getTabEntryOptions('query', readyFiles(['query.md']), 1).map(
|
||||
(option) => option.classification.kind
|
||||
)
|
||||
).toEqual(['existing-file'])
|
||||
expect(
|
||||
getTabEntryOptions('query text', readyFiles(['query.md']), 1).map(
|
||||
(option) => option.classification.kind
|
||||
)
|
||||
).toEqual(['search'])
|
||||
})
|
||||
|
||||
it('forces search before URL, path, and file classification', () => {
|
||||
for (const [input, expectedQuery] of [
|
||||
['?react hooks', 'react hooks'],
|
||||
['??foo', '?foo'],
|
||||
['?https://example.com', 'https://example.com'],
|
||||
['?/tmp/file.ts', '/tmp/file.ts']
|
||||
]) {
|
||||
expect(classifyTabEntryQuery(input, readyFiles(['/tmp/file.ts']))).toEqual({
|
||||
kind: 'search',
|
||||
engine: 'google',
|
||||
query: expectedQuery
|
||||
})
|
||||
}
|
||||
expect(classifyTabEntryQuery('?', readyFiles([]))).toMatchObject({ kind: 'empty' })
|
||||
})
|
||||
|
||||
it('retains the configured engine without building a URL', () => {
|
||||
expect(classifyTabEntryQuery('search me', readyFiles([]), { searchEngine: 'kagi' })).toEqual({
|
||||
kind: 'search',
|
||||
engine: 'kagi',
|
||||
query: 'search me'
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks oversized pasted file-entry queries before reading listed files', () => {
|
||||
const oversizedQuery = `src/${'secret-tab-create'.repeat(QUICK_OPEN_QUERY_MAX_BYTES)}.ts`
|
||||
const fileList = {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { isQuickOpenQueryTooLarge, prepareQuickOpenFiles } from '../quick-open-search'
|
||||
import { getPreparedQuickOpenFiles, isQuickOpenQueryTooLarge } from '../quick-open-search'
|
||||
import type { RuntimeFileListState } from '../quick-open-file-list'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { DEFAULT_SEARCH_ENGINE, type SearchEngine } from '../../../../shared/browser-url'
|
||||
import { findExistingFileMatches, isLikelyNewFileIntent } from './tab-create-entry-file-matches'
|
||||
import { parseForcedSearchQuery } from './tab-create-entry-forced-search'
|
||||
import {
|
||||
isTabEntryAbsolutePathLike,
|
||||
type TabEntryLocalPlatform,
|
||||
@@ -19,6 +21,7 @@ export {
|
||||
export type TabEntryOptionsContext = {
|
||||
allowAbsolutePaths?: boolean
|
||||
localPlatform?: TabEntryLocalPlatform
|
||||
searchEngine?: SearchEngine
|
||||
}
|
||||
|
||||
export const TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE =
|
||||
@@ -33,6 +36,7 @@ export type TabEntryClassification =
|
||||
relativePath: string
|
||||
}
|
||||
| { kind: 'host-url'; url: string }
|
||||
| { kind: 'search'; engine: SearchEngine; query: string }
|
||||
| { kind: 'new-file'; relativePath: string }
|
||||
| { kind: 'absolute-file'; filePath: string }
|
||||
| { kind: 'blocked'; message: string }
|
||||
@@ -57,22 +61,78 @@ function tabEntryActionOptionId(classification: TabEntryActionClassification): s
|
||||
case 'explicit-url':
|
||||
case 'host-url':
|
||||
return `${classification.kind}:${classification.url}`
|
||||
case 'search':
|
||||
return `search:${classification.query}`
|
||||
}
|
||||
}
|
||||
|
||||
function emptyOption(): TabEntryOption {
|
||||
return {
|
||||
id: 'empty',
|
||||
classification: {
|
||||
kind: 'empty',
|
||||
message: translate(
|
||||
'auto.components.tab.bar.tab.create.entry.classifier.c41f8d20b7',
|
||||
'Search open tabs, files, URLs, agents…'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function blockedOption(id: string, message: string): TabEntryOption {
|
||||
return { id, classification: { kind: 'blocked', message } }
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function invalidPathOption(error: unknown): TabEntryOption {
|
||||
return blockedOption('invalid-path', errorMessage(error))
|
||||
}
|
||||
|
||||
function fileListStatusOption(fileList: RuntimeFileListState): TabEntryOption | null {
|
||||
if (fileList.loading) {
|
||||
return blockedOption(
|
||||
'loading',
|
||||
translate(
|
||||
'auto.components.tab.bar.tab.create.entry.classifier.097a982ee0',
|
||||
'Loading files...'
|
||||
)
|
||||
)
|
||||
}
|
||||
return fileList.loadError ? blockedOption('load-error', fileList.loadError) : null
|
||||
}
|
||||
|
||||
function clampActionLimit(limit: number): number {
|
||||
return Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 0
|
||||
}
|
||||
|
||||
function toOptions(
|
||||
classifications: TabEntryActionClassification[],
|
||||
limit: number,
|
||||
status?: TabEntryOption | null
|
||||
): TabEntryOption[] {
|
||||
const options: TabEntryOption[] = classifications
|
||||
.slice(0, clampActionLimit(limit))
|
||||
.map((classification) => ({
|
||||
id: tabEntryActionOptionId(classification),
|
||||
classification
|
||||
}))
|
||||
if (status) {
|
||||
options.push(status)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
export function classifyTabEntryQuery(
|
||||
query: string,
|
||||
fileList: RuntimeFileListState,
|
||||
context: TabEntryOptionsContext = {}
|
||||
): TabEntryClassification {
|
||||
return (
|
||||
getTabEntryOptions(query, fileList, 1, context)[0]?.classification ?? {
|
||||
kind: 'empty',
|
||||
message: translate(
|
||||
'auto.components.tab.bar.tab.create.entry.classifier.5553b283ce',
|
||||
'Enter a URL or file path.'
|
||||
)
|
||||
}
|
||||
getTabEntryOptions(query, fileList, 1, context)[0]?.classification ??
|
||||
emptyOption().classification
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,164 +144,126 @@ export function getTabEntryOptions(
|
||||
): TabEntryOption[] {
|
||||
if (isQuickOpenQueryTooLarge(query)) {
|
||||
return [
|
||||
{
|
||||
id: 'query-too-large',
|
||||
classification: {
|
||||
kind: 'blocked',
|
||||
message: translate(
|
||||
'auto.components.tab.bar.tab.create.entry.classifier.queryTooLarge',
|
||||
'Search text is too large.'
|
||||
)
|
||||
}
|
||||
}
|
||||
blockedOption(
|
||||
'query-too-large',
|
||||
translate(
|
||||
'auto.components.tab.bar.tab.create.entry.classifier.queryTooLarge',
|
||||
'Search text is too large.'
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
const trimmed = query.trim()
|
||||
const parsedSearch = parseForcedSearchQuery(query)
|
||||
const trimmed = parsedSearch.query
|
||||
const engine = context.searchEngine ?? DEFAULT_SEARCH_ENGINE
|
||||
const search: TabEntryActionClassification = { kind: 'search', engine, query: trimmed }
|
||||
if (parsedSearch.forced) {
|
||||
return trimmed ? toOptions([search], limit) : [emptyOption()]
|
||||
}
|
||||
if (!trimmed) {
|
||||
return [
|
||||
{
|
||||
id: 'empty',
|
||||
classification: {
|
||||
kind: 'empty',
|
||||
message: translate(
|
||||
'auto.components.tab.bar.tab.create.entry.classifier.c41f8d20b7',
|
||||
'Search open tabs, files, URLs, agents…'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
return [emptyOption()]
|
||||
}
|
||||
|
||||
if (isTabEntryAbsolutePathLike(trimmed)) {
|
||||
if (!context.allowAbsolutePaths) {
|
||||
return [
|
||||
{
|
||||
id: 'absolute-path-blocked',
|
||||
classification: {
|
||||
kind: 'blocked',
|
||||
message: translate(
|
||||
'auto.components.tab.bar.tab.create.entry.classifier.absolutePathRemoteBlocked',
|
||||
'Absolute paths require a local workspace.'
|
||||
)
|
||||
}
|
||||
}
|
||||
blockedOption(
|
||||
'absolute-path-blocked',
|
||||
translate(
|
||||
'auto.components.tab.bar.tab.create.entry.classifier.absolutePathRemoteBlocked',
|
||||
'Absolute paths require a local workspace.'
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
try {
|
||||
const filePath = validateNewTabEntryAbsolutePath(trimmed, context.localPlatform)
|
||||
return [
|
||||
{
|
||||
id: `absolute-file:${filePath}`,
|
||||
classification: { kind: 'absolute-file', filePath }
|
||||
}
|
||||
]
|
||||
return toOptions([{ kind: 'absolute-file', filePath }], limit)
|
||||
} catch (error) {
|
||||
return [
|
||||
{
|
||||
id: 'invalid-absolute-path',
|
||||
classification: {
|
||||
kind: 'blocked',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
]
|
||||
return [blockedOption('invalid-absolute-path', errorMessage(error))]
|
||||
}
|
||||
}
|
||||
|
||||
const explicitUrl = classifyExplicitUrl(trimmed)
|
||||
if (explicitUrl) {
|
||||
return [
|
||||
{
|
||||
id: explicitUrl.kind === 'blocked' ? 'invalid-url' : `url:${explicitUrl.url}`,
|
||||
classification: explicitUrl
|
||||
}
|
||||
]
|
||||
return explicitUrl.kind === 'blocked'
|
||||
? [blockedOption('invalid-url', explicitUrl.message)]
|
||||
: toOptions([explicitUrl], limit)
|
||||
}
|
||||
|
||||
if (fileList.loading) {
|
||||
return [
|
||||
{
|
||||
id: 'loading',
|
||||
classification: {
|
||||
kind: 'blocked',
|
||||
message: translate(
|
||||
'auto.components.tab.bar.tab.create.entry.classifier.097a982ee0',
|
||||
'Loading files...'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
const hostUrl = classifyHostUrl(trimmed)
|
||||
let newFile: TabEntryActionClassification | null = null
|
||||
let pathError: unknown = null
|
||||
try {
|
||||
newFile = { kind: 'new-file', relativePath: validateNewTabEntryRelativePath(trimmed) }
|
||||
} catch (error) {
|
||||
pathError = error
|
||||
}
|
||||
if (fileList.loadError) {
|
||||
return [{ id: 'load-error', classification: { kind: 'blocked', message: fileList.loadError } }]
|
||||
|
||||
const fileStatus = fileListStatusOption(fileList)
|
||||
if (fileStatus) {
|
||||
if (hostUrl?.kind === 'blocked') {
|
||||
return [fileStatus]
|
||||
}
|
||||
if (hostUrl?.kind === 'host-url') {
|
||||
return toOptions([hostUrl], limit, fileStatus)
|
||||
}
|
||||
// Why: path-shaped text waits on the scan whether or not it is creatable, so
|
||||
// "src/" reports the scan instead of flashing a path error it will not keep.
|
||||
if (isLikelyNewFileIntent(trimmed)) {
|
||||
return [fileStatus]
|
||||
}
|
||||
if (pathError) {
|
||||
return [invalidPathOption(pathError)]
|
||||
}
|
||||
return toOptions([search], limit, fileStatus)
|
||||
}
|
||||
|
||||
const actionLimit = clampActionLimit(limit)
|
||||
const existingFiles = findExistingFileMatches(
|
||||
trimmed,
|
||||
prepareQuickOpenFiles(fileList.files),
|
||||
Math.max(limit, 1)
|
||||
getPreparedQuickOpenFiles(fileList.files),
|
||||
Math.max(actionLimit, 1)
|
||||
)
|
||||
const exactExistingFiles = existingFiles.filter((file) => file.matchKind !== 'fuzzy')
|
||||
const fuzzyExistingFiles = existingFiles.filter((file) => file.matchKind === 'fuzzy')
|
||||
|
||||
let newFile: TabEntryActionClassification | null = null
|
||||
try {
|
||||
newFile = { kind: 'new-file', relativePath: validateNewTabEntryRelativePath(trimmed) }
|
||||
} catch {
|
||||
newFile = null
|
||||
}
|
||||
|
||||
const hostUrl = classifyHostUrl(trimmed)
|
||||
|
||||
const options: TabEntryActionClassification[] = []
|
||||
if (exactExistingFiles.length > 0) {
|
||||
options.push(...exactExistingFiles)
|
||||
if (hostUrl) {
|
||||
const options: TabEntryActionClassification[] = [...exactExistingFiles]
|
||||
if (hostUrl?.kind === 'host-url') {
|
||||
options.push(hostUrl)
|
||||
} else if (!hostUrl && newFile) {
|
||||
options.push(search)
|
||||
}
|
||||
} else if (hostUrl) {
|
||||
options.push(hostUrl)
|
||||
options.push(...fuzzyExistingFiles)
|
||||
} else if (newFile && isLikelyNewFileIntent(trimmed)) {
|
||||
options.push(newFile, ...fuzzyExistingFiles)
|
||||
} else {
|
||||
options.push(...fuzzyExistingFiles)
|
||||
if (newFile) {
|
||||
options.push(newFile)
|
||||
}
|
||||
return toOptions(options, actionLimit)
|
||||
}
|
||||
|
||||
if (options.length > 0) {
|
||||
return options.slice(0, limit).map((classification) => ({
|
||||
id: tabEntryActionOptionId(classification),
|
||||
classification
|
||||
}))
|
||||
if (hostUrl?.kind === 'blocked') {
|
||||
return [blockedOption('invalid-url', hostUrl.message)]
|
||||
}
|
||||
|
||||
try {
|
||||
validateNewTabEntryRelativePath(trimmed)
|
||||
} catch (error) {
|
||||
return [
|
||||
{
|
||||
id: 'invalid-path',
|
||||
classification: {
|
||||
kind: 'blocked',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
]
|
||||
if (hostUrl?.kind === 'host-url') {
|
||||
return toOptions([hostUrl, ...fuzzyExistingFiles], actionLimit)
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'blocked',
|
||||
classification: {
|
||||
kind: 'blocked',
|
||||
message: translate(
|
||||
'auto.components.tab.bar.tab.create.entry.classifier.42e6262ae9',
|
||||
'No action available.'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
if (pathError || !newFile) {
|
||||
// Why: an unusable path is still a live quick-open prefix — "src/" cannot be
|
||||
// created, but it matches real files, and dropping them turns every typed
|
||||
// separator into an error row mid-keystroke.
|
||||
return fuzzyExistingFiles.length > 0
|
||||
? toOptions(fuzzyExistingFiles, actionLimit)
|
||||
: [invalidPathOption(pathError)]
|
||||
}
|
||||
if (isLikelyNewFileIntent(trimmed)) {
|
||||
return toOptions([newFile, search, ...fuzzyExistingFiles], actionLimit)
|
||||
}
|
||||
if (/\s/.test(trimmed)) {
|
||||
return toOptions([search, ...fuzzyExistingFiles, newFile], actionLimit)
|
||||
}
|
||||
// Why: a single token is still a quick-open attempt ("btn" → Button.tsx), so
|
||||
// only phrases promote web search over fuzzy matches. Fuzzy matching is a
|
||||
// subsequence scan that fills every slot in a real repo, so hold one back —
|
||||
// otherwise search silently disappears from the list it should always offer.
|
||||
return toOptions(
|
||||
[...fuzzyExistingFiles.slice(0, Math.max(actionLimit - 1, 1)), search, newFile],
|
||||
actionLimit
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function getTabEntryOmniboxPlaceholder(): string {
|
||||
return translate(
|
||||
'auto.components.tab.bar.TabBarCreateEntry.0e5b7a3f16',
|
||||
'Search open tabs, files, URLs, agents…'
|
||||
)
|
||||
}
|
||||
|
||||
export function getTabEntryChooseActionMessage(): string {
|
||||
return translate('auto.components.tab.bar.TabBarCreateEntry.chooseAction', 'Choose an action.')
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { OpenTabSearchResult } from './open-tab-search'
|
||||
import type { TabAgentLaunchOption } from './tab-agent-launch-options'
|
||||
import type { TabCreateMenuOption } from './tab-create-menu-options'
|
||||
|
||||
export const EMPTY_AGENT_OPTIONS: readonly TabAgentLaunchOption[] = []
|
||||
export const EMPTY_MENU_OPTIONS: readonly TabCreateMenuOption[] = []
|
||||
export const EMPTY_TAB_RESULTS: readonly OpenTabSearchResult[] = []
|
||||
@@ -18,8 +18,22 @@ function hasFilenameExtension(query: string): boolean {
|
||||
return /(?:^|[\\/])[^\\/]+\.[^\\/]+$/.test(query.trim())
|
||||
}
|
||||
|
||||
// Why: multi-word text without path or filename syntax cannot overtake ranking
|
||||
// as a file match, so callers may treat its search ranking as already final.
|
||||
export function isUnambiguousSearchQuery(query: string): boolean {
|
||||
const trimmed = query.trim()
|
||||
return /\s/.test(trimmed) && !hasPathSeparator(trimmed) && !hasFilenameExtension(trimmed)
|
||||
}
|
||||
|
||||
export function isLikelyNewFileIntent(query: string): boolean {
|
||||
return hasPathSeparator(query) || hasFilenameExtension(query)
|
||||
const trimmed = query.trim()
|
||||
if (hasPathSeparator(trimmed)) {
|
||||
return true
|
||||
}
|
||||
if (/\s/.test(trimmed)) {
|
||||
return false
|
||||
}
|
||||
return hasFilenameExtension(trimmed) || /^\.[^.].*$/.test(trimmed)
|
||||
}
|
||||
|
||||
function dedupeMatches(matches: ExistingFileMatch[]): ExistingFileMatch[] {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseForcedSearchQuery } from './tab-create-entry-forced-search'
|
||||
|
||||
describe('parseForcedSearchQuery', () => {
|
||||
it.each([
|
||||
['react', { forced: false, query: 'react' }],
|
||||
[' react ', { forced: false, query: 'react' }],
|
||||
[' ?react', { forced: false, query: '?react' }],
|
||||
['?react hooks', { forced: true, query: 'react hooks' }],
|
||||
['??foo', { forced: true, query: '?foo' }],
|
||||
['?https://x.dev', { forced: true, query: 'https://x.dev' }],
|
||||
['?', { forced: true, query: '' }],
|
||||
['? ', { forced: true, query: '' }]
|
||||
])('parses %j', (input, expected) => {
|
||||
expect(parseForcedSearchQuery(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export type ForcedSearchQuery = { forced: false; query: string } | { forced: true; query: string }
|
||||
|
||||
export function parseForcedSearchQuery(input: string): ForcedSearchQuery {
|
||||
if (!input.startsWith('?')) {
|
||||
return { forced: false, query: input.trim() }
|
||||
}
|
||||
return { forced: true, query: input.slice(1).trim() }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function focusTabEntryMenuItemAtEdge(
|
||||
fromElement: HTMLElement,
|
||||
edge: 'first' | 'last'
|
||||
): boolean {
|
||||
const menu = fromElement.closest('[role="menu"]')
|
||||
if (!menu) {
|
||||
return false
|
||||
}
|
||||
const items = menu.querySelectorAll<HTMLElement>(
|
||||
'[role="menuitem"]:not([data-disabled]):not([aria-disabled="true"])'
|
||||
)
|
||||
const target = edge === 'first' ? items[0] : items.item(items.length - 1)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
target.focus()
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { getActiveOptionId, type ActiveOption } from './tab-create-entry-active-option'
|
||||
import { isUnambiguousSearchQuery } from './tab-create-entry-file-matches'
|
||||
|
||||
type NetworkSelectionArgs = {
|
||||
activeOptions: ActiveOption[]
|
||||
fileIndexFailed: boolean
|
||||
fileIndexReady: boolean
|
||||
forcedSearch: boolean
|
||||
menuOpen: boolean
|
||||
pinnedOptionId: string | null
|
||||
query: string
|
||||
}
|
||||
|
||||
export function useNetworkSafeTabEntrySelection({
|
||||
activeOptions,
|
||||
fileIndexFailed,
|
||||
fileIndexReady,
|
||||
forcedSearch,
|
||||
menuOpen,
|
||||
pinnedOptionId,
|
||||
query
|
||||
}: NetworkSelectionArgs): {
|
||||
activeSelectedIndex: number | null
|
||||
selectedActiveOption: ActiveOption | undefined
|
||||
} {
|
||||
const pinnedOptionIndex = pinnedOptionId
|
||||
? activeOptions.findIndex((option) => getActiveOptionId(option) === pinnedOptionId)
|
||||
: -1
|
||||
const rankedOption = pinnedOptionIndex < 0 ? activeOptions[0] : undefined
|
||||
const rankedClassification =
|
||||
rankedOption?.kind === 'entry' ? rankedOption.option.classification : null
|
||||
const rankedNetworkAction =
|
||||
!forcedSearch &&
|
||||
(rankedClassification?.kind === 'search' || rankedClassification?.kind === 'host-url')
|
||||
// Why: a still-scanning index can promote a file match later, so hold Enter
|
||||
// unless the scan can no longer change the ranking — it failed outright, or
|
||||
// the text is a phrase that would never rank as a file.
|
||||
const networkRankingSettled =
|
||||
fileIndexReady ||
|
||||
fileIndexFailed ||
|
||||
(rankedClassification?.kind === 'search' &&
|
||||
isUnambiguousSearchQuery(rankedClassification.query))
|
||||
const rankingKey = `${menuOpen}:${query}`
|
||||
const hasRankedOption = rankedOption !== undefined
|
||||
// Why: once a query ranked a local action first, a later re-index that promotes
|
||||
// a network row must not arm Enter under the user's fingers — the text they
|
||||
// aimed at a file would silently leave for a search engine. Retyping clears it.
|
||||
const blockedNetworkRankingRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (fileIndexReady && hasRankedOption && !rankedNetworkAction) {
|
||||
blockedNetworkRankingRef.current = rankingKey
|
||||
}
|
||||
}, [fileIndexReady, hasRankedOption, rankedNetworkAction, rankingKey])
|
||||
const networkActionAllowed =
|
||||
networkRankingSettled && blockedNetworkRankingRef.current !== rankingKey
|
||||
const activeSelectedIndex =
|
||||
pinnedOptionIndex >= 0
|
||||
? pinnedOptionIndex
|
||||
: activeOptions.length === 0 || (rankedNetworkAction && !networkActionAllowed)
|
||||
? null
|
||||
: 0
|
||||
return {
|
||||
activeSelectedIndex,
|
||||
selectedActiveOption:
|
||||
activeSelectedIndex === null ? undefined : activeOptions[activeSelectedIndex]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { TabCreateEntryArgs } from './tab-create-entry-action'
|
||||
import type { TabAgentLaunchOption } from './tab-agent-launch-options'
|
||||
import type { TabCreateMenuOption } from './tab-create-menu-options'
|
||||
|
||||
export 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
|
||||
/** Runs after the menu closes, so the tab jumped to actually takes focus. */
|
||||
onQueueSwitchFocus?: (focus: () => void) => void
|
||||
onSelectMenuOption?: (option: TabCreateMenuOption) => void
|
||||
worktreeId: string
|
||||
}
|
||||
@@ -16,72 +16,138 @@ const HOST_FILE_EXTENSIONS = new Set([
|
||||
'yml'
|
||||
])
|
||||
|
||||
const LOCALHOST_WITH_PORT_PATTERN = /^localhost(?::\d{1,5})?$/i
|
||||
const IPV4_WITH_PORT_PATTERN =
|
||||
/^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?::\d{1,5})?$/
|
||||
const DOMAIN_WITH_PORT_PATTERN =
|
||||
/^(?=.{1,253}(?::\d{1,5})?$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d{1,5})?$/i
|
||||
const IPV4_PATTERN =
|
||||
/^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/
|
||||
const DOMAIN_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i
|
||||
const HTTP_SCHEME_PATTERN = /^https?:\/\//i
|
||||
const BRACKETED_IPV6_ATTEMPT_PATTERN = /^\[[0-9a-f:]+\](?::[^/?#]*)?(?:[/?#].*)?$/i
|
||||
const SCHEME_PREFIX_PATTERN = /^[a-z][a-z0-9+.-]*:/i
|
||||
const SCHEME_WITH_SLASHES_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i
|
||||
|
||||
export type ExplicitUrlClassification =
|
||||
| { kind: 'blocked'; message: string }
|
||||
| { kind: 'explicit-url'; url: string }
|
||||
|
||||
export type HostUrlClassification = { kind: 'host-url'; url: string }
|
||||
export type HostUrlClassification =
|
||||
| { kind: 'blocked'; message: string }
|
||||
| { kind: 'host-url'; url: string }
|
||||
|
||||
export function classifyExplicitUrl(query: string): ExplicitUrlClassification | null {
|
||||
// Local dev inputs are handled by host-url classification so users can
|
||||
// enter localhost/IP addresses without an explicit scheme.
|
||||
if (classifySchemeLessLocalDevAddress(query)) {
|
||||
return null
|
||||
function invalidUrl(): { kind: 'blocked'; message: string } {
|
||||
return {
|
||||
kind: 'blocked',
|
||||
message: translate(
|
||||
'auto.components.tab.bar.tab.create.entry.classifier.90eb94dc48',
|
||||
'Enter an http:// or https:// URL.'
|
||||
)
|
||||
}
|
||||
let url: URL
|
||||
}
|
||||
|
||||
function parseHttpUrl(query: string): ExplicitUrlClassification {
|
||||
try {
|
||||
url = new URL(query)
|
||||
const url = new URL(query)
|
||||
return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname
|
||||
? { kind: 'explicit-url', url: url.href }
|
||||
: invalidUrl()
|
||||
} catch {
|
||||
return null
|
||||
return invalidUrl()
|
||||
}
|
||||
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) {
|
||||
return {
|
||||
kind: 'blocked',
|
||||
message: translate(
|
||||
'auto.components.tab.bar.tab.create.entry.classifier.90eb94dc48',
|
||||
'Enter an http:// or https:// URL.'
|
||||
)
|
||||
}
|
||||
}
|
||||
return { kind: 'explicit-url', url: url.href }
|
||||
}
|
||||
|
||||
function classifyLocalDevUrl(query: string): HostUrlClassification | null {
|
||||
const url = classifySchemeLessLocalDevAddress(query)
|
||||
return url?.hostname ? { kind: 'host-url', url: url.href } : null
|
||||
}
|
||||
|
||||
function classifyHostLikeUrl(query: string): HostUrlClassification | null {
|
||||
if (/[\\/]/.test(query) || /\s/.test(query)) {
|
||||
function splitHostCandidate(query: string): { host: string; port: string | null } | null {
|
||||
if (/[\\/\s?#]/.test(query)) {
|
||||
return null
|
||||
}
|
||||
const extension = query.split(':')[0]?.split('.').pop()?.toLowerCase() ?? ''
|
||||
const colonIndex = query.indexOf(':')
|
||||
const host = colonIndex < 0 ? query : query.slice(0, colonIndex)
|
||||
const port = colonIndex < 0 ? null : query.slice(colonIndex + 1)
|
||||
const extension = host.split('.').pop()?.toLowerCase() ?? ''
|
||||
if (HOST_FILE_EXTENSIONS.has(extension)) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
!LOCALHOST_WITH_PORT_PATTERN.test(query) &&
|
||||
!IPV4_WITH_PORT_PATTERN.test(query) &&
|
||||
!DOMAIN_WITH_PORT_PATTERN.test(query)
|
||||
host.toLowerCase() !== 'localhost' &&
|
||||
!IPV4_PATTERN.test(host) &&
|
||||
!DOMAIN_PATTERN.test(host)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const url = new URL(`https://${query}`)
|
||||
return url.hostname ? { kind: 'host-url', url: url.href } : null
|
||||
} catch {
|
||||
return { host, port }
|
||||
}
|
||||
|
||||
// Why: a bare LAN/dev address almost never serves TLS, but a public IP still
|
||||
// deserves https — plaintext there would be a downgrade, and IPs get no HSTS.
|
||||
function isPrivateIpv4(host: string): boolean {
|
||||
if (!IPV4_PATTERN.test(host)) {
|
||||
return false
|
||||
}
|
||||
const [first = 0, second = 0] = host.split('.').map(Number)
|
||||
return (
|
||||
first === 0 ||
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
(first === 100 && second >= 64 && second <= 127) || // CGNAT, incl. Tailscale
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 192 && second === 168)
|
||||
)
|
||||
}
|
||||
|
||||
function hasSourceExtensionBeforeColon(query: string): boolean {
|
||||
const colonIndex = query.indexOf(':')
|
||||
if (colonIndex < 0) {
|
||||
return false
|
||||
}
|
||||
const extension = query.slice(0, colonIndex).split('.').pop()?.toLowerCase() ?? ''
|
||||
return HOST_FILE_EXTENSIONS.has(extension)
|
||||
}
|
||||
|
||||
export function classifyExplicitUrl(query: string): ExplicitUrlClassification | null {
|
||||
if (HTTP_SCHEME_PATTERN.test(query)) {
|
||||
return parseHttpUrl(query)
|
||||
}
|
||||
if (SCHEME_WITH_SLASHES_PATTERN.test(query)) {
|
||||
return invalidUrl()
|
||||
}
|
||||
if (classifySchemeLessLocalDevAddress(query)) {
|
||||
return null
|
||||
}
|
||||
if (splitHostCandidate(query)) {
|
||||
return null
|
||||
}
|
||||
if (hasSourceExtensionBeforeColon(query)) {
|
||||
return null
|
||||
}
|
||||
if (!SCHEME_PREFIX_PATTERN.test(query)) {
|
||||
return null
|
||||
}
|
||||
if (/\s/.test(query)) {
|
||||
return null
|
||||
}
|
||||
return invalidUrl()
|
||||
}
|
||||
|
||||
export function classifyHostUrl(query: string): HostUrlClassification | null {
|
||||
// Try exact local-dev forms first so localhost keeps http:// parity with
|
||||
// the previous inline classifier before falling back to public hostnames.
|
||||
return classifyLocalDevUrl(query) ?? classifyHostLikeUrl(query)
|
||||
const candidate = splitHostCandidate(query)
|
||||
if (!candidate) {
|
||||
const localDevUrl = classifySchemeLessLocalDevAddress(query)
|
||||
if (localDevUrl?.hostname) {
|
||||
return { kind: 'host-url', url: localDevUrl.href }
|
||||
}
|
||||
return BRACKETED_IPV6_ATTEMPT_PATTERN.test(query) ? invalidUrl() : null
|
||||
}
|
||||
if (candidate.port !== null && !/^\d+$/.test(candidate.port)) {
|
||||
// Why: "docker.io:latest" reads as a tag, not a URL attempt — fall through so
|
||||
// file matches and search still get offered instead of blocking every row.
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const scheme =
|
||||
candidate.host.toLowerCase() === 'localhost' || isPrivateIpv4(candidate.host)
|
||||
? 'http'
|
||||
: 'https'
|
||||
const url = new URL(`${scheme}://${query}`)
|
||||
return url.hostname ? { kind: 'host-url', url: url.href } : invalidUrl()
|
||||
} catch {
|
||||
return invalidUrl()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,6 +644,14 @@
|
||||
"64555d0014": "No base branch found"
|
||||
}
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"tab": {
|
||||
"open": {
|
||||
"urlFailed": "Unable to open URL.",
|
||||
"searchFailed": "Unable to search with {{value0}}."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
@@ -3122,7 +3130,9 @@
|
||||
"2c38630a01": "Workspace no longer exists",
|
||||
"4f0d9a71c2": "Tab no longer exists",
|
||||
"d7d496a451": "Browser page no longer exists",
|
||||
"7726ce9970": "Mobile emulator tab no longer exists"
|
||||
"7726ce9970": "Mobile emulator tab no longer exists",
|
||||
"chooseAction": "Choose an action.",
|
||||
"searchProvider": "Search {{value0}}"
|
||||
},
|
||||
"TabBarQuickCommandsButton": {
|
||||
"a2c7a33831": "Command",
|
||||
|
||||
@@ -563,6 +563,14 @@
|
||||
"64555d0014": "No se encontró ninguna rama base"
|
||||
}
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"tab": {
|
||||
"open": {
|
||||
"urlFailed": "No se pudo abrir la URL.",
|
||||
"searchFailed": "No se pudo buscar con {{value0}}."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
@@ -2991,7 +2999,9 @@
|
||||
"2c38630a01": "El espacio de trabajo ya no existe",
|
||||
"4f0d9a71c2": "La pestaña ya no existe",
|
||||
"d7d496a451": "La página del navegador ya no existe",
|
||||
"7726ce9970": "La pestaña del emulador móvil ya no existe"
|
||||
"7726ce9970": "La pestaña del emulador móvil ya no existe",
|
||||
"chooseAction": "Elige una acción.",
|
||||
"searchProvider": "Buscar con {{value0}}"
|
||||
},
|
||||
"TabBarQuickCommandsButton": {
|
||||
"a2c7a33831": "Comando",
|
||||
|
||||
@@ -563,6 +563,14 @@
|
||||
"64555d0014": "ベースブランチが見つかりません"
|
||||
}
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"tab": {
|
||||
"open": {
|
||||
"urlFailed": "URLを開けませんでした。",
|
||||
"searchFailed": "{{value0}} で検索できませんでした。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
@@ -2991,7 +2999,9 @@
|
||||
"2c38630a01": "ワークスペースは存在しません",
|
||||
"4f0d9a71c2": "タブは存在しません",
|
||||
"d7d496a451": "ブラウザページは存在しません",
|
||||
"7726ce9970": "モバイルエミュレータのタブは存在しません"
|
||||
"7726ce9970": "モバイルエミュレータのタブは存在しません",
|
||||
"chooseAction": "アクションを選択してください。",
|
||||
"searchProvider": "{{value0}} で検索"
|
||||
},
|
||||
"TabBarQuickCommandsButton": {
|
||||
"a2c7a33831": "コマンド",
|
||||
|
||||
@@ -563,6 +563,14 @@
|
||||
"64555d0014": "기본 브랜치를 찾을 수 없습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"tab": {
|
||||
"open": {
|
||||
"urlFailed": "URL을 열 수 없습니다.",
|
||||
"searchFailed": "{{value0}}에서 검색할 수 없습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
@@ -2991,7 +2999,9 @@
|
||||
"2c38630a01": "워크스페이스가 더 이상 존재하지 않습니다",
|
||||
"4f0d9a71c2": "탭이 더 이상 존재하지 않습니다",
|
||||
"d7d496a451": "브라우저 페이지가 더 이상 존재하지 않습니다",
|
||||
"7726ce9970": "모바일 에뮬레이터 탭이 더 이상 존재하지 않습니다"
|
||||
"7726ce9970": "모바일 에뮬레이터 탭이 더 이상 존재하지 않습니다",
|
||||
"chooseAction": "작업을 선택하세요.",
|
||||
"searchProvider": "{{value0}}에서 검색"
|
||||
},
|
||||
"TabBarQuickCommandsButton": {
|
||||
"a2c7a33831": "명령",
|
||||
|
||||
@@ -563,6 +563,14 @@
|
||||
"64555d0014": "未找到基础分支"
|
||||
}
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"tab": {
|
||||
"open": {
|
||||
"urlFailed": "无法打开网址。",
|
||||
"searchFailed": "无法使用 {{value0}} 搜索。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
@@ -3003,7 +3011,9 @@
|
||||
"2c38630a01": "工作区已不存在",
|
||||
"4f0d9a71c2": "标签页已不存在",
|
||||
"d7d496a451": "浏览器页面已不存在",
|
||||
"7726ce9970": "移动模拟器标签页已不存在"
|
||||
"7726ce9970": "移动模拟器标签页已不存在",
|
||||
"chooseAction": "请选择一项操作。",
|
||||
"searchProvider": "使用 {{value0}} 搜索"
|
||||
},
|
||||
"TabBarQuickCommandsButton": {
|
||||
"a2c7a33831": "命令",
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toRuntimeExecutionHostId,
|
||||
toSshExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import { openWorkspaceBrowserTab } from './workspace-browser-tab-open'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createRemote: vi.fn(),
|
||||
getState: vi.fn(),
|
||||
state: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => mocks.getState() }
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/web-runtime-session', () => ({
|
||||
createWebRuntimeSessionBrowserTab: (...args: unknown[]) => mocks.createRemote(...args)
|
||||
}))
|
||||
|
||||
const WORKSPACE_ID = 'repo-1::/repo/worktree'
|
||||
|
||||
function ownerState(hostId?: string, runtimeOwnerEnvironmentId?: string): Record<string, unknown> {
|
||||
return {
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: WORKSPACE_ID,
|
||||
repoId: 'repo-1',
|
||||
...(hostId ? { hostId } : {}),
|
||||
...(runtimeOwnerEnvironmentId ? { runtimeOwnerEnvironmentId } : {})
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.createRemote.mockReset().mockResolvedValue(true)
|
||||
mocks.getState.mockReset().mockImplementation(() => mocks.state)
|
||||
mocks.state = {}
|
||||
})
|
||||
|
||||
describe('openWorkspaceBrowserTab', () => {
|
||||
it('opens client-owned searches with a safe title and host-specific profile', async () => {
|
||||
const createBrowserTab = vi.fn()
|
||||
const sshHost = toSshExecutionHostId('ssh-target')
|
||||
mocks.state = {
|
||||
...ownerState(sshHost),
|
||||
createBrowserTab,
|
||||
defaultBrowserSessionProfileId: 'focused-profile',
|
||||
defaultBrowserSessionProfileIdByHostId: { [sshHost]: 'ssh-profile' }
|
||||
}
|
||||
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
targetGroupId: 'group-1',
|
||||
url: 'https://www.google.com/search?q=private%20query',
|
||||
intent: { kind: 'search', engine: 'google' }
|
||||
})
|
||||
|
||||
expect(createBrowserTab).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
'https://www.google.com/search?q=private%20query',
|
||||
{
|
||||
activate: true,
|
||||
browserRuntimeEnvironmentId: null,
|
||||
focusAddressBar: false,
|
||||
sessionProfileId: 'ssh-profile',
|
||||
targetGroupId: 'group-1',
|
||||
title: 'Search Google'
|
||||
}
|
||||
)
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces the opening workspace and titles runtime-owned URL tabs by target', async () => {
|
||||
const createBrowserTab = vi.fn()
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-a')),
|
||||
createBrowserTab,
|
||||
defaultBrowserSessionProfileId: 'client-profile',
|
||||
defaultBrowserSessionProfileIdByHostId: {}
|
||||
}
|
||||
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://example.com/docs?token=secret',
|
||||
intent: { kind: 'url' }
|
||||
})
|
||||
|
||||
expect(mocks.createRemote).toHaveBeenCalledWith({
|
||||
worktreeId: WORKSPACE_ID,
|
||||
environmentId: 'hub-a',
|
||||
url: 'https://example.com/docs?token=secret',
|
||||
targetGroupId: undefined,
|
||||
selectWorktree: true,
|
||||
stagedTitle: 'example.com/docs',
|
||||
stagedFocusAddressBar: false,
|
||||
failureLogMode: 'operation-only'
|
||||
})
|
||||
expect(createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Two dev servers on one host must not share a tab title.
|
||||
it('keeps the port in local-dev tab titles', async () => {
|
||||
const createBrowserTab = vi.fn()
|
||||
mocks.state = {
|
||||
...ownerState(LOCAL_EXECUTION_HOST_ID),
|
||||
createBrowserTab,
|
||||
defaultBrowserSessionProfileId: 'focused-profile',
|
||||
defaultBrowserSessionProfileIdByHostId: {}
|
||||
}
|
||||
|
||||
for (const url of ['http://localhost:3000/', 'http://localhost:5173/app']) {
|
||||
await openWorkspaceBrowserTab({ workspaceId: WORKSPACE_ID, url, intent: { kind: 'url' } })
|
||||
}
|
||||
|
||||
expect(createBrowserTab.mock.calls.map((call) => call[2].title)).toEqual([
|
||||
'localhost:3000',
|
||||
'localhost:5173/app'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the worktree session profile when a runtime open soft-fails', async () => {
|
||||
const createBrowserTab = vi.fn()
|
||||
const sshHost = toSshExecutionHostId('ssh-target')
|
||||
mocks.state = {
|
||||
...ownerState(sshHost, 'hub-a'),
|
||||
createBrowserTab,
|
||||
defaultBrowserSessionProfileId: 'focused-profile',
|
||||
defaultBrowserSessionProfileIdByHostId: { local: 'local-profile', [sshHost]: 'ssh-profile' }
|
||||
}
|
||||
mocks.createRemote.mockResolvedValue(false)
|
||||
|
||||
await openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'https://www.google.com/search?q=hooks',
|
||||
intent: { kind: 'search', engine: 'google' }
|
||||
})
|
||||
|
||||
expect(mocks.createRemote).toHaveBeenCalledOnce()
|
||||
expect(createBrowserTab).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
'https://www.google.com/search?q=hooks',
|
||||
expect.objectContaining({ sessionProfileId: 'ssh-profile', title: 'Search Google' })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed for invalid targets and unresolved owners, then falls back locally', async () => {
|
||||
const secretUrl = 'https://example.com/?q=secret-value'
|
||||
const request = {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: secretUrl,
|
||||
intent: { kind: 'search' as const, engine: 'kagi' as const }
|
||||
}
|
||||
mocks.state = {}
|
||||
await expect(
|
||||
openWorkspaceBrowserTab({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
url: 'file:///secret',
|
||||
intent: { kind: 'url' }
|
||||
})
|
||||
).rejects.toThrow('Unable to open URL.')
|
||||
expect(mocks.getState).not.toHaveBeenCalled()
|
||||
|
||||
// The friendly copy stays query-free; the diagnosable reason rides on cause.
|
||||
await expect(openWorkspaceBrowserTab(request)).rejects.toMatchObject({
|
||||
message: 'Unable to search with Kagi.',
|
||||
cause: expect.objectContaining({ message: 'no active worktree route' })
|
||||
})
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
|
||||
for (const state of [
|
||||
ownerState('not-a-host', 'hub-a'),
|
||||
ownerState(toRuntimeExecutionHostId('hub-b'), 'hub-a')
|
||||
]) {
|
||||
mocks.state = state
|
||||
await expect(openWorkspaceBrowserTab(request)).rejects.toThrow('Unable to search with Kagi.')
|
||||
}
|
||||
expect(mocks.createRemote).not.toHaveBeenCalled()
|
||||
|
||||
mocks.state = {
|
||||
...ownerState(toRuntimeExecutionHostId('hub-a')),
|
||||
createBrowserTab: vi.fn(),
|
||||
defaultBrowserSessionProfileId: 'focused-profile',
|
||||
defaultBrowserSessionProfileIdByHostId: { local: 'local-profile' }
|
||||
}
|
||||
mocks.createRemote.mockResolvedValue(false)
|
||||
await openWorkspaceBrowserTab(request)
|
||||
expect(mocks.state.createBrowserTab).toHaveBeenCalledWith(WORKSPACE_ID, secretUrl, {
|
||||
activate: true,
|
||||
browserRuntimeEnvironmentId: null,
|
||||
focusAddressBar: false,
|
||||
sessionProfileId: 'local-profile',
|
||||
targetGroupId: undefined,
|
||||
title: 'Search Kagi'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { createWebRuntimeSessionBrowserTab } from '@/runtime/web-runtime-session'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import { SEARCH_ENGINE_LABELS, type SearchEngine } from '../../../shared/browser-url'
|
||||
import { resolveWorktreeOperationRoute } from './worktree-operation-route'
|
||||
|
||||
export type WorkspaceBrowserTabIntent = { kind: 'url' } | { kind: 'search'; engine: SearchEngine }
|
||||
|
||||
export type OpenWorkspaceBrowserTabRequest = {
|
||||
workspaceId: string
|
||||
targetGroupId?: string
|
||||
url: string
|
||||
intent: WorkspaceBrowserTabIntent
|
||||
}
|
||||
|
||||
// Why: concurrent URL tabs are indistinguishable under a shared "Open URL"
|
||||
// label until the page title loads; the query string stays out so a typed URL
|
||||
// does not park credentials or tokens in persisted tab state.
|
||||
function urlTabTitle(url: string): string | null {
|
||||
try {
|
||||
// host, not hostname: localhost:3000 and localhost:5173 are different tabs.
|
||||
const parsed = new URL(url)
|
||||
return `${parsed.host}${parsed.pathname === '/' ? '' : parsed.pathname}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function intentPresentation(
|
||||
intent: WorkspaceBrowserTabIntent,
|
||||
url: string
|
||||
): {
|
||||
error: string
|
||||
title: string
|
||||
} {
|
||||
if (intent.kind === 'url') {
|
||||
return {
|
||||
error: translate('auto.lib.workspace.browser.tab.open.urlFailed', 'Unable to open URL.'),
|
||||
title:
|
||||
urlTabTitle(url) ??
|
||||
translate('auto.components.tab.bar.TabBarCreateEntry.7cdf8ee0c8', 'Open URL')
|
||||
}
|
||||
}
|
||||
const engine = SEARCH_ENGINE_LABELS[intent.engine]
|
||||
return {
|
||||
error: translate(
|
||||
'auto.lib.workspace.browser.tab.open.searchFailed',
|
||||
'Unable to search with {{value0}}.',
|
||||
{ value0: engine }
|
||||
),
|
||||
title: translate(
|
||||
'auto.components.tab.bar.TabBarCreateEntry.searchProvider',
|
||||
'Search {{value0}}',
|
||||
{ value0: engine }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the UI string stays friendly and query-free, but the diagnosable reason
|
||||
// rides along as `cause` so a failed open is not indistinguishable in logs.
|
||||
function openFailure(message: string, reason: string, cause?: unknown): Error {
|
||||
// Why: callers only surface `message`, so log the reason here or a failed open
|
||||
// leaves no trace at all. The reason alone — never `cause` — keeps the typed
|
||||
// URL and search query out of the console.
|
||||
console.warn(`[workspace-browser-tab-open] ${reason}`)
|
||||
return new Error(message, {
|
||||
cause: new Error(reason, cause === undefined ? undefined : { cause })
|
||||
})
|
||||
}
|
||||
|
||||
function validateTarget(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && !!parsed.hostname
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function createClientBrowserTab(
|
||||
state: AppState,
|
||||
request: OpenWorkspaceBrowserTabRequest,
|
||||
hostId: ExecutionHostId,
|
||||
presentation: { error: string; title: string }
|
||||
): void {
|
||||
try {
|
||||
state.createBrowserTab(request.workspaceId, request.url, {
|
||||
activate: true,
|
||||
browserRuntimeEnvironmentId: null,
|
||||
focusAddressBar: false,
|
||||
sessionProfileId:
|
||||
state.defaultBrowserSessionProfileIdByHostId[hostId] ??
|
||||
state.defaultBrowserSessionProfileId,
|
||||
targetGroupId: request.targetGroupId,
|
||||
title: presentation.title
|
||||
})
|
||||
} catch (error) {
|
||||
throw openFailure(presentation.error, 'client tab creation rejected', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function openWorkspaceBrowserTab(
|
||||
request: OpenWorkspaceBrowserTabRequest
|
||||
): Promise<void> {
|
||||
const presentation = intentPresentation(request.intent, request.url)
|
||||
if (!validateTarget(request.url)) {
|
||||
throw openFailure(presentation.error, 'target is not an http(s) URL')
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const route = resolveWorktreeOperationRoute(state, request.workspaceId)
|
||||
if (!route) {
|
||||
throw openFailure(presentation.error, 'no active worktree route')
|
||||
}
|
||||
const environmentId = route.runtimeEnvironmentId?.trim() || null
|
||||
const host = parseExecutionHostId(route.executionHostId)
|
||||
if (!environmentId) {
|
||||
if (!host || host.kind === 'runtime') {
|
||||
throw openFailure(presentation.error, `unresolved client host: ${route.executionHostId}`)
|
||||
}
|
||||
createClientBrowserTab(state, request, host.id, presentation)
|
||||
return
|
||||
}
|
||||
if (
|
||||
route.executionHostId &&
|
||||
(!host || (host.kind === 'runtime' && host.environmentId !== environmentId))
|
||||
) {
|
||||
throw openFailure(
|
||||
presentation.error,
|
||||
`host ${route.executionHostId} does not own runtime ${environmentId}`
|
||||
)
|
||||
}
|
||||
let created = false
|
||||
try {
|
||||
created = await createWebRuntimeSessionBrowserTab({
|
||||
worktreeId: request.workspaceId,
|
||||
environmentId,
|
||||
url: request.url,
|
||||
targetGroupId: request.targetGroupId,
|
||||
// Why: the tab is opened from this workspace's tab bar, so surface that
|
||||
// workspace — otherwise a background worktree looks like nothing happened.
|
||||
selectWorktree: true,
|
||||
stagedTitle: presentation.title,
|
||||
stagedFocusAddressBar: false,
|
||||
failureLogMode: 'operation-only'
|
||||
})
|
||||
} catch (error) {
|
||||
throw openFailure(presentation.error, 'runtime browser tab creation failed', error)
|
||||
}
|
||||
if (!created) {
|
||||
// Why: a soft runtime failure still opens client-side, so keep the
|
||||
// workspace's own session profile rather than collapsing every remote
|
||||
// host onto the local cookie jar.
|
||||
const fallbackHostId = host && host.kind !== 'runtime' ? host.id : LOCAL_EXECUTION_HOST_ID
|
||||
createClientBrowserTab(state, request, fallbackHostId, presentation)
|
||||
}
|
||||
}
|
||||
@@ -530,6 +530,63 @@ describe('createWebRuntimeSessionBrowserTab', () => {
|
||||
await vi.waitFor(() => expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.setRemoteBrowserPageHandle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses renderer-local staged presentation without changing the RPC shape', async () => {
|
||||
const runtimeCall = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'create',
|
||||
ok: true,
|
||||
result: { browserPageId: 'remote-browser-page-1' }
|
||||
})
|
||||
.mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() })
|
||||
vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } })
|
||||
|
||||
await expect(
|
||||
createWebRuntimeSessionBrowserTab({
|
||||
worktreeId: WORKTREE_ID,
|
||||
environmentId: ENVIRONMENT_ID,
|
||||
url: 'https://example.com/?q=private',
|
||||
selectWorktree: false,
|
||||
stagedTitle: 'Search Google',
|
||||
stagedFocusAddressBar: false
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(mocks.createBrowserTab).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
'https://example.com/?q=private',
|
||||
{
|
||||
title: 'Search Google',
|
||||
focusAddressBar: false,
|
||||
browserRuntimeEnvironmentId: ENVIRONMENT_ID
|
||||
}
|
||||
)
|
||||
expect(runtimeCall.mock.calls[0][0].params).not.toHaveProperty('stagedTitle')
|
||||
})
|
||||
|
||||
it('can log remote browser failure without retaining downstream details', async () => {
|
||||
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
call: vi.fn().mockRejectedValue(new Error('failed https://example.com/?q=private'))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
createWebRuntimeSessionBrowserTab({
|
||||
worktreeId: WORKTREE_ID,
|
||||
environmentId: ENVIRONMENT_ID,
|
||||
url: 'https://example.com/?q=private',
|
||||
failureLogMode: 'operation-only'
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalledWith('[web-runtime-session] failed to create browser tab')
|
||||
expect(JSON.stringify(consoleWarn.mock.calls)).not.toContain('private')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWebRuntimeSessionTerminal', () => {
|
||||
|
||||
@@ -424,6 +424,9 @@ export async function createWebRuntimeSessionBrowserTab(args: {
|
||||
profileId?: string | null
|
||||
targetGroupId?: string
|
||||
selectWorktree?: boolean
|
||||
stagedTitle?: string
|
||||
stagedFocusAddressBar?: boolean
|
||||
failureLogMode?: 'details' | 'operation-only'
|
||||
}): Promise<boolean> {
|
||||
const environmentId =
|
||||
args.environmentId?.trim() ??
|
||||
@@ -467,6 +470,8 @@ export async function createWebRuntimeSessionBrowserTab(args: {
|
||||
remotePageId: created.browserPageId,
|
||||
url: args.url,
|
||||
targetGroupId: args.targetGroupId,
|
||||
title: args.stagedTitle,
|
||||
focusAddressBar: args.stagedFocusAddressBar,
|
||||
restoreFocus:
|
||||
shouldSelectWorktree &&
|
||||
(stagedFromWorktreeId === args.worktreeId ||
|
||||
@@ -477,10 +482,14 @@ export async function createWebRuntimeSessionBrowserTab(args: {
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[web-runtime-session] failed to create browser tab:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
if (args.failureLogMode === 'operation-only') {
|
||||
console.warn('[web-runtime-session] failed to create browser tab')
|
||||
} else {
|
||||
console.warn(
|
||||
'[web-runtime-session] failed to create browser tab:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -491,6 +500,8 @@ function stageWebRuntimeBrowserTab(args: {
|
||||
remotePageId: string
|
||||
url?: string
|
||||
targetGroupId?: string
|
||||
title?: string
|
||||
focusAddressBar?: boolean
|
||||
restoreFocus?: boolean
|
||||
}): void {
|
||||
const remotePageId = args.remotePageId.trim()
|
||||
@@ -519,8 +530,8 @@ function stageWebRuntimeBrowserTab(args: {
|
||||
const url = args.url?.trim() || 'about:blank'
|
||||
// Why: the snapshot can arrive after React renders a fallback; stage the handle now so the worktree stays selected.
|
||||
const browserTab = useAppStore.getState().createBrowserTab(args.worktreeId, url, {
|
||||
title: url === 'about:blank' ? 'New Browser Tab' : url,
|
||||
focusAddressBar: true,
|
||||
title: args.title ?? (url === 'about:blank' ? 'New Browser Tab' : url),
|
||||
focusAddressBar: args.focusAddressBar ?? true,
|
||||
browserRuntimeEnvironmentId: args.environmentId,
|
||||
targetGroupId: args.targetGroupId
|
||||
})
|
||||
|
||||
@@ -222,6 +222,21 @@ describe('browser-url helpers', () => {
|
||||
expect(buildSearchUrl('hello world', 'kagi')).toBe('https://kagi.com/search?q=hello%20world')
|
||||
})
|
||||
|
||||
it('encodes Unicode, reserved characters, and emoji for every search engine', () => {
|
||||
const query = 'snow 雪 &?# 😀'
|
||||
const encoded = 'snow%20%E9%9B%AA%20%26%3F%23%20%F0%9F%98%80'
|
||||
for (const engine of ['google', 'duckduckgo', 'bing', 'kagi'] as const) {
|
||||
expect(buildSearchUrl(query, engine)).toContain(encoded)
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces unmatched surrogates without changing valid pairs', () => {
|
||||
expect(buildSearchUrl('\ud800a\udc00😀', 'google')).toBe(
|
||||
'https://www.google.com/search?q=%EF%BF%BDa%EF%BF%BD%F0%9F%98%80'
|
||||
)
|
||||
expect(buildSearchUrl('\ud800', 'google')).toBe('https://www.google.com/search?q=%EF%BF%BD')
|
||||
})
|
||||
|
||||
it('uses a Kagi private session link when configured', () => {
|
||||
const sessionLink = 'https://kagi.com/search?token=secret&q=%s#ignored'
|
||||
expect(normalizeKagiSessionLink(sessionLink)).toBe('https://kagi.com/search?token=secret')
|
||||
|
||||
@@ -223,7 +223,7 @@ export function buildSearchUrl(
|
||||
return sessionSearchUrl
|
||||
}
|
||||
}
|
||||
return `${SEARCH_ENGINE_URLS[engine]}${encodeURIComponent(query)}`
|
||||
return `${SEARCH_ENGINE_URLS[engine]}${encodeURIComponent(query.toWellFormed())}`
|
||||
}
|
||||
|
||||
export function looksLikeSearchQuery(input: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user