Add direct URL and file entry to tab menu (#3026)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
1cf754580d
commit
606eef5839
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 Code–style).
|
||||
// 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}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
@ -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']
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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.' } }]
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue