Add Cmd-J settings and quick actions (#2769)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
99d580ddf8
commit
3ef4a42305
|
|
@ -10,9 +10,7 @@ import {
|
|||
} from '@/constants/terminal'
|
||||
import { useAppStore } from '../store'
|
||||
import { useAllWorktrees } from '../store/selectors'
|
||||
import { createUntitledMarkdownFile } from '../lib/create-untitled-markdown'
|
||||
import { getConnectionId } from '../lib/connection-context'
|
||||
import { extractIpcErrorMessage } from '../lib/ipc-error'
|
||||
import { basename } from '../lib/path'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -53,11 +51,11 @@ import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
|
|||
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
|
||||
import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair'
|
||||
import { addBackgroundMountedTerminalWorktree } from './terminal/background-terminal-worktree-mount'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import {
|
||||
getEffectiveLayoutForWorktree as getEffectiveLayout,
|
||||
anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout
|
||||
} from './terminal/split-group-mount'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
|
||||
import CodexRestartChip from './CodexRestartChip'
|
||||
import {
|
||||
|
|
@ -128,11 +126,17 @@ function Terminal(): React.JSX.Element | null {
|
|||
)
|
||||
const setActiveTabType = useAppStore((s) => s.setActiveTabType)
|
||||
const setActiveFile = useAppStore((s) => s.setActiveFile)
|
||||
const openFile = useAppStore((s) => s.openFile)
|
||||
const closeFile = useAppStore((s) => s.closeFile)
|
||||
const pinFile = useAppStore((s) => s.pinFile)
|
||||
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
|
||||
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
|
||||
const openNewBrowserTabInActiveWorkspace = useAppStore(
|
||||
(s) => s.openNewBrowserTabInActiveWorkspace
|
||||
)
|
||||
const openNewMarkdownInActiveWorkspace = useAppStore((s) => s.openNewMarkdownInActiveWorkspace)
|
||||
const openNewTerminalTabInActiveWorkspace = useAppStore(
|
||||
(s) => s.openNewTerminalTabInActiveWorkspace
|
||||
)
|
||||
const closeBrowserTab = useAppStore((s) => s.closeBrowserTab)
|
||||
const setActiveBrowserTab = useAppStore((s) => s.setActiveBrowserTab)
|
||||
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
|
||||
|
|
@ -643,6 +647,13 @@ function Terminal(): React.JSX.Element | null {
|
|||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
const targetGroupId =
|
||||
useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] ??
|
||||
useAppStore.getState().groupsByWorktree[activeWorktreeId]?.[0]?.id
|
||||
if (!shellOverride && targetGroupId) {
|
||||
void openNewTerminalTabInActiveWorkspace(targetGroupId)
|
||||
return
|
||||
}
|
||||
if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) {
|
||||
void createWebRuntimeSessionTerminal({
|
||||
worktreeId: activeWorktreeId,
|
||||
|
|
@ -679,20 +690,31 @@ function Terminal(): React.JSX.Element | null {
|
|||
const order = base.filter((id) => id !== newTab.id)
|
||||
order.push(newTab.id)
|
||||
setTabBarOrder(activeWorktreeId, order)
|
||||
// Why: keyboard (Cmd/Ctrl+T) creation should leave the user ready to type
|
||||
// in the new shell. Without an explicit focus call, the window-level
|
||||
// keydown handler keeps focus on whatever surface dispatched the shortcut
|
||||
// (often <body>), so the first keystroke is dropped instead of reaching
|
||||
// the new xterm. Matches the "+" menu path in TabBar.tsx.
|
||||
// Why: shell-specific creation still uses the legacy path; keep the
|
||||
// keyboard shortcut focused until the lifted action accepts shell overrides.
|
||||
focusTerminalTabSurface(newTab.id)
|
||||
},
|
||||
[activeRuntimeEnvironmentId, activeWorktreeId, createTab, setActiveTabType, setTabBarOrder]
|
||||
[
|
||||
activeRuntimeEnvironmentId,
|
||||
activeWorktreeId,
|
||||
createTab,
|
||||
openNewTerminalTabInActiveWorkspace,
|
||||
setActiveTabType,
|
||||
setTabBarOrder
|
||||
]
|
||||
)
|
||||
|
||||
const handleNewBrowserTab = useCallback(() => {
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
const targetGroupId =
|
||||
useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] ??
|
||||
useAppStore.getState().groupsByWorktree[activeWorktreeId]?.[0]?.id
|
||||
if (targetGroupId) {
|
||||
void openNewBrowserTabInActiveWorkspace(targetGroupId)
|
||||
return
|
||||
}
|
||||
const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank'
|
||||
if (isWebRuntimeSessionActive(activeRuntimeEnvironmentId)) {
|
||||
void createWebRuntimeSessionBrowserTab({
|
||||
|
|
@ -706,7 +728,12 @@ function Terminal(): React.JSX.Element | null {
|
|||
title: 'New Browser Tab',
|
||||
focusAddressBar: true
|
||||
})
|
||||
}, [activeRuntimeEnvironmentId, activeWorktreeId, createBrowserTab])
|
||||
}, [
|
||||
activeRuntimeEnvironmentId,
|
||||
activeWorktreeId,
|
||||
createBrowserTab,
|
||||
openNewBrowserTabInActiveWorkspace
|
||||
])
|
||||
|
||||
const handleDuplicateBrowserTab = useCallback(
|
||||
(browserTabId: string) => {
|
||||
|
|
@ -740,29 +767,14 @@ function Terminal(): React.JSX.Element | null {
|
|||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
const worktree = useAppStore.getState().getKnownWorktreeById(activeWorktreeId)
|
||||
if (!worktree) {
|
||||
const targetGroupId =
|
||||
useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] ??
|
||||
useAppStore.getState().groupsByWorktree[activeWorktreeId]?.[0]?.id
|
||||
if (!targetGroupId) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: the global Cmd/Ctrl+Shift+M shortcut is handled here rather than
|
||||
// inside a specific TabGroupPanel, so it must snapshot the store's
|
||||
// current focused group explicitly. Otherwise split layouts fall back to
|
||||
// the ambient/default group and open the file in the wrong pane.
|
||||
const targetGroupId = useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId]
|
||||
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
|
||||
const settings = useAppStore.getState().settings
|
||||
const fileInfo = await createUntitledMarkdownFile(
|
||||
worktree.path,
|
||||
activeWorktreeId,
|
||||
connectionId,
|
||||
settings
|
||||
)
|
||||
openFile(fileInfo, { preview: false, targetGroupId })
|
||||
} catch (err) {
|
||||
toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.'))
|
||||
}
|
||||
}, [activeWorktreeId, openFile])
|
||||
await openNewMarkdownInActiveWorkspace(targetGroupId)
|
||||
}, [activeWorktreeId, openNewMarkdownInActiveWorkspace])
|
||||
|
||||
const handleCloseTab = useCallback(
|
||||
(tabId: string) => {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,22 @@ import {
|
|||
queueBrowserFocusRequest
|
||||
} from '@/components/browser-pane/browser-focus'
|
||||
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
||||
import { useSettingsNavigationMetadata } from '@/hooks/useSettingsNavigationMetadata'
|
||||
import {
|
||||
buildCmdJActionResults,
|
||||
buildCmdJSettingsResults,
|
||||
rankCmdJMiddleResults,
|
||||
type CmdJActionResult,
|
||||
type CmdJSettingsResult
|
||||
} from '@/components/cmd-j/palette-results'
|
||||
import {
|
||||
buildCmdJQuickActionContext,
|
||||
captureCmdJActiveGroupSnapshot,
|
||||
getUnavailableQuickActionMessage,
|
||||
type CmdJActiveGroupSnapshot
|
||||
} from '@/components/cmd-j/quick-action-context'
|
||||
import { CMD_J_QUICK_ACTIONS } from '@/components/cmd-j/quick-actions'
|
||||
import type { SettingsNavTarget } from '@/lib/settings-navigation-types'
|
||||
import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types'
|
||||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||||
|
||||
|
|
@ -64,6 +80,18 @@ type BrowserPaletteItem = {
|
|||
result: BrowserPaletteSearchResult
|
||||
}
|
||||
|
||||
type SettingsPaletteItem = {
|
||||
id: string
|
||||
type: 'settings'
|
||||
result: CmdJSettingsResult
|
||||
}
|
||||
|
||||
type QuickActionPaletteItem = {
|
||||
id: string
|
||||
type: 'quick-action'
|
||||
result: CmdJActionResult
|
||||
}
|
||||
|
||||
type SectionHeader = {
|
||||
id: string
|
||||
type: 'section-header'
|
||||
|
|
@ -76,9 +104,20 @@ type HintRow = {
|
|||
label: string
|
||||
}
|
||||
|
||||
type PaletteItem = WorktreePaletteItem | BrowserPaletteItem
|
||||
type CreateWorktreePaletteItem = {
|
||||
id: typeof CREATE_WORKTREE_ITEM_ID
|
||||
type: 'create-worktree'
|
||||
}
|
||||
|
||||
type PaletteListEntry = PaletteItem | SectionHeader | HintRow
|
||||
// Why: Cmd+J is a fast intent surface, not a dump of every setup button.
|
||||
// Keep future quick actions curated; route one-time setup flows through Settings.
|
||||
type PaletteItem =
|
||||
| WorktreePaletteItem
|
||||
| SettingsPaletteItem
|
||||
| QuickActionPaletteItem
|
||||
| BrowserPaletteItem
|
||||
|
||||
type PaletteListEntry = PaletteItem | CreateWorktreePaletteItem | SectionHeader | HintRow
|
||||
|
||||
type BrowserSelection = {
|
||||
worktree: Worktree
|
||||
|
|
@ -148,10 +187,22 @@ function findBrowserSelection(
|
|||
return { page, workspace, worktree }
|
||||
}
|
||||
|
||||
function getSettingsTargetFromSectionId(sectionId: string): {
|
||||
pane: SettingsNavTarget
|
||||
repoId: string | null
|
||||
} {
|
||||
if (sectionId.startsWith('repo-')) {
|
||||
return { pane: 'repo', repoId: sectionId.slice('repo-'.length) }
|
||||
}
|
||||
return { pane: sectionId as SettingsNavTarget, repoId: null }
|
||||
}
|
||||
|
||||
export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
const visible = useAppStore((s) => s.activeModal === 'worktree-palette')
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
|
|
@ -176,11 +227,22 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
|
||||
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
|
||||
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
|
||||
useAppStore((s) => s.activeGroupIdByWorktree)
|
||||
useAppStore((s) => s.groupsByWorktree)
|
||||
useAppStore((s) => s.settings?.activeRuntimeEnvironmentId)
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
|
||||
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
|
||||
const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId)
|
||||
const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null)
|
||||
const openNewBrowserTabInActiveWorkspace = useAppStore(
|
||||
(s) => s.openNewBrowserTabInActiveWorkspace
|
||||
)
|
||||
const openNewMarkdownInActiveWorkspace = useAppStore((s) => s.openNewMarkdownInActiveWorkspace)
|
||||
const openNewTerminalTabInActiveWorkspace = useAppStore(
|
||||
(s) => s.openNewTerminalTabInActiveWorkspace
|
||||
)
|
||||
const settingsSections = useSettingsNavigationMetadata()
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const deferredQuery = useDeferredValue(query)
|
||||
|
|
@ -189,6 +251,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal'>('terminal')
|
||||
const previousBrowserPageIdRef = useRef<string | null>(null)
|
||||
const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview')
|
||||
const activeGroupSnapshotRef = useRef<CmdJActiveGroupSnapshot | null>(null)
|
||||
const wasVisibleRef = useRef(false)
|
||||
const skipRestoreFocusRef = useRef(false)
|
||||
const prevQueryRef = useRef('')
|
||||
|
|
@ -200,6 +263,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
const canCreateWorktree = repos.length > 0
|
||||
|
||||
const hasQuery = deferredQuery.trim().length > 0
|
||||
const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0
|
||||
|
||||
// Why: the empty-query palette mirrors sidebar filters so opening Search
|
||||
// starts from the same quiet list. Typed search switches to the global
|
||||
|
|
@ -411,6 +475,61 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
[browserMatches]
|
||||
)
|
||||
|
||||
const settingsResults = useMemo(
|
||||
() => buildCmdJSettingsResults(settingsSections),
|
||||
[settingsSections]
|
||||
)
|
||||
const actionResults = useMemo(() => buildCmdJActionResults(CMD_J_QUICK_ACTIONS), [])
|
||||
|
||||
const openCreateWorkspaceAction = useCallback(() => {
|
||||
queueMicrotask(() =>
|
||||
openModal('new-workspace-composer', { telemetrySource: 'command_palette' })
|
||||
)
|
||||
}, [openModal])
|
||||
|
||||
const openAddQuickCommandAction = useCallback(() => {
|
||||
openSettingsTarget({ pane: 'quick-commands', repoId: null, intent: 'add-quick-command' })
|
||||
openSettingsPage()
|
||||
}, [openSettingsPage, openSettingsTarget])
|
||||
|
||||
const buildQuickActionContext = useCallback(
|
||||
() =>
|
||||
buildCmdJQuickActionContext({
|
||||
state: useAppStore.getState(),
|
||||
activeGroupSnapshot: activeGroupSnapshotRef.current,
|
||||
openNewBrowserTab: openNewBrowserTabInActiveWorkspace,
|
||||
openNewMarkdownFile: openNewMarkdownInActiveWorkspace,
|
||||
openNewTerminalTab: openNewTerminalTabInActiveWorkspace,
|
||||
openCreateWorkspace: openCreateWorkspaceAction,
|
||||
openAddQuickCommand: openAddQuickCommandAction
|
||||
}),
|
||||
[
|
||||
openAddQuickCommandAction,
|
||||
openCreateWorkspaceAction,
|
||||
openNewBrowserTabInActiveWorkspace,
|
||||
openNewMarkdownInActiveWorkspace,
|
||||
openNewTerminalTabInActiveWorkspace
|
||||
]
|
||||
)
|
||||
|
||||
const quickActionContext = buildQuickActionContext()
|
||||
|
||||
const middleItems = useMemo<(SettingsPaletteItem | QuickActionPaletteItem)[]>(
|
||||
() =>
|
||||
rankCmdJMiddleResults({
|
||||
query: deferredQuery,
|
||||
settingsResults,
|
||||
actionResults: actionResults.filter(
|
||||
(action) => action.isAvailable(quickActionContext).available
|
||||
)
|
||||
}).map((result) =>
|
||||
result.kind === 'settings'
|
||||
? { id: result.id, type: 'settings' as const, result }
|
||||
: { id: `quick-action:${result.id}`, type: 'quick-action' as const, result }
|
||||
),
|
||||
[actionResults, deferredQuery, quickActionContext, settingsResults]
|
||||
)
|
||||
|
||||
// Why: on empty query we cap the worktree section (not browser tabs) so the
|
||||
// BROWSER TABS header + ≥1 page row stays visible above the fold — users
|
||||
// with 30+ worktrees would otherwise never see browser pages. The cap is
|
||||
|
|
@ -422,68 +541,34 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
const EMPTY_QUERY_WORKTREE_CAP = 5
|
||||
const EMPTY_QUERY_BROWSER_CAP = 5
|
||||
|
||||
const listEntries = useMemo<PaletteListEntry[]>(() => {
|
||||
const entries: PaletteListEntry[] = []
|
||||
|
||||
const paletteSections = useMemo(() => {
|
||||
// Why: the worktree cap only earns its keep when there are browser tabs
|
||||
// to protect above-the-fold. With zero browser pages, capping would force
|
||||
// the user to type for no reason — uncap so the recent list fills the
|
||||
// viewport naturally.
|
||||
const worktreeCap = !hasQuery && browserItems.length > 0 ? EMPTY_QUERY_WORKTREE_CAP : Infinity
|
||||
const visibleWorktreeItems = hasQuery ? worktreeItems : worktreeItems.slice(0, worktreeCap)
|
||||
const visibleMiddleItems = hasQuery ? middleItems : []
|
||||
const visibleBrowserItems = hasQuery
|
||||
? browserItems
|
||||
: browserItems.slice(0, EMPTY_QUERY_BROWSER_CAP)
|
||||
|
||||
// Header rule: on empty query each section is categorically distinct
|
||||
// (worktrees vs. tabs), so a lone header is a useful signpost. On query,
|
||||
// suppress headers unless both sections are populated — otherwise a lone
|
||||
// header above one list is noise.
|
||||
const showWorktreeHeader = hasQuery
|
||||
? visibleWorktreeItems.length > 0 && visibleBrowserItems.length > 0
|
||||
: visibleWorktreeItems.length > 0
|
||||
const showBrowserHeader = hasQuery
|
||||
? visibleWorktreeItems.length > 0 && visibleBrowserItems.length > 0
|
||||
: visibleBrowserItems.length > 0
|
||||
|
||||
// Why: only surface the hint when there's actually something hidden,
|
||||
// otherwise the row would be a lie.
|
||||
const showWorktreeHint = !hasQuery && worktreeItems.length > worktreeCap
|
||||
|
||||
if (visibleWorktreeItems.length > 0) {
|
||||
if (showWorktreeHeader) {
|
||||
entries.push({
|
||||
id: '__header_worktrees__',
|
||||
type: 'section-header',
|
||||
label: hasQuery ? 'Workspaces' : 'Recent Workspaces'
|
||||
})
|
||||
}
|
||||
entries.push(...visibleWorktreeItems)
|
||||
if (showWorktreeHint) {
|
||||
entries.push({
|
||||
id: '__hint_worktree_cap__',
|
||||
type: 'hint',
|
||||
label: `Type to see all ${worktreeItems.length} workspaces`
|
||||
})
|
||||
}
|
||||
return {
|
||||
visibleWorktreeItems,
|
||||
visibleMiddleItems,
|
||||
visibleBrowserItems,
|
||||
showWorktreeHint
|
||||
}
|
||||
if (visibleBrowserItems.length > 0) {
|
||||
if (showBrowserHeader) {
|
||||
entries.push({
|
||||
id: '__header_browser__',
|
||||
type: 'section-header',
|
||||
label: 'Browser Tabs'
|
||||
})
|
||||
}
|
||||
entries.push(...visibleBrowserItems)
|
||||
}
|
||||
return entries
|
||||
}, [worktreeItems, browserItems, hasQuery])
|
||||
}, [worktreeItems, middleItems, browserItems, hasQuery])
|
||||
|
||||
const selectableItems = useMemo<PaletteItem[]>(
|
||||
() =>
|
||||
listEntries.filter((e): e is PaletteItem => e.type !== 'section-header' && e.type !== 'hint'),
|
||||
[listEntries]
|
||||
() => [
|
||||
...paletteSections.visibleWorktreeItems,
|
||||
...paletteSections.visibleMiddleItems,
|
||||
...paletteSections.visibleBrowserItems
|
||||
],
|
||||
[paletteSections]
|
||||
)
|
||||
|
||||
const selectableItemIds = useMemo(() => selectableItems.map((item) => item.id), [selectableItems])
|
||||
|
|
@ -498,7 +583,74 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
[canCreateWorktree, deferredQuery, selectableItemIds]
|
||||
)
|
||||
|
||||
const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0
|
||||
const listEntries = useMemo<PaletteListEntry[]>(() => {
|
||||
const entries: PaletteListEntry[] = []
|
||||
const { visibleWorktreeItems, visibleMiddleItems, visibleBrowserItems, showWorktreeHint } =
|
||||
paletteSections
|
||||
const visibleWorkspaceItemCount = visibleWorktreeItems.length + (showCreateAction ? 1 : 0)
|
||||
const populatedSectionCount = [
|
||||
visibleWorkspaceItemCount,
|
||||
visibleMiddleItems.length,
|
||||
visibleBrowserItems.length
|
||||
].filter((count) => count > 0).length
|
||||
|
||||
// Header rule: on empty query each section is categorically distinct
|
||||
// (worktrees vs. tabs), so a lone header is a useful signpost. On query,
|
||||
// suppress headers unless both sections are populated — otherwise a lone
|
||||
// header above one list is noise.
|
||||
const showWorktreeHeader = hasQuery
|
||||
? visibleWorkspaceItemCount > 0 && populatedSectionCount > 1
|
||||
: visibleWorktreeItems.length > 0
|
||||
const showBrowserHeader = hasQuery
|
||||
? visibleBrowserItems.length > 0 && populatedSectionCount > 1
|
||||
: visibleBrowserItems.length > 0
|
||||
const showMiddleHeader = hasQuery && visibleMiddleItems.length > 0 && populatedSectionCount > 1
|
||||
|
||||
if (visibleWorkspaceItemCount > 0) {
|
||||
if (showWorktreeHeader) {
|
||||
entries.push({
|
||||
id: '__header_worktrees__',
|
||||
type: 'section-header',
|
||||
label: hasQuery ? 'Workspaces' : 'Recent Workspaces'
|
||||
})
|
||||
}
|
||||
entries.push(...visibleWorktreeItems)
|
||||
if (showCreateAction) {
|
||||
// Why: the typed create affordance is workspace-scoped, so keep it
|
||||
// directly under workspace matches instead of after actions/tabs.
|
||||
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
|
||||
}
|
||||
if (showWorktreeHint) {
|
||||
entries.push({
|
||||
id: '__hint_worktree_cap__',
|
||||
type: 'hint',
|
||||
label: `Type to see all ${worktreeItems.length} workspaces`
|
||||
})
|
||||
}
|
||||
}
|
||||
if (visibleMiddleItems.length > 0) {
|
||||
if (showMiddleHeader) {
|
||||
entries.push({
|
||||
id: '__header_actions_settings__',
|
||||
type: 'section-header',
|
||||
label: 'Actions & Settings'
|
||||
})
|
||||
}
|
||||
entries.push(...visibleMiddleItems)
|
||||
}
|
||||
if (visibleBrowserItems.length > 0) {
|
||||
if (showBrowserHeader) {
|
||||
entries.push({
|
||||
id: '__header_browser__',
|
||||
type: 'section-header',
|
||||
label: 'Browser Tabs'
|
||||
})
|
||||
}
|
||||
entries.push(...visibleBrowserItems)
|
||||
}
|
||||
return entries
|
||||
}, [hasQuery, paletteSections, showCreateAction, worktreeItems.length])
|
||||
|
||||
// Why: empty-state / "has any worktrees?" uses the full visible list
|
||||
// (including current) so the palette never claims to be empty just
|
||||
// because the only visible worktree is the currently active one.
|
||||
|
|
@ -506,10 +658,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
const hasAnyWorktrees = visibleWorktreesForState.length > 0
|
||||
const hasAnySearchableWorktrees = hasQuery ? searchScopeWorktrees.length > 0 : hasAnyWorktrees
|
||||
const hasAnyBrowserPages = browserPageEntries.length > 0
|
||||
const hasAnyMiddleResults = middleItems.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && !wasVisibleRef.current) {
|
||||
createLookupGuard.invalidate()
|
||||
activeGroupSnapshotRef.current = captureCmdJActiveGroupSnapshot(
|
||||
useAppStore.getState(),
|
||||
activeWorktreeId
|
||||
)
|
||||
previousWorktreeIdRef.current = activeWorktreeId
|
||||
previousActiveTabTypeRef.current = activeTabType
|
||||
previousBrowserPageIdRef.current =
|
||||
|
|
@ -541,6 +698,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
} else {
|
||||
createLookupGuard.invalidate()
|
||||
}
|
||||
activeGroupSnapshotRef.current = null
|
||||
}
|
||||
|
||||
wasVisibleRef.current = visible
|
||||
|
|
@ -679,15 +837,46 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
[closeModal, requestBrowserFocus]
|
||||
)
|
||||
|
||||
const handleSelectSettings = useCallback(
|
||||
(result: CmdJSettingsResult) => {
|
||||
const target = getSettingsTargetFromSectionId(result.sectionId)
|
||||
skipRestoreFocusRef.current = true
|
||||
closeModal()
|
||||
setSelectedItemId('')
|
||||
openSettingsTarget(target)
|
||||
openSettingsPage()
|
||||
},
|
||||
[closeModal, openSettingsPage, openSettingsTarget]
|
||||
)
|
||||
|
||||
const handleSelectQuickAction = useCallback(
|
||||
(action: CmdJActionResult) => {
|
||||
skipRestoreFocusRef.current = true
|
||||
closeModal()
|
||||
setSelectedItemId('')
|
||||
const ctx = buildQuickActionContext()
|
||||
void action.run(ctx).then((result) => {
|
||||
if (result.status === 'unavailable') {
|
||||
toast.error(getUnavailableQuickActionMessage(action.title, result.reason))
|
||||
}
|
||||
})
|
||||
},
|
||||
[buildQuickActionContext, closeModal]
|
||||
)
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(item: PaletteItem) => {
|
||||
if (item.type === 'worktree') {
|
||||
handleSelectWorktree(item.worktree.id)
|
||||
} else {
|
||||
} else if (item.type === 'browser-page') {
|
||||
handleSelectBrowserPage(item.result)
|
||||
} else if (item.type === 'settings') {
|
||||
handleSelectSettings(item.result)
|
||||
} else {
|
||||
handleSelectQuickAction(item.result)
|
||||
}
|
||||
},
|
||||
[handleSelectBrowserPage, handleSelectWorktree]
|
||||
[handleSelectBrowserPage, handleSelectQuickAction, handleSelectSettings, handleSelectWorktree]
|
||||
)
|
||||
|
||||
const handleCreateWorktree = useCallback(() => {
|
||||
|
|
@ -862,10 +1051,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
|
||||
const resultCount = selectableItems.length
|
||||
const emptyState = (() => {
|
||||
if ((hasAnySearchableWorktrees || hasAnyBrowserPages) && hasQuery) {
|
||||
if ((hasAnySearchableWorktrees || hasAnyMiddleResults || hasAnyBrowserPages) && hasQuery) {
|
||||
return {
|
||||
title: 'No results match your search',
|
||||
subtitle: 'Try a name, branch, repo, port, comment, PR, page title, or URL.'
|
||||
subtitle: 'Try a workspace, setting, action, page title, URL, PR, or port.'
|
||||
}
|
||||
}
|
||||
// Why: empty-query rows exclude the current worktree, so a single-worktree
|
||||
|
|
@ -875,12 +1064,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
if (!hasQuery && hasAnyWorktrees && !hasAnyBrowserPages) {
|
||||
return {
|
||||
title: 'No other worktrees to switch to',
|
||||
subtitle: 'Type to search or create a new worktree.'
|
||||
subtitle: 'Type to search workspaces, settings, tabs, and actions.'
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'No active worktrees or browser tabs',
|
||||
subtitle: 'Create a worktree or open a page in Orca to get started.'
|
||||
title: 'No active worktrees, settings, actions, or browser tabs',
|
||||
subtitle: 'Create a workspace or open a page in Orca to get started.'
|
||||
}
|
||||
})()
|
||||
|
||||
|
|
@ -892,7 +1081,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
onOpenAutoFocus={handleOpenAutoFocus}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
title="Jump to..."
|
||||
description="Search worktrees and browser tabs"
|
||||
description="Search workspaces, settings, tabs, and actions"
|
||||
overlayClassName="bg-black/55 backdrop-blur-[2px]"
|
||||
contentClassName="top-[13%] w-[736px] max-w-[94vw] overflow-hidden rounded-xl border border-border/70 bg-background/96 shadow-[0_26px_84px_rgba(0,0,0,0.32)] backdrop-blur-xl"
|
||||
commandProps={{
|
||||
|
|
@ -903,7 +1092,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
}}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder={'Jump to worktree or browser tab\u2026 try "repo/worktree"'}
|
||||
placeholder="Search workspaces, settings, tabs, and actions..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
wrapperClassName="mx-3 mt-3 rounded-lg border border-border/55 bg-muted/28 px-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
|
||||
|
|
@ -911,7 +1100,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
className="h-12 text-[14px] placeholder:text-muted-foreground/75"
|
||||
/>
|
||||
<CommandList ref={listRef} className="max-h-[min(460px,62vh)] px-2.5 pb-2.5 pt-2">
|
||||
{isLoading ? (
|
||||
{isLoading && selectableItems.length === 0 && !showCreateAction ? (
|
||||
<PaletteState
|
||||
title="Loading jump targets"
|
||||
subtitle="Gathering your recent worktrees and open browser pages."
|
||||
|
|
@ -947,6 +1136,26 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
)
|
||||
}
|
||||
|
||||
if (entry.type === 'create-worktree') {
|
||||
return (
|
||||
<CommandItem
|
||||
key={entry.id}
|
||||
value={CREATE_WORKTREE_ITEM_ID}
|
||||
onSelect={handleCreateWorktree}
|
||||
className="group mx-0.5 mt-1 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-1.5 text-left outline-none transition-[background-color,border-color,box-shadow] data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground"
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-dashed border-border/60 bg-muted/25 text-muted-foreground/70">
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{`Create workspace "${createWorktreeName}"`}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
if (entry.type === 'worktree') {
|
||||
const worktree = entry.worktree
|
||||
const repo = repoMap.get(worktree.repoId)
|
||||
|
|
@ -1069,6 +1278,40 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
)
|
||||
}
|
||||
|
||||
if (entry.type === 'settings' || entry.type === 'quick-action') {
|
||||
const result = entry.result
|
||||
const Icon = result.icon
|
||||
const kindLabel = entry.type === 'settings' ? 'Settings' : 'Action'
|
||||
return (
|
||||
<CommandItem
|
||||
key={entry.id}
|
||||
value={entry.id}
|
||||
onSelect={() => handleSelectItem(entry)}
|
||||
className={cn(
|
||||
'group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]',
|
||||
'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5 text-muted-foreground/85">
|
||||
<Icon className="size-3.5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{result.title}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
|
||||
{kindLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[12px] leading-5 text-muted-foreground/88">
|
||||
{result.description}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
const result = entry.result
|
||||
const browserWorktree = worktreeMap.get(result.worktreeId)
|
||||
const browserRepo = browserWorktree ? repoMap.get(browserWorktree.repoId) : undefined
|
||||
|
|
@ -1138,25 +1381,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
{showCreateAction && (
|
||||
// Why: render the create action last so cmdk does not briefly
|
||||
// auto-select it before our effect promotes the first real match
|
||||
// when the query only matches browser pages.
|
||||
<CommandItem
|
||||
value={CREATE_WORKTREE_ITEM_ID}
|
||||
onSelect={handleCreateWorktree}
|
||||
className="group mx-0.5 mt-1 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-1.5 text-left outline-none transition-[background-color,border-color,box-shadow] data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground"
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-dashed border-border/60 bg-muted/25 text-muted-foreground/70">
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{`Create workspace "${createWorktreeName}"`}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { Globe, Settings } from 'lucide-react'
|
||||
import type { CmdJQuickAction } from './quick-actions'
|
||||
import {
|
||||
buildCmdJActionResults,
|
||||
buildCmdJSettingsResults,
|
||||
rankCmdJMiddleResults
|
||||
} from './palette-results'
|
||||
import type { SettingsNavSection } from '@/lib/settings-navigation-types'
|
||||
|
||||
const noopRun: CmdJQuickAction['run'] = async () => ({ status: 'ok' })
|
||||
const available: CmdJQuickAction['isAvailable'] = () => ({ available: true })
|
||||
|
||||
const actions: CmdJQuickAction[] = [
|
||||
{
|
||||
id: 'new-browser-tab',
|
||||
kind: 'action',
|
||||
title: 'New Browser Tab',
|
||||
description: 'Open a browser tab.',
|
||||
icon: Globe,
|
||||
verbKeywords: ['new browser', 'new browser tab'],
|
||||
isAvailable: available,
|
||||
run: noopRun
|
||||
},
|
||||
{
|
||||
id: 'new-terminal-tab',
|
||||
kind: 'action',
|
||||
title: 'New Terminal Tab',
|
||||
description: 'Open a terminal tab.',
|
||||
icon: Globe,
|
||||
verbKeywords: ['new terminal', 'new terminal tab'],
|
||||
isAvailable: available,
|
||||
run: noopRun
|
||||
},
|
||||
{
|
||||
id: 'new-markdown-file',
|
||||
kind: 'action',
|
||||
title: 'New Markdown File',
|
||||
description: 'Create markdown.',
|
||||
icon: Globe,
|
||||
verbKeywords: ['new markdown', 'new mark'],
|
||||
isAvailable: available,
|
||||
run: noopRun
|
||||
},
|
||||
{
|
||||
id: 'create-workspace',
|
||||
kind: 'action',
|
||||
title: 'Create Workspace',
|
||||
description: 'Create workspace.',
|
||||
icon: Globe,
|
||||
verbKeywords: ['create workspace', 'add workspace', 'new workspace'],
|
||||
isAvailable: available,
|
||||
run: noopRun
|
||||
},
|
||||
{
|
||||
id: 'add-quick-command',
|
||||
kind: 'action',
|
||||
title: 'Add Quick Command',
|
||||
description: 'Create a saved terminal command.',
|
||||
icon: Globe,
|
||||
verbKeywords: ['add quick command', 'new quick command'],
|
||||
isAvailable: available,
|
||||
run: noopRun
|
||||
}
|
||||
]
|
||||
|
||||
const sections: SettingsNavSection[] = [
|
||||
{
|
||||
id: 'terminal',
|
||||
title: 'Terminal',
|
||||
description: 'Shell configuration.',
|
||||
icon: Settings,
|
||||
searchEntries: [{ title: 'Terminal Font' }],
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
title: 'Browser',
|
||||
description: 'Cookie import setup.',
|
||||
icon: Settings,
|
||||
searchEntries: [{ title: 'Default Browser URL' }],
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'ssh',
|
||||
title: 'SSH Hosts',
|
||||
description: 'Remote hosts.',
|
||||
icon: Settings,
|
||||
searchEntries: [{ title: 'Remote Shell' }],
|
||||
group: 'remote'
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
title: 'Appearance',
|
||||
description: 'Theme and chrome.',
|
||||
icon: Settings,
|
||||
searchEntries: [{ title: 'Theme' }],
|
||||
group: 'interface'
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
title: 'Agents',
|
||||
description: 'Manage AI agents.',
|
||||
icon: Settings,
|
||||
searchEntries: [{ title: 'Default Agent' }],
|
||||
group: 'setup'
|
||||
},
|
||||
{
|
||||
id: 'quick-commands',
|
||||
title: 'Quick Commands',
|
||||
description: 'Saved commands.',
|
||||
icon: Settings,
|
||||
searchEntries: [{ title: 'Command Scope' }],
|
||||
group: 'workflows'
|
||||
}
|
||||
]
|
||||
|
||||
function top(query: string): string | undefined {
|
||||
return rankCmdJMiddleResults({
|
||||
query,
|
||||
settingsResults: buildCmdJSettingsResults(sections),
|
||||
actionResults: buildCmdJActionResults(actions)
|
||||
})[0]?.id
|
||||
}
|
||||
|
||||
describe('Cmd+J palette middle-band ranking', () => {
|
||||
it.each([
|
||||
['new terminal', 'new-terminal-tab'],
|
||||
['new markdown', 'new-markdown-file'],
|
||||
['new browser', 'new-browser-tab'],
|
||||
['create workspace', 'create-workspace'],
|
||||
['add workspace', 'create-workspace'],
|
||||
['new workspace', 'create-workspace'],
|
||||
['terminal settings', 'settings:terminal'],
|
||||
['browser settings', 'settings:browser'],
|
||||
['ssh', 'settings:ssh'],
|
||||
['agents', 'settings:agents'],
|
||||
['new terminal settings', 'settings:terminal'],
|
||||
['new mark', 'new-markdown-file'],
|
||||
['appear', 'settings:appearance'],
|
||||
['terminal', 'settings:terminal'],
|
||||
['browser', 'settings:browser'],
|
||||
['quick commands', 'settings:quick-commands'],
|
||||
['add quick command', 'add-quick-command']
|
||||
])('ranks %s first', (query, expectedId) => {
|
||||
expect(top(query)).toBe(expectedId)
|
||||
})
|
||||
|
||||
it('does not match settings on one-character or description-only queries', () => {
|
||||
expect(top('t')).toBeUndefined()
|
||||
expect(top('cookie import')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { SettingsNavSection } from '@/lib/settings-navigation-types'
|
||||
import type { CmdJQuickAction } from './quick-actions'
|
||||
|
||||
export type CmdJSettingsResult = {
|
||||
id: string
|
||||
kind: 'settings'
|
||||
title: string
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
sectionId: string
|
||||
order: number
|
||||
configKeywords: string[]
|
||||
}
|
||||
|
||||
export type CmdJActionResult = CmdJQuickAction & {
|
||||
order: number
|
||||
}
|
||||
|
||||
export type CmdJMiddleResult = CmdJSettingsResult | CmdJActionResult
|
||||
|
||||
type RankedResult = {
|
||||
result: CmdJMiddleResult
|
||||
rule: number
|
||||
score: number
|
||||
}
|
||||
|
||||
const SETTINGS_ALIASES: Record<string, string[]> = {
|
||||
browser: ['browser settings'],
|
||||
terminal: ['terminal settings'],
|
||||
ssh: ['ssh'],
|
||||
shortcuts: ['keyboard shortcuts'],
|
||||
appearance: ['theme', 'themes'],
|
||||
agents: ['ai agents'],
|
||||
'quick-commands': ['quick commands', 'quick command'],
|
||||
repo: ['repository settings', 'project settings'],
|
||||
integrations: ['gitlab', 'github', 'linear'],
|
||||
notifications: ['notification settings'],
|
||||
mobile: ['phone'],
|
||||
voice: ['dictation'],
|
||||
'computer-use': ['computer use'],
|
||||
stats: ['usage'],
|
||||
privacy: ['telemetry']
|
||||
}
|
||||
|
||||
function normalizeQuery(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
function keywordParts(section: SettingsNavSection): string[] {
|
||||
const baseId = section.id.startsWith('repo-') ? 'repo' : section.id
|
||||
const idWords = baseId.replace(/-/g, ' ')
|
||||
return [
|
||||
section.id,
|
||||
baseId,
|
||||
idWords,
|
||||
section.title,
|
||||
`${section.title} settings`,
|
||||
`${idWords} settings`,
|
||||
...(SETTINGS_ALIASES[baseId] ?? []),
|
||||
...section.searchEntries.map((entry) => entry.title)
|
||||
]
|
||||
}
|
||||
|
||||
function uniqueNormalized(values: readonly string[]): string[] {
|
||||
return [...new Set(values.map(normalizeQuery).filter(Boolean))]
|
||||
}
|
||||
|
||||
export function buildCmdJSettingsResults(
|
||||
sections: readonly SettingsNavSection[]
|
||||
): CmdJSettingsResult[] {
|
||||
return sections.map((section, order) => ({
|
||||
id: `settings:${section.id}`,
|
||||
kind: 'settings',
|
||||
title: section.title,
|
||||
description: section.description,
|
||||
icon: section.icon,
|
||||
sectionId: section.id,
|
||||
order,
|
||||
configKeywords: uniqueNormalized(keywordParts(section))
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildCmdJActionResults(actions: readonly CmdJQuickAction[]): CmdJActionResult[] {
|
||||
return actions.map((action, order) => ({ ...action, order }))
|
||||
}
|
||||
|
||||
function startsOrIsStartedBy(query: string, keyword: string): boolean {
|
||||
return keyword.startsWith(query) || query.startsWith(keyword)
|
||||
}
|
||||
|
||||
function tokenize(value: string): string[] {
|
||||
return normalizeQuery(value)
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function tokenScore(query: string, values: readonly string[]): number {
|
||||
const candidateTokens = values.flatMap(tokenize)
|
||||
if (candidateTokens.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let score = 0
|
||||
for (const queryToken of tokenize(query)) {
|
||||
let best = 0
|
||||
for (const candidateToken of candidateTokens) {
|
||||
if (candidateToken === queryToken) {
|
||||
best = Math.max(best, 3)
|
||||
} else if (candidateToken.startsWith(queryToken)) {
|
||||
best = Math.max(best, 2)
|
||||
} else if (candidateToken.includes(queryToken)) {
|
||||
best = Math.max(best, 1)
|
||||
}
|
||||
}
|
||||
score += best
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
function rankingForCandidate(
|
||||
query: string,
|
||||
candidate: CmdJMiddleResult,
|
||||
actionVerbKeywords: readonly string[],
|
||||
settingsConfigKeywords: readonly string[]
|
||||
): RankedResult | null {
|
||||
if (!query) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (candidate.kind === 'action' && candidate.verbKeywords.some((keyword) => query === keyword)) {
|
||||
return { result: candidate, rule: 1, score: 0 }
|
||||
}
|
||||
|
||||
if (
|
||||
candidate.kind === 'settings' &&
|
||||
candidate.configKeywords.some((keyword) => query === keyword)
|
||||
) {
|
||||
return { result: candidate, rule: 2, score: 0 }
|
||||
}
|
||||
|
||||
if (
|
||||
candidate.kind === 'settings' &&
|
||||
actionVerbKeywords.some((keyword) => query.startsWith(keyword)) &&
|
||||
candidate.configKeywords.some((keyword) => query.endsWith(keyword))
|
||||
) {
|
||||
return { result: candidate, rule: 3, score: 0 }
|
||||
}
|
||||
|
||||
if (
|
||||
candidate.kind === 'action' &&
|
||||
candidate.verbKeywords.some((keyword) => startsOrIsStartedBy(query, keyword)) &&
|
||||
!settingsConfigKeywords.some((keyword) => query.endsWith(keyword))
|
||||
) {
|
||||
return { result: candidate, rule: 4, score: 0 }
|
||||
}
|
||||
|
||||
if (
|
||||
candidate.kind === 'settings' &&
|
||||
candidate.configKeywords.some((keyword) => keyword.startsWith(query) && keyword !== query)
|
||||
) {
|
||||
return { result: candidate, rule: 5, score: 0 }
|
||||
}
|
||||
|
||||
const values =
|
||||
candidate.kind === 'settings'
|
||||
? [candidate.title, ...candidate.configKeywords]
|
||||
: [candidate.title, ...candidate.verbKeywords]
|
||||
const score = tokenScore(query, values)
|
||||
return score > 0 ? { result: candidate, rule: 6, score } : null
|
||||
}
|
||||
|
||||
function compareRanked(a: RankedResult, b: RankedResult): number {
|
||||
if (a.rule !== b.rule) {
|
||||
return a.rule - b.rule
|
||||
}
|
||||
if (a.rule === 6 && a.score !== b.score) {
|
||||
return b.score - a.score
|
||||
}
|
||||
if (a.result.kind !== b.result.kind) {
|
||||
return a.result.kind === 'settings' ? -1 : 1
|
||||
}
|
||||
if (a.result.order !== b.result.order) {
|
||||
return a.result.order - b.result.order
|
||||
}
|
||||
return a.result.id.localeCompare(b.result.id)
|
||||
}
|
||||
|
||||
export function rankCmdJMiddleResults({
|
||||
query,
|
||||
settingsResults,
|
||||
actionResults
|
||||
}: {
|
||||
query: string
|
||||
settingsResults: readonly CmdJSettingsResult[]
|
||||
actionResults: readonly CmdJActionResult[]
|
||||
}): CmdJMiddleResult[] {
|
||||
const normalizedQuery = normalizeQuery(query)
|
||||
if (normalizedQuery.length < 2) {
|
||||
return []
|
||||
}
|
||||
const settings = settingsResults
|
||||
const actions = actionResults
|
||||
const actionVerbKeywords = actions.flatMap((action) => action.verbKeywords)
|
||||
const settingsConfigKeywords = settings.flatMap((setting) => setting.configKeywords)
|
||||
|
||||
return [...settings, ...actions]
|
||||
.map((candidate) =>
|
||||
rankingForCandidate(normalizedQuery, candidate, actionVerbKeywords, settingsConfigKeywords)
|
||||
)
|
||||
.filter((entry): entry is RankedResult => entry !== null)
|
||||
.sort(compareRanked)
|
||||
.map((entry) => entry.result)
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildCmdJQuickActionContext,
|
||||
getWorkspaceScopedActionAvailability,
|
||||
resolveCmdJActiveGroupId,
|
||||
type CmdJQuickActionContext
|
||||
} from './quick-action-context'
|
||||
import { CMD_J_QUICK_ACTIONS } from './quick-actions'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
|
||||
type GroupState = Pick<AppState, 'activeGroupIdByWorktree' | 'groupsByWorktree'>
|
||||
|
||||
function ctx(
|
||||
overrides: Partial<
|
||||
Pick<CmdJQuickActionContext, 'activeGroupId' | 'activeWorktreeId' | 'isLoading' | 'sshStatus'>
|
||||
>
|
||||
): Pick<CmdJQuickActionContext, 'activeGroupId' | 'activeWorktreeId' | 'isLoading' | 'sshStatus'> {
|
||||
return {
|
||||
activeGroupId: 'group-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
isLoading: false,
|
||||
sshStatus: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('Cmd+J quick action context', () => {
|
||||
it('resolves the snapshot group, then falls back when stale or missing', () => {
|
||||
const state: GroupState = {
|
||||
activeGroupIdByWorktree: { 'wt-1': 'focused-group' },
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{ id: 'first-group', worktreeId: 'wt-1', activeTabId: null, tabOrder: [] },
|
||||
{ id: 'focused-group', worktreeId: 'wt-1', activeTabId: null, tabOrder: [] }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveCmdJActiveGroupId(state, 'wt-1', {
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'focused-group'
|
||||
})
|
||||
).toBe('focused-group')
|
||||
expect(
|
||||
resolveCmdJActiveGroupId(state, 'wt-1', {
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'closed-group'
|
||||
})
|
||||
).toBe('first-group')
|
||||
expect(resolveCmdJActiveGroupId(state, 'wt-1', null)).toBe('focused-group')
|
||||
})
|
||||
|
||||
it('applies workspace-scoped action availability gates in order', () => {
|
||||
expect(getWorkspaceScopedActionAvailability(ctx({ activeWorktreeId: null }))).toEqual({
|
||||
available: false,
|
||||
reason: 'no-active-workspace'
|
||||
})
|
||||
expect(getWorkspaceScopedActionAvailability(ctx({ isLoading: true }))).toEqual({
|
||||
available: false,
|
||||
reason: 'loading'
|
||||
})
|
||||
expect(getWorkspaceScopedActionAvailability(ctx({ sshStatus: 'disconnected' }))).toEqual({
|
||||
available: false,
|
||||
reason: 'ssh-disconnected'
|
||||
})
|
||||
expect(getWorkspaceScopedActionAvailability(ctx({ activeGroupId: null }))).toEqual({
|
||||
available: false,
|
||||
reason: 'no-active-group'
|
||||
})
|
||||
expect(getWorkspaceScopedActionAvailability(ctx({}))).toEqual({ available: true })
|
||||
})
|
||||
|
||||
it('keeps workspace-agnostic actions available while loading without an active workspace', () => {
|
||||
const context = {
|
||||
...ctx({ activeWorktreeId: null, activeGroupId: null, isLoading: true }),
|
||||
activeWorktree: null,
|
||||
runtimeMode: 'local-desktop' as const,
|
||||
openNewBrowserTab: async () => {},
|
||||
openNewMarkdownFile: async () => {},
|
||||
openNewTerminalTab: async () => {},
|
||||
openCreateWorkspace: () => {},
|
||||
openAddQuickCommand: () => {}
|
||||
} satisfies CmdJQuickActionContext
|
||||
|
||||
expect(
|
||||
CMD_J_QUICK_ACTIONS.find((action) => action.id === 'new-terminal-tab')?.isAvailable(context)
|
||||
).toEqual({ available: false, reason: 'no-active-workspace' })
|
||||
expect(
|
||||
CMD_J_QUICK_ACTIONS.find((action) => action.id === 'create-workspace')?.isAvailable(context)
|
||||
).toEqual({ available: true })
|
||||
expect(
|
||||
CMD_J_QUICK_ACTIONS.find((action) => action.id === 'add-quick-command')?.isAvailable(context)
|
||||
).toEqual({ available: true })
|
||||
})
|
||||
|
||||
it('applies the availability matrix across curated actions', () => {
|
||||
const workspaceActions = ['new-browser-tab', 'new-markdown-file', 'new-terminal-tab']
|
||||
const workspaceAgnosticActions = ['create-workspace', 'add-quick-command']
|
||||
const actionById = new Map(CMD_J_QUICK_ACTIONS.map((action) => [action.id, action]))
|
||||
const baseContext = {
|
||||
...ctx({}),
|
||||
activeWorktree: null,
|
||||
runtimeMode: 'local-desktop' as const,
|
||||
openNewBrowserTab: async () => {},
|
||||
openNewMarkdownFile: async () => {},
|
||||
openNewTerminalTab: async () => {},
|
||||
openCreateWorkspace: () => {},
|
||||
openAddQuickCommand: () => {}
|
||||
} satisfies CmdJQuickActionContext
|
||||
|
||||
for (const actionId of workspaceActions) {
|
||||
expect(actionById.get(actionId)?.isAvailable(baseContext)).toEqual({ available: true })
|
||||
expect(
|
||||
actionById.get(actionId)?.isAvailable({ ...baseContext, runtimeMode: 'paired-web' })
|
||||
).toEqual({ available: true })
|
||||
expect(
|
||||
actionById.get(actionId)?.isAvailable({
|
||||
...baseContext,
|
||||
activeWorktreeId: null,
|
||||
activeGroupId: null
|
||||
})
|
||||
).toEqual({ available: false, reason: 'no-active-workspace' })
|
||||
expect(actionById.get(actionId)?.isAvailable({ ...baseContext, isLoading: true })).toEqual({
|
||||
available: false,
|
||||
reason: 'loading'
|
||||
})
|
||||
expect(
|
||||
actionById.get(actionId)?.isAvailable({ ...baseContext, sshStatus: 'disconnected' })
|
||||
).toEqual({ available: false, reason: 'ssh-disconnected' })
|
||||
}
|
||||
|
||||
for (const actionId of workspaceAgnosticActions) {
|
||||
expect(
|
||||
actionById.get(actionId)?.isAvailable({
|
||||
...baseContext,
|
||||
activeWorktreeId: null,
|
||||
activeGroupId: null,
|
||||
isLoading: true,
|
||||
sshStatus: 'disconnected'
|
||||
})
|
||||
).toEqual({ available: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('recomputes active group from the open snapshot against fresh store state', () => {
|
||||
const worktree = {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/wt',
|
||||
displayName: 'Workspace',
|
||||
branch: 'main',
|
||||
createdAt: 0
|
||||
} as Worktree
|
||||
const state = {
|
||||
activeWorktreeId: 'wt-1',
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
repos: [{ id: 'repo-1', path: '/repo', displayName: 'Repo', addedAt: 0 }],
|
||||
sshConnectionStates: new Map(),
|
||||
activeGroupIdByWorktree: { 'wt-1': 'closed-group' },
|
||||
groupsByWorktree: {
|
||||
'wt-1': [{ id: 'first-group', worktreeId: 'wt-1', activeTabId: null, tabOrder: [] }]
|
||||
},
|
||||
settings: null
|
||||
} as unknown as AppState
|
||||
|
||||
const context = buildCmdJQuickActionContext({
|
||||
state,
|
||||
activeGroupSnapshot: { worktreeId: 'wt-1', groupId: 'closed-group' },
|
||||
openNewBrowserTab: async () => {},
|
||||
openNewMarkdownFile: async () => {},
|
||||
openNewTerminalTab: async () => {},
|
||||
openCreateWorkspace: () => {},
|
||||
openAddQuickCommand: () => {}
|
||||
})
|
||||
|
||||
expect(context.activeGroupId).toBe('first-group')
|
||||
})
|
||||
|
||||
it('derives loading from fresh store state when building the run-time context', () => {
|
||||
const state = {
|
||||
activeWorktreeId: null,
|
||||
worktreesByRepo: {},
|
||||
repos: [{ id: 'repo-1', path: '/repo', displayName: 'Repo', addedAt: 0 }],
|
||||
sshConnectionStates: new Map(),
|
||||
activeGroupIdByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
settings: null
|
||||
} as unknown as AppState
|
||||
|
||||
const context = buildCmdJQuickActionContext({
|
||||
state,
|
||||
activeGroupSnapshot: null,
|
||||
openNewBrowserTab: async () => {},
|
||||
openNewMarkdownFile: async () => {},
|
||||
openNewTerminalTab: async () => {},
|
||||
openCreateWorkspace: () => {},
|
||||
openAddQuickCommand: () => {}
|
||||
})
|
||||
|
||||
expect(context.isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it('runtime re-check returns unavailable without invoking the action helper', async () => {
|
||||
const calls: string[] = []
|
||||
const action = CMD_J_QUICK_ACTIONS.find((entry) => entry.id === 'new-terminal-tab')
|
||||
const context = {
|
||||
...ctx({ activeGroupId: null }),
|
||||
activeWorktree: null,
|
||||
runtimeMode: 'local-desktop' as const,
|
||||
openNewBrowserTab: async () => {},
|
||||
openNewMarkdownFile: async () => {},
|
||||
openNewTerminalTab: async (groupId: string) => {
|
||||
calls.push(groupId)
|
||||
},
|
||||
openCreateWorkspace: () => {},
|
||||
openAddQuickCommand: () => {}
|
||||
} satisfies CmdJQuickActionContext
|
||||
|
||||
await expect(action?.run(context)).resolves.toEqual({
|
||||
status: 'unavailable',
|
||||
reason: 'no-active-group'
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
import type { AppState } from '@/store/types'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
|
||||
export type CmdJUnavailableReason =
|
||||
| 'loading'
|
||||
| 'no-active-workspace'
|
||||
| 'ssh-disconnected'
|
||||
| 'no-active-group'
|
||||
|
||||
export type CmdJQuickActionAvailability =
|
||||
| { available: true }
|
||||
| { available: false; reason: CmdJUnavailableReason }
|
||||
|
||||
export type CmdJActiveGroupSnapshot = {
|
||||
worktreeId: string
|
||||
groupId: string | null
|
||||
}
|
||||
|
||||
export type CmdJQuickActionContext = {
|
||||
activeWorktreeId: string | null
|
||||
activeWorktree: Worktree | null
|
||||
isLoading: boolean
|
||||
sshStatus: SshConnectionStatus | null
|
||||
runtimeMode: 'local-desktop' | 'paired-web'
|
||||
activeGroupId: string | null
|
||||
openNewBrowserTab: (groupId: string) => Promise<void>
|
||||
openNewMarkdownFile: (groupId: string) => Promise<void>
|
||||
openNewTerminalTab: (groupId: string) => Promise<void>
|
||||
openCreateWorkspace: () => void
|
||||
openAddQuickCommand: () => void
|
||||
}
|
||||
|
||||
export function resolveCmdJActiveGroupId(
|
||||
state: Pick<AppState, 'activeGroupIdByWorktree' | 'groupsByWorktree'>,
|
||||
worktreeId: string | null,
|
||||
snapshot?: CmdJActiveGroupSnapshot | null
|
||||
): string | null {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
const groups = state.groupsByWorktree[worktreeId] ?? []
|
||||
if (groups.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (snapshot?.worktreeId === worktreeId) {
|
||||
if (snapshot.groupId && groups.some((group) => group.id === snapshot.groupId)) {
|
||||
return snapshot.groupId
|
||||
}
|
||||
return groups[0]?.id ?? null
|
||||
}
|
||||
|
||||
const focusedGroupId = state.activeGroupIdByWorktree[worktreeId]
|
||||
if (focusedGroupId && groups.some((group) => group.id === focusedGroupId)) {
|
||||
return focusedGroupId
|
||||
}
|
||||
return groups[0]?.id ?? null
|
||||
}
|
||||
|
||||
export function captureCmdJActiveGroupSnapshot(
|
||||
state: Pick<AppState, 'activeGroupIdByWorktree' | 'groupsByWorktree'>,
|
||||
worktreeId: string | null
|
||||
): CmdJActiveGroupSnapshot | null {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
worktreeId,
|
||||
groupId: resolveCmdJActiveGroupId(state, worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveWorktreeSshStatus(
|
||||
state: Pick<AppState, 'repos' | 'sshConnectionStates' | 'worktreesByRepo'>,
|
||||
activeWorktree: Worktree | null
|
||||
): SshConnectionStatus | null {
|
||||
if (!activeWorktree) {
|
||||
return null
|
||||
}
|
||||
const repo = state.repos.find((entry) => entry.id === activeWorktree.repoId)
|
||||
const connectionId = repo?.connectionId ?? null
|
||||
if (!connectionId) {
|
||||
return null
|
||||
}
|
||||
return state.sshConnectionStates.get(connectionId)?.status ?? 'disconnected'
|
||||
}
|
||||
|
||||
export function getWorkspaceScopedActionAvailability(
|
||||
ctx: Pick<
|
||||
CmdJQuickActionContext,
|
||||
'activeGroupId' | 'activeWorktreeId' | 'isLoading' | 'sshStatus'
|
||||
>
|
||||
): CmdJQuickActionAvailability {
|
||||
if (!ctx.activeWorktreeId) {
|
||||
return { available: false, reason: 'no-active-workspace' }
|
||||
}
|
||||
if (ctx.isLoading) {
|
||||
return { available: false, reason: 'loading' }
|
||||
}
|
||||
if (ctx.sshStatus != null && ctx.sshStatus !== 'connected') {
|
||||
return { available: false, reason: 'ssh-disconnected' }
|
||||
}
|
||||
if (!ctx.activeGroupId) {
|
||||
return { available: false, reason: 'no-active-group' }
|
||||
}
|
||||
return { available: true }
|
||||
}
|
||||
|
||||
export function buildCmdJQuickActionContext(args: {
|
||||
state: AppState
|
||||
activeGroupSnapshot: CmdJActiveGroupSnapshot | null
|
||||
openNewBrowserTab: (groupId: string) => Promise<void>
|
||||
openNewMarkdownFile: (groupId: string) => Promise<void>
|
||||
openNewTerminalTab: (groupId: string) => Promise<void>
|
||||
openCreateWorkspace: () => void
|
||||
openAddQuickCommand: () => void
|
||||
}): CmdJQuickActionContext {
|
||||
const activeWorktreeId = args.state.activeWorktreeId
|
||||
const activeWorktree = activeWorktreeId
|
||||
? (findWorktreeById(args.state.worktreesByRepo, activeWorktreeId) ?? null)
|
||||
: null
|
||||
const activeGroupId = resolveCmdJActiveGroupId(
|
||||
args.state,
|
||||
activeWorktreeId,
|
||||
args.activeGroupSnapshot
|
||||
)
|
||||
const isLoading =
|
||||
args.state.repos.length > 0 && Object.keys(args.state.worktreesByRepo).length === 0
|
||||
const runtimeMode =
|
||||
(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ &&
|
||||
args.state.settings?.activeRuntimeEnvironmentId?.trim()
|
||||
? 'paired-web'
|
||||
: 'local-desktop'
|
||||
|
||||
return {
|
||||
activeWorktreeId,
|
||||
activeWorktree,
|
||||
isLoading,
|
||||
sshStatus: getActiveWorktreeSshStatus(args.state, activeWorktree),
|
||||
runtimeMode,
|
||||
activeGroupId,
|
||||
openNewBrowserTab: args.openNewBrowserTab,
|
||||
openNewMarkdownFile: args.openNewMarkdownFile,
|
||||
openNewTerminalTab: args.openNewTerminalTab,
|
||||
openCreateWorkspace: args.openCreateWorkspace,
|
||||
openAddQuickCommand: args.openAddQuickCommand
|
||||
}
|
||||
}
|
||||
|
||||
export function getUnavailableQuickActionMessage(
|
||||
actionTitle: string,
|
||||
reason: CmdJUnavailableReason
|
||||
): string {
|
||||
switch (reason) {
|
||||
case 'loading':
|
||||
return `Can't ${actionTitle.toLowerCase()} — workspace is still loading.`
|
||||
case 'no-active-workspace':
|
||||
return `Can't ${actionTitle.toLowerCase()} — no workspace is active.`
|
||||
case 'ssh-disconnected':
|
||||
return `Can't ${actionTitle.toLowerCase()} — workspace is disconnected.`
|
||||
case 'no-active-group':
|
||||
return `Can't ${actionTitle.toLowerCase()} — no tab group is available.`
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { FileText, FolderPlus, Globe, Play, SquareTerminal } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { CmdJQuickActionAvailability, CmdJQuickActionContext } from './quick-action-context'
|
||||
import { getWorkspaceScopedActionAvailability } from './quick-action-context'
|
||||
|
||||
export type CmdJQuickActionRunResult =
|
||||
| { status: 'ok' }
|
||||
| {
|
||||
status: 'unavailable'
|
||||
reason: Exclude<CmdJQuickActionAvailability, { available: true }>['reason']
|
||||
}
|
||||
|
||||
export type CmdJQuickAction = {
|
||||
id: string
|
||||
kind: 'action'
|
||||
title: string
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
verbKeywords: string[]
|
||||
isAvailable: (ctx: CmdJQuickActionContext) => CmdJQuickActionAvailability
|
||||
run: (ctx: CmdJQuickActionContext) => Promise<CmdJQuickActionRunResult>
|
||||
}
|
||||
|
||||
function workspaceActionAvailability(ctx: CmdJQuickActionContext): CmdJQuickActionAvailability {
|
||||
return getWorkspaceScopedActionAvailability(ctx)
|
||||
}
|
||||
|
||||
async function runWorkspaceAction(
|
||||
ctx: CmdJQuickActionContext,
|
||||
run: (groupId: string) => Promise<void>
|
||||
): Promise<CmdJQuickActionRunResult> {
|
||||
const availability = workspaceActionAvailability(ctx)
|
||||
if (!availability.available) {
|
||||
return { status: 'unavailable', reason: availability.reason }
|
||||
}
|
||||
if (!ctx.activeGroupId) {
|
||||
return { status: 'unavailable', reason: 'no-active-group' }
|
||||
}
|
||||
await run(ctx.activeGroupId)
|
||||
return { status: 'ok' }
|
||||
}
|
||||
|
||||
// Why: Cmd+J actions are for high-frequency, safe, context-light verbs.
|
||||
// Context-heavy setup flows such as Ghostty import and browser cookie import
|
||||
// stay inside their Settings panes where explanatory UI and failure states fit.
|
||||
export const CMD_J_QUICK_ACTIONS: readonly CmdJQuickAction[] = [
|
||||
{
|
||||
id: 'new-browser-tab',
|
||||
kind: 'action',
|
||||
title: 'New Browser Tab',
|
||||
description: 'Open a browser tab in the active workspace.',
|
||||
icon: Globe,
|
||||
verbKeywords: ['new browser', 'new browser tab', 'open browser', 'browser tab'],
|
||||
isAvailable: workspaceActionAvailability,
|
||||
run: (ctx) => runWorkspaceAction(ctx, ctx.openNewBrowserTab)
|
||||
},
|
||||
{
|
||||
id: 'new-markdown-file',
|
||||
kind: 'action',
|
||||
title: 'New Markdown File',
|
||||
description: 'Create an untitled markdown file in the active workspace.',
|
||||
icon: FileText,
|
||||
verbKeywords: ['new markdown', 'new markdown file', 'new mark', 'new file', 'markdown file'],
|
||||
isAvailable: workspaceActionAvailability,
|
||||
run: (ctx) => runWorkspaceAction(ctx, ctx.openNewMarkdownFile)
|
||||
},
|
||||
{
|
||||
id: 'new-terminal-tab',
|
||||
kind: 'action',
|
||||
title: 'New Terminal Tab',
|
||||
description: 'Open a terminal tab in the active workspace.',
|
||||
icon: SquareTerminal,
|
||||
verbKeywords: ['new terminal', 'new terminal tab', 'new shell', 'terminal tab'],
|
||||
isAvailable: workspaceActionAvailability,
|
||||
run: (ctx) => runWorkspaceAction(ctx, ctx.openNewTerminalTab)
|
||||
},
|
||||
{
|
||||
id: 'create-workspace',
|
||||
kind: 'action',
|
||||
title: 'Create Workspace',
|
||||
description: 'Start a new workspace.',
|
||||
icon: FolderPlus,
|
||||
verbKeywords: ['create workspace', 'add workspace', 'new workspace'],
|
||||
isAvailable: () => ({ available: true }),
|
||||
run: async (ctx) => {
|
||||
ctx.openCreateWorkspace()
|
||||
return { status: 'ok' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'add-quick-command',
|
||||
kind: 'action',
|
||||
title: 'Add Quick Command',
|
||||
description: 'Create a saved terminal command.',
|
||||
icon: Play,
|
||||
verbKeywords: ['add quick command', 'new quick command'],
|
||||
isAvailable: () => ({ available: true }),
|
||||
run: async (ctx) => {
|
||||
ctx.openAddQuickCommand()
|
||||
return { status: 'ok' }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import type React from 'react'
|
||||
import type { GlobalSettings, StatusBarItem } from '../../../../shared/types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { Separator } from '../ui/separator'
|
||||
import { UIZoomControl } from './UIZoomControl'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useShortcutKeyCombos } from '@/hooks/useShortcutLabel'
|
||||
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
|
||||
|
|
@ -16,6 +16,18 @@ import {
|
|||
} from './SettingsFormControls'
|
||||
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
|
||||
import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles'
|
||||
import {
|
||||
APPEARANCE_PANE_SEARCH_ENTRIES,
|
||||
LAYOUT_ENTRIES,
|
||||
SIDEBAR_ENTRIES,
|
||||
STATUS_BAR_ENTRIES,
|
||||
STATUS_BAR_TOGGLES,
|
||||
THEME_ENTRIES,
|
||||
TITLEBAR_ENTRIES,
|
||||
TYPOGRAPHY_ENTRIES,
|
||||
ZOOM_ENTRIES
|
||||
} from './appearance-search'
|
||||
export { APPEARANCE_PANE_SEARCH_ENTRIES }
|
||||
|
||||
type AppearancePaneProps = {
|
||||
settings: GlobalSettings
|
||||
|
|
@ -24,139 +36,6 @@ type AppearancePaneProps = {
|
|||
fontSuggestions: string[]
|
||||
}
|
||||
|
||||
const STATUS_BAR_TOGGLES: readonly {
|
||||
id: StatusBarItem
|
||||
title: string
|
||||
description: string
|
||||
keywords: string[]
|
||||
toggleDescription: string
|
||||
}[] = [
|
||||
{
|
||||
id: 'claude',
|
||||
title: 'Claude Usage',
|
||||
description: 'Show Claude token and cost usage in the status bar.',
|
||||
keywords: ['status bar', 'claude', 'usage', 'tokens', 'cost', 'anthropic'],
|
||||
toggleDescription: 'Show Claude token and cost usage for the active workspace.'
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
title: 'Codex Usage',
|
||||
description: 'Show Codex token and cost usage in the status bar.',
|
||||
keywords: ['status bar', 'codex', 'usage', 'tokens', 'cost', 'openai'],
|
||||
toggleDescription: 'Show Codex token and cost usage for the active workspace.'
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
title: 'Gemini Usage',
|
||||
description: 'Show Gemini token and cost usage in the status bar.',
|
||||
keywords: ['status bar', 'gemini', 'usage', 'tokens', 'cost', 'google'],
|
||||
toggleDescription: 'Show Gemini token and cost usage for the active workspace.'
|
||||
},
|
||||
{
|
||||
id: 'opencode-go',
|
||||
title: 'OpenCode Go Usage',
|
||||
description: 'Show OpenCode Go token and cost usage in the status bar.',
|
||||
keywords: ['status bar', 'opencode', 'opencode-go', 'usage', 'tokens', 'cost'],
|
||||
toggleDescription: 'Show OpenCode Go token and cost usage for the active workspace.'
|
||||
},
|
||||
{
|
||||
id: 'ssh',
|
||||
title: 'SSH Status',
|
||||
description: 'Show the active SSH connection status in the status bar.',
|
||||
keywords: ['status bar', 'ssh', 'remote', 'connection', 'host'],
|
||||
toggleDescription:
|
||||
'Show the active SSH connection. Only visible once an SSH target is configured.'
|
||||
},
|
||||
{
|
||||
id: 'resource-usage',
|
||||
title: 'Resource Manager',
|
||||
description: 'Show CPU, memory, terminal sessions, and workspace disk usage in the status bar.',
|
||||
keywords: ['status bar', 'resource', 'manager', 'memory', 'cpu', 'terminal', 'disk', 'space'],
|
||||
toggleDescription:
|
||||
'Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.'
|
||||
},
|
||||
{
|
||||
id: 'ports',
|
||||
title: 'Ports',
|
||||
description: 'Show live workspace ports in the status bar.',
|
||||
keywords: ['status bar', 'ports', 'localhost', 'server', 'workspace'],
|
||||
toggleDescription:
|
||||
'Show live workspace ports. Click it for workspace-scoped ports and external listeners.'
|
||||
}
|
||||
]
|
||||
|
||||
const THEME_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Theme',
|
||||
description: 'Choose how Orca looks in the app window.',
|
||||
keywords: ['dark', 'light', 'system']
|
||||
}
|
||||
]
|
||||
|
||||
const ZOOM_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'UI Zoom',
|
||||
description: 'Scale the entire application interface.',
|
||||
keywords: ['zoom', 'scale', 'shortcut']
|
||||
}
|
||||
]
|
||||
|
||||
const TYPOGRAPHY_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'IDE Font',
|
||||
description: 'Choose the font used by the Orca interface.',
|
||||
keywords: ['font', 'typeface', 'typography', 'ide', 'orca', 'interface', 'app', 'ui']
|
||||
}
|
||||
]
|
||||
|
||||
const LAYOUT_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Open Right Sidebar by Default',
|
||||
description: 'Automatically expand the file explorer panel when creating a new worktree.',
|
||||
keywords: ['layout', 'file explorer', 'sidebar']
|
||||
},
|
||||
{
|
||||
title: 'Show Git-Ignored Files',
|
||||
description: 'Dim files matched by .gitignore in the file explorer.',
|
||||
keywords: ['git', 'gitignore', 'ignored', 'file explorer', 'sidebar', 'hide']
|
||||
}
|
||||
]
|
||||
|
||||
const TITLEBAR_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Titlebar App Name',
|
||||
description: 'Show Orca in the titlebar.',
|
||||
keywords: ['titlebar', 'orca', 'app', 'name', 'brand']
|
||||
}
|
||||
]
|
||||
|
||||
const STATUS_BAR_ENTRIES: SettingsSearchEntry[] = STATUS_BAR_TOGGLES.map(
|
||||
({ title, description, keywords }) => ({ title, description, keywords })
|
||||
)
|
||||
|
||||
const SIDEBAR_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Show Tasks Button',
|
||||
description: 'Show the Tasks button at the top of the left sidebar.',
|
||||
keywords: ['tasks', 'sidebar', 'button', 'hide', 'show', 'github', 'linear']
|
||||
},
|
||||
{
|
||||
title: 'Show Orca Mobile Button',
|
||||
description: 'Show the Orca Mobile button at the top of the left sidebar.',
|
||||
keywords: ['mobile', 'phone', 'sidebar', 'button', 'hide', 'show', 'toolbox']
|
||||
}
|
||||
]
|
||||
|
||||
export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
...THEME_ENTRIES,
|
||||
...TYPOGRAPHY_ENTRIES,
|
||||
...ZOOM_ENTRIES,
|
||||
...LAYOUT_ENTRIES,
|
||||
...TITLEBAR_ENTRIES,
|
||||
...STATUS_BAR_ENTRIES,
|
||||
...SIDEBAR_ENTRIES
|
||||
]
|
||||
|
||||
function ShortcutHintList({ combos }: { combos: string[][] }): React.JSX.Element {
|
||||
if (combos.length === 0) {
|
||||
return <span className="text-xs text-muted-foreground">Unassigned</span>
|
||||
|
|
|
|||
|
|
@ -18,14 +18,11 @@ import { SearchableSetting } from './SearchableSetting'
|
|||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { BROWSER_PANE_SEARCH_ENTRIES as BROWSER_CORE_SEARCH_ENTRIES } from './browser-search'
|
||||
import { BROWSER_USE_PANE_SEARCH_ENTRIES } from './browser-use-search'
|
||||
import { BROWSER_PANE_SEARCH_ENTRIES } from './browser-pane-search'
|
||||
import { BrowserProfileRow } from './BrowserProfileRow'
|
||||
import { BrowserUseSetup } from './BrowserUsePane'
|
||||
import { KagiSessionLinkForm } from './KagiSessionLinkForm'
|
||||
|
||||
export const BROWSER_PANE_SEARCH_ENTRIES = [
|
||||
...BROWSER_USE_PANE_SEARCH_ENTRIES,
|
||||
...BROWSER_CORE_SEARCH_ENTRIES
|
||||
]
|
||||
export { BROWSER_PANE_SEARCH_ENTRIES }
|
||||
|
||||
type BrowserPaneProps = {
|
||||
settings: GlobalSettings
|
||||
|
|
|
|||
|
|
@ -27,22 +27,7 @@ import {
|
|||
} from '@/hooks/useInstalledAgentSkills'
|
||||
import { Button } from '../ui/button'
|
||||
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const COMPUTER_USE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Computer Use',
|
||||
description: 'Allow agents to inspect screenshots and operate local apps when you ask.',
|
||||
keywords: [
|
||||
'computer use',
|
||||
'accessibility',
|
||||
'screen recording',
|
||||
'screenshot',
|
||||
'automation',
|
||||
'skill'
|
||||
]
|
||||
}
|
||||
]
|
||||
export { COMPUTER_USE_PANE_SEARCH_ENTRIES } from './computer-use-search'
|
||||
|
||||
type PermissionDefinition = {
|
||||
id: ComputerUsePermissionId
|
||||
|
|
|
|||
|
|
@ -20,35 +20,7 @@ import type {
|
|||
DeveloperPermissionStatus
|
||||
} from '../../../../shared/developer-permissions-types'
|
||||
import { Button } from '../ui/button'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Developer Permissions',
|
||||
description: 'macOS permissions for terminal-launched developer tools.',
|
||||
keywords: ['permissions', 'privacy', 'tcc', 'macos', 'developer tools']
|
||||
},
|
||||
{
|
||||
title: 'Microphone and Camera',
|
||||
description: 'Allow voice, transcription, webcam, and media capture tools.',
|
||||
keywords: ['microphone', 'camera', 'voice', 'audio', 'video', 'sox', 'ffmpeg', 'whisper']
|
||||
},
|
||||
{
|
||||
title: 'Screen Recording and Accessibility',
|
||||
description: 'Allow screenshots, screen inspection, keystrokes, and window automation.',
|
||||
keywords: ['screen recording', 'accessibility', 'screenshot', 'automation', 'window']
|
||||
},
|
||||
{
|
||||
title: 'Full Disk Access',
|
||||
description: 'Open the macOS privacy pane for broad terminal file access.',
|
||||
keywords: ['full disk access', 'documents', 'downloads', 'desktop', 'icloud']
|
||||
},
|
||||
{
|
||||
title: 'Local Network, USB, and Bluetooth',
|
||||
description: 'Allow device and local-network tools used from terminal sessions.',
|
||||
keywords: ['local network', 'usb', 'bluetooth', 'bonjour', 'mdns', 'device']
|
||||
}
|
||||
]
|
||||
export { DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES } from './developer-permissions-search'
|
||||
|
||||
type PermissionDefinition = {
|
||||
id: DeveloperPermissionId
|
||||
|
|
|
|||
|
|
@ -1,29 +1,8 @@
|
|||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { Label } from '../ui/label'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
import { isDefaultPrimarySelectionMiddleClickPasteUserAgent } from '@/hooks/usePrimarySelectionPaste'
|
||||
|
||||
export const INPUT_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Middle-click Paste from Selection',
|
||||
description:
|
||||
'Enabled by default on Linux and macOS. Linux uses the system selection clipboard; other platforms use a private buffer.',
|
||||
keywords: [
|
||||
'input',
|
||||
'editing',
|
||||
'selection',
|
||||
'primary selection',
|
||||
'middle click',
|
||||
'middle mouse',
|
||||
'paste',
|
||||
'clipboard',
|
||||
'x11',
|
||||
'linux',
|
||||
'macos'
|
||||
]
|
||||
}
|
||||
]
|
||||
export { INPUT_PANE_SEARCH_ENTRIES } from './input-search'
|
||||
|
||||
type InputPaneProps = {
|
||||
settings: GlobalSettings
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
export { INTEGRATIONS_PANE_SEARCH_ENTRIES } from './integrations-search'
|
||||
|
||||
function LinearIcon({ className }: { className?: string }): React.JSX.Element {
|
||||
return (
|
||||
|
|
@ -37,39 +37,6 @@ function LinearIcon({ className }: { className?: string }): React.JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
export const INTEGRATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'GitHub Integration',
|
||||
description: 'GitHub authentication via the gh CLI.',
|
||||
keywords: ['github', 'gh', 'integration']
|
||||
},
|
||||
{
|
||||
title: 'GitLab Integration',
|
||||
description: 'GitLab authentication via the glab CLI.',
|
||||
keywords: ['gitlab', 'glab', 'integration', 'mr', 'merge request']
|
||||
},
|
||||
{
|
||||
title: 'Bitbucket Integration',
|
||||
description: 'Bitbucket Cloud authentication via API token environment variables.',
|
||||
keywords: ['bitbucket', 'integration', 'pull request', 'api token']
|
||||
},
|
||||
{
|
||||
title: 'Azure DevOps Integration',
|
||||
description: 'Azure DevOps Repos authentication via token environment variables.',
|
||||
keywords: ['azure devops', 'azure repos', 'ado', 'integration', 'pull request', 'api token']
|
||||
},
|
||||
{
|
||||
title: 'Gitea Integration',
|
||||
description: 'Gitea authentication via API token environment variables.',
|
||||
keywords: ['gitea', 'self-hosted', 'integration', 'pull request', 'api token']
|
||||
},
|
||||
{
|
||||
title: 'Linear Integration',
|
||||
description: 'Connect Linear to browse and link issues.',
|
||||
keywords: ['linear', 'integration', 'api key', 'connect', 'disconnect']
|
||||
}
|
||||
]
|
||||
|
||||
type GhStatus = 'checking' | 'connected' | 'not-installed' | 'not-authenticated'
|
||||
// Why: parallel to GhStatus — GitLab uses glab and the same three failure
|
||||
// modes (probe in-flight / installed-but-unauth / missing entirely).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { Check, Copy, Maximize2, Smartphone, Trash2 } from 'lucide-react'
|
|||
import { Button } from '../ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useMobilePairingDevicePolling } from './mobile-pairing-device-polling'
|
||||
import {
|
||||
|
|
@ -12,6 +11,7 @@ import {
|
|||
type MobileNetworkInterface
|
||||
} from './mobile-network-interface-selection'
|
||||
import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection'
|
||||
export { MOBILE_PANE_SEARCH_ENTRIES } from './mobile-pane-search'
|
||||
|
||||
// Why: the section heading "When you leave the mobile app" carries the
|
||||
// "what happens" framing so the option labels only need to vary on the
|
||||
|
|
@ -32,54 +32,6 @@ function autoRestoreValueFromMs(ms: number | null | undefined): string {
|
|||
return exact ? exact.value : 'indefinite'
|
||||
}
|
||||
|
||||
export const MOBILE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Mobile Pairing',
|
||||
description: 'Pair a mobile device by scanning a QR code.',
|
||||
keywords: ['mobile', 'qr', 'code', 'pair', 'phone', 'scan']
|
||||
},
|
||||
{
|
||||
title: 'Connected Devices',
|
||||
description: 'Manage paired mobile devices.',
|
||||
keywords: ['mobile', 'devices', 'revoke', 'paired', 'connected']
|
||||
},
|
||||
{
|
||||
title: 'Network Interface',
|
||||
description: 'Choose which network address to use for mobile pairing.',
|
||||
keywords: [
|
||||
'network',
|
||||
'interface',
|
||||
'tailscale',
|
||||
'tailnet',
|
||||
'vpn',
|
||||
'overlay',
|
||||
'ip',
|
||||
'address',
|
||||
'wifi',
|
||||
'lan',
|
||||
'remote'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'When you leave the mobile app',
|
||||
description:
|
||||
'Choose what happens to terminals you were viewing on mobile after you close the app or switch away.',
|
||||
keywords: [
|
||||
'mobile',
|
||||
'terminal',
|
||||
'restore',
|
||||
'phone',
|
||||
'fit',
|
||||
'width',
|
||||
'resize',
|
||||
'hold',
|
||||
'leave',
|
||||
'background',
|
||||
'close'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
type PairedDevice = {
|
||||
deviceId: string
|
||||
name: string
|
||||
|
|
|
|||
|
|
@ -1,36 +1,18 @@
|
|||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { Label } from '../ui/label'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { useAppStore } from '../../store'
|
||||
import { MobilePane, MOBILE_PANE_SEARCH_ENTRIES } from './MobilePane'
|
||||
import { MobilePane } from './MobilePane'
|
||||
import {
|
||||
MOBILE_ENABLE_SEARCH_ENTRY,
|
||||
MOBILE_SETTINGS_PANE_SEARCH_ENTRIES
|
||||
} from './mobile-settings-search'
|
||||
export { MOBILE_SETTINGS_PANE_SEARCH_ENTRIES }
|
||||
|
||||
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
|
||||
const ORCA_ANDROID_RELEASE_URL = 'https://github.com/stablyai/orca/releases/tag/mobile-v0.0.9'
|
||||
|
||||
const MOBILE_ENABLE_SEARCH_ENTRY: SettingsSearchEntry = {
|
||||
title: 'Mobile',
|
||||
description: 'Control terminals and agents from your phone.',
|
||||
keywords: [
|
||||
'mobile',
|
||||
'phone',
|
||||
'pair',
|
||||
'qr',
|
||||
'code',
|
||||
'scan',
|
||||
'remote',
|
||||
'android',
|
||||
'apk',
|
||||
'beta',
|
||||
'experimental'
|
||||
]
|
||||
}
|
||||
|
||||
export const MOBILE_SETTINGS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
MOBILE_ENABLE_SEARCH_ENTRY,
|
||||
...MOBILE_PANE_SEARCH_ENTRIES
|
||||
]
|
||||
|
||||
type MobileSettingsPaneProps = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
|
|
|
|||
|
|
@ -15,59 +15,8 @@ import {
|
|||
SelectValue
|
||||
} from '../ui/select'
|
||||
import { BellRing, Bot, FileAudio, Siren, Upload, Volume2 } from 'lucide-react'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
import { getNotificationSoundOptions } from '@/components/notification-sound-options'
|
||||
|
||||
export const NOTIFICATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Enable Notifications',
|
||||
description: 'Master switch for Orca desktop notifications.',
|
||||
keywords: ['notifications', 'desktop', 'system', 'native']
|
||||
},
|
||||
{
|
||||
title: 'Agent Task Complete',
|
||||
description: 'Notify when a coding agent transitions from working to idle.',
|
||||
keywords: ['notifications', 'agent', 'complete', 'idle', 'task']
|
||||
},
|
||||
{
|
||||
title: 'Terminal Bell',
|
||||
description: 'Notify when a background terminal emits a bell character.',
|
||||
keywords: ['notifications', 'terminal', 'bell', 'attention']
|
||||
},
|
||||
{
|
||||
title: 'Suppress While Focused',
|
||||
description: 'Avoid notifying when Orca is focused on the active worktree.',
|
||||
keywords: ['notifications', 'focused', 'suppress', 'filtering']
|
||||
},
|
||||
{
|
||||
title: 'Notification Sound',
|
||||
description:
|
||||
'Choose the built-in, system, or local audio file Orca plays for desktop notifications.',
|
||||
keywords: [
|
||||
'notifications',
|
||||
'sound',
|
||||
'audio',
|
||||
'mp3',
|
||||
'wav',
|
||||
'ogg',
|
||||
'm4a',
|
||||
'aac',
|
||||
'flac',
|
||||
'ding',
|
||||
'bong'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Notification Volume',
|
||||
description: 'Playback volume for non-system notification sounds.',
|
||||
keywords: ['notifications', 'sound', 'volume', 'loudness']
|
||||
},
|
||||
{
|
||||
title: 'Send Test Notification',
|
||||
description: 'Trigger a sample desktop notification using the native delivery path.',
|
||||
keywords: ['notifications', 'test']
|
||||
}
|
||||
]
|
||||
export { NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './notifications-search'
|
||||
|
||||
type NotificationsPaneProps = {
|
||||
settings: GlobalSettings
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldOpenQuickCommandAddIntent } from './QuickCommandsPane'
|
||||
|
||||
describe('QuickCommandsPane add-command intent', () => {
|
||||
it('opens the add flow once for each new intent signal', () => {
|
||||
expect(shouldOpenQuickCommandAddIntent(undefined, 0)).toBe(false)
|
||||
expect(shouldOpenQuickCommandAddIntent(0, 0)).toBe(false)
|
||||
expect(shouldOpenQuickCommandAddIntent(1, 0)).toBe(true)
|
||||
expect(shouldOpenQuickCommandAddIntent(1, 1)).toBe(false)
|
||||
expect(shouldOpenQuickCommandAddIntent(2, 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronsUpDown, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import type {
|
||||
GlobalSettings,
|
||||
|
|
@ -24,6 +24,7 @@ import { useConfirmationDialog } from '@/components/confirmation-dialog'
|
|||
type QuickCommandsPaneProps = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
addCommandIntentSignal?: number
|
||||
}
|
||||
|
||||
const GLOBAL_SCOPE_KEY = '__global__'
|
||||
|
|
@ -43,6 +44,13 @@ function getRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string {
|
|||
return repo.displayName || repo.path
|
||||
}
|
||||
|
||||
export function shouldOpenQuickCommandAddIntent(
|
||||
addCommandIntentSignal: number | undefined,
|
||||
consumedAddIntentSignal: number
|
||||
): boolean {
|
||||
return Boolean(addCommandIntentSignal && consumedAddIntentSignal !== addCommandIntentSignal)
|
||||
}
|
||||
|
||||
function getScopeLabel(
|
||||
scope: TerminalQuickCommandScope,
|
||||
repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>>
|
||||
|
|
@ -56,7 +64,8 @@ function getScopeLabel(
|
|||
|
||||
export function QuickCommandsPane({
|
||||
settings,
|
||||
updateSettings
|
||||
updateSettings,
|
||||
addCommandIntentSignal
|
||||
}: QuickCommandsPaneProps): React.JSX.Element {
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
|
|
@ -64,6 +73,7 @@ export function QuickCommandsPane({
|
|||
const confirm = useConfirmationDialog()
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>(null)
|
||||
const consumedAddIntentSignalRef = useRef(0)
|
||||
// Why: `null` means "show all" (sticky-all), independent of the current repo
|
||||
// list — mirrors the tasks-page repo combobox so newly added repos appear
|
||||
// automatically rather than being silently excluded.
|
||||
|
|
@ -90,7 +100,7 @@ export function QuickCommandsPane({
|
|||
return effectiveSelection.has(scope.repoId)
|
||||
})
|
||||
|
||||
const createDraftForCurrentFilter = (): TerminalQuickCommand => {
|
||||
const createDraftForCurrentFilter = useCallback((): TerminalQuickCommand => {
|
||||
// Why: when the user has narrowed to a single repo scope, the natural
|
||||
// intent for "Add Command" is to create one in that repo. When the filter
|
||||
// is narrowed to Global-only, honor that. Otherwise prefer the active
|
||||
|
|
@ -108,7 +118,19 @@ export function QuickCommandsPane({
|
|||
return createTerminalQuickCommandDraft({ type: 'repo', repoId: activeRepoId })
|
||||
}
|
||||
return createTerminalQuickCommandDraft({ type: 'global' })
|
||||
}
|
||||
}, [activeRepoId, effectiveSelection, repoById, showAll])
|
||||
|
||||
useEffect(() => {
|
||||
const intentSignal = addCommandIntentSignal
|
||||
if (
|
||||
typeof intentSignal !== 'number' ||
|
||||
!shouldOpenQuickCommandAddIntent(intentSignal, consumedAddIntentSignalRef.current)
|
||||
) {
|
||||
return
|
||||
}
|
||||
consumedAddIntentSignalRef.current = intentSignal
|
||||
setEditor({ mode: 'add', command: createDraftForCurrentFilter() })
|
||||
}, [addCommandIntentSignal, createDraftForCurrentFilter])
|
||||
|
||||
const toggleScope = (key: string): void => {
|
||||
const current = new Set(effectiveSelection)
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@ import { McpConfigSection } from './McpConfigSection'
|
|||
import { WorktreeSymlinksSection } from './WorktreeSymlinksSection'
|
||||
import { SparsePresetSettingsSection } from './SparsePresetSettingsSection'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { useAppStore } from '../../store'
|
||||
import { getRepositoryIconSectionId } from './repository-settings-targets'
|
||||
import { RepositoryIconPicker } from './RepositoryIconPicker'
|
||||
import { getRepositoryPaneSearchEntries } from './repository-search'
|
||||
export { getRepositoryPaneSearchEntries }
|
||||
|
||||
type RepositoryPaneProps = {
|
||||
repo: Repo
|
||||
|
|
@ -27,157 +29,6 @@ type RepositoryPaneProps = {
|
|||
removeRepo: (repoId: string) => void
|
||||
}
|
||||
|
||||
export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[] {
|
||||
const isFolder = isFolderRepo(repo)
|
||||
return [
|
||||
{
|
||||
title: 'Display Name',
|
||||
description: 'Project-specific display details for the sidebar and tabs.',
|
||||
keywords: [repo.displayName, repo.path, 'project name', 'repository name']
|
||||
},
|
||||
{
|
||||
title: 'Project Icon',
|
||||
description: 'Project icon and color used in the sidebar and tabs.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'project icon',
|
||||
'repository icon',
|
||||
'color',
|
||||
'badge',
|
||||
'emoji',
|
||||
'favicon'
|
||||
]
|
||||
},
|
||||
...(isFolder
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: 'Default Worktree Base',
|
||||
description: 'Default base branch or ref when creating worktrees.',
|
||||
keywords: [repo.displayName, 'base ref', 'branch']
|
||||
},
|
||||
{
|
||||
title: 'Sparse Checkout Presets',
|
||||
description: 'Saved directory sets for sparse worktree creation.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'sparse',
|
||||
'checkout',
|
||||
'preset',
|
||||
'presets',
|
||||
'directory',
|
||||
'directories',
|
||||
'monorepo'
|
||||
]
|
||||
}
|
||||
]),
|
||||
{
|
||||
title: 'Remove Project',
|
||||
description: 'Remove this project from Orca.',
|
||||
keywords: [repo.displayName, 'delete', 'project', 'repository']
|
||||
},
|
||||
...(isFolder
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: 'Worktree Symlinks',
|
||||
description: 'Paths to symlink from the primary checkout into newly created worktrees.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'symlink',
|
||||
'symlinks',
|
||||
'worktree',
|
||||
'link',
|
||||
'shared',
|
||||
'env',
|
||||
'node_modules'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'MCP Configs',
|
||||
description: 'Inspect project-level MCP server config files.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'mcp',
|
||||
'model context protocol',
|
||||
'.mcp.json',
|
||||
'.cursor/mcp.json',
|
||||
'.claude.json',
|
||||
'.claude/mcp.json'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Setup Script',
|
||||
description: 'Local and shared scripts that run after a new worktree is created.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'hooks',
|
||||
'setup',
|
||||
'setup script',
|
||||
'setup command',
|
||||
'local settings scripts',
|
||||
'orca.yaml hooks',
|
||||
'yaml'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Archive Script',
|
||||
description: 'Local and shared scripts that run before a worktree is archived.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'hooks',
|
||||
'archive',
|
||||
'archive script',
|
||||
'archive command',
|
||||
'local settings scripts',
|
||||
'orca.yaml hooks',
|
||||
'yaml'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Advanced',
|
||||
description: 'Command source and orca.yaml details.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'advanced',
|
||||
'command source',
|
||||
'local',
|
||||
'orca.yaml',
|
||||
'shared',
|
||||
'both',
|
||||
'source',
|
||||
'authoritative'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'When to Run Setup',
|
||||
description: 'Choose the default behavior when a setup script is available.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'setup run policy',
|
||||
'ask',
|
||||
'run by default',
|
||||
'skip by default'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Custom GitHub Issue Command',
|
||||
description:
|
||||
'File-based linked-issue command configured via orca.yaml and optional local override.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'github issue command',
|
||||
'issue command',
|
||||
'workflow',
|
||||
'github',
|
||||
'orca.yaml',
|
||||
'.orca/issue-command'
|
||||
]
|
||||
}
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
export function RepositoryPane({
|
||||
repo,
|
||||
yamlHooks,
|
||||
|
|
|
|||
|
|
@ -1,34 +1,8 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
Bot,
|
||||
Cable,
|
||||
FlaskConical,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Info,
|
||||
Keyboard,
|
||||
ListChecks,
|
||||
Lock,
|
||||
MousePointerClick,
|
||||
Network,
|
||||
PanelsTopLeft,
|
||||
Play,
|
||||
ShieldCheck,
|
||||
Palette,
|
||||
Server,
|
||||
SlidersHorizontal,
|
||||
Smartphone,
|
||||
Blocks,
|
||||
Mic,
|
||||
SquareTerminal,
|
||||
TextCursorInput,
|
||||
UserCog
|
||||
} from 'lucide-react'
|
||||
import { Info } from 'lucide-react'
|
||||
import type { OrcaHooks } from '../../../../shared/types'
|
||||
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
|
||||
import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
|
||||
|
|
@ -36,58 +10,52 @@ import { applyDocumentTheme } from '@/lib/document-theme'
|
|||
import { useConfirmationDialog } from '@/components/confirmation-dialog'
|
||||
import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants'
|
||||
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
|
||||
import { GeneralPane, GENERAL_PANE_SEARCH_ENTRIES } from './GeneralPane'
|
||||
import { BrowserPane, BROWSER_PANE_SEARCH_ENTRIES } from './BrowserPane'
|
||||
import { AppearancePane, APPEARANCE_PANE_SEARCH_ENTRIES } from './AppearancePane'
|
||||
import { InputPane, INPUT_PANE_SEARCH_ENTRIES } from './InputPane'
|
||||
import { ShortcutsPane, SHORTCUTS_PANE_SEARCH_ENTRIES } from './ShortcutsPane'
|
||||
import { GeneralPane } from './GeneralPane'
|
||||
import { BrowserPane } from './BrowserPane'
|
||||
import { AppearancePane } from './AppearancePane'
|
||||
import { InputPane } from './InputPane'
|
||||
import { ShortcutsPane } from './ShortcutsPane'
|
||||
import { TerminalPane } from './TerminalPane'
|
||||
import { FloatingWorkspacePane } from './FloatingWorkspacePane'
|
||||
import { useGhosttyImport } from './useGhosttyImport'
|
||||
import { Button } from '../ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import ghosttyIcon from '../../../../../resources/ghostty.svg'
|
||||
import { RepositoryPane, getRepositoryPaneSearchEntries } from './RepositoryPane'
|
||||
import { getTerminalPaneSearchEntries } from './terminal-search'
|
||||
import { FLOATING_WORKSPACE_SEARCH_ENTRIES } from './floating-workspace-search'
|
||||
import { GitPane, GIT_PANE_SEARCH_ENTRIES } from './GitPane'
|
||||
import { RepositoryPane } from './RepositoryPane'
|
||||
import { GitPane } from './GitPane'
|
||||
import { CommitMessageAiPane } from './CommitMessageAiPane'
|
||||
import { COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES } from './commit-message-ai-search'
|
||||
import { NotificationsPane, NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './NotificationsPane'
|
||||
import { NotificationsPane } from './NotificationsPane'
|
||||
import { VoicePane } from './VoicePane'
|
||||
import { VOICE_PANE_SEARCH_ENTRIES } from './voice-pane-search'
|
||||
import { SshPane, SSH_PANE_SEARCH_ENTRIES } from './SshPane'
|
||||
import { ExperimentalPane, EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './ExperimentalPane'
|
||||
import { AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane'
|
||||
import { SshPane } from './SshPane'
|
||||
import { ExperimentalPane } from './ExperimentalPane'
|
||||
import { AgentsPane } from './AgentsPane'
|
||||
import { OrchestrationPane } from './OrchestrationPane'
|
||||
import { ORCHESTRATION_PANE_SEARCH_ENTRIES } from './orchestration-search'
|
||||
import { AccountsPane, ACCOUNTS_PANE_SEARCH_ENTRIES } from './AccountsPane'
|
||||
import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane'
|
||||
import { IntegrationsPane, INTEGRATIONS_PANE_SEARCH_ENTRIES } from './IntegrationsPane'
|
||||
import { AccountsPane } from './AccountsPane'
|
||||
import { StatsPane } from '../stats/StatsPane'
|
||||
import { IntegrationsPane } from './IntegrationsPane'
|
||||
import { TasksPane } from './TasksPane'
|
||||
import { TASKS_PANE_SEARCH_ENTRIES } from './tasks-search'
|
||||
import { QuickCommandsPane } from './QuickCommandsPane'
|
||||
import { QUICK_COMMANDS_PANE_SEARCH_ENTRIES } from './quick-commands-search'
|
||||
import {
|
||||
DeveloperPermissionsPane,
|
||||
DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES
|
||||
} from './DeveloperPermissionsPane'
|
||||
import { ComputerUsePane, COMPUTER_USE_PANE_SEARCH_ENTRIES } from './ComputerUsePane'
|
||||
import { MobileSettingsPane, MOBILE_SETTINGS_PANE_SEARCH_ENTRIES } from './MobileSettingsPane'
|
||||
import { DeveloperPermissionsPane } from './DeveloperPermissionsPane'
|
||||
import { ComputerUsePane } from './ComputerUsePane'
|
||||
import { MobileSettingsPane } from './MobileSettingsPane'
|
||||
import { RuntimeEnvironmentsPane } from './RuntimeEnvironmentsPane'
|
||||
import {
|
||||
RUNTIME_ENVIRONMENTS_SEARCH_ENTRY,
|
||||
WEB_RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
|
||||
} from './runtime-environments-search'
|
||||
import { PrivacyPane } from './PrivacyPane'
|
||||
import { PRIVACY_PANE_SEARCH_ENTRIES } from './privacy-search'
|
||||
import { SettingsSidebar } from './SettingsSidebar'
|
||||
import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection'
|
||||
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
|
||||
import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { keybindingMatchesAction } from '../../../../shared/keybindings'
|
||||
import {
|
||||
isWebClientLocation,
|
||||
useSettingsNavigationMetadata
|
||||
} from '@/hooks/useSettingsNavigationMetadata'
|
||||
import type {
|
||||
SettingsNavGroup,
|
||||
SettingsNavSection,
|
||||
SettingsNavTarget
|
||||
} from '@/lib/settings-navigation-types'
|
||||
import {
|
||||
deriveNeededRepoIds,
|
||||
deriveNeededSectionIds,
|
||||
|
|
@ -95,50 +63,6 @@ import {
|
|||
getRuntimeTargetIdentity
|
||||
} from './settings-load-performance'
|
||||
|
||||
type SettingsNavTarget =
|
||||
| 'general'
|
||||
| 'integrations'
|
||||
| 'accounts'
|
||||
| 'browser'
|
||||
| 'git'
|
||||
| 'tasks'
|
||||
| 'appearance'
|
||||
| 'input'
|
||||
| 'floating-workspace'
|
||||
| 'terminal'
|
||||
| 'quick-commands'
|
||||
| 'notifications'
|
||||
| 'computer-use'
|
||||
| 'developer-permissions'
|
||||
| 'privacy'
|
||||
| 'voice'
|
||||
| 'shortcuts'
|
||||
| 'stats'
|
||||
| 'ssh'
|
||||
| 'privacy'
|
||||
| 'experimental'
|
||||
| 'agents'
|
||||
| 'orchestration'
|
||||
| 'servers'
|
||||
| 'mobile'
|
||||
| 'repo'
|
||||
|
||||
type SettingsNavSection = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: typeof SlidersHorizontal
|
||||
searchEntries: SettingsSearchEntry[]
|
||||
group: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
type SettingsNavGroup = {
|
||||
id: string
|
||||
title: string
|
||||
sections: SettingsNavSection[]
|
||||
}
|
||||
|
||||
const SETTINGS_NAV_GROUPS = [
|
||||
{ id: 'setup', title: 'Set Up' },
|
||||
{ id: 'workflows', title: 'Workflows' },
|
||||
|
|
@ -211,13 +135,6 @@ function isEditableTarget(target: EventTarget | null): boolean {
|
|||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|
||||
}
|
||||
|
||||
function isWebClientLocation(): boolean {
|
||||
return (
|
||||
Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) ||
|
||||
window.location.pathname.endsWith('/web-index.html')
|
||||
)
|
||||
}
|
||||
|
||||
function Settings(): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const keybindings = useAppStore((s) => s.keybindings)
|
||||
|
|
@ -248,10 +165,6 @@ function Settings(): React.JSX.Element {
|
|||
// Why: the Terminal settings section shares one search index with the
|
||||
// sidebar. We trim platform-only entries on other platforms so search never
|
||||
// reveals controls that the renderer will intentionally hide.
|
||||
const terminalPaneSearchEntries = useMemo(
|
||||
() => getTerminalPaneSearchEntries({ isWindows, isMac }),
|
||||
[isWindows, isMac]
|
||||
)
|
||||
const [scrollbackMode, setScrollbackMode] = useState<'preset' | 'custom'>('preset')
|
||||
const [prevScrollbackBytes, setPrevScrollbackBytes] = useState(settings?.terminalScrollbackBytes)
|
||||
// Why: lifted out of TerminalPane so the Terminal section header can render
|
||||
|
|
@ -266,6 +179,7 @@ function Settings(): React.JSX.Element {
|
|||
getInitialMountedSectionIds
|
||||
)
|
||||
const [pendingNavRequestTick, setPendingNavRequestTick] = useState(0)
|
||||
const [quickCommandAddIntentSignal, setQuickCommandAddIntentSignal] = useState(0)
|
||||
const [hasUnsavedCommitPromptChanges, setHasUnsavedCommitPromptChanges] = useState(false)
|
||||
const [commitPromptDiscardSignal, setCommitPromptDiscardSignal] = useState(0)
|
||||
const confirm = useConfirmationDialog()
|
||||
|
|
@ -408,6 +322,9 @@ function Settings(): React.JSX.Element {
|
|||
)
|
||||
pendingNavSectionRef.current = paneSectionId
|
||||
pendingScrollTargetRef.current = settingsNavigationTarget.sectionId ?? paneSectionId
|
||||
if (settingsNavigationTarget.intent === 'add-quick-command') {
|
||||
setQuickCommandAddIntentSignal((signal) => signal + 1)
|
||||
}
|
||||
setMountedSectionIds((previous) => {
|
||||
if (previous.has(paneSectionId)) {
|
||||
return previous
|
||||
|
|
@ -439,243 +356,13 @@ function Settings(): React.JSX.Element {
|
|||
}, [])
|
||||
|
||||
const displayedGitUsername = repos[0]?.gitUsername ?? ''
|
||||
const runtimeEnvironmentsSearchEntry = isWebClient
|
||||
? WEB_RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
|
||||
: RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
|
||||
|
||||
const navSections = useMemo<SettingsNavSection[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'general',
|
||||
title: 'General',
|
||||
description: 'Workspace defaults, app setup, and maintenance.',
|
||||
icon: SlidersHorizontal,
|
||||
searchEntries: GENERAL_PANE_SEARCH_ENTRIES,
|
||||
group: 'setup'
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
title: 'Agents',
|
||||
description: 'Manage AI agents, set a default, and customize commands.',
|
||||
icon: Bot,
|
||||
searchEntries: AGENTS_PANE_SEARCH_ENTRIES,
|
||||
group: 'setup'
|
||||
},
|
||||
{
|
||||
id: 'accounts',
|
||||
title: 'AI Provider Accounts',
|
||||
description: 'Optional account switching for Claude, Codex, Gemini, and OpenCode Go.',
|
||||
icon: UserCog,
|
||||
searchEntries: ACCOUNTS_PANE_SEARCH_ENTRIES,
|
||||
group: 'setup',
|
||||
badge: 'Optional'
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
title: 'Integrations',
|
||||
description: 'Connect GitHub, GitLab, Linear, and source-hosting services.',
|
||||
icon: Blocks,
|
||||
searchEntries: INTEGRATIONS_PANE_SEARCH_ENTRIES,
|
||||
group: 'setup'
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
title: 'Git & Source Control',
|
||||
description: 'Branch naming, base refs, attribution, and AI commit messages.',
|
||||
icon: GitBranch,
|
||||
// Why: the AI commit messages pane is rendered inside the Git section,
|
||||
// so its search entries belong to Git too — that way a query like
|
||||
// "claude" or "thinking" still surfaces the section.
|
||||
searchEntries: [...GIT_PANE_SEARCH_ENTRIES, ...COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES],
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
title: 'Task Sources',
|
||||
description: 'Choose which task providers appear in the Tasks page and sidebar.',
|
||||
icon: ListChecks,
|
||||
searchEntries: TASKS_PANE_SEARCH_ENTRIES,
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'floating-workspace',
|
||||
title: 'Floating Workspace',
|
||||
description: 'Global terminal, browser, and markdown tabs.',
|
||||
icon: PanelsTopLeft,
|
||||
searchEntries: FLOATING_WORKSPACE_SEARCH_ENTRIES,
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
title: 'Appearance',
|
||||
description: 'Theme, zoom, app font, sidebars, and status bar.',
|
||||
icon: Palette,
|
||||
searchEntries: APPEARANCE_PANE_SEARCH_ENTRIES,
|
||||
group: 'interface'
|
||||
},
|
||||
{
|
||||
id: 'input',
|
||||
title: 'Input & Editing',
|
||||
description: 'Selection and editing behavior.',
|
||||
icon: TextCursorInput,
|
||||
searchEntries: INPUT_PANE_SEARCH_ENTRIES,
|
||||
group: 'interface'
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
title: 'Terminal',
|
||||
description: 'Shells, terminal appearance, and pane behavior.',
|
||||
icon: SquareTerminal,
|
||||
searchEntries: terminalPaneSearchEntries,
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'quick-commands',
|
||||
title: 'Quick Commands',
|
||||
description: 'Saved terminal commands, scoped globally or per project.',
|
||||
icon: Play,
|
||||
searchEntries: QUICK_COMMANDS_PANE_SEARCH_ENTRIES,
|
||||
group: 'workflows'
|
||||
},
|
||||
...(showDesktopOnlySettings
|
||||
? [
|
||||
{
|
||||
id: 'browser' as const,
|
||||
title: 'Browser',
|
||||
description: 'Home page, link routing, and session cookies.',
|
||||
icon: Globe,
|
||||
searchEntries: BROWSER_PANE_SEARCH_ENTRIES,
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'notifications' as const,
|
||||
title: 'Notifications',
|
||||
description: 'Native desktop notifications for agent and terminal events.',
|
||||
icon: Bell,
|
||||
searchEntries: NOTIFICATIONS_PANE_SEARCH_ENTRIES,
|
||||
group: 'interface'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'orchestration',
|
||||
title: 'Orchestration',
|
||||
description: 'Coordinate multiple coding agents through Orca.',
|
||||
icon: Network,
|
||||
searchEntries: ORCHESTRATION_PANE_SEARCH_ENTRIES,
|
||||
group: 'capabilities'
|
||||
},
|
||||
{
|
||||
id: 'servers',
|
||||
title: 'Remote Orca Servers',
|
||||
description: isWebClient
|
||||
? 'Connect this browser to a saved Orca server.'
|
||||
: 'Switch between local desktop mode and paired remote Orca runtimes.',
|
||||
icon: Server,
|
||||
searchEntries: [runtimeEnvironmentsSearchEntry],
|
||||
group: 'remote',
|
||||
badge: 'Beta'
|
||||
},
|
||||
...(showDesktopOnlySettings
|
||||
? [
|
||||
{
|
||||
id: 'ssh' as const,
|
||||
title: 'SSH Hosts',
|
||||
description: 'Remote SSH hosts for files, terminals, and git.',
|
||||
icon: Cable,
|
||||
searchEntries: SSH_PANE_SEARCH_ENTRIES,
|
||||
group: 'remote'
|
||||
},
|
||||
{
|
||||
id: 'mobile' as const,
|
||||
title: 'Mobile',
|
||||
description: 'Control terminals and agents from your phone.',
|
||||
icon: Smartphone,
|
||||
searchEntries: MOBILE_SETTINGS_PANE_SEARCH_ENTRIES,
|
||||
group: 'remote'
|
||||
},
|
||||
{
|
||||
id: 'computer-use' as const,
|
||||
title: 'Computer Use',
|
||||
description: 'Enable agents to control any app on your computer.',
|
||||
icon: MousePointerClick,
|
||||
searchEntries: COMPUTER_USE_PANE_SEARCH_ENTRIES,
|
||||
group: 'capabilities',
|
||||
badge: 'Beta'
|
||||
},
|
||||
{
|
||||
id: 'voice' as const,
|
||||
title: 'Voice',
|
||||
description: 'Local speech-to-text dictation with on-device models.',
|
||||
icon: Mic,
|
||||
searchEntries: VOICE_PANE_SEARCH_ENTRIES,
|
||||
group: 'capabilities',
|
||||
badge: 'Beta'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(showDesktopOnlySettings && isMac
|
||||
? [
|
||||
{
|
||||
id: 'developer-permissions' as const,
|
||||
title: 'macOS Permissions',
|
||||
description: 'macOS privacy access for terminal-launched developer tools.',
|
||||
icon: ShieldCheck,
|
||||
searchEntries: DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES,
|
||||
group: 'safety'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'privacy',
|
||||
title: 'Privacy & Telemetry',
|
||||
description: 'Anonymous usage data and telemetry controls.',
|
||||
icon: Lock,
|
||||
searchEntries: PRIVACY_PANE_SEARCH_ENTRIES,
|
||||
group: 'safety'
|
||||
},
|
||||
{
|
||||
id: 'shortcuts',
|
||||
title: 'Shortcuts',
|
||||
description: 'Keyboard shortcuts for common actions.',
|
||||
icon: Keyboard,
|
||||
searchEntries: SHORTCUTS_PANE_SEARCH_ENTRIES,
|
||||
group: 'interface'
|
||||
},
|
||||
{
|
||||
id: 'stats',
|
||||
title: 'Stats & Usage',
|
||||
description: 'Orca stats plus Claude, Codex, and OpenCode usage analytics.',
|
||||
icon: BarChart3,
|
||||
searchEntries: STATS_PANE_SEARCH_ENTRIES,
|
||||
group: 'interface'
|
||||
},
|
||||
{
|
||||
id: 'experimental',
|
||||
title: 'Experimental',
|
||||
description: 'New features that are still taking shape. Give them a try.',
|
||||
icon: FlaskConical,
|
||||
searchEntries: EXPERIMENTAL_PANE_SEARCH_ENTRIES,
|
||||
group: 'experimental'
|
||||
},
|
||||
...repos.map((repo) => ({
|
||||
id: `repo-${repo.id}`,
|
||||
title: repo.displayName,
|
||||
description: `${getRepoKindLabel(repo)} • ${repo.path}`,
|
||||
icon: SlidersHorizontal,
|
||||
searchEntries: getRepositoryPaneSearchEntries(repo),
|
||||
group: 'repositories'
|
||||
}))
|
||||
],
|
||||
[
|
||||
isMac,
|
||||
isWebClient,
|
||||
repos,
|
||||
runtimeEnvironmentsSearchEntry,
|
||||
showDesktopOnlySettings,
|
||||
terminalPaneSearchEntries
|
||||
]
|
||||
const navSections = useSettingsNavigationMetadata()
|
||||
const navSectionById = useMemo(
|
||||
() => new Map(navSections.map((section) => [section.id, section] as const)),
|
||||
[navSections]
|
||||
)
|
||||
const getSectionSearchEntries = (sectionId: string) =>
|
||||
navSectionById.get(sectionId)?.searchEntries ?? []
|
||||
|
||||
const visibleNavSections = useMemo(
|
||||
() =>
|
||||
|
|
@ -996,7 +683,7 @@ function Settings(): React.JSX.Element {
|
|||
id="general"
|
||||
title="General"
|
||||
description="Workspace defaults, app setup, and maintenance."
|
||||
searchEntries={GENERAL_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('general')}
|
||||
>
|
||||
{isSectionMounted('general') ? (
|
||||
<GeneralPane settings={settings} updateSettings={updateSettings} />
|
||||
|
|
@ -1007,7 +694,7 @@ function Settings(): React.JSX.Element {
|
|||
id="agents"
|
||||
title="Agents"
|
||||
description="Manage AI agents, set a default, and customize commands."
|
||||
searchEntries={AGENTS_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('agents')}
|
||||
>
|
||||
{isSectionMounted('agents') ? (
|
||||
<AgentsPane settings={settings} updateSettings={updateSettings} />
|
||||
|
|
@ -1019,7 +706,7 @@ function Settings(): React.JSX.Element {
|
|||
title="AI Provider Accounts"
|
||||
description="Optional. Orca works with your existing provider logins; add accounts only if you want Orca to help switch between them."
|
||||
badge="Optional"
|
||||
searchEntries={ACCOUNTS_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('accounts')}
|
||||
>
|
||||
{isSectionMounted('accounts') ? (
|
||||
<AccountsPane settings={settings} updateSettings={updateSettings} />
|
||||
|
|
@ -1030,7 +717,7 @@ function Settings(): React.JSX.Element {
|
|||
id="integrations"
|
||||
title="Integrations"
|
||||
description="Connect GitHub, GitLab, Linear, and source-hosting services."
|
||||
searchEntries={INTEGRATIONS_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('integrations')}
|
||||
>
|
||||
{isSectionMounted('integrations') ? <IntegrationsPane /> : null}
|
||||
</SettingsSection>
|
||||
|
|
@ -1039,10 +726,7 @@ function Settings(): React.JSX.Element {
|
|||
id="git"
|
||||
title="Git & Source Control"
|
||||
description="Branch naming, base refs, attribution, and AI commit messages."
|
||||
searchEntries={[
|
||||
...GIT_PANE_SEARCH_ENTRIES,
|
||||
...COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES
|
||||
]}
|
||||
searchEntries={getSectionSearchEntries('git')}
|
||||
forceVisible={hasUnsavedCommitPromptChanges}
|
||||
>
|
||||
{isSectionMounted('git') ? (
|
||||
|
|
@ -1066,7 +750,7 @@ function Settings(): React.JSX.Element {
|
|||
id="tasks"
|
||||
title="Task Sources"
|
||||
description="Choose which task providers appear in the Tasks page and sidebar."
|
||||
searchEntries={TASKS_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('tasks')}
|
||||
>
|
||||
{isSectionMounted('tasks') ? (
|
||||
<TasksPane settings={settings} updateSettings={updateSettings} />
|
||||
|
|
@ -1077,7 +761,7 @@ function Settings(): React.JSX.Element {
|
|||
id="floating-workspace"
|
||||
title="Floating Workspace"
|
||||
description="Global terminal, browser, and markdown tabs."
|
||||
searchEntries={FLOATING_WORKSPACE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('floating-workspace')}
|
||||
>
|
||||
{isSectionMounted('floating-workspace') ? (
|
||||
<FloatingWorkspacePane settings={settings} updateSettings={updateSettings} />
|
||||
|
|
@ -1088,7 +772,7 @@ function Settings(): React.JSX.Element {
|
|||
id="terminal"
|
||||
title="Terminal"
|
||||
description="Shells, terminal appearance, and pane behavior."
|
||||
searchEntries={terminalPaneSearchEntries}
|
||||
searchEntries={getSectionSearchEntries('terminal')}
|
||||
headerAction={
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -1122,10 +806,14 @@ function Settings(): React.JSX.Element {
|
|||
id="quick-commands"
|
||||
title="Quick Commands"
|
||||
description="Saved terminal commands, scoped globally or per project."
|
||||
searchEntries={QUICK_COMMANDS_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('quick-commands')}
|
||||
>
|
||||
{isSectionMounted('quick-commands') ? (
|
||||
<QuickCommandsPane settings={settings} updateSettings={updateSettings} />
|
||||
<QuickCommandsPane
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
addCommandIntentSignal={quickCommandAddIntentSignal}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
|
|
@ -1134,7 +822,7 @@ function Settings(): React.JSX.Element {
|
|||
id="browser"
|
||||
title="Browser"
|
||||
description="Home page, link routing, and session cookies."
|
||||
searchEntries={BROWSER_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('browser')}
|
||||
>
|
||||
{isSectionMounted('browser') ? (
|
||||
<BrowserPane
|
||||
|
|
@ -1150,7 +838,7 @@ function Settings(): React.JSX.Element {
|
|||
id="appearance"
|
||||
title="Appearance"
|
||||
description="Theme, zoom, app font, sidebars, and status bar."
|
||||
searchEntries={APPEARANCE_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('appearance')}
|
||||
>
|
||||
{isSectionMounted('appearance') ? (
|
||||
<AppearancePane
|
||||
|
|
@ -1166,7 +854,7 @@ function Settings(): React.JSX.Element {
|
|||
id="input"
|
||||
title="Input & Editing"
|
||||
description="Selection and editing behavior."
|
||||
searchEntries={INPUT_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('input')}
|
||||
>
|
||||
<InputPane settings={settings} updateSettings={updateSettings} />
|
||||
</SettingsSection>
|
||||
|
|
@ -1176,7 +864,7 @@ function Settings(): React.JSX.Element {
|
|||
id="notifications"
|
||||
title="Notifications"
|
||||
description="Native desktop notifications for agent activity and terminal events."
|
||||
searchEntries={NOTIFICATIONS_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('notifications')}
|
||||
>
|
||||
{isSectionMounted('notifications') ? (
|
||||
<NotificationsPane settings={settings} updateSettings={updateSettings} />
|
||||
|
|
@ -1188,7 +876,7 @@ function Settings(): React.JSX.Element {
|
|||
id="shortcuts"
|
||||
title="Shortcuts"
|
||||
description="Keyboard shortcuts for common actions."
|
||||
searchEntries={SHORTCUTS_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('shortcuts')}
|
||||
>
|
||||
{isSectionMounted('shortcuts') ? <ShortcutsPane /> : null}
|
||||
</SettingsSection>
|
||||
|
|
@ -1197,7 +885,7 @@ function Settings(): React.JSX.Element {
|
|||
id="stats"
|
||||
title="Stats & Usage"
|
||||
description="Orca stats plus Claude, Codex, and OpenCode usage analytics."
|
||||
searchEntries={STATS_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('stats')}
|
||||
>
|
||||
{isSectionMounted('stats') ? <StatsPane /> : null}
|
||||
</SettingsSection>
|
||||
|
|
@ -1206,7 +894,7 @@ function Settings(): React.JSX.Element {
|
|||
id="orchestration"
|
||||
title="Orchestration"
|
||||
description="Coordinate multiple coding agents through Orca."
|
||||
searchEntries={ORCHESTRATION_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('orchestration')}
|
||||
>
|
||||
{isSectionMounted('orchestration') ? <OrchestrationPane /> : null}
|
||||
</SettingsSection>
|
||||
|
|
@ -1241,7 +929,7 @@ function Settings(): React.JSX.Element {
|
|||
) : null
|
||||
}
|
||||
description="Enable agents to control any app on your computer."
|
||||
searchEntries={COMPUTER_USE_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('computer-use')}
|
||||
>
|
||||
{isSectionMounted('computer-use') ? <ComputerUsePane /> : null}
|
||||
</SettingsSection>
|
||||
|
|
@ -1251,7 +939,7 @@ function Settings(): React.JSX.Element {
|
|||
title="Voice"
|
||||
badge="Beta"
|
||||
description="Local speech-to-text dictation with on-device models."
|
||||
searchEntries={VOICE_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('voice')}
|
||||
>
|
||||
{isSectionMounted('voice') ? (
|
||||
<VoicePane settings={settings} updateSettings={updateSettings} />
|
||||
|
|
@ -1269,7 +957,7 @@ function Settings(): React.JSX.Element {
|
|||
? 'Connect this browser to a saved Orca server.'
|
||||
: 'Switch between local desktop mode and paired remote Orca runtimes.'
|
||||
}
|
||||
searchEntries={[runtimeEnvironmentsSearchEntry]}
|
||||
searchEntries={getSectionSearchEntries('servers')}
|
||||
>
|
||||
{isSectionMounted('servers') ? (
|
||||
<RuntimeEnvironmentsPane
|
||||
|
|
@ -1287,7 +975,7 @@ function Settings(): React.JSX.Element {
|
|||
id="ssh"
|
||||
title="SSH Hosts"
|
||||
description="Remote SSH hosts for files, terminals, and git."
|
||||
searchEntries={SSH_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('ssh')}
|
||||
>
|
||||
{isSectionMounted('ssh') ? <SshPane /> : null}
|
||||
</SettingsSection>
|
||||
|
|
@ -1297,7 +985,7 @@ function Settings(): React.JSX.Element {
|
|||
title="Mobile"
|
||||
badge="Beta"
|
||||
description="Control terminals and agents from your phone."
|
||||
searchEntries={MOBILE_SETTINGS_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('mobile')}
|
||||
>
|
||||
{isSectionMounted('mobile') ? (
|
||||
<MobileSettingsPane settings={settings} updateSettings={updateSettings} />
|
||||
|
|
@ -1311,7 +999,7 @@ function Settings(): React.JSX.Element {
|
|||
id="developer-permissions"
|
||||
title="macOS Permissions"
|
||||
description="macOS privacy access for terminal-launched developer tools."
|
||||
searchEntries={DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('developer-permissions')}
|
||||
>
|
||||
{isSectionMounted('developer-permissions') ? (
|
||||
<DeveloperPermissionsPane />
|
||||
|
|
@ -1323,7 +1011,7 @@ function Settings(): React.JSX.Element {
|
|||
id="privacy"
|
||||
title="Privacy & Telemetry"
|
||||
description="Anonymous usage data and telemetry controls."
|
||||
searchEntries={PRIVACY_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('privacy')}
|
||||
>
|
||||
{isSectionMounted('privacy') ? <PrivacyPane settings={settings} /> : null}
|
||||
</SettingsSection>
|
||||
|
|
@ -1332,7 +1020,7 @@ function Settings(): React.JSX.Element {
|
|||
id="experimental"
|
||||
title="Experimental"
|
||||
description="New features that are still taking shape. Give them a try."
|
||||
searchEntries={EXPERIMENTAL_PANE_SEARCH_ENTRIES}
|
||||
searchEntries={getSectionSearchEntries('experimental')}
|
||||
>
|
||||
{isSectionMounted('experimental') ? (
|
||||
<ExperimentalPane
|
||||
|
|
@ -1353,7 +1041,7 @@ function Settings(): React.JSX.Element {
|
|||
id={repoSectionId}
|
||||
title={`Project Settings > ${repo.displayName}`}
|
||||
description={repo.path}
|
||||
searchEntries={getRepositoryPaneSearchEntries(repo)}
|
||||
searchEntries={getSectionSearchEntries(repoSectionId)}
|
||||
>
|
||||
{isSectionMounted(repoSectionId) ? (
|
||||
<RepositoryPane
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ import { SearchableSetting } from './SearchableSetting'
|
|||
import { SettingsRow, SettingsSubsectionHeader } from './SettingsFormControls'
|
||||
import { ShortcutBindingRow, type ShortcutTerminalStatus } from './ShortcutBindingRow'
|
||||
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
|
||||
import {
|
||||
CTRL_TAB_BEHAVIOR_SEARCH_ENTRY,
|
||||
SHORTCUTS_PANE_SEARCH_ENTRIES,
|
||||
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY
|
||||
} from './shortcuts-search'
|
||||
export { SHORTCUTS_PANE_SEARCH_ENTRIES }
|
||||
|
||||
type ShortcutGroup = {
|
||||
title: string
|
||||
|
|
@ -37,38 +43,6 @@ const platform: NodeJS.Platform = isMac
|
|||
? 'win32'
|
||||
: 'linux'
|
||||
|
||||
const CTRL_TAB_BEHAVIOR_SEARCH_ENTRY: SettingsSearchEntry = {
|
||||
title: 'Recent Tab Order',
|
||||
description: 'Choose recent or sequential tab switching.',
|
||||
keywords: ['shortcut', 'tab', 'ctrl', 'control', 'recent', 'mru', 'sequential', 'switch']
|
||||
}
|
||||
|
||||
const TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY: SettingsSearchEntry = {
|
||||
title: 'Shortcuts in Terminal',
|
||||
description: 'Choose whether Orca or the focused terminal wins when shortcuts overlap.',
|
||||
keywords: [
|
||||
'shortcut',
|
||||
'keyboard',
|
||||
'terminal',
|
||||
'tui',
|
||||
'shell',
|
||||
'agent',
|
||||
'conflict',
|
||||
'orca first',
|
||||
'terminal first'
|
||||
]
|
||||
}
|
||||
|
||||
export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
...KEYBINDING_DEFINITIONS.map((item) => ({
|
||||
title: item.title,
|
||||
description: `${item.group} shortcut`,
|
||||
keywords: [...item.searchKeywords]
|
||||
})),
|
||||
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY,
|
||||
CTRL_TAB_BEHAVIOR_SEARCH_ENTRY
|
||||
]
|
||||
|
||||
function groupDefinitions(): ShortcutGroup[] {
|
||||
const groups = new Map<string, KeybindingDefinition[]>()
|
||||
for (const definition of KEYBINDING_DEFINITIONS) {
|
||||
|
|
|
|||
|
|
@ -10,34 +10,11 @@ import {
|
|||
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../../../shared/constants'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '../ui/button'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
import { removeSshTargetWithBestEffortCleanup } from './ssh-target-remove'
|
||||
import { SshTargetCard } from './SshTargetCard'
|
||||
import { SshTargetDestructiveActions } from './SshTargetDestructiveActions'
|
||||
import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm'
|
||||
|
||||
export const SSH_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'SSH Connections',
|
||||
description: 'Manage remote SSH targets.',
|
||||
keywords: ['ssh', 'remote', 'server', 'connection', 'host']
|
||||
},
|
||||
{
|
||||
title: 'Add SSH Target',
|
||||
description: 'Add a new remote SSH target.',
|
||||
keywords: ['ssh', 'add', 'new', 'target', 'host', 'server']
|
||||
},
|
||||
{
|
||||
title: 'Import from SSH Config',
|
||||
description: 'Import hosts from ~/.ssh/config.',
|
||||
keywords: ['ssh', 'import', 'config', 'hosts']
|
||||
},
|
||||
{
|
||||
title: 'Test Connection',
|
||||
description: 'Test connectivity to an SSH target.',
|
||||
keywords: ['ssh', 'test', 'connection', 'ping']
|
||||
}
|
||||
]
|
||||
export { SSH_PANE_SEARCH_ENTRIES } from './ssh-search'
|
||||
|
||||
type SshPaneProps = Record<string, never>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
import type { StatusBarItem } from '../../../../shared/types'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const STATUS_BAR_TOGGLES: readonly {
|
||||
id: StatusBarItem
|
||||
title: string
|
||||
description: string
|
||||
keywords: string[]
|
||||
toggleDescription: string
|
||||
}[] = [
|
||||
{
|
||||
id: 'claude',
|
||||
title: 'Claude Usage',
|
||||
description: 'Show Claude token and cost usage in the status bar.',
|
||||
keywords: ['status bar', 'claude', 'usage', 'tokens', 'cost', 'anthropic'],
|
||||
toggleDescription: 'Show Claude token and cost usage for the active workspace.'
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
title: 'Codex Usage',
|
||||
description: 'Show Codex token and cost usage in the status bar.',
|
||||
keywords: ['status bar', 'codex', 'usage', 'tokens', 'cost', 'openai'],
|
||||
toggleDescription: 'Show Codex token and cost usage for the active workspace.'
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
title: 'Gemini Usage',
|
||||
description: 'Show Gemini token and cost usage in the status bar.',
|
||||
keywords: ['status bar', 'gemini', 'usage', 'tokens', 'cost', 'google'],
|
||||
toggleDescription: 'Show Gemini token and cost usage for the active workspace.'
|
||||
},
|
||||
{
|
||||
id: 'opencode-go',
|
||||
title: 'OpenCode Go Usage',
|
||||
description: 'Show OpenCode Go token and cost usage in the status bar.',
|
||||
keywords: ['status bar', 'opencode', 'opencode-go', 'usage', 'tokens', 'cost'],
|
||||
toggleDescription: 'Show OpenCode Go token and cost usage for the active workspace.'
|
||||
},
|
||||
{
|
||||
id: 'ssh',
|
||||
title: 'SSH Status',
|
||||
description: 'Show the active SSH connection status in the status bar.',
|
||||
keywords: ['status bar', 'ssh', 'remote', 'connection', 'host'],
|
||||
toggleDescription:
|
||||
'Show the active SSH connection. Only visible once an SSH target is configured.'
|
||||
},
|
||||
{
|
||||
id: 'resource-usage',
|
||||
title: 'Resource Manager',
|
||||
description: 'Show CPU, memory, terminal sessions, and workspace disk usage in the status bar.',
|
||||
keywords: ['status bar', 'resource', 'manager', 'memory', 'cpu', 'terminal', 'disk', 'space'],
|
||||
toggleDescription:
|
||||
'Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.'
|
||||
},
|
||||
{
|
||||
id: 'ports',
|
||||
title: 'Ports',
|
||||
description: 'Show live workspace ports in the status bar.',
|
||||
keywords: ['status bar', 'ports', 'localhost', 'server', 'workspace'],
|
||||
toggleDescription:
|
||||
'Show live workspace ports. Click it for workspace-scoped ports and external listeners.'
|
||||
}
|
||||
]
|
||||
|
||||
export const THEME_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Theme',
|
||||
description: 'Choose how Orca looks in the app window.',
|
||||
keywords: ['dark', 'light', 'system']
|
||||
}
|
||||
]
|
||||
|
||||
export const ZOOM_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'UI Zoom',
|
||||
description: 'Scale the entire application interface.',
|
||||
keywords: ['zoom', 'scale', 'shortcut']
|
||||
}
|
||||
]
|
||||
|
||||
export const TYPOGRAPHY_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'IDE Font',
|
||||
description: 'Choose the font used by the Orca interface.',
|
||||
keywords: ['font', 'typeface', 'typography', 'ide', 'orca', 'interface', 'app', 'ui']
|
||||
}
|
||||
]
|
||||
|
||||
export const LAYOUT_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Open Right Sidebar by Default',
|
||||
description: 'Automatically expand the file explorer panel when creating a new worktree.',
|
||||
keywords: ['layout', 'file explorer', 'sidebar']
|
||||
},
|
||||
{
|
||||
title: 'Show Git-Ignored Files',
|
||||
description: 'Dim files matched by .gitignore in the file explorer.',
|
||||
keywords: ['git', 'gitignore', 'ignored', 'file explorer', 'sidebar', 'hide']
|
||||
}
|
||||
]
|
||||
|
||||
export const TITLEBAR_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Titlebar App Name',
|
||||
description: 'Show Orca in the titlebar.',
|
||||
keywords: ['titlebar', 'orca', 'app', 'name', 'brand']
|
||||
}
|
||||
]
|
||||
|
||||
export const STATUS_BAR_ENTRIES: SettingsSearchEntry[] = STATUS_BAR_TOGGLES.map(
|
||||
({ title, description, keywords }) => ({ title, description, keywords })
|
||||
)
|
||||
|
||||
export const SIDEBAR_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Show Tasks Button',
|
||||
description: 'Show the Tasks button at the top of the left sidebar.',
|
||||
keywords: ['tasks', 'sidebar', 'button', 'hide', 'show', 'github', 'linear']
|
||||
},
|
||||
{
|
||||
title: 'Show Orca Mobile Button',
|
||||
description: 'Show the Orca Mobile button at the top of the left sidebar.',
|
||||
keywords: ['mobile', 'phone', 'sidebar', 'button', 'hide', 'show', 'toolbox']
|
||||
}
|
||||
]
|
||||
|
||||
export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
...THEME_ENTRIES,
|
||||
...TYPOGRAPHY_ENTRIES,
|
||||
...ZOOM_ENTRIES,
|
||||
...LAYOUT_ENTRIES,
|
||||
...TITLEBAR_ENTRIES,
|
||||
...STATUS_BAR_ENTRIES,
|
||||
...SIDEBAR_ENTRIES
|
||||
]
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import type { SettingsSearchEntry } from './settings-search'
|
||||
import { BROWSER_PANE_SEARCH_ENTRIES as BROWSER_CORE_SEARCH_ENTRIES } from './browser-search'
|
||||
import { BROWSER_USE_PANE_SEARCH_ENTRIES } from './browser-use-search'
|
||||
|
||||
export const BROWSER_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
...BROWSER_USE_PANE_SEARCH_ENTRIES,
|
||||
...BROWSER_CORE_SEARCH_ENTRIES
|
||||
]
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const COMPUTER_USE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Computer Use',
|
||||
description: 'Allow agents to inspect screenshots and operate local apps when you ask.',
|
||||
keywords: [
|
||||
'computer use',
|
||||
'accessibility',
|
||||
'screen recording',
|
||||
'screenshot',
|
||||
'automation',
|
||||
'skill'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Developer Permissions',
|
||||
description: 'macOS permissions for terminal-launched developer tools.',
|
||||
keywords: ['permissions', 'privacy', 'tcc', 'macos', 'developer tools']
|
||||
},
|
||||
{
|
||||
title: 'Microphone and Camera',
|
||||
description: 'Allow voice, transcription, webcam, and media capture tools.',
|
||||
keywords: ['microphone', 'camera', 'voice', 'audio', 'video', 'sox', 'ffmpeg', 'whisper']
|
||||
},
|
||||
{
|
||||
title: 'Screen Recording and Accessibility',
|
||||
description: 'Allow screenshots, screen inspection, keystrokes, and window automation.',
|
||||
keywords: ['screen recording', 'accessibility', 'screenshot', 'automation', 'window']
|
||||
},
|
||||
{
|
||||
title: 'Full Disk Access',
|
||||
description: 'Open the macOS privacy pane for broad terminal file access.',
|
||||
keywords: ['full disk access', 'documents', 'downloads', 'desktop', 'icloud']
|
||||
},
|
||||
{
|
||||
title: 'Local Network, USB, and Bluetooth',
|
||||
description: 'Allow device and local-network tools used from terminal sessions.',
|
||||
keywords: ['local network', 'usb', 'bluetooth', 'bonjour', 'mdns', 'device']
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const INPUT_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Middle-click Paste from Selection',
|
||||
description:
|
||||
'Enabled by default on Linux and macOS. Linux uses the system selection clipboard; other platforms use a private buffer.',
|
||||
keywords: [
|
||||
'input',
|
||||
'editing',
|
||||
'selection',
|
||||
'primary selection',
|
||||
'middle click',
|
||||
'middle mouse',
|
||||
'paste',
|
||||
'clipboard',
|
||||
'x11',
|
||||
'linux',
|
||||
'macos'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const INTEGRATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'GitHub Integration',
|
||||
description: 'GitHub authentication via the gh CLI.',
|
||||
keywords: ['github', 'gh', 'integration']
|
||||
},
|
||||
{
|
||||
title: 'GitLab Integration',
|
||||
description: 'GitLab authentication via the glab CLI.',
|
||||
keywords: ['gitlab', 'glab', 'integration', 'mr', 'merge request']
|
||||
},
|
||||
{
|
||||
title: 'Bitbucket Integration',
|
||||
description: 'Bitbucket Cloud authentication via API token environment variables.',
|
||||
keywords: ['bitbucket', 'integration', 'pull request', 'api token']
|
||||
},
|
||||
{
|
||||
title: 'Azure DevOps Integration',
|
||||
description: 'Azure DevOps Repos authentication via token environment variables.',
|
||||
keywords: ['azure devops', 'azure repos', 'ado', 'integration', 'pull request', 'api token']
|
||||
},
|
||||
{
|
||||
title: 'Gitea Integration',
|
||||
description: 'Gitea authentication via API token environment variables.',
|
||||
keywords: ['gitea', 'self-hosted', 'integration', 'pull request', 'api token']
|
||||
},
|
||||
{
|
||||
title: 'Linear Integration',
|
||||
description: 'Connect Linear to browse and link issues.',
|
||||
keywords: ['linear', 'integration', 'api key', 'connect', 'disconnect']
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const MOBILE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Mobile Pairing',
|
||||
description: 'Pair a mobile device by scanning a QR code.',
|
||||
keywords: ['mobile', 'qr', 'code', 'pair', 'phone', 'scan']
|
||||
},
|
||||
{
|
||||
title: 'Connected Devices',
|
||||
description: 'Manage paired mobile devices.',
|
||||
keywords: ['mobile', 'devices', 'revoke', 'paired', 'connected']
|
||||
},
|
||||
{
|
||||
title: 'Network Interface',
|
||||
description: 'Choose which network address to use for mobile pairing.',
|
||||
keywords: [
|
||||
'network',
|
||||
'interface',
|
||||
'tailscale',
|
||||
'tailnet',
|
||||
'vpn',
|
||||
'overlay',
|
||||
'ip',
|
||||
'address',
|
||||
'wifi',
|
||||
'lan',
|
||||
'remote'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'When you leave the mobile app',
|
||||
description:
|
||||
'Choose what happens to terminals you were viewing on mobile after you close the app or switch away.',
|
||||
keywords: [
|
||||
'mobile',
|
||||
'terminal',
|
||||
'restore',
|
||||
'phone',
|
||||
'fit',
|
||||
'width',
|
||||
'resize',
|
||||
'hold',
|
||||
'leave',
|
||||
'background',
|
||||
'close'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import type { SettingsSearchEntry } from './settings-search'
|
||||
import { MOBILE_PANE_SEARCH_ENTRIES } from './mobile-pane-search'
|
||||
|
||||
export const MOBILE_ENABLE_SEARCH_ENTRY: SettingsSearchEntry = {
|
||||
title: 'Mobile',
|
||||
description: 'Control terminals and agents from your phone.',
|
||||
keywords: [
|
||||
'mobile',
|
||||
'phone',
|
||||
'pair',
|
||||
'qr',
|
||||
'code',
|
||||
'scan',
|
||||
'remote',
|
||||
'android',
|
||||
'apk',
|
||||
'beta',
|
||||
'experimental'
|
||||
]
|
||||
}
|
||||
|
||||
export const MOBILE_SETTINGS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
MOBILE_ENABLE_SEARCH_ENTRY,
|
||||
...MOBILE_PANE_SEARCH_ENTRIES
|
||||
]
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const NOTIFICATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Enable Notifications',
|
||||
description: 'Master switch for Orca desktop notifications.',
|
||||
keywords: ['notifications', 'desktop', 'system', 'native']
|
||||
},
|
||||
{
|
||||
title: 'Agent Task Complete',
|
||||
description: 'Notify when a coding agent transitions from working to idle.',
|
||||
keywords: ['notifications', 'agent', 'complete', 'idle', 'task']
|
||||
},
|
||||
{
|
||||
title: 'Terminal Bell',
|
||||
description: 'Notify when a background terminal emits a bell character.',
|
||||
keywords: ['notifications', 'terminal', 'bell', 'attention']
|
||||
},
|
||||
{
|
||||
title: 'Suppress While Focused',
|
||||
description: 'Avoid notifying when Orca is focused on the active worktree.',
|
||||
keywords: ['notifications', 'focused', 'suppress', 'filtering']
|
||||
},
|
||||
{
|
||||
title: 'Notification Sound',
|
||||
description:
|
||||
'Choose the built-in, system, or local audio file Orca plays for desktop notifications.',
|
||||
keywords: [
|
||||
'notifications',
|
||||
'sound',
|
||||
'audio',
|
||||
'mp3',
|
||||
'wav',
|
||||
'ogg',
|
||||
'm4a',
|
||||
'aac',
|
||||
'flac',
|
||||
'ding',
|
||||
'bong'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Notification Volume',
|
||||
description: 'Playback volume for non-system notification sounds.',
|
||||
keywords: ['notifications', 'sound', 'volume', 'loudness']
|
||||
},
|
||||
{
|
||||
title: 'Send Test Notification',
|
||||
description: 'Trigger a sample desktop notification using the native delivery path.',
|
||||
keywords: ['notifications', 'test']
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import type { Repo } from '../../../../shared/types'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[] {
|
||||
const isFolder = isFolderRepo(repo)
|
||||
return [
|
||||
{
|
||||
title: 'Display Name',
|
||||
description: 'Project-specific display details for the sidebar and tabs.',
|
||||
keywords: [repo.displayName, repo.path, 'project name', 'repository name']
|
||||
},
|
||||
{
|
||||
title: 'Project Icon',
|
||||
description: 'Project icon and color used in the sidebar and tabs.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'project icon',
|
||||
'repository icon',
|
||||
'color',
|
||||
'badge',
|
||||
'emoji',
|
||||
'favicon'
|
||||
]
|
||||
},
|
||||
...(isFolder
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: 'Default Worktree Base',
|
||||
description: 'Default base branch or ref when creating worktrees.',
|
||||
keywords: [repo.displayName, 'base ref', 'branch']
|
||||
},
|
||||
{
|
||||
title: 'Sparse Checkout Presets',
|
||||
description: 'Saved directory sets for sparse worktree creation.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'sparse',
|
||||
'checkout',
|
||||
'preset',
|
||||
'presets',
|
||||
'directory',
|
||||
'directories',
|
||||
'monorepo'
|
||||
]
|
||||
}
|
||||
]),
|
||||
{
|
||||
title: 'Remove Project',
|
||||
description: 'Remove this project from Orca.',
|
||||
keywords: [repo.displayName, 'delete', 'project', 'repository']
|
||||
},
|
||||
...(isFolder
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: 'Worktree Symlinks',
|
||||
description: 'Paths to symlink from the primary checkout into newly created worktrees.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'symlink',
|
||||
'symlinks',
|
||||
'worktree',
|
||||
'link',
|
||||
'shared',
|
||||
'env',
|
||||
'node_modules'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'MCP Configs',
|
||||
description: 'Inspect project-level MCP server config files.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'mcp',
|
||||
'model context protocol',
|
||||
'.mcp.json',
|
||||
'.cursor/mcp.json',
|
||||
'.claude.json',
|
||||
'.claude/mcp.json'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Setup Script',
|
||||
description: 'Local and shared scripts that run after a new worktree is created.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'hooks',
|
||||
'setup',
|
||||
'setup script',
|
||||
'setup command',
|
||||
'local settings scripts',
|
||||
'orca.yaml hooks',
|
||||
'yaml'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Archive Script',
|
||||
description: 'Local and shared scripts that run before a worktree is archived.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'hooks',
|
||||
'archive',
|
||||
'archive script',
|
||||
'archive command',
|
||||
'local settings scripts',
|
||||
'orca.yaml hooks',
|
||||
'yaml'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Advanced',
|
||||
description: 'Command source and orca.yaml details.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'advanced',
|
||||
'command source',
|
||||
'local',
|
||||
'orca.yaml',
|
||||
'shared',
|
||||
'both',
|
||||
'source',
|
||||
'authoritative'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'When to Run Setup',
|
||||
description: 'Choose the default behavior when a setup script is available.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'setup run policy',
|
||||
'ask',
|
||||
'run by default',
|
||||
'skip by default'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Custom GitHub Issue Command',
|
||||
description:
|
||||
'File-based linked-issue command configured via orca.yaml and optional local override.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'github issue command',
|
||||
'issue command',
|
||||
'workflow',
|
||||
'github',
|
||||
'orca.yaml',
|
||||
'.orca/issue-command'
|
||||
]
|
||||
}
|
||||
])
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { KEYBINDING_DEFINITIONS } from '../../../../shared/keybindings'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const CTRL_TAB_BEHAVIOR_SEARCH_ENTRY: SettingsSearchEntry = {
|
||||
title: 'Recent Tab Order',
|
||||
description: 'Choose recent or sequential tab switching.',
|
||||
keywords: ['shortcut', 'tab', 'ctrl', 'control', 'recent', 'mru', 'sequential', 'switch']
|
||||
}
|
||||
|
||||
export const TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY: SettingsSearchEntry = {
|
||||
title: 'Shortcuts in Terminal',
|
||||
description: 'Choose whether Orca or the focused terminal wins when shortcuts overlap.',
|
||||
keywords: [
|
||||
'shortcut',
|
||||
'keyboard',
|
||||
'terminal',
|
||||
'tui',
|
||||
'shell',
|
||||
'agent',
|
||||
'conflict',
|
||||
'orca first',
|
||||
'terminal first'
|
||||
]
|
||||
}
|
||||
|
||||
export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
...KEYBINDING_DEFINITIONS.map((item) => ({
|
||||
title: item.title,
|
||||
description: `${item.group} shortcut`,
|
||||
keywords: [...item.searchKeywords]
|
||||
})),
|
||||
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY,
|
||||
CTRL_TAB_BEHAVIOR_SEARCH_ENTRY
|
||||
]
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const SSH_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'SSH Connections',
|
||||
description: 'Manage remote SSH targets.',
|
||||
keywords: ['ssh', 'remote', 'server', 'connection', 'host']
|
||||
},
|
||||
{
|
||||
title: 'Add SSH Target',
|
||||
description: 'Add a new remote SSH target.',
|
||||
keywords: ['ssh', 'add', 'new', 'target', 'host', 'server']
|
||||
},
|
||||
{
|
||||
title: 'Import from SSH Config',
|
||||
description: 'Import hosts from ~/.ssh/config.',
|
||||
keywords: ['ssh', 'import', 'config', 'hosts']
|
||||
},
|
||||
{
|
||||
title: 'Test Connection',
|
||||
description: 'Test connectivity to an SSH target.',
|
||||
keywords: ['ssh', 'test', 'connection', 'ping']
|
||||
}
|
||||
]
|
||||
|
|
@ -6,7 +6,6 @@ import { ClaudeUsagePane } from './ClaudeUsagePane'
|
|||
import { CodexUsagePane } from './CodexUsagePane'
|
||||
import { OpenCodeUsagePane } from './OpenCodeUsagePane'
|
||||
import { UsageOverviewPane } from './UsageOverviewPane'
|
||||
import type { SettingsSearchEntry } from '../settings/settings-search'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -15,28 +14,7 @@ import {
|
|||
DropdownMenuTrigger
|
||||
} from '../ui/dropdown-menu'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
|
||||
export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Stats & Usage',
|
||||
description:
|
||||
'Orca stats plus combined Claude, Codex, and OpenCode usage analytics, tokens, cache, models, and sessions.',
|
||||
keywords: [
|
||||
'stats',
|
||||
'usage',
|
||||
'statistics',
|
||||
'agents',
|
||||
'prs',
|
||||
'time',
|
||||
'tracking',
|
||||
'claude',
|
||||
'codex',
|
||||
'opencode',
|
||||
'tokens',
|
||||
'cache'
|
||||
]
|
||||
}
|
||||
]
|
||||
export { STATS_PANE_SEARCH_ENTRIES } from './stats-search'
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms <= 0) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import type { SettingsSearchEntry } from '../settings/settings-search'
|
||||
|
||||
export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Stats & Usage',
|
||||
description:
|
||||
'Orca stats plus combined Claude, Codex, and OpenCode usage analytics, tokens, cache, models, and sessions.',
|
||||
keywords: [
|
||||
'stats',
|
||||
'usage',
|
||||
'statistics',
|
||||
'agents',
|
||||
'prs',
|
||||
'time',
|
||||
'tracking',
|
||||
'claude',
|
||||
'codex',
|
||||
'opencode',
|
||||
'tokens',
|
||||
'cache'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
group-scoped activation, close, split, and tab-order rules together so the extracted
|
||||
controller cannot drift from the TabGroupPanel surface it coordinates. */
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type {
|
||||
|
|
@ -12,10 +11,6 @@ import type {
|
|||
TerminalTab
|
||||
} from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useAllWorktrees } from '../../store/selectors'
|
||||
import { createUntitledMarkdownFile } from '../../lib/create-untitled-markdown'
|
||||
import { getConnectionId } from '../../lib/connection-context'
|
||||
import { extractIpcErrorMessage } from '../../lib/ipc-error'
|
||||
import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup'
|
||||
import { requestEditorFileClose } from '../editor/editor-autosave'
|
||||
import { focusTerminalTabSurface } from '../../lib/focus-terminal-tab-surface'
|
||||
|
|
@ -45,7 +40,6 @@ export function useTabGroupWorkspaceModel({
|
|||
groupId: string
|
||||
worktreeId: string
|
||||
}) {
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const worktreeState = useAppStore(
|
||||
useShallow((state) => ({
|
||||
// Why: Zustand v5 expects selector snapshots to be referentially stable
|
||||
|
|
@ -72,6 +66,15 @@ export function useTabGroupWorkspaceModel({
|
|||
const setActiveFile = useAppStore((state) => state.setActiveFile)
|
||||
const setActiveTabType = useAppStore((state) => state.setActiveTabType)
|
||||
const createBrowserTab = useAppStore((state) => state.createBrowserTab)
|
||||
const openNewBrowserTabInActiveWorkspace = useAppStore(
|
||||
(state) => state.openNewBrowserTabInActiveWorkspace
|
||||
)
|
||||
const openNewMarkdownInActiveWorkspace = useAppStore(
|
||||
(state) => state.openNewMarkdownInActiveWorkspace
|
||||
)
|
||||
const openNewTerminalTabInActiveWorkspace = useAppStore(
|
||||
(state) => state.openNewTerminalTabInActiveWorkspace
|
||||
)
|
||||
const closeFile = useAppStore((state) => state.closeFile)
|
||||
const pinFile = useAppStore((state) => state.pinFile)
|
||||
const closeBrowserTab = useAppStore((state) => state.closeBrowserTab)
|
||||
|
|
@ -81,16 +84,11 @@ export function useTabGroupWorkspaceModel({
|
|||
const createEmptySplitGroup = useAppStore((state) => state.createEmptySplitGroup)
|
||||
const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle)
|
||||
const setTabColor = useAppStore((state) => state.setTabColor)
|
||||
const openFile = useAppStore((state) => state.openFile)
|
||||
|
||||
const group = useMemo(
|
||||
() => worktreeState.groups.find((item) => item.id === groupId) ?? null,
|
||||
[groupId, worktreeState.groups]
|
||||
)
|
||||
const worktree = useMemo(
|
||||
() => allWorktrees.find((candidate) => candidate.id === worktreeId) ?? null,
|
||||
[allWorktrees, worktreeId]
|
||||
)
|
||||
const groupTabs = useMemo(
|
||||
() => worktreeState.unifiedTabs.filter((item) => item.groupId === groupId),
|
||||
[groupId, worktreeState.unifiedTabs]
|
||||
|
|
@ -533,24 +531,7 @@ export function useTabGroupWorkspaceModel({
|
|||
closeToRight,
|
||||
createSplitGroup,
|
||||
newBrowserTab: () => {
|
||||
void (async () => {
|
||||
const state = useAppStore.getState()
|
||||
const defaultUrl = state.browserDefaultUrl ?? 'about:blank'
|
||||
if (
|
||||
await createWebRuntimeSessionBrowserTab({
|
||||
worktreeId,
|
||||
url: defaultUrl,
|
||||
targetGroupId: groupId
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
createBrowserTab(worktreeId, defaultUrl, {
|
||||
title: 'New Browser Tab',
|
||||
focusAddressBar: true,
|
||||
targetGroupId: groupId
|
||||
})
|
||||
})()
|
||||
void openNewBrowserTabInActiveWorkspace(groupId)
|
||||
},
|
||||
duplicateBrowserTab: (browserTabId: string) => {
|
||||
void (async () => {
|
||||
|
|
@ -582,40 +563,10 @@ export function useTabGroupWorkspaceModel({
|
|||
// assistive-tech activation because the "+" menu can be triggered from
|
||||
// an unfocused panel without first updating global group focus.
|
||||
newFileTab: async () => {
|
||||
const path = worktree?.path
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const connectionId = getConnectionId(worktreeId) ?? undefined
|
||||
const settings = useAppStore.getState().settings
|
||||
const fileInfo = await createUntitledMarkdownFile(
|
||||
path,
|
||||
worktreeId,
|
||||
connectionId,
|
||||
settings
|
||||
)
|
||||
openFile(fileInfo, { preview: false, targetGroupId: groupId })
|
||||
} catch (err) {
|
||||
toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.'))
|
||||
}
|
||||
await openNewMarkdownInActiveWorkspace(groupId)
|
||||
},
|
||||
newTerminalTab: () => {
|
||||
void (async () => {
|
||||
if (
|
||||
await createWebRuntimeSessionTerminal({
|
||||
worktreeId,
|
||||
targetGroupId: groupId,
|
||||
activate: true
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
const terminal = createTab(worktreeId, groupId)
|
||||
setActiveTab(terminal.id)
|
||||
setActiveTabType('terminal')
|
||||
focusTerminalTabSurface(terminal.id)
|
||||
})()
|
||||
void openNewTerminalTabInActiveWorkspace(groupId)
|
||||
},
|
||||
newTerminalWithShell: (shellOverride: string) => {
|
||||
void (async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSettingsNavigationMetadata } from './useSettingsNavigationMetadata'
|
||||
import type { Repo } from '../../../shared/types'
|
||||
|
||||
const repo = {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
} satisfies Repo
|
||||
|
||||
function ids(args: { isMac?: boolean; isWindows?: boolean; isWebClient?: boolean } = {}): string[] {
|
||||
return buildSettingsNavigationMetadata({
|
||||
isMac: args.isMac ?? false,
|
||||
isWindows: args.isWindows ?? false,
|
||||
isWebClient: args.isWebClient ?? false,
|
||||
repos: [repo]
|
||||
}).map((section) => section.id)
|
||||
}
|
||||
|
||||
describe('settings navigation metadata', () => {
|
||||
it('keeps desktop-only Settings panes out of web metadata', () => {
|
||||
const webIds = ids({ isWebClient: true })
|
||||
|
||||
expect(webIds).not.toContain('browser')
|
||||
expect(webIds).not.toContain('ssh')
|
||||
expect(webIds).not.toContain('mobile')
|
||||
expect(webIds).not.toContain('computer-use')
|
||||
expect(webIds).not.toContain('voice')
|
||||
expect(webIds).toContain('servers')
|
||||
expect(webIds).toContain('repo-repo-1')
|
||||
})
|
||||
|
||||
it('keeps macOS permissions mac-only', () => {
|
||||
expect(ids({ isMac: false })).not.toContain('developer-permissions')
|
||||
expect(ids({ isMac: true })).toContain('developer-permissions')
|
||||
})
|
||||
|
||||
it('does not import Settings page or pane UI modules from the metadata hook', () => {
|
||||
const testDir = dirname(fileURLToPath(import.meta.url))
|
||||
const hookSource = readFileSync(resolve(testDir, 'useSettingsNavigationMetadata.ts'), 'utf8')
|
||||
const importLines = hookSource
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().startsWith('import '))
|
||||
.join('\n')
|
||||
|
||||
expect(importLines).not.toMatch(/components\/settings\/Settings(?:'|")/)
|
||||
expect(importLines).not.toMatch(/components\/settings\/[A-Z][A-Za-z]+Pane(?:'|")/)
|
||||
expect(importLines).not.toMatch(/components\/stats\/StatsPane(?:'|")/)
|
||||
})
|
||||
|
||||
it('does not import Settings page or pane UI modules from the quick action registry', () => {
|
||||
const testDir = dirname(fileURLToPath(import.meta.url))
|
||||
const registrySource = readFileSync(
|
||||
resolve(testDir, '../components/cmd-j/quick-actions.ts'),
|
||||
'utf8'
|
||||
)
|
||||
const importLines = registrySource
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().startsWith('import '))
|
||||
.join('\n')
|
||||
|
||||
expect(importLines).not.toMatch(/components\/settings\/Settings(?:'|")/)
|
||||
expect(importLines).not.toMatch(/components\/settings\/[A-Z][A-Za-z]+Pane(?:'|")/)
|
||||
expect(importLines).not.toMatch(/components\/stats\/StatsPane(?:'|")/)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
/* oxlint-disable max-lines */
|
||||
import { useMemo } from 'react'
|
||||
// Why: this registry mirrors the Settings sidebar in one neutral module so
|
||||
// Cmd+J and Settings visibility cannot drift. Keep it free of Settings pane UI
|
||||
// imports; the boundary is enforced by a focused architecture test.
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
Blocks,
|
||||
Bot,
|
||||
Cable,
|
||||
FlaskConical,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Keyboard,
|
||||
ListChecks,
|
||||
Lock,
|
||||
Mic,
|
||||
MousePointerClick,
|
||||
Network,
|
||||
Palette,
|
||||
PanelsTopLeft,
|
||||
Play,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Smartphone,
|
||||
SquareTerminal,
|
||||
TextCursorInput,
|
||||
UserCog
|
||||
} from 'lucide-react'
|
||||
import type { Repo } from '../../../shared/types'
|
||||
import { getRepoKindLabel } from '../../../shared/repo-kind'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
|
||||
import type { SettingsNavSection } from '@/lib/settings-navigation-types'
|
||||
import { GENERAL_PANE_SEARCH_ENTRIES } from '@/components/settings/general-search'
|
||||
import { AGENTS_PANE_SEARCH_ENTRIES } from '@/components/settings/agents-search'
|
||||
import { ACCOUNTS_PANE_SEARCH_ENTRIES } from '@/components/settings/accounts-search'
|
||||
import { INTEGRATIONS_PANE_SEARCH_ENTRIES } from '@/components/settings/integrations-search'
|
||||
import { GIT_PANE_SEARCH_ENTRIES } from '@/components/settings/git-search'
|
||||
import { COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES } from '@/components/settings/commit-message-ai-search'
|
||||
import { TASKS_PANE_SEARCH_ENTRIES } from '@/components/settings/tasks-search'
|
||||
import { FLOATING_WORKSPACE_SEARCH_ENTRIES } from '@/components/settings/floating-workspace-search'
|
||||
import { APPEARANCE_PANE_SEARCH_ENTRIES } from '@/components/settings/appearance-search'
|
||||
import { INPUT_PANE_SEARCH_ENTRIES } from '@/components/settings/input-search'
|
||||
import { getTerminalPaneSearchEntries } from '@/components/settings/terminal-search'
|
||||
import { QUICK_COMMANDS_PANE_SEARCH_ENTRIES } from '@/components/settings/quick-commands-search'
|
||||
import { BROWSER_PANE_SEARCH_ENTRIES } from '@/components/settings/browser-pane-search'
|
||||
import { NOTIFICATIONS_PANE_SEARCH_ENTRIES } from '@/components/settings/notifications-search'
|
||||
import { ORCHESTRATION_PANE_SEARCH_ENTRIES } from '@/components/settings/orchestration-search'
|
||||
import {
|
||||
RUNTIME_ENVIRONMENTS_SEARCH_ENTRY,
|
||||
WEB_RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
|
||||
} from '@/components/settings/runtime-environments-search'
|
||||
import { SSH_PANE_SEARCH_ENTRIES } from '@/components/settings/ssh-search'
|
||||
import { MOBILE_SETTINGS_PANE_SEARCH_ENTRIES } from '@/components/settings/mobile-settings-search'
|
||||
import { COMPUTER_USE_PANE_SEARCH_ENTRIES } from '@/components/settings/computer-use-search'
|
||||
import { VOICE_PANE_SEARCH_ENTRIES } from '@/components/settings/voice-pane-search'
|
||||
import { DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES } from '@/components/settings/developer-permissions-search'
|
||||
import { PRIVACY_PANE_SEARCH_ENTRIES } from '@/components/settings/privacy-search'
|
||||
import { SHORTCUTS_PANE_SEARCH_ENTRIES } from '@/components/settings/shortcuts-search'
|
||||
import { STATS_PANE_SEARCH_ENTRIES } from '@/components/stats/stats-search'
|
||||
import { EXPERIMENTAL_PANE_SEARCH_ENTRIES } from '@/components/settings/experimental-search'
|
||||
import { getRepositoryPaneSearchEntries } from '@/components/settings/repository-search'
|
||||
|
||||
export function isWebClientLocation(): boolean {
|
||||
return (
|
||||
Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) ||
|
||||
window.location.pathname.endsWith('/web-index.html')
|
||||
)
|
||||
}
|
||||
|
||||
export function buildSettingsNavigationMetadata({
|
||||
isMac,
|
||||
isWindows,
|
||||
isWebClient,
|
||||
repos
|
||||
}: {
|
||||
isMac: boolean
|
||||
isWindows: boolean
|
||||
isWebClient: boolean
|
||||
repos: readonly Repo[]
|
||||
}): SettingsNavSection[] {
|
||||
const showDesktopOnlySettings = !isWebClient
|
||||
const terminalPaneSearchEntries = getTerminalPaneSearchEntries({
|
||||
isWindows,
|
||||
isMac
|
||||
})
|
||||
const runtimeEnvironmentsSearchEntry = isWebClient
|
||||
? WEB_RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
|
||||
: RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'general',
|
||||
title: 'General',
|
||||
description: 'Workspace defaults, app setup, and maintenance.',
|
||||
icon: SlidersHorizontal,
|
||||
searchEntries: GENERAL_PANE_SEARCH_ENTRIES,
|
||||
group: 'setup'
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
title: 'Agents',
|
||||
description: 'Manage AI agents, set a default, and customize commands.',
|
||||
icon: Bot,
|
||||
searchEntries: AGENTS_PANE_SEARCH_ENTRIES,
|
||||
group: 'setup'
|
||||
},
|
||||
{
|
||||
id: 'accounts',
|
||||
title: 'AI Provider Accounts',
|
||||
description: 'Optional account switching for Claude, Codex, Gemini, and OpenCode Go.',
|
||||
icon: UserCog,
|
||||
searchEntries: ACCOUNTS_PANE_SEARCH_ENTRIES,
|
||||
group: 'setup',
|
||||
badge: 'Optional'
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
title: 'Integrations',
|
||||
description: 'Connect GitHub, GitLab, Linear, and source-hosting services.',
|
||||
icon: Blocks,
|
||||
searchEntries: INTEGRATIONS_PANE_SEARCH_ENTRIES,
|
||||
group: 'setup'
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
title: 'Git & Source Control',
|
||||
description: 'Branch naming, base refs, attribution, and AI commit messages.',
|
||||
icon: GitBranch,
|
||||
// Why: the AI commit messages pane is rendered inside Git, so shared
|
||||
// metadata must search both surfaces wherever Git appears.
|
||||
searchEntries: [...GIT_PANE_SEARCH_ENTRIES, ...COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES],
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
title: 'Task Sources',
|
||||
description: 'Choose which task providers appear in the Tasks page and sidebar.',
|
||||
icon: ListChecks,
|
||||
searchEntries: TASKS_PANE_SEARCH_ENTRIES,
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'floating-workspace',
|
||||
title: 'Floating Workspace',
|
||||
description: 'Global terminal, browser, and markdown tabs.',
|
||||
icon: PanelsTopLeft,
|
||||
searchEntries: FLOATING_WORKSPACE_SEARCH_ENTRIES,
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
title: 'Appearance',
|
||||
description: 'Theme, zoom, app font, sidebars, and status bar.',
|
||||
icon: Palette,
|
||||
searchEntries: APPEARANCE_PANE_SEARCH_ENTRIES,
|
||||
group: 'interface'
|
||||
},
|
||||
{
|
||||
id: 'input',
|
||||
title: 'Input & Editing',
|
||||
description: 'Selection and editing behavior.',
|
||||
icon: TextCursorInput,
|
||||
searchEntries: INPUT_PANE_SEARCH_ENTRIES,
|
||||
group: 'interface'
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
title: 'Terminal',
|
||||
description: 'Shells, terminal appearance, and pane behavior.',
|
||||
icon: SquareTerminal,
|
||||
searchEntries: terminalPaneSearchEntries,
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'quick-commands',
|
||||
title: 'Quick Commands',
|
||||
description: 'Saved terminal commands, scoped globally or per project.',
|
||||
icon: Play,
|
||||
searchEntries: QUICK_COMMANDS_PANE_SEARCH_ENTRIES,
|
||||
group: 'workflows'
|
||||
},
|
||||
...(showDesktopOnlySettings
|
||||
? [
|
||||
{
|
||||
id: 'browser',
|
||||
title: 'Browser',
|
||||
description: 'Home page, link routing, and session cookies.',
|
||||
icon: Globe,
|
||||
searchEntries: BROWSER_PANE_SEARCH_ENTRIES,
|
||||
group: 'workflows'
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
title: 'Notifications',
|
||||
description: 'Native desktop notifications for agent and terminal events.',
|
||||
icon: Bell,
|
||||
searchEntries: NOTIFICATIONS_PANE_SEARCH_ENTRIES,
|
||||
group: 'interface'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'orchestration',
|
||||
title: 'Orchestration',
|
||||
description: 'Coordinate multiple coding agents through Orca.',
|
||||
icon: Network,
|
||||
searchEntries: ORCHESTRATION_PANE_SEARCH_ENTRIES,
|
||||
group: 'capabilities'
|
||||
},
|
||||
{
|
||||
id: 'servers',
|
||||
title: 'Remote Orca Servers',
|
||||
description: isWebClient
|
||||
? 'Connect this browser to a saved Orca server.'
|
||||
: 'Switch between local desktop mode and paired remote Orca runtimes.',
|
||||
icon: Server,
|
||||
searchEntries: [runtimeEnvironmentsSearchEntry],
|
||||
group: 'remote',
|
||||
badge: 'Beta'
|
||||
},
|
||||
...(showDesktopOnlySettings
|
||||
? [
|
||||
{
|
||||
id: 'ssh',
|
||||
title: 'SSH Hosts',
|
||||
description: 'Remote SSH hosts for files, terminals, and git.',
|
||||
icon: Cable,
|
||||
searchEntries: SSH_PANE_SEARCH_ENTRIES,
|
||||
group: 'remote'
|
||||
},
|
||||
{
|
||||
id: 'mobile',
|
||||
title: 'Mobile',
|
||||
description: 'Control terminals and agents from your phone.',
|
||||
icon: Smartphone,
|
||||
searchEntries: MOBILE_SETTINGS_PANE_SEARCH_ENTRIES,
|
||||
group: 'remote'
|
||||
},
|
||||
{
|
||||
id: 'computer-use',
|
||||
title: 'Computer Use',
|
||||
description: 'Enable agents to control any app on your computer.',
|
||||
icon: MousePointerClick,
|
||||
searchEntries: COMPUTER_USE_PANE_SEARCH_ENTRIES,
|
||||
group: 'capabilities',
|
||||
badge: 'Beta'
|
||||
},
|
||||
{
|
||||
id: 'voice',
|
||||
title: 'Voice',
|
||||
description: 'Local speech-to-text dictation with on-device models.',
|
||||
icon: Mic,
|
||||
searchEntries: VOICE_PANE_SEARCH_ENTRIES,
|
||||
group: 'capabilities',
|
||||
badge: 'Beta'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(showDesktopOnlySettings && isMac
|
||||
? [
|
||||
{
|
||||
id: 'developer-permissions',
|
||||
title: 'macOS Permissions',
|
||||
description: 'macOS privacy access for terminal-launched developer tools.',
|
||||
icon: ShieldCheck,
|
||||
searchEntries: DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES,
|
||||
group: 'safety'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'privacy',
|
||||
title: 'Privacy & Telemetry',
|
||||
description: 'Anonymous usage data and telemetry controls.',
|
||||
icon: Lock,
|
||||
searchEntries: PRIVACY_PANE_SEARCH_ENTRIES,
|
||||
group: 'safety'
|
||||
},
|
||||
{
|
||||
id: 'shortcuts',
|
||||
title: 'Shortcuts',
|
||||
description: 'Keyboard shortcuts for common actions.',
|
||||
icon: Keyboard,
|
||||
searchEntries: SHORTCUTS_PANE_SEARCH_ENTRIES,
|
||||
group: 'interface'
|
||||
},
|
||||
{
|
||||
id: 'stats',
|
||||
title: 'Stats & Usage',
|
||||
description: 'Orca stats plus Claude, Codex, and OpenCode usage analytics.',
|
||||
icon: BarChart3,
|
||||
searchEntries: STATS_PANE_SEARCH_ENTRIES,
|
||||
group: 'interface'
|
||||
},
|
||||
{
|
||||
id: 'experimental',
|
||||
title: 'Experimental',
|
||||
description: 'New features that are still taking shape. Give them a try.',
|
||||
icon: FlaskConical,
|
||||
searchEntries: EXPERIMENTAL_PANE_SEARCH_ENTRIES,
|
||||
group: 'experimental'
|
||||
},
|
||||
...repos.map((repo) => ({
|
||||
id: `repo-${repo.id}`,
|
||||
title: repo.displayName,
|
||||
description: `${getRepoKindLabel(repo)} • ${repo.path}`,
|
||||
icon: SlidersHorizontal,
|
||||
searchEntries: getRepositoryPaneSearchEntries(repo),
|
||||
group: 'repositories'
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
export function useSettingsNavigationMetadata(): SettingsNavSection[] {
|
||||
const repos = useAppStore((state) => state.repos)
|
||||
const isMac = isMacUserAgent()
|
||||
const isWindows = isWindowsUserAgent()
|
||||
const isWebClient = isWebClientLocation()
|
||||
|
||||
// Why: Settings and Cmd+J share this metadata so platform/runtime visibility
|
||||
// and search entries cannot drift. Keep this hook free of Settings pane UI
|
||||
// imports; see docs/reference/cmd-j-settings-actions-plan.md.
|
||||
return useMemo(
|
||||
() => buildSettingsNavigationMetadata({ isMac, isWindows, isWebClient, repos }),
|
||||
[isMac, isWindows, isWebClient, repos]
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { SettingsSearchEntry } from '@/components/settings/settings-search'
|
||||
|
||||
export type SettingsNavTarget =
|
||||
| 'general'
|
||||
| 'integrations'
|
||||
| 'accounts'
|
||||
| 'browser'
|
||||
| 'git'
|
||||
| 'tasks'
|
||||
| 'appearance'
|
||||
| 'input'
|
||||
| 'floating-workspace'
|
||||
| 'terminal'
|
||||
| 'quick-commands'
|
||||
| 'notifications'
|
||||
| 'computer-use'
|
||||
| 'developer-permissions'
|
||||
| 'privacy'
|
||||
| 'voice'
|
||||
| 'shortcuts'
|
||||
| 'stats'
|
||||
| 'ssh'
|
||||
| 'experimental'
|
||||
| 'agents'
|
||||
| 'orchestration'
|
||||
| 'servers'
|
||||
| 'mobile'
|
||||
| 'repo'
|
||||
|
||||
export type SettingsNavSection = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
searchEntries: SettingsSearchEntry[]
|
||||
group: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export type SettingsNavGroup = {
|
||||
id: string
|
||||
title: string
|
||||
sections: SettingsNavSection[]
|
||||
}
|
||||
|
|
@ -109,6 +109,7 @@ export type BrowserSlice = {
|
|||
url: string,
|
||||
options?: CreateBrowserTabOptions
|
||||
) => BrowserWorkspace
|
||||
openNewBrowserTabInActiveWorkspace: (groupId: string) => Promise<void>
|
||||
closeBrowserTab: (tabId: string) => void
|
||||
shutdownWorktreeBrowsers: (worktreeId: string) => Promise<void>
|
||||
reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null
|
||||
|
|
@ -519,6 +520,34 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
return browserTab
|
||||
},
|
||||
|
||||
openNewBrowserTabInActiveWorkspace: async (groupId) => {
|
||||
const state = get()
|
||||
const worktreeId = state.activeWorktreeId
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
const defaultUrl = state.browserDefaultUrl ?? 'about:blank'
|
||||
const pairedWebRuntimeEnvironmentId = (globalThis as { __ORCA_WEB_CLIENT__?: boolean })
|
||||
.__ORCA_WEB_CLIENT__
|
||||
? state.settings?.activeRuntimeEnvironmentId?.trim()
|
||||
: null
|
||||
if (pairedWebRuntimeEnvironmentId) {
|
||||
const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session')
|
||||
await createWebRuntimeSessionBrowserTab({
|
||||
worktreeId,
|
||||
environmentId: pairedWebRuntimeEnvironmentId,
|
||||
url: defaultUrl,
|
||||
targetGroupId: groupId
|
||||
})
|
||||
return
|
||||
}
|
||||
get().createBrowserTab(worktreeId, defaultUrl, {
|
||||
title: 'New Browser Tab',
|
||||
focusAddressBar: true,
|
||||
targetGroupId: groupId
|
||||
})
|
||||
},
|
||||
|
||||
closeBrowserTab: (tabId) => {
|
||||
let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = []
|
||||
set((s) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import { createTestStore, makeWorktree, seedStore, TEST_REPO } from './store-test-helpers'
|
||||
|
||||
const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn())
|
||||
const createWebRuntimeSessionTerminalMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/runtime/web-runtime-session', () => ({
|
||||
createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock,
|
||||
createWebRuntimeSessionTerminal: createWebRuntimeSessionTerminalMock
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
|
||||
focusTerminalTabSurface: vi.fn()
|
||||
}))
|
||||
|
||||
const pairedWebFlag = globalThis as { __ORCA_WEB_CLIENT__?: boolean }
|
||||
|
||||
function seedActiveWorkspace(store: ReturnType<typeof createTestStore>): void {
|
||||
seedStore(store, {
|
||||
activeWorktreeId: 'wt-1',
|
||||
settings: { activeRuntimeEnvironmentId: 'runtime-1' } as AppState['settings'],
|
||||
worktreesByRepo: {
|
||||
[TEST_REPO.id]: [makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id })]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [{ id: 'group-1', worktreeId: 'wt-1', activeTabId: null, tabOrder: [] }]
|
||||
},
|
||||
activeGroupIdByWorktree: { 'wt-1': 'group-1' }
|
||||
})
|
||||
}
|
||||
|
||||
describe('Cmd+J lifted creation actions', () => {
|
||||
beforeEach(() => {
|
||||
pairedWebFlag.__ORCA_WEB_CLIENT__ = true
|
||||
createWebRuntimeSessionBrowserTabMock.mockReset()
|
||||
createWebRuntimeSessionTerminalMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete pairedWebFlag.__ORCA_WEB_CLIENT__
|
||||
})
|
||||
|
||||
it('does not fall back to a local browser tab when paired-web creation fails', async () => {
|
||||
createWebRuntimeSessionBrowserTabMock.mockResolvedValue(false)
|
||||
const store = createTestStore()
|
||||
seedActiveWorkspace(store)
|
||||
|
||||
await store.getState().openNewBrowserTabInActiveWorkspace('group-1')
|
||||
|
||||
expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
environmentId: 'runtime-1',
|
||||
url: 'about:blank',
|
||||
targetGroupId: 'group-1'
|
||||
})
|
||||
expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toEqual([])
|
||||
})
|
||||
|
||||
it('does not fall back to a local terminal tab when paired-web creation fails', async () => {
|
||||
createWebRuntimeSessionTerminalMock.mockResolvedValue(false)
|
||||
const store = createTestStore()
|
||||
seedActiveWorkspace(store)
|
||||
|
||||
await store.getState().openNewTerminalTabInActiveWorkspace('group-1')
|
||||
|
||||
expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
environmentId: 'runtime-1',
|
||||
targetGroupId: 'group-1',
|
||||
activate: true
|
||||
})
|
||||
expect(store.getState().tabsByWorktree['wt-1'] ?? []).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -45,6 +45,8 @@ import {
|
|||
} from '@/runtime/runtime-file-client'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers'
|
||||
import { createUntitledMarkdownFile } from '@/lib/create-untitled-markdown'
|
||||
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
||||
|
||||
export type DiffSource =
|
||||
| 'unstaged'
|
||||
|
|
@ -286,6 +288,7 @@ export type EditorSlice = {
|
|||
suppressActiveRuntimeFallback?: boolean
|
||||
}
|
||||
) => void
|
||||
openNewMarkdownInActiveWorkspace: (groupId: string) => Promise<void>
|
||||
// Why: dispatcher for markdown link activation. Lives on the slice because it
|
||||
// sequences openFile, setMarkdownViewMode, and setPendingEditorReveal around
|
||||
// an async Monaco remount — all reading/writing state in this slice. See
|
||||
|
|
@ -1380,6 +1383,31 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
)
|
||||
},
|
||||
|
||||
openNewMarkdownInActiveWorkspace: async (groupId) => {
|
||||
const state = get()
|
||||
const worktreeId = state.activeWorktreeId
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
const worktree = state.getKnownWorktreeById(worktreeId)
|
||||
if (!worktree) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const connectionId =
|
||||
state.repos.find((entry) => entry.id === worktree.repoId)?.connectionId ?? undefined
|
||||
const fileInfo = await createUntitledMarkdownFile(
|
||||
worktree.path,
|
||||
worktreeId,
|
||||
connectionId,
|
||||
get().settings
|
||||
)
|
||||
get().openFile(fileInfo, { preview: false, targetGroupId: groupId })
|
||||
} catch (err) {
|
||||
toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.'))
|
||||
}
|
||||
},
|
||||
|
||||
openMarkdownPreview: (file, options) => {
|
||||
const initialState = get()
|
||||
const resolvedRuntimeEnvironmentId =
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
|||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
|
||||
import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
|
||||
function getNextTerminalOrdinal(tabs: TerminalTab[]): number {
|
||||
const usedOrdinals = new Set<number>()
|
||||
|
|
@ -264,6 +265,7 @@ export type TerminalSlice = {
|
|||
id?: string
|
||||
}
|
||||
) => TerminalTab
|
||||
openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise<void>
|
||||
closeTab: (tabId: string) => void
|
||||
reorderTabs: (worktreeId: string, tabIds: string[]) => void
|
||||
setTabBarOrder: (worktreeId: string, order: string[]) => void
|
||||
|
|
@ -619,6 +621,52 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
return tab
|
||||
},
|
||||
|
||||
openNewTerminalTabInActiveWorkspace: async (groupId) => {
|
||||
const state = get()
|
||||
const worktreeId = state.activeWorktreeId
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
const pairedWebRuntimeEnvironmentId = (globalThis as { __ORCA_WEB_CLIENT__?: boolean })
|
||||
.__ORCA_WEB_CLIENT__
|
||||
? state.settings?.activeRuntimeEnvironmentId?.trim()
|
||||
: null
|
||||
if (pairedWebRuntimeEnvironmentId) {
|
||||
const { createWebRuntimeSessionTerminal } = await import('@/runtime/web-runtime-session')
|
||||
await createWebRuntimeSessionTerminal({
|
||||
worktreeId,
|
||||
environmentId: pairedWebRuntimeEnvironmentId,
|
||||
targetGroupId: groupId,
|
||||
activate: true
|
||||
})
|
||||
return
|
||||
}
|
||||
const terminal = get().createTab(worktreeId, groupId)
|
||||
get().setActiveTab(terminal.id)
|
||||
get().setActiveTabType('terminal')
|
||||
const latest = get()
|
||||
const currentTerminals = latest.tabsByWorktree[worktreeId] ?? []
|
||||
const currentEditors = latest.openFiles.filter((file) => file.worktreeId === worktreeId)
|
||||
const currentBrowsers = latest.browserTabsByWorktree[worktreeId] ?? []
|
||||
const stored = latest.tabBarOrderByWorktree[worktreeId]
|
||||
const validIds = new Set([
|
||||
...currentTerminals.map((tab) => tab.id),
|
||||
...currentEditors.map((file) => file.id),
|
||||
...currentBrowsers.map((tab) => tab.id)
|
||||
])
|
||||
const base = (stored ?? []).filter((id) => validIds.has(id))
|
||||
const inBase = new Set(base)
|
||||
for (const id of validIds) {
|
||||
if (!inBase.has(id)) {
|
||||
base.push(id)
|
||||
}
|
||||
}
|
||||
// Why: Cmd+J uses the same creation path as the titlebar button, so a new
|
||||
// terminal should append after mixed editor/browser tabs rather than jump first.
|
||||
get().setTabBarOrder(worktreeId, [...base.filter((id) => id !== terminal.id), terminal.id])
|
||||
focusTerminalTabSurface(terminal.id)
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
set((s) => {
|
||||
const next = { ...s.tabsByWorktree }
|
||||
|
|
|
|||
|
|
@ -400,26 +400,32 @@ export type UISlice = {
|
|||
pane:
|
||||
| 'general'
|
||||
| 'integrations'
|
||||
| 'accounts'
|
||||
| 'browser'
|
||||
| 'git'
|
||||
| 'appearance'
|
||||
| 'input'
|
||||
| 'tasks'
|
||||
| 'floating-workspace'
|
||||
| 'terminal'
|
||||
| 'quick-commands'
|
||||
| 'notifications'
|
||||
| 'computer-use'
|
||||
| 'developer-permissions'
|
||||
| 'privacy'
|
||||
| 'shortcuts'
|
||||
| 'stats'
|
||||
| 'repo'
|
||||
| 'agents'
|
||||
| 'accounts'
|
||||
| 'voice'
|
||||
| 'experimental'
|
||||
| 'orchestration'
|
||||
| 'servers'
|
||||
| 'mobile'
|
||||
| 'notifications'
|
||||
| 'ssh'
|
||||
repoId: string | null
|
||||
sectionId?: string
|
||||
intent?: 'add-quick-command'
|
||||
} | null
|
||||
openSettingsTarget: (target: NonNullable<UISlice['settingsNavigationTarget']>) => void
|
||||
clearSettingsTarget: () => void
|
||||
|
|
|
|||
Loading…
Reference in New Issue