Add direct URL and file entry to tab menu (#3026)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-29 16:14:57 -04:00 committed by GitHub
parent 1cf754580d
commit 606eef5839
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1728 additions and 125 deletions

View File

@ -0,0 +1,151 @@
# Direct URL Or File Entry
## Problem
The tab bar `+` menu only offers fixed actions: terminal, browser, new markdown, and open markdown in `src/renderer/src/components/tab-bar/TabBar.tsx`. It has no text entry point for a user who already knows the URL or file path they want.
Quick Open already loads files for the active worktree through `listRuntimeFiles`, watches the active SSH target status, excludes nested linked worktrees, ranks via `prepareQuickOpenFiles`/`rankQuickOpenFiles`, and opens a selected match with `openFile`. That flow is modal and file-only. It does not live in the `+` menu, does not accept URLs, and cannot create a named new file from the typed query.
The runtime file client has the required local/SSH/runtime primitives, with caveats:
- `listRuntimeFiles(context, { rootPath, excludePaths })` returns relative paths only and can fail for auth, missing provider, ripgrep/size, or stale worktree reasons.
- `statRuntimePath(context, absolutePath)` is a one-path existence/type check.
- `createRuntimePath(context, absolutePath, 'file')` creates a single empty file and creates parent directories on the current local, SSH, and runtime-backed paths. Directory creation has separate no-clobber/recursive semantics elsewhere in the file stack, so v1 should create files directly and not expose directory creation as a separate action.
## Goal
Add an entry field at the top of the tab bar `+` menu so users can type:
1. A URL to open an Orca browser tab.
2. An existing file name/path to open an editor tab.
3. A new relative file path to create in the active worktree and open.
The behavior must work from both the titlebar tab strip and split-group tab strips. File operations must route through `RuntimeFileOperationArgs`; browser/editor creation must preserve the target group.
## Non-goals
- Do not replace global Quick Open.
- Do not add persisted launcher state.
- Do not support standalone directory creation.
- Do not open external URLs outside Orca.
- Do not make URL/file detection configurable.
## Design
1. Add a `TabBarCreateEntry` surface inside `TabBar`'s dropdown content above the fixed rows. Use the existing `DropdownMenu` shell, but render the input in a plain form/container, not as a `DropdownMenuItem`; Radix menu item typeahead/selection should not own text input keystrokes. Stop propagation only where required for input typing, and close the dropdown only after a successful submit.
2. Extend `TabBarProps` with a presentation callback that resolves success by returning and failure by throwing:
```ts
onOpenEntry?: (args: { query: string; worktreeId: string; groupId: string }) => Promise<void>
```
`TabBar` owns input text, pending state, focus, and dropdown close behavior. It does not create browser tabs or files. `groupId` should be `groupId ?? worktreeId`, matching the existing `resolvedGroupId` fallback.
3. Add a small hook/helper pair instead of duplicating Quick Open logic:
- `useTabEntryFileList` loads the file list when the menu opens, using the same inputs as `QuickOpen`: active worktree path, `getConnectionId(worktreeId)`, nested worktree exclusions, and active SSH target status. It cancels stale requests on close or key changes.
- A pure classifier/opening helper accepts the query, file-list snapshot, load/error state, worktree metadata, runtime context, and target group.
4. Wire the callback in both owners:
- `Terminal.tsx` titlebar fallback resolves the current active worktree and target group the same way `handleNewTab` / `handleNewBrowserTab` do.
- `useTabGroupWorkspaceModel` passes its explicit `worktreeId` and `groupId`. Do not rely on ambient `activeGroupIdByWorktree`; split-group `+` can be invoked from an unfocused group.
5. Classify submissions in this order:
- Empty after trim: reject inline.
- Explicit URL: accept only `http://` and `https://` URLs with a parseable host.
- Existing file: once the file-list snapshot is ready, normalize query separators for matching, prefer exact relative-path match, then exact basename match, then `rankQuickOpenFiles`. Before opening, `statRuntimePath` the matched absolute path and reject directories/stale missing matches instead of blindly opening stale list entries.
- Host-like URL: only after there is no existing file match, accept strict bare hosts such as `example.com`, `localhost:3000`, or `127.0.0.1:3000`, normalized to `https://...` when no scheme is present. Do not run host-like URL parsing for bare input containing `/` or `\`; `new URL('https://docs/readme.md')` parses, so parsing alone is not a path/file guard. Also reject common source/document filename extensions such as `md`, `ts`, `tsx`, `js`, `jsx`, `json`, `yml`, `yaml`, `toml`, `css`, `html`, and `py` so `README.md` and `src/foo.test.ts` stay file/create candidates.
- New file: only after file listing has completed successfully with no existing-file match and no host-like URL match. Treat the query as a relative worktree path. If listing fails, allow explicit `http://` / `https://` URLs only; keep bare host-like inputs blocked because they cannot be disambiguated from files.
6. Validate new file paths before joining:
- Reject POSIX absolute paths, Windows drive paths, UNC paths, `~`, empty path, trailing slash, control characters, `.` / `..` segments, and empty raw segments such as `a//b`.
- Normalize `\` to `/` only after absolute-path checks, then run segment validation on the normalized path too so traversal like `a\..\b` is still rejected.
- Build the absolute path with `joinPath(worktreePath, relativePath)`, then create with `createRuntimePath(context, absolutePath, 'file')`.
- On `EEXIST` / "exists", immediately `statRuntimePath`; if it is now a file, open it. If it is a directory, show an error. This handles another window/process winning the create race.
7. Open actions:
- URL: in paired web clients, call `createWebRuntimeSessionBrowserTab({ worktreeId, url, targetGroupId: groupId })`; otherwise call `createBrowserTab(worktreeId, url, { activate: true, targetGroupId: groupId, title: url })`. Do not use `openNewBrowserTabInActiveWorkspace`; it only opens the default URL.
- Existing/new file: call `openFile(fileInfo, { preview: false, targetGroupId: groupId })` with `language: detectLanguage(relativePath)`. Include the active runtime environment owner as `runtimeEnvironmentId` through the normal `openFile` fallback; do not suppress runtime ownership.
8. Preserve current fixed actions. Existing `New Terminal`, `New Browser Tab`, `New Markdown`, `Open Markdown...`, and quick-launch rows remain below the entry and keep their current shortcuts/icons.
9. Surface errors with existing toast or compact inline text. Keep the dropdown open and preserve the query on validation/runtime errors. For Quick Open's special ripgrep guidance, either extract its parser/UI deliberately or show the cleaned error string; do not duplicate a private parser inline.
## Data Flow
- User opens the `+` menu.
- `TabBarCreateEntry` focuses the input and `useTabEntryFileList` starts/reuses the menu-local file-list request.
- User types; the menu may show the best existing-file match or "create file" affordance based on the current snapshot.
- Enter calls `onOpenEntry({ query, worktreeId, groupId })`.
- Helper classifies and dispatches:
- URL -> browser tab creation with target group.
- Existing file -> stat matched path -> `openFile(..., { targetGroupId })`.
- New file -> validate -> `createRuntimePath(..., 'file')` -> `openFile(..., { targetGroupId })`.
- Success closes the dropdown. Failure keeps it open.
## Edge Cases
- No active worktree: disable the entry, including URL entry, because Orca browser tabs are worktree-scoped.
- SSH/runtime connection not ready: mirror Quick Open by keying the list request on active target status. Non-URL submissions are disabled while loading/connecting to avoid creating a duplicate before the real list arrives.
- File listing failure: explicit `http://` / `https://` URL submissions still work; file and bare host-like submissions are blocked with the cleaned list error.
- Ambiguous host-like/file names: an existing listed file wins over bare host-like URL normalization. Explicit `http://` or `https://` input is the escape hatch when the user wants a browser tab despite a file-name collision.
- File list stale because another window/process added or removed a file: stat before opening matched files; handle create `EEXIST` by stat-and-open.
- Existing directory match: show an error; do not open as an editor tab.
- Internal spaces in file paths are allowed. Leading/trailing whitespace is trimmed. Control characters are rejected.
- Windows-style separators match existing relative paths after normalization, but Windows absolute and UNC paths are rejected for creation.
- Duplicate Enter while pending is disabled.
- Browser creation in paired web/mobile clients must use `createWebRuntimeSessionBrowserTab`; local desktop uses `createBrowserTab`.
- External file-watch invalidation is not required for v1. The menu-local list reloads on each open and on worktree/connection/status changes; successful create can close the menu without mutating the list.
## Test Plan
- Unit test URL classification: schemes, host-like domains, localhost/IP ports, listed file named `example.com` winning over host-like normalization, `README.md`/`readme.md`, `src/foo.test.ts`, `docs/readme.md`, whitespace, and invalid schemes.
- Unit test path validation: Windows/POSIX absolute paths, UNC, `~`, traversal, empty segments, trailing slash, control characters, spaces, and separator normalization.
- Unit test existing-file selection with `prepareQuickOpenFiles`/`rankQuickOpenFiles`: exact path beats basename, basename beats fuzzy, stale stat failure blocks open, directory stat blocks open.
- Unit/helper test new-file creation with `RuntimeFileOperationArgs`, `createRuntimePath`, `statRuntimePath`, EEXIST stat-and-open, and SSH/runtime connection context.
- Component test `TabBar`: input renders above fixed rows, focuses on open, typing does not close the menu, Enter awaits the callback, success closes, failure preserves text, fixed actions still fire.
- Store/model tests: titlebar and split-group callbacks pass `targetGroupId`; URL creation uses `createWebRuntimeSessionBrowserTab` in web runtime and `createBrowserTab` otherwise.
- Electron validation: open URL, open existing file by exact path/name, create nested new file, invalid traversal/absolute path error, and smoke-test existing menu actions.
## UI Quality Bar
- Follow `docs/STYLEGUIDE.md` and existing `DropdownMenu`/`Input` tokens. Do not add custom colors, shadows, or a modal-like panel inside the menu.
- The input is the first focus target and must not break menu keyboarding.
- The menu remains compact; loading, match preview, create preview, and error rows fit at the current menu width without overlap or awkward height jumps.
- Long typed paths scroll/truncate within the input; fixed rows retain icons, shortcuts, hover states, and dense spacing.
## Review Screenshots
Attach evidence to the PR conversation; do not commit images.
1. `+` menu open in a normal workspace with the entry focused and fixed rows visible.
2. Typed URL state.
3. Typed existing-file query with visible best match.
4. Typed new-file path/create state.
5. Rejected absolute/traversal path error.
6. Adjacent-feature smoke: fixed menu rows still visible and aligned.
## Rollout
1. Add classifier/path-validation helper and tests.
2. Add menu-local file-list hook by extracting the reusable Quick Open loading inputs, not by copying private UI-only parsing.
3. Add `TabBarCreateEntry` and wire it into `TabBar`.
4. Add titlebar and split-group callbacks.
5. Add component/store tests.
6. Run typecheck, lint, targeted tests, UI review, and Electron validation with screenshots.
## Lightweight Eng Review
- Scope: Correctly scoped to the tab bar `+` menu. The implementation must not mutate Quick Open behavior except for extracting reusable search/listing code.
- Architecture/data flow: Good if `TabBar` stays presentational and all worktree/runtime/group decisions live in owners plus a shared helper. The original `onOpenEntry(query, groupId?)` shape was under-specified; include `worktreeId` and a resolved `groupId`.
- Failure modes: Must explicitly handle loading list, failed list, stale list, directory matches, create races, no active worktree, no SSH provider yet, and paired web runtime browser creation.
- Feasibility: Specific URL browser tabs cannot go through `openNewBrowserTabInActiveWorkspace` because that action uses the default URL. `createRuntimePath(..., 'file')` is feasible and creates parent directories for current local/SSH/runtime paths, but do not expose recursive directory creation as a separate v1 behavior.
- Concurrency/invalidation: A menu-local file list is acceptable if every matched file is statted before open and `EEXIST` is handled on create. No global cache is needed.
- Performance/blast radius: Listing on menu open has Quick Open's cost class, but the menu is more casually opened than the modal; cancel stale loads and key requests tightly by worktree path, connection, exclusions, and SSH status.
- Tests: Add pure helper tests first; then component and store/model tests. Electron screenshots are required because Radix menu focus/input behavior is the highest-risk UI part.
- Residual risk: Host-like URL heuristics can surprise users. Keep the heuristic narrow and prefer file matches over host-like normalization whenever the query contains path separators or matches a listed file.

View File

@ -124,6 +124,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
experimentalTerminalAttention: false,
experimentalCompactWorktreeCards: false,
experimentalWorktreeSymlinks: false,
experimentalUnifiedNewTabLauncher: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,

View File

@ -111,6 +111,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
experimentalTerminalAttention: false,
experimentalCompactWorktreeCards: false,
experimentalWorktreeSymlinks: false,
experimentalUnifiedNewTabLauncher: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,

View File

@ -1,13 +1,11 @@
/* oxlint-disable max-lines */
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useDeferredValue, useMemo, useState } from 'react'
import { AlertTriangle, Check, Copy } from 'lucide-react'
import { useAppStore } from '@/store'
import { useActiveWorktree, useWorktreesForRepo } from '@/store/selectors'
import { useActiveWorktree } from '@/store/selectors'
import { detectLanguage } from '@/lib/language-detect'
import { joinPath } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { listRuntimeFiles } from '@/runtime/runtime-file-client'
import {
CommandDialog,
CommandInput,
@ -16,6 +14,7 @@ import {
CommandItem
} from '@/components/ui/command'
import { prepareQuickOpenFiles, rankQuickOpenFiles } from '@/components/quick-open-search'
import { useRuntimeFileListForWorktree } from '@/components/quick-open-file-list'
/**
* Parses the install-ripgrep guidance message produced by the relay's
@ -49,18 +48,6 @@ function parseInstallRgGuidance(
}
}
function isNestedPath(parentPath: string, childPath: string): boolean {
const windowsPath = /^[a-zA-Z]:[\\/]/.test(parentPath) || parentPath.startsWith('\\\\')
const parent = parentPath.replace(/[\\/]+$/, '').replace(/\\/g, '/')
const child = childPath.replace(/\\/g, '/')
// Why: Windows paths are case-insensitive and can arrive with mixed slash
// styles from git/Electron. Normalize before deciding whether to exclude a
// nested linked worktree from Quick Open scans.
const comparableParent = windowsPath ? parent.toLowerCase() : parent
const comparableChild = windowsPath ? child.toLowerCase() : child
return comparableChild.startsWith(`${comparableParent}/`)
}
function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Element {
return (
<span className="rounded-full border border-border/60 bg-muted/35 px-2 py-0.5 text-[10px] font-medium text-foreground/85">
@ -138,52 +125,16 @@ export default function QuickOpen(): React.JSX.Element | null {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const openFile = useAppStore((s) => s.openFile)
const activeWorktree = useActiveWorktree()
const repoWorktrees = useWorktreesForRepo(activeWorktree?.repoId ?? null)
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const [files, setFiles] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const lastFilesRequestKeyRef = useRef('')
const { files, loading, loadError } = useRuntimeFileListForWorktree({
enabled: visible,
worktreeId: activeWorktreeId
})
const worktreePath = activeWorktree?.path ?? null
const excludePathsKey = useMemo(() => {
if (!activeWorktreeId || !worktreePath || repoWorktrees.length === 0) {
return ''
}
// Why: when the active worktree is the repo root (isMainWorktree), linked
// worktrees are nested subdirectories. Restricting the exclusion scan to
// sibling worktrees in the same repo avoids rescanning the entire store.
return repoWorktrees
.filter(
(worktree) => worktree.id !== activeWorktreeId && isNestedPath(worktreePath, worktree.path)
)
.map((worktree) => worktree.path)
.sort()
.join('\n')
}, [activeWorktreeId, repoWorktrees, worktreePath])
const connectionId = useMemo(
() => getConnectionId(activeWorktreeId ?? null) ?? undefined,
[activeWorktreeId]
)
// Why: when quick-open opens before the SSH connection is established,
// fs:listFiles returns [] (no provider yet). Watching the active target's
// connection status lets the file-load effect re-fire automatically once
// that specific connection comes up, without being affected by unrelated
// SSH targets reconnecting.
const activeTargetStatus = useAppStore((s) =>
connectionId ? s.sshConnectionStates.get(connectionId)?.status : undefined
)
const filesRequestKey = useMemo(
() =>
`${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludePathsKey}\n${activeTargetStatus ?? ''}`,
[connectionId, excludePathsKey, worktreePath, activeTargetStatus]
)
// Why: reset input only on open. Keeping this out of the file-load effect
// prevents unrelated store updates (which can produce a new excludePaths
// array reference) from wiping a query the user is currently typing.
@ -195,70 +146,6 @@ export default function QuickOpen(): React.JSX.Element | null {
}
}
// Load file list when opened
useEffect(() => {
if (!visible) {
return
}
if (!worktreePath) {
setFiles([])
setLoadError(null)
setLoading(false)
return
}
let cancelled = false
const requestKeyChanged = lastFilesRequestKeyRef.current !== filesRequestKey
if (requestKeyChanged) {
setFiles([])
}
lastFilesRequestKeyRef.current = filesRequestKey
setLoadError(null)
setLoading(true)
const excludePaths = excludePathsKey ? excludePathsKey.split('\n') : undefined
void listRuntimeFiles(
{
settings: useAppStore.getState().settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
{
rootPath: worktreePath,
excludePaths
}
)
.then((result) => {
if (!cancelled) {
setFiles(result)
}
})
.catch((error) => {
if (!cancelled) {
setFiles([])
// Why: treating list-files failures as "no matches" hides the real
// cause when the active worktree path is unauthorized or stale.
// Strip Electron's "Error invoking remote method 'fs:listFiles':
// Error:" wrapper so the user sees only the actionable message.
const raw = error instanceof Error ? error.message : String(error)
const cleaned = raw.replace(/^Error invoking remote method '[^']+':\s*Error:\s*/, '')
setLoadError(cleaned)
}
})
.finally(() => {
if (!cancelled) {
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [visible, activeWorktreeId, worktreePath, connectionId, excludePathsKey, filesRequestKey])
const indexedFiles = useMemo(() => prepareQuickOpenFiles(files), [files])
const filtered = useMemo(
() => rankQuickOpenFiles(deferredQuery, indexedFiles),

View File

@ -85,6 +85,7 @@ import {
} from '../../../shared/keybindings'
import { matchesRecentTabSwitcherChord } from '../../../shared/window-shortcut-policy'
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
import { openTabBarEntry, type TabCreateEntryArgs } from './tab-bar/tab-create-entry-action'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
@ -738,6 +739,10 @@ function Terminal(): React.JSX.Element | null {
openNewBrowserTabInActiveWorkspace
])
const handleOpenEntry = useCallback(async (args: TabCreateEntryArgs) => {
await openTabBarEntry(args)
}, [])
const handleDuplicateBrowserTab = useCallback(
(browserTabId: string) => {
if (!activeWorktreeId) {
@ -1140,7 +1145,7 @@ function Terminal(): React.JSX.Element | null {
}
// Cmd/Ctrl+Shift+T — reopen closed browser tab when browser is active,
// otherwise reopen the most recently closed editor tab (VS Codestyle).
// otherwise reopen the most recently closed editor tab.
if (!e.repeat && matchShortcut('tab.reopenClosed')) {
e.preventDefault()
notifyTerminalCapture('tab.reopenClosed')
@ -1232,7 +1237,7 @@ function Terminal(): React.JSX.Element | null {
// Cmd/Ctrl+Shift+] and Cmd/Ctrl+Shift+[ - switch tabs (scoped to the
// active tab type). Cmd/Ctrl+Alt+] and Cmd/Ctrl+Alt+[ cycles across
// every tab type as an escape hatch from the type-scoped default, and
// mirrors Safari/Chrome's tab-switch chord on macOS.
// matches the platform tab-switch chord on macOS.
// Why: use e.code instead of e.key because on macOS, Shift+[ reports '{'
// as the key value (the shifted character), not '['. Option+[ also
// composes to dead-key / punctuation on many layouts, so matching on
@ -1448,7 +1453,7 @@ function Terminal(): React.JSX.Element | null {
<EditorAutosaveController />
{/* Why: once split groups are enabled, each group owns its own tab strip
inline like VS Code. The old titlebar portal stays only as a fallback
inline. The old titlebar portal stays only as a fallback
before the root-group layout has been established. */}
{activeWorktreeId &&
!effectiveActiveLayout &&
@ -1465,6 +1470,7 @@ function Terminal(): React.JSX.Element | null {
onNewTerminalTab={() => handleNewTab()}
onNewTerminalWithShell={handleNewTab}
onNewBrowserTab={handleNewBrowserTab}
onOpenEntry={handleOpenEntry}
onNewFileTab={handleNewFile}
onSetCustomTitle={setTabCustomTitle}
onSetTabColor={setTabColor}

View File

@ -0,0 +1,139 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Worktree } from '../../../shared/types'
import { getConnectionId } from '@/lib/connection-context'
import { listRuntimeFiles } from '@/runtime/runtime-file-client'
import { useAppStore } from '@/store'
import { useWorktreeById, useWorktreesForRepo } from '@/store/selectors'
export type RuntimeFileListState = {
files: string[]
loading: boolean
loadError: string | null
}
export function cleanRuntimeFileListError(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error)
return raw.replace(/^Error invoking remote method '[^']+':\s*Error:\s*/, '')
}
export function isNestedWorktreePath(parentPath: string, childPath: string): boolean {
const windowsPath = /^[a-zA-Z]:[\\/]/.test(parentPath) || parentPath.startsWith('\\\\')
const parent = parentPath.replace(/[\\/]+$/, '').replace(/\\/g, '/')
const child = childPath.replace(/\\/g, '/')
// Why: Windows paths are case-insensitive and can arrive with mixed slash
// styles from git/Electron. Normalize before deciding whether to exclude a
// nested linked worktree from file scans.
const comparableParent = windowsPath ? parent.toLowerCase() : parent
const comparableChild = windowsPath ? child.toLowerCase() : child
return comparableChild.startsWith(`${comparableParent}/`)
}
export function getNestedWorktreeExcludePaths(
worktreeId: string,
worktreePath: string,
repoWorktrees: readonly Worktree[]
): string[] {
return repoWorktrees
.filter(
(worktree) => worktree.id !== worktreeId && isNestedWorktreePath(worktreePath, worktree.path)
)
.map((worktree) => worktree.path)
.sort()
}
export function useRuntimeFileListForWorktree({
enabled,
worktreeId
}: {
enabled: boolean
worktreeId: string | null
}): RuntimeFileListState {
const worktree = useWorktreeById(worktreeId)
const repoWorktrees = useWorktreesForRepo(worktree?.repoId ?? null)
const [files, setFiles] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const lastRequestKeyRef = useRef('')
const worktreePath = worktree?.path ?? null
const excludePathsKey = useMemo(() => {
if (!worktreeId || !worktreePath || repoWorktrees.length === 0) {
return ''
}
return getNestedWorktreeExcludePaths(worktreeId, worktreePath, repoWorktrees).join('\n')
}, [repoWorktrees, worktreeId, worktreePath])
const connectionId = useMemo(() => getConnectionId(worktreeId) ?? undefined, [worktreeId])
const activeTargetStatus = useAppStore((state) =>
connectionId ? state.sshConnectionStates.get(connectionId)?.status : undefined
)
const connectionPending =
activeTargetStatus === 'connecting' ||
activeTargetStatus === 'deploying-relay' ||
activeTargetStatus === 'reconnecting'
const requestKey = useMemo(
() =>
`${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludePathsKey}\n${activeTargetStatus ?? ''}`,
[activeTargetStatus, connectionId, excludePathsKey, worktreePath]
)
useEffect(() => {
if (!enabled) {
setLoading(false)
return
}
if (!worktreeId || !worktreePath) {
setFiles([])
setLoadError(null)
setLoading(false)
return
}
let cancelled = false
const requestKeyChanged = lastRequestKeyRef.current !== requestKey
if (requestKeyChanged) {
setFiles([])
}
lastRequestKeyRef.current = requestKey
setLoadError(null)
setLoading(true)
const excludePaths = excludePathsKey ? excludePathsKey.split('\n') : undefined
void listRuntimeFiles(
{
settings: useAppStore.getState().settings,
worktreeId,
worktreePath,
connectionId
},
{
rootPath: worktreePath,
excludePaths
}
)
.then((result) => {
if (!cancelled) {
setFiles(result)
}
})
.catch((error) => {
if (!cancelled) {
setFiles([])
setLoadError(cleanRuntimeFileListError(error))
}
})
.finally(() => {
if (!cancelled) {
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [connectionId, enabled, excludePathsKey, requestKey, worktreeId, worktreePath])
return { files, loading: loading || connectionPending, loadError }
}

View File

@ -33,6 +33,9 @@ export function ExperimentalPane({
const showWorktreeSymlinks = matchesSettingsSearch(searchQuery, [
EXPERIMENTAL_SEARCH_ENTRY.symlinks
])
const showUnifiedNewTabLauncher = matchesSettingsSearch(searchQuery, [
EXPERIMENTAL_SEARCH_ENTRY.unifiedNewTabLauncher
])
return (
<div className="space-y-4">
@ -231,6 +234,46 @@ export function ExperimentalPane({
</SearchableSetting>
) : null}
{showUnifiedNewTabLauncher ? (
<SearchableSetting
title="Smart New Tab menu"
description="Type in the New Tab menu to open a terminal, launch an agent, visit a URL, or open/create a file."
keywords={EXPERIMENTAL_SEARCH_ENTRY.unifiedNewTabLauncher.keywords}
className="space-y-3 py-2"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>Smart New Tab menu</Label>
<p className="text-xs text-muted-foreground">
Type in the New Tab menu to open a terminal, launch an agent, visit a URL, or
open/create a file.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.experimentalUnifiedNewTabLauncher}
onClick={() =>
updateSettings({
experimentalUnifiedNewTabLauncher: !settings.experimentalUnifiedNewTabLauncher
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.experimentalUnifiedNewTabLauncher
? 'bg-foreground'
: 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.experimentalUnifiedNewTabLauncher ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</SearchableSetting>
) : null}
{hiddenExperimentalUnlocked ? <HiddenExperimentalGroup /> : null}
</div>
)

View File

@ -79,6 +79,26 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'env',
'node_modules'
]
},
{
title: 'Smart New Tab menu',
description:
'Type in the New Tab menu to open a terminal, launch an agent, visit a URL, or open/create a file.',
keywords: [
'experimental',
'smart',
'new tab',
'new tab menu',
'launcher',
'unified',
'plus',
'terminal',
'agents',
'claude',
'codex',
'url',
'file'
]
}
]
@ -98,5 +118,6 @@ export const EXPERIMENTAL_SEARCH_ENTRY = {
activity: findEntry('Agents View'),
terminalAttention: findEntry('Terminal attention'),
compactWorktreeCards: findEntry('Compact worktree cards'),
symlinks: findEntry('Symlinks on worktrees')
symlinks: findEntry('Symlinks on worktrees'),
unifiedNewTabLauncher: findEntry('Smart New Tab menu')
} as const

View File

@ -6,9 +6,11 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { SortableContext } from '@dnd-kit/sortable'
import { FilePlus, FileText, Globe, Plus, TerminalSquare } from 'lucide-react'
import { toast } from 'sonner'
import type {
BrowserTab as BrowserTabState,
TerminalTab,
TuiAgent,
WorkspaceVisibleTabType
} from '../../../../shared/types'
import { useAppStore } from '../../store'
@ -23,9 +25,12 @@ import { reconcileTabOrder } from './reconcile-order'
import type { HoveredTabInsertion, TabDragItemData } from '../tab-group/useTabDragSplit'
import { resolveTabIndicatorEdges } from '../tab-group/tab-insertion'
import { getEditorDisplayLabel } from '@/components/editor/editor-labels'
import TabBarCreateEntry from './TabBarCreateEntry'
import { ShellIcon } from './shell-icons'
import { resolveWindowsShellLaunchTarget } from './windows-shell-launch'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { useDetectedAgents } from '@/hooks/useDetectedAgents'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import {
@ -36,12 +41,15 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import type { TabCreateEntryArgs } from './tab-create-entry-action'
import { buildTabAgentLaunchOptions, orderTabLaunchAgents } from './tab-agent-launch-options'
const isWindows = navigator.userAgent.includes('Windows')
const NEW_TAB_MENU_TERMINAL_FOCUS_RETRY_MS = 50
const NEW_TAB_MENU_TERMINAL_FOCUS_TIMEOUT_MS = 5000
type GitStatusEntries = ReturnType<typeof useAppStore.getState>['gitStatusByWorktree'][string]
const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntries = []
const EMPTY_AGENT_CMD_OVERRIDES: Partial<Record<TuiAgent, string>> = {}
type TabBarProps = {
tabs: (TerminalTab & { unifiedTabId?: string })[]
@ -57,6 +65,7 @@ type TabBarProps = {
/** On Windows, opens a new terminal with a specific shell instead of the default. */
onNewTerminalWithShell?: (shell: string) => void
onNewBrowserTab: () => void
onOpenEntry?: (args: TabCreateEntryArgs) => Promise<void>
terminalOnly?: boolean
showAgentLaunchItems?: boolean
onNewFileTab?: () => void
@ -122,6 +131,7 @@ function TabBarInner({
onNewTerminalTab,
onNewTerminalWithShell,
onNewBrowserTab,
onOpenEntry,
terminalOnly = false,
showAgentLaunchItems = true,
onNewFileTab,
@ -157,6 +167,34 @@ function TabBarInner({
const defaultWindowsPowerShellImplementation = useAppStore(
(s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto'
)
const unifiedNewTabLauncherEnabled = useAppStore(
(s) => s.settings?.experimentalUnifiedNewTabLauncher === true
)
const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent)
const agentCmdOverrides = useAppStore(
(s) => s.settings?.agentCmdOverrides ?? EMPTY_AGENT_CMD_OVERRIDES
)
const connectionId = useAppStore((s) => {
if (!unifiedNewTabLauncherEnabled) {
return undefined
}
const allWorktrees = Object.values(s.worktreesByRepo ?? {}).flat()
const worktree = allWorktrees.find((w) => w.id === worktreeId)
if (!worktree) {
return undefined
}
const repo = s.repos?.find((r) => r.id === worktree.repoId)
return repo?.connectionId ?? null
})
const { detectedIds } = useDetectedAgents(connectionId)
const agentLaunchOptions = useMemo(
() =>
buildTabAgentLaunchOptions(
orderTabLaunchAgents(defaultAgent, detectedIds ?? []),
agentCmdOverrides
),
[agentCmdOverrides, defaultAgent, detectedIds]
)
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(isWindows)
const resolvedGroupId = groupId ?? worktreeId
@ -221,6 +259,20 @@ function TabBarInner({
const queueTerminalTabFocusAfterNewTabMenuClose = (tabId: string): void => {
pendingNewTabMenuFocusRef.current = () => focusTerminalTabSurface(tabId)
}
const launchAgentFromNewTabEntry = (agent: TuiAgent): void => {
const option = agentLaunchOptions.find((candidate) => candidate.agent === agent)
const result = launchAgentInNewTab({
agent,
worktreeId,
groupId: resolvedGroupId,
launchSource: 'tab_bar_quick_launch'
})
if (!result) {
toast.error(`Could not build launch command for ${option?.label ?? agent}.`)
return
}
queueTerminalTabFocusAfterNewTabMenuClose(result.tabId)
}
const runPendingNewTabMenuFocusAfterClose = (): void => {
const pendingFocus = pendingNewTabMenuFocusRef.current
pendingNewTabMenuFocusRef.current = null
@ -535,7 +587,7 @@ function TabBarInner({
<DropdownMenuContent
align="start"
sideOffset={6}
className="min-w-[11rem] rounded-[11px] border-border/80 p-1 shadow-[0_16px_36px_rgba(0,0,0,0.24)]"
className={`${unifiedNewTabLauncherEnabled ? 'w-72 max-w-[calc(100vw-1rem)]' : 'min-w-[11rem]'} rounded-[11px] border-border/80 p-1 shadow-[0_16px_36px_rgba(0,0,0,0.24)]`}
onCloseAutoFocus={(e) => {
// Why: terminal-producing menu actions activate a freshly-mounted
// xterm. Radix's default focus restore sends focus back to the "+"
@ -544,6 +596,24 @@ function TabBarInner({
runPendingNewTabMenuFocusAfterClose()
}}
>
{!terminalOnly && onOpenEntry && unifiedNewTabLauncherEnabled ? (
<>
<TabBarCreateEntry
worktreeId={worktreeId}
groupId={resolvedGroupId}
menuOpen={newTabMenuOpen}
agentOptions={agentLaunchOptions}
onLaunchAgent={launchAgentFromNewTabEntry}
onOpenDefaultTerminal={() => {
queueNewActiveTerminalFocusAfterNewTabMenuClose()
onNewTerminalTab()
}}
onOpenEntry={onOpenEntry}
onDidOpenEntry={() => setNewTabMenuOpen(false)}
/>
<DropdownMenuSeparator />
</>
) : null}
{isWindows && onNewTerminalWithShell ? (
// Why: previously the Windows path nested shell choices under a
// Radix submenu. In practice the submenu frequently failed to open

View File

@ -0,0 +1,294 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { FilePlus, FileText, Globe, Loader2, Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import { useRuntimeFileListForWorktree } from '../quick-open-file-list'
import {
getTabEntryOptions,
type TabCreateEntryArgs,
type TabEntryActionClassification,
type TabEntryOption
} from './tab-create-entry-action'
import {
findMatchingTabAgentLaunchOptions,
type TabAgentLaunchOption
} from './tab-agent-launch-options'
import type { TuiAgent } from '../../../../shared/types'
type TabBarCreateEntryProps = {
agentOptions?: readonly TabAgentLaunchOption[]
groupId: string
menuOpen: boolean
onDidOpenEntry?: () => void
onLaunchAgent?: (agent: TuiAgent) => void
onOpenDefaultTerminal?: () => void
onOpenEntry?: (args: TabCreateEntryArgs) => Promise<void>
worktreeId: string
}
export default function TabBarCreateEntry({
agentOptions = [],
groupId,
menuOpen,
onDidOpenEntry,
onLaunchAgent,
onOpenDefaultTerminal,
onOpenEntry,
worktreeId
}: TabBarCreateEntryProps): React.JSX.Element {
const [query, setQuery] = useState('')
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const fileList = useRuntimeFileListForWorktree({ enabled: menuOpen, worktreeId })
useEffect(() => {
if (!menuOpen) {
setQuery('')
setPending(false)
setError(null)
setSelectedIndex(0)
return
}
requestAnimationFrame(() => inputRef.current?.focus())
}, [menuOpen])
const options = useMemo(() => getTabEntryOptions(query, fileList), [fileList, query])
const matchingAgentOptions = useMemo(
() => findMatchingTabAgentLaunchOptions(query, agentOptions),
[agentOptions, query]
)
useEffect(() => {
setSelectedIndex(0)
}, [query])
const disabled = !onOpenEntry
const hasQuery = query.trim().length > 0
const activeOptions: ActiveOption[] = [
...matchingAgentOptions.map((option) => ({
kind: 'agent' as const,
option
})),
...options.filter(isActiveEntryOption).map((option) => ({
kind: 'entry' as const,
option
}))
]
const activeSelectedIndex = Math.min(selectedIndex, Math.max(activeOptions.length - 1, 0))
const selectedActiveOption = activeOptions[activeSelectedIndex]
const statusOption = options.find(
(option) => option.classification.kind === 'empty' || option.classification.kind === 'blocked'
)
const statusMessage =
statusOption?.classification.kind === 'empty' || statusOption?.classification.kind === 'blocked'
? statusOption.classification.message
: 'URL, file, or new file'
const submitOption = (option?: ActiveOption) => {
if (disabled || pending) {
return
}
const selectedOption = option ?? selectedActiveOption ?? null
if (!selectedOption) {
if (!hasQuery && onOpenDefaultTerminal) {
onOpenDefaultTerminal()
onDidOpenEntry?.()
return
}
setError(statusMessage)
return
}
if (selectedOption.kind === 'agent') {
onLaunchAgent?.(selectedOption.option.agent)
onDidOpenEntry?.()
return
}
setPending(true)
setError(null)
void onOpenEntry({
query,
worktreeId,
groupId,
fileList,
classification: selectedOption.option.classification
})
.then(() => {
onDidOpenEntry?.()
})
.catch((caught) => {
setError(caught instanceof Error ? caught.message : String(caught))
})
.finally(() => {
setPending(false)
})
}
return (
<form
className="px-1 pb-1"
onSubmit={(event) => {
event.preventDefault()
submitOption()
}}
onKeyDown={(event) => {
if (activeOptions.length > 1 && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
event.preventDefault()
event.stopPropagation()
setSelectedIndex((current) => {
const delta = event.key === 'ArrowDown' ? 1 : -1
return (current + delta + activeOptions.length) % activeOptions.length
})
return
}
if (event.key !== 'Escape') {
event.stopPropagation()
}
}}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="relative">
<Search
className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
ref={inputRef}
value={query}
onChange={(event) => {
setQuery(event.target.value)
setError(null)
}}
disabled={disabled}
aria-label="Open URL, file, or new file"
aria-invalid={error ? true : undefined}
placeholder="URL, file, or new file"
className="h-8 rounded-[7px] pl-7 pr-2 text-[12px]"
/>
</div>
{error || activeOptions.length > 0 || hasQuery ? (
<div className="mt-1 space-y-0.5">
{error ? (
<EntryStatusRow message={error} />
) : activeOptions.length > 0 ? (
activeOptions.map((option, index) => (
<EntryActionRow
key={getActiveOptionId(option)}
option={option}
selected={index === activeSelectedIndex}
onClick={() => submitOption(option)}
/>
))
) : (
<EntryStatusRow loading={fileList.loading} message={statusMessage} />
)}
</div>
) : null}
</form>
)
}
type ActiveEntryOption = TabEntryOption & {
classification: TabEntryActionClassification
}
type ActiveOption =
| {
kind: 'agent'
option: TabAgentLaunchOption
}
| {
kind: 'entry'
option: ActiveEntryOption
}
function isActiveEntryOption(option: TabEntryOption): option is ActiveEntryOption {
return option.classification.kind !== 'empty' && option.classification.kind !== 'blocked'
}
function getActiveOptionId(option: ActiveOption): string {
return option.kind === 'agent' ? `agent:${option.option.agent}` : option.option.id
}
function EntryStatusRow({
loading = false,
message
}: {
loading?: boolean
message: string
}): React.JSX.Element {
return (
<div className="flex min-h-6 items-center gap-1.5 rounded-[7px] px-1 text-[11px] leading-5 text-muted-foreground">
{loading ? <Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden="true" /> : null}
<span className="truncate">{message}</span>
</div>
)
}
function EntryActionRow({
onClick,
option,
selected
}: {
onClick: () => void
option: ActiveOption
selected: boolean
}): React.JSX.Element {
const presentation = getActionPresentation(option)
return (
<button
type="button"
className={cn(
'flex h-6 w-full items-center gap-1.5 rounded-[7px] px-1 text-left text-[11px] leading-5 outline-none',
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'
)}
onClick={onClick}
>
{presentation.icon}
<span className="shrink-0 font-medium">{presentation.label}</span>
<span className="text-muted-foreground/70" aria-hidden="true">
·
</span>
<span className="min-w-0 truncate">{presentation.detail}</span>
</button>
)
}
function getActionPresentation(option: ActiveOption): {
detail: string
icon: React.ReactNode
label: string
} {
if (option.kind === 'agent') {
return {
detail: option.option.label,
icon: <AgentIcon agent={option.option.agent} size={14} />,
label: 'Launch agent'
}
}
const { classification } = option.option
if (classification.kind === 'explicit-url' || classification.kind === 'host-url') {
return {
detail: classification.url,
icon: <Globe className="size-3.5 shrink-0" aria-hidden="true" />,
label: 'Open URL'
}
}
if (classification.kind === 'existing-file') {
return {
detail: classification.relativePath,
icon: <FileText className="size-3.5 shrink-0" aria-hidden="true" />,
label: 'Open file'
}
}
return {
detail: classification.relativePath,
icon: <FilePlus className="size-3.5 shrink-0" aria-hidden="true" />,
label: 'Create file'
}
}

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import {
buildTabAgentLaunchOptions,
findMatchingTabAgentLaunchOptions,
orderTabLaunchAgents
} from './tab-agent-launch-options'
describe('tab agent launch options', () => {
it('orders detected agents by the configured default first', () => {
expect(orderTabLaunchAgents('codex', ['claude', 'codex', 'gemini'])).toEqual([
'codex',
'claude',
'gemini'
])
})
it('matches detected agents by id, label, command, and command override', () => {
const options = buildTabAgentLaunchOptions(['claude', 'codex', 'antigravity'], {
codex: 'codex-beta'
})
expect(
findMatchingTabAgentLaunchOptions('Claude', options).map((option) => option.agent)
).toEqual(['claude'])
expect(findMatchingTabAgentLaunchOptions('openai codex', options)).toEqual([])
expect(
findMatchingTabAgentLaunchOptions('codex-beta', options).map((option) => option.agent)
).toEqual(['codex'])
expect(findMatchingTabAgentLaunchOptions('agy', options).map((option) => option.agent)).toEqual(
['antigravity']
)
})
})

View File

@ -0,0 +1,73 @@
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import type { TuiAgent } from '../../../../shared/types'
export type TabAgentLaunchOption = {
agent: TuiAgent
aliases: readonly string[]
label: string
}
function normalizeAgentAlias(value: string): string {
return value.trim().toLowerCase()
}
function compactAgentAlias(value: string): string {
return normalizeAgentAlias(value).replace(/[\s_-]+/g, '')
}
function getCatalogEntry(agent: TuiAgent): { id: TuiAgent; label: string; cmd: string } | null {
return AGENT_CATALOG.find((entry) => entry.id === agent) ?? null
}
export function orderTabLaunchAgents(
defaultAgent: TuiAgent | 'blank' | null | undefined,
detected: readonly TuiAgent[]
): TuiAgent[] {
const inCatalogOrder = AGENT_CATALOG.filter((entry) => detected.includes(entry.id)).map(
(entry) => entry.id
)
if (!defaultAgent || defaultAgent === 'blank' || !inCatalogOrder.includes(defaultAgent)) {
return inCatalogOrder
}
return [defaultAgent, ...inCatalogOrder.filter((id) => id !== defaultAgent)]
}
export function buildTabAgentLaunchOptions(
agents: readonly TuiAgent[],
commandOverrides: Partial<Record<TuiAgent, string>> = {}
): TabAgentLaunchOption[] {
return agents.map((agent) => {
const entry = getCatalogEntry(agent)
const label = entry?.label ?? agent
const aliases = new Set<string>([
normalizeAgentAlias(agent),
normalizeAgentAlias(label),
compactAgentAlias(agent),
compactAgentAlias(label)
])
if (entry?.cmd) {
aliases.add(normalizeAgentAlias(entry.cmd))
aliases.add(compactAgentAlias(entry.cmd))
}
const commandOverride = commandOverrides[agent]?.trim()
if (commandOverride) {
aliases.add(normalizeAgentAlias(commandOverride))
aliases.add(compactAgentAlias(commandOverride))
}
return { agent, aliases: [...aliases], label }
})
}
export function findMatchingTabAgentLaunchOptions(
query: string,
agents: readonly TabAgentLaunchOption[]
): TabAgentLaunchOption[] {
const normalizedQuery = normalizeAgentAlias(query)
if (!normalizedQuery) {
return []
}
const compactQuery = compactAgentAlias(query)
return agents.filter(
(option) => option.aliases.includes(normalizedQuery) || option.aliases.includes(compactQuery)
)
}

View File

@ -0,0 +1,197 @@
import { describe, expect, it, vi } from 'vitest'
import { openTabEntryWithOperations, type TabEntryOperations } from './tab-create-entry-action'
const readyFiles = (files: string[]) => ({ files, loading: false, loadError: null })
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),
openFile: vi.fn(),
statRuntimePath: vi.fn().mockResolvedValue({ size: 1, isDirectory: false, mtime: 1 }),
...overrides
}
}
const baseArgs = {
fileList: readyFiles(['src/index.ts']),
worktreeId: 'wt-1',
groupId: 'group-1',
worktreePath: '/repo',
runtimeContext: {
settings: null,
worktreeId: 'wt-1',
worktreePath: '/repo'
},
activeRuntimeEnvironmentId: null
}
it('stats existing files before opening and rejects directories', async () => {
const operations = makeOperations({
statRuntimePath: vi.fn().mockResolvedValue({ size: 0, isDirectory: true, mtime: 1 })
})
await expect(
openTabEntryWithOperations({ ...baseArgs, query: 'src/index.ts', operations })
).rejects.toThrow('Cannot open a directory')
expect(operations.openFile).not.toHaveBeenCalled()
})
it('creates new files and opens them in the target group', async () => {
const operations = makeOperations()
await openTabEntryWithOperations({ ...baseArgs, query: 'docs/new.md', operations })
expect(operations.createRuntimePath).toHaveBeenCalledWith(
baseArgs.runtimeContext,
'/repo/docs',
'directory'
)
expect(operations.createRuntimePath).toHaveBeenCalledWith(
baseArgs.runtimeContext,
'/repo/docs/new.md',
'file'
)
expect(operations.openFile).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/repo/docs/new.md',
relativePath: 'docs/new.md',
worktreeId: 'wt-1'
}),
{ preview: false, targetGroupId: 'group-1' }
)
})
it('uses the selected action instead of reclassifying the query', async () => {
const operations = makeOperations()
await openTabEntryWithOperations({
...baseArgs,
classification: {
kind: 'existing-file',
matchKind: 'fuzzy',
relativePath: 'README.md'
},
fileList: readyFiles(['README.md']),
query: 'read.md',
operations
})
expect(operations.createRuntimePath).not.toHaveBeenCalled()
expect(operations.openFile).toHaveBeenCalledWith(
expect.objectContaining({ relativePath: 'README.md' }),
{ preview: false, targetGroupId: 'group-1' }
)
})
it('creates missing parent directories one level at a time before nested new files', async () => {
const operations = makeOperations()
await openTabEntryWithOperations({
...baseArgs,
query: '.tmp/direct-entry-validation/created.md',
operations
})
expect(operations.createRuntimePath).toHaveBeenNthCalledWith(
1,
baseArgs.runtimeContext,
'/repo/.tmp',
'directory'
)
expect(operations.createRuntimePath).toHaveBeenNthCalledWith(
2,
baseArgs.runtimeContext,
'/repo/.tmp/direct-entry-validation',
'directory'
)
expect(operations.createRuntimePath).toHaveBeenNthCalledWith(
3,
baseArgs.runtimeContext,
'/repo/.tmp/direct-entry-validation/created.md',
'file'
)
expect(operations.openFile).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/repo/.tmp/direct-entry-validation/created.md',
relativePath: '.tmp/direct-entry-validation/created.md'
}),
{ preview: false, targetGroupId: 'group-1' }
)
})
it('continues when a parent directory already exists', async () => {
const operations = makeOperations({
createRuntimePath: vi
.fn()
.mockRejectedValueOnce(new Error("A file or folder named 'docs' already exists"))
.mockResolvedValue(undefined),
statRuntimePath: vi
.fn()
.mockResolvedValueOnce({ size: 1, isDirectory: true, mtime: 1 })
.mockResolvedValue({ size: 1, isDirectory: false, mtime: 1 })
})
await openTabEntryWithOperations({ ...baseArgs, query: 'docs/new.md', operations })
expect(operations.statRuntimePath).toHaveBeenCalledWith(baseArgs.runtimeContext, '/repo/docs')
expect(operations.createRuntimePath).toHaveBeenLastCalledWith(
baseArgs.runtimeContext,
'/repo/docs/new.md',
'file'
)
expect(operations.openFile).toHaveBeenCalled()
})
it('rejects invalid new file paths before creating parent directories', async () => {
const operations = makeOperations()
await expect(
openTabEntryWithOperations({ ...baseArgs, query: '../escape.md', operations })
).rejects.toThrow('File paths cannot contain . or .. segments.')
expect(operations.createRuntimePath).not.toHaveBeenCalled()
expect(operations.openFile).not.toHaveBeenCalled()
})
it('stats and opens when create loses an EEXIST race to a file', async () => {
const operations = makeOperations({
createRuntimePath: vi
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('EEXIST: file already exists'))
})
await openTabEntryWithOperations({ ...baseArgs, query: 'docs/race.md', operations })
expect(operations.statRuntimePath).toHaveBeenCalledWith(
baseArgs.runtimeContext,
'/repo/docs/race.md'
)
expect(operations.openFile).toHaveBeenCalled()
})
it('routes paired runtime browser creation through the web session API', async () => {
const operations = makeOperations({
isWebRuntimeSessionActive: vi.fn().mockReturnValue(true)
})
await openTabEntryWithOperations({
...baseArgs,
query: 'https://example.com',
activeRuntimeEnvironmentId: 'runtime-1',
operations
})
expect(operations.createWebRuntimeSessionBrowserTab).toHaveBeenCalledWith({
worktreeId: 'wt-1',
environmentId: 'runtime-1',
url: 'https://example.com/',
targetGroupId: 'group-1'
})
expect(operations.createBrowserTab).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,236 @@
import { detectLanguage } from '@/lib/language-detect'
import { getConnectionId } from '@/lib/connection-context'
import { joinPath } from '@/lib/path'
import {
createRuntimePath,
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 type { RuntimeFileListState } from '../quick-open-file-list'
import {
classifyTabEntryQuery,
type TabEntryActionClassification
} from './tab-create-entry-classifier'
export {
classifyTabEntryQuery,
getTabEntryOptions,
validateNewTabEntryRelativePath,
type TabEntryActionClassification,
type TabEntryClassification,
type TabEntryOption
} from './tab-create-entry-classifier'
export type TabCreateEntryArgs = {
classification?: TabEntryActionClassification
query: string
worktreeId: string
groupId: string
fileList: RuntimeFileListState
}
export type TabEntryOperations = {
createBrowserTab: (
worktreeId: string,
url: string,
options?: {
activate?: boolean
targetGroupId?: string
title?: string
}
) => BrowserTabState
createRuntimePath: typeof createRuntimePath
createWebRuntimeSessionBrowserTab: typeof createWebRuntimeSessionBrowserTab
isWebRuntimeSessionActive: typeof isWebRuntimeSessionActive
openFile: (
file: Omit<OpenFile, 'id' | 'isDirty'>,
options?: { preview?: boolean; targetGroupId?: string }
) => void
statRuntimePath: typeof statRuntimePath
}
type OpenTabEntryWithOperationsArgs = {
query: string
fileList: RuntimeFileListState
worktreeId: string
groupId: string
worktreePath: string
runtimeContext: RuntimeFileOperationArgs
activeRuntimeEnvironmentId: string | null
classification?: TabEntryActionClassification
operations: TabEntryOperations
}
function isExistsError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /\bEEXIST\b|already exists|file exists/i.test(message)
}
async function createParentDirectoriesForNewFile(args: {
context: RuntimeFileOperationArgs
operations: TabEntryOperations
relativePath: string
worktreePath: string
}): Promise<void> {
const directorySegments = args.relativePath.split('/').slice(0, -1)
let currentPath = args.worktreePath
for (const segment of directorySegments) {
currentPath = joinPath(currentPath, segment)
try {
// Why: file creation authorizes the immediate parent before its own mkdir,
// so nested new-file paths must materialize parents one level at a time.
await args.operations.createRuntimePath(args.context, currentPath, 'directory')
} catch (error) {
if (!isExistsError(error)) {
throw error
}
const stat = await args.operations.statRuntimePath(args.context, currentPath)
if (!stat.isDirectory) {
throw new Error(`Cannot create file because ${currentPath} is not a directory.`)
}
}
}
}
async function openExistingFile(args: {
context: RuntimeFileOperationArgs
groupId: string
operations: TabEntryOperations
relativePath: string
worktreeId: string
worktreePath: string
}): Promise<void> {
const filePath = joinPath(args.worktreePath, args.relativePath)
let stat: Awaited<ReturnType<typeof statRuntimePath>>
try {
stat = await args.operations.statRuntimePath(args.context, filePath)
} catch {
throw new Error(`File no longer exists: ${args.relativePath}`)
}
if (stat.isDirectory) {
throw new Error(`Cannot open a directory: ${args.relativePath}`)
}
args.operations.openFile(
{
filePath,
relativePath: args.relativePath,
worktreeId: args.worktreeId,
language: detectLanguage(args.relativePath),
mode: 'edit'
},
{ preview: false, targetGroupId: args.groupId }
)
}
export async function openTabEntryWithOperations({
activeRuntimeEnvironmentId,
classification: selectedClassification,
fileList,
groupId,
operations,
query,
runtimeContext,
worktreeId,
worktreePath
}: OpenTabEntryWithOperationsArgs): Promise<void> {
const classification = selectedClassification ?? classifyTabEntryQuery(query, fileList)
if (classification.kind === 'empty' || classification.kind === 'blocked') {
throw new Error(classification.message)
}
if (classification.kind === 'explicit-url' || classification.kind === 'host-url') {
if (
operations.isWebRuntimeSessionActive(activeRuntimeEnvironmentId) &&
!(await operations.createWebRuntimeSessionBrowserTab({
worktreeId,
environmentId: activeRuntimeEnvironmentId,
url: classification.url,
targetGroupId: groupId
}))
) {
throw new Error('Failed to create browser tab.')
}
if (!operations.isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) {
operations.createBrowserTab(worktreeId, classification.url, {
activate: true,
targetGroupId: groupId,
title: classification.url
})
}
return
}
if (classification.kind === 'existing-file') {
await openExistingFile({
context: runtimeContext,
groupId,
operations,
relativePath: classification.relativePath,
worktreeId,
worktreePath
})
return
}
const filePath = joinPath(worktreePath, classification.relativePath)
try {
await createParentDirectoriesForNewFile({
context: runtimeContext,
operations,
relativePath: classification.relativePath,
worktreePath
})
await operations.createRuntimePath(runtimeContext, filePath, 'file')
} catch (error) {
if (!isExistsError(error)) {
throw error
}
}
await openExistingFile({
context: runtimeContext,
groupId,
operations,
relativePath: classification.relativePath,
worktreeId,
worktreePath
})
}
export async function openTabBarEntry(args: TabCreateEntryArgs): Promise<void> {
const state = useAppStore.getState()
const worktree = state.getKnownWorktreeById(args.worktreeId)
if (!worktree) {
throw new Error('No active worktree.')
}
const runtimeContext: RuntimeFileOperationArgs = {
settings: state.settings,
worktreeId: args.worktreeId,
worktreePath: worktree.path,
connectionId: getConnectionId(args.worktreeId) ?? undefined
}
await openTabEntryWithOperations({
query: args.query,
fileList: args.fileList,
worktreeId: args.worktreeId,
groupId: args.groupId,
worktreePath: worktree.path,
runtimeContext,
activeRuntimeEnvironmentId: state.settings?.activeRuntimeEnvironmentId?.trim() ?? null,
classification: args.classification,
operations: {
createBrowserTab: state.createBrowserTab,
createRuntimePath,
createWebRuntimeSessionBrowserTab,
isWebRuntimeSessionActive,
openFile: state.openFile,
statRuntimePath
}
})
}

View File

@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import {
classifyTabEntryQuery,
getTabEntryOptions,
validateNewTabEntryRelativePath
} from './tab-create-entry-action'
const readyFiles = (files: string[]) => ({ files, loading: false, loadError: null })
describe('tab create entry classification', () => {
it('accepts explicit http and https URLs only', () => {
expect(classifyTabEntryQuery(' https://example.com/docs ', readyFiles([]))).toMatchObject({
kind: 'explicit-url',
url: 'https://example.com/docs'
})
expect(classifyTabEntryQuery('http://localhost:3000', readyFiles([]))).toMatchObject({
kind: 'explicit-url',
url: 'http://localhost:3000/'
})
expect(classifyTabEntryQuery('ftp://example.com', readyFiles([]))).toMatchObject({
kind: 'blocked'
})
})
it('lets existing listed files win over bare host-like URLs', () => {
expect(classifyTabEntryQuery('example.com', readyFiles(['example.com']))).toEqual({
kind: 'existing-file',
matchKind: 'exact-path',
relativePath: 'example.com'
})
expect(classifyTabEntryQuery('example.com', readyFiles([]))).toMatchObject({
kind: 'host-url',
url: 'https://example.com/'
})
})
it('keeps common source/document filenames as file candidates', () => {
expect(classifyTabEntryQuery('README.md', readyFiles([]))).toEqual({
kind: 'new-file',
relativePath: 'README.md'
})
expect(classifyTabEntryQuery('src/foo.test.ts', readyFiles([]))).toEqual({
kind: 'new-file',
relativePath: 'src/foo.test.ts'
})
expect(classifyTabEntryQuery('docs/readme.md', readyFiles([]))).toEqual({
kind: 'new-file',
relativePath: 'docs/readme.md'
})
})
it('blocks non-explicit URLs and file paths while list state is not ready', () => {
expect(
classifyTabEntryQuery('example.com', { files: [], loading: true, loadError: null })
).toEqual({
kind: 'blocked',
message: 'Loading files...'
})
expect(
classifyTabEntryQuery('https://example.com', { files: [], loading: true, loadError: null })
).toMatchObject({ kind: 'explicit-url' })
expect(
classifyTabEntryQuery('example.com', {
files: [],
loading: false,
loadError: 'scan failed'
})
).toEqual({ kind: 'blocked', message: 'scan failed' })
})
it('matches exact relative path before basename and fuzzy results', () => {
const files = readyFiles(['src/index.ts', 'docs/index.ts', 'src/components/Button.tsx'])
expect(classifyTabEntryQuery('docs/index.ts', files)).toEqual({
kind: 'existing-file',
matchKind: 'exact-path',
relativePath: 'docs/index.ts'
})
expect(classifyTabEntryQuery('Button.tsx', files)).toEqual({
kind: 'existing-file',
matchKind: 'exact-basename',
relativePath: 'src/components/Button.tsx'
})
expect(classifyTabEntryQuery('btn', files)).toEqual({
kind: 'existing-file',
matchKind: 'fuzzy',
relativePath: 'src/components/Button.tsx'
})
})
it('returns duplicate basename matches as separate open-file options', () => {
expect(
getTabEntryOptions('index.ts', readyFiles(['src/index.ts', 'docs/index.ts'])).map(
(option) => option.classification
)
).toEqual([
{ kind: 'existing-file', matchKind: 'exact-basename', relativePath: 'src/index.ts' },
{ kind: 'existing-file', matchKind: 'exact-basename', relativePath: 'docs/index.ts' }
])
})
it('prefers creating typed file paths over fuzzy matches', () => {
expect(
getTabEntryOptions('read.md', readyFiles(['README.md'])).map(
(option) => option.classification
)
).toEqual([
{ kind: 'new-file', relativePath: 'read.md' },
{ kind: 'existing-file', matchKind: 'fuzzy', relativePath: 'README.md' }
])
})
it('offers both exact file and URL actions for host-like filenames', () => {
expect(
getTabEntryOptions('example.com', readyFiles(['example.com'])).map(
(option) => option.classification
)
).toEqual([
{ kind: 'existing-file', matchKind: 'exact-path', relativePath: 'example.com' },
{ kind: 'host-url', url: 'https://example.com/' }
])
})
})
describe('tab create entry path validation', () => {
it('rejects unsafe or non-relative paths', () => {
for (const path of [
'',
'/tmp/file.ts',
'C:/tmp/file.ts',
'C:tmp/file.ts',
'\\\\server\\share\\file.ts',
'~',
'~/file.ts',
'src/',
'src//file.ts',
'src/../file.ts',
'src\\.\\file.ts',
'src\\..\\file.ts',
'src/\u0000file.ts'
]) {
expect(() => validateNewTabEntryRelativePath(path), path).toThrow()
}
})
it('allows spaces and normalizes Windows separators after absolute checks', () => {
expect(validateNewTabEntryRelativePath(' docs/My Note.md ')).toBe('docs/My Note.md')
expect(validateNewTabEntryRelativePath('src\\new-file.ts')).toBe('src/new-file.ts')
})
})

View File

@ -0,0 +1,292 @@
import {
prepareQuickOpenFiles,
rankQuickOpenFiles,
type QuickOpenIndexedFile
} from '../quick-open-search'
import type { RuntimeFileListState } from '../quick-open-file-list'
const HOST_FILE_EXTENSIONS = new Set([
'css',
'html',
'js',
'jsx',
'json',
'md',
'py',
'toml',
'ts',
'tsx',
'yaml',
'yml'
])
export type TabEntryClassification =
| { kind: 'empty'; message: string }
| { kind: 'explicit-url'; url: string }
| {
kind: 'existing-file'
matchKind: 'exact-path' | 'exact-basename' | 'fuzzy'
relativePath: string
}
| { kind: 'host-url'; url: string }
| { kind: 'new-file'; relativePath: string }
| { kind: 'blocked'; message: string }
export type TabEntryActionClassification = Exclude<
TabEntryClassification,
{ kind: 'blocked' | 'empty' }
>
export type TabEntryOption = {
classification: TabEntryClassification
id: string
}
function normalizeFileMatchQuery(query: string): string {
return query.trim().replace(/\\/g, '/')
}
function hasPathSeparator(query: string): boolean {
return /[\\/]/.test(query)
}
function hasFilenameExtension(query: string): boolean {
return /(?:^|[\\/])[^\\/]+\.[^\\/]+$/.test(query.trim())
}
function isLikelyNewFileIntent(query: string): boolean {
return hasPathSeparator(query) || hasFilenameExtension(query)
}
function dedupeMatches(matches: ExistingFileMatch[]): ExistingFileMatch[] {
const seen = new Set<string>()
return matches.filter((match) => {
if (seen.has(match.relativePath)) {
return false
}
seen.add(match.relativePath)
return true
})
}
type ExistingFileMatch = Extract<TabEntryActionClassification, { kind: 'existing-file' }>
function findExistingFileMatches(
query: string,
indexedFiles: readonly QuickOpenIndexedFile[],
limit: number
): ExistingFileMatch[] {
const normalizedQuery = normalizeFileMatchQuery(query)
if (!normalizedQuery || limit <= 0) {
return []
}
const lowerQuery = normalizedQuery.toLowerCase()
const exactPathMatches = indexedFiles
.filter((file) => file.lowerPath === lowerQuery)
.map((file) => ({
kind: 'existing-file' as const,
matchKind: 'exact-path' as const,
relativePath: file.path
}))
const exactBasenameMatches = indexedFiles
.filter((file) => file.lowerFilename === lowerQuery)
.map((file) => ({
kind: 'existing-file' as const,
matchKind: 'exact-basename' as const,
relativePath: file.path
}))
const fuzzyMatches = rankQuickOpenFiles(normalizedQuery, indexedFiles, limit).map((file) => ({
kind: 'existing-file' as const,
matchKind: 'fuzzy' as const,
relativePath: file.path
}))
return dedupeMatches([...exactPathMatches, ...exactBasenameMatches, ...fuzzyMatches]).slice(
0,
limit
)
}
function classifyExplicitUrl(
query: string
): Extract<TabEntryClassification, { kind: 'blocked' | 'explicit-url' }> | null {
let url: URL
try {
url = new URL(query)
} catch {
return null
}
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) {
return { kind: 'blocked', message: 'Enter an http:// or https:// URL.' }
}
return { kind: 'explicit-url', url: url.href }
}
function classifyHostLikeUrl(
query: string
): Extract<TabEntryActionClassification, { kind: 'host-url' }> | null {
if (/[\\/]/.test(query) || /\s/.test(query)) {
return null
}
const extension = query.split(':')[0]?.split('.').pop()?.toLowerCase() ?? ''
if (HOST_FILE_EXTENSIONS.has(extension)) {
return null
}
const hostPort = '(?::\\d{1,5})?'
const localhost = new RegExp(`^localhost${hostPort}$`, 'i')
const ipv4 = new RegExp(`^(?:\\d{1,3}\\.){3}\\d{1,3}${hostPort}$`)
const domain = new RegExp(
`^(?=.{1,253}${hostPort}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}${hostPort}$`,
'i'
)
if (!localhost.test(query) && !ipv4.test(query) && !domain.test(query)) {
return null
}
try {
const url = new URL(`https://${query}`)
return url.hostname ? { kind: 'host-url', url: url.href } : null
} catch {
return null
}
}
export function validateNewTabEntryRelativePath(query: string): string {
const trimmed = query.trim()
if (!trimmed) {
throw new Error('Enter a URL or file path.')
}
if (Array.from(trimmed).some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) {
throw new Error('File paths cannot contain control characters.')
}
if (trimmed.startsWith('/')) {
throw new Error('Enter a relative file path.')
}
if (/^[A-Za-z]:/.test(trimmed)) {
throw new Error('Windows drive paths are not supported here.')
}
if (/^[\\/]{2}/.test(trimmed)) {
throw new Error('UNC paths are not supported here.')
}
if (trimmed === '~' || trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
throw new Error('Home-relative paths are not supported here.')
}
if (/[\\/]$/.test(trimmed)) {
throw new Error('Enter a file path, not a directory path.')
}
if (trimmed.split(/[\\/]/).some((segment) => segment.length === 0)) {
throw new Error('File paths cannot contain empty segments.')
}
const normalized = trimmed.replace(/\\/g, '/')
const segments = normalized.split('/')
if (segments.some((segment) => segment === '.' || segment === '..')) {
throw new Error('File paths cannot contain . or .. segments.')
}
if (segments.some((segment) => segment === '~')) {
throw new Error('File paths cannot contain ~ segments.')
}
return normalized
}
export function classifyTabEntryQuery(
query: string,
fileList: RuntimeFileListState
): TabEntryClassification {
return (
getTabEntryOptions(query, fileList, 1)[0]?.classification ?? {
kind: 'empty',
message: 'Enter a URL or file path.'
}
)
}
export function getTabEntryOptions(
query: string,
fileList: RuntimeFileListState,
limit = 4
): TabEntryOption[] {
const trimmed = query.trim()
if (!trimmed) {
return [{ id: 'empty', classification: { kind: 'empty', message: 'URL, file, or new file' } }]
}
const explicitUrl = classifyExplicitUrl(trimmed)
if (explicitUrl) {
return [
{
id: explicitUrl.kind === 'blocked' ? 'invalid-url' : `url:${explicitUrl.url}`,
classification: explicitUrl
}
]
}
if (fileList.loading) {
return [{ id: 'loading', classification: { kind: 'blocked', message: 'Loading files...' } }]
}
if (fileList.loadError) {
return [{ id: 'load-error', classification: { kind: 'blocked', message: fileList.loadError } }]
}
const existingFiles = findExistingFileMatches(
trimmed,
prepareQuickOpenFiles(fileList.files),
Math.max(limit, 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 = classifyHostLikeUrl(trimmed)
const options: TabEntryActionClassification[] = []
if (exactExistingFiles.length > 0) {
options.push(...exactExistingFiles)
if (hostUrl) {
options.push(hostUrl)
}
} 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)
}
}
if (options.length > 0) {
return options.slice(0, limit).map((classification) => ({
id:
classification.kind === 'existing-file'
? `${classification.kind}:${classification.relativePath}`
: classification.kind === 'new-file'
? `${classification.kind}:${classification.relativePath}`
: `${classification.kind}:${classification.url}`,
classification
}))
}
try {
validateNewTabEntryRelativePath(trimmed)
} catch (error) {
return [
{
id: 'invalid-path',
classification: {
kind: 'blocked',
message: error instanceof Error ? error.message : String(error)
}
}
]
}
return [{ id: 'blocked', classification: { kind: 'blocked', message: 'No action available.' } }]
}

View File

@ -113,6 +113,7 @@ export default function TabGroupPanel({
onNewTerminalTab={commands.newTerminalTab}
onNewTerminalWithShell={commands.newTerminalWithShell}
onNewBrowserTab={commands.newBrowserTab}
onOpenEntry={commands.openEntry}
onNewFileTab={commands.newFileTab}
onSetCustomTitle={commands.setTabCustomTitle}
onSetTabColor={commands.setTabColor}

View File

@ -22,6 +22,7 @@ import {
createWebRuntimeSessionTerminal,
isWebRuntimeSessionActive
} from '../../runtime/web-runtime-session'
import { openTabBarEntry, type TabCreateEntryArgs } from '../tab-bar/tab-create-entry-action'
export type GroupEditorItem = OpenFile & { tabId: string }
export type GroupBrowserItem = BrowserTabState & { tabId: string }
@ -533,6 +534,9 @@ export function useTabGroupWorkspaceModel({
newBrowserTab: () => {
void openNewBrowserTabInActiveWorkspace(groupId)
},
openEntry: async (args: TabCreateEntryArgs) => {
await openTabBarEntry(args)
},
duplicateBrowserTab: (browserTabId: string) => {
void (async () => {
const state = useAppStore.getState()

View File

@ -277,6 +277,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
experimentalTerminalAttention: false,
experimentalCompactWorktreeCards: false,
experimentalWorktreeSymlinks: false,
experimentalUnifiedNewTabLauncher: false,
// Why: local desktop remains the default server until the user explicitly
// selects a saved runtime environment.
activeRuntimeEnvironmentId: null,

View File

@ -240,6 +240,7 @@ export const SETTINGS_CHANGED_WHITELIST = [
'experimentalActivity',
'experimentalTerminalAttention',
'experimentalWorktreeSymlinks',
'experimentalUnifiedNewTabLauncher',
'geminiCliOAuthEnabled'
] as const satisfies readonly BooleanGlobalSettingsKey[]
export const settingsChangedKeySchema = z.enum(SETTINGS_CHANGED_WHITELIST)

View File

@ -1933,6 +1933,9 @@ export type GlobalSettings = {
* configuration surface and edge cases (conflicts with existing paths,
* cleanup on worktree delete) are still being worked out. */
experimentalWorktreeSymlinks: boolean
/** Experimental: replaces the New Tab menu's static preview row with a
* command-style launcher for terminals, detected agents, URLs, and files. */
experimentalUnifiedNewTabLauncher: boolean
/** Active non-local runtime environment for client-routed RPC. `null`
* preserves the current local desktop behavior. */
activeRuntimeEnvironmentId?: string | null