Improve Open In apps settings (#4668)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-04 20:02:00 -04:00 committed by GitHub
parent a5437c16fa
commit 1cd6df9137
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1710 additions and 1166 deletions

View File

@ -296,6 +296,7 @@ describe('registerShellHandlers', () => {
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath)).resolves.toEqual({ ok: true })
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND)
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
])

View File

@ -273,7 +273,9 @@ describe('Store', () => {
expect(settings.showTasksButton).toBe(true)
expect(settings.showAutomationsButton).toBe(true)
expect(settings.visibleTaskProviders).toEqual(['github', 'gitlab', 'linear', 'jira'])
expect(settings.openInApplications).toEqual([])
expect(settings.openInApplications).toEqual([
{ id: 'vscode', label: 'VS Code', command: 'code' }
])
expect(settings.experimentalActivity).toBe(false)
expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true)
expect(settings.experimentalTerminalAttention).toBe(false)

View File

@ -1901,7 +1901,9 @@ export class Store {
parsed.settings?.terminalShortcutPolicy
),
disabledTuiAgents: normalizeDisabledTuiAgents(parsed.settings?.disabledTuiAgents),
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications),
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, {
seedDefaults: true
}),
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
sourceControlAi: migratedSourceControlAi,
// Why: new builds read sourceControlAi, but rollback builds still

View File

@ -0,0 +1,88 @@
import type React from 'react'
import { Timer } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { GENERAL_CACHE_TIMER_SEARCH_ENTRIES } from './general-search'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSubsectionHeader, SettingsSwitch } from './SettingsFormControls'
type GeneralCacheTimerSectionProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
export function GeneralCacheTimerSection({
settings,
updateSettings
}: GeneralCacheTimerSectionProps): React.JSX.Element {
return (
<section key="cache-timer" className="space-y-4">
<SettingsSubsectionHeader
title="Prompt Cache Timer"
description="Claude caches your conversation to reduce costs. When idle too long the cache expires and the next message resends full context at higher cost. This shows a countdown so you know when to resume."
/>
<SearchableSetting
title="Cache Timer"
description="Show a countdown after a Claude agent becomes idle."
keywords={GENERAL_CACHE_TIMER_SEARCH_ENTRIES.flatMap((entry) => [
entry.title,
entry.description ?? '',
...(entry.keywords ?? [])
])}
className="flex items-center justify-between gap-4 py-2"
>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-2">
<Timer className="size-4 text-muted-foreground" />
<Label>Cache Timer</Label>
</div>
<p className="text-xs text-muted-foreground">
Show a countdown in the sidebar after a Claude agent becomes idle.
</p>
</div>
<SettingsSwitch
ariaLabel="Cache Timer"
checked={settings.promptCacheTimerEnabled}
onChange={() => {
const enabling = !settings.promptCacheTimerEnabled
updateSettings({ promptCacheTimerEnabled: enabling })
if (enabling) {
useAppStore.getState().seedCacheTimersForIdleTabs()
}
}}
/>
</SearchableSetting>
{settings.promptCacheTimerEnabled && (
<SearchableSetting
title="Timer Duration"
description="Match this to your provider's cache TTL."
keywords={['cache', 'timer', 'duration', 'ttl']}
className="flex items-center justify-between gap-4 py-2 pl-7"
>
<div className="min-w-0 flex-1 space-y-0.5">
<Label>Timer Duration</Label>
<p className="text-xs text-muted-foreground">
Match this to your provider&apos;s cache TTL. The default is 5 minutes.
</p>
</div>
<Select
value={String(settings.promptCacheTtlMs)}
onValueChange={(v) => updateSettings({ promptCacheTtlMs: Number(v) })}
>
<SelectTrigger size="sm" className="h-7 text-xs w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="300000">5 minutes</SelectItem>
<SelectItem value="3600000">1 hour</SelectItem>
</SelectContent>
</Select>
</SearchableSetting>
)}
</section>
)
}

View File

@ -0,0 +1,239 @@
import type React from 'react'
import { useState } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import {
DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS,
MAX_EDITOR_AUTO_SAVE_DELAY_MS,
MIN_EDITOR_AUTO_SAVE_DELAY_MS
} from '../../../../shared/constants'
import { clampNumber } from '@/lib/terminal-theme'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { SearchableSetting } from './SearchableSetting'
import {
SettingsSegmentedControl,
SettingsSubsectionHeader,
SettingsSwitchRow
} from './SettingsFormControls'
export type AutoSaveDelayDraftState = {
sourceDelayMs: number
draft: string
}
export function createAutoSaveDelayDraftState(
editorAutoSaveDelayMs: number
): AutoSaveDelayDraftState {
return {
sourceDelayMs: editorAutoSaveDelayMs,
draft: String(editorAutoSaveDelayMs)
}
}
function resolveAutoSaveDelayDraftState(
state: AutoSaveDelayDraftState,
editorAutoSaveDelayMs: number
): AutoSaveDelayDraftState {
return state.sourceDelayMs === editorAutoSaveDelayMs
? state
: createAutoSaveDelayDraftState(editorAutoSaveDelayMs)
}
export function updateAutoSaveDelayDraftState(
state: AutoSaveDelayDraftState,
editorAutoSaveDelayMs: number,
draft: string
): AutoSaveDelayDraftState {
return {
// Why: settings persistence is async, so a committed draft must stay tied
// to the current source until the persisted value reloads.
...resolveAutoSaveDelayDraftState(state, editorAutoSaveDelayMs),
draft
}
}
type GeneralEditorSettingsSectionProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
export function GeneralEditorSettingsSection({
settings,
updateSettings
}: GeneralEditorSettingsSectionProps): React.JSX.Element {
const [autoSaveDelayDraftState, setAutoSaveDelayDraftState] = useState(() =>
createAutoSaveDelayDraftState(settings.editorAutoSaveDelayMs)
)
const resolvedAutoSaveDelayDraftState = resolveAutoSaveDelayDraftState(
autoSaveDelayDraftState,
settings.editorAutoSaveDelayMs
)
if (resolvedAutoSaveDelayDraftState !== autoSaveDelayDraftState) {
// Why: Settings can be updated outside this pane; reconcile drafts before
// paint so the visible input never lags behind the persisted value.
setAutoSaveDelayDraftState(resolvedAutoSaveDelayDraftState)
}
const autoSaveDelayDraft = resolvedAutoSaveDelayDraftState.draft
const updateAutoSaveDelayDraft = (draft: string): void => {
setAutoSaveDelayDraftState((current) =>
updateAutoSaveDelayDraftState(current, settings.editorAutoSaveDelayMs, draft)
)
}
const commitAutoSaveDelay = (): void => {
const trimmed = autoSaveDelayDraft.trim()
if (trimmed === '') {
setAutoSaveDelayDraftState(createAutoSaveDelayDraftState(settings.editorAutoSaveDelayMs))
return
}
const value = Number(trimmed)
if (!Number.isFinite(value)) {
setAutoSaveDelayDraftState(createAutoSaveDelayDraftState(settings.editorAutoSaveDelayMs))
return
}
const next = clampNumber(
Math.round(value),
MIN_EDITOR_AUTO_SAVE_DELAY_MS,
MAX_EDITOR_AUTO_SAVE_DELAY_MS
)
updateSettings({ editorAutoSaveDelayMs: next })
setAutoSaveDelayDraftState((current) =>
updateAutoSaveDelayDraftState(current, settings.editorAutoSaveDelayMs, String(next))
)
}
return (
<section key="editor" className="space-y-4">
<SettingsSubsectionHeader
title="Editor"
description="Configure how Orca persists file edits."
/>
<SearchableSetting
title="Auto Save Files"
description="Save editor and editable diff changes automatically after a short pause."
keywords={['autosave', 'save']}
>
<SettingsSwitchRow
label="Auto Save Files"
description="Save editor and editable diff changes automatically after a short pause."
checked={settings.editorAutoSave}
onChange={() => updateSettings({ editorAutoSave: !settings.editorAutoSave })}
/>
</SearchableSetting>
<SearchableSetting
title="Auto Save Delay"
description="How long Orca waits after your last edit before saving automatically."
keywords={['autosave', 'delay', 'milliseconds']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="min-w-0 flex-1 space-y-0.5">
<Label>Auto Save Delay</Label>
<p className="text-xs text-muted-foreground">
How long Orca waits after your last edit before saving automatically. First launch
defaults to {DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS} ms.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Input
type="number"
min={MIN_EDITOR_AUTO_SAVE_DELAY_MS}
max={MAX_EDITOR_AUTO_SAVE_DELAY_MS}
step={250}
value={autoSaveDelayDraft}
onChange={(e) => updateAutoSaveDelayDraft(e.target.value)}
onBlur={commitAutoSaveDelay}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitAutoSaveDelay()
}
}}
className="number-input-clean w-28 text-right tabular-nums"
/>
<span className="text-xs text-muted-foreground">ms</span>
</div>
</SearchableSetting>
<SearchableSetting
title="Default Diff View"
description="Preferred presentation format for showing git diffs by default."
keywords={['diff', 'view', 'inline', 'side-by-side', 'split']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="min-w-0 flex-1 space-y-0.5">
<Label>Default Diff View</Label>
<p className="text-xs text-muted-foreground">
Preferred presentation format for showing git diffs by default.
</p>
</div>
<SettingsSegmentedControl
ariaLabel="Default Diff View"
value={settings.diffDefaultView}
onChange={(option) => updateSettings({ diffDefaultView: option })}
options={[
{ value: 'inline', label: 'Inline' },
{ value: 'side-by-side', label: 'Side-by-side' }
]}
/>
</SearchableSetting>
<SearchableSetting
title="Default Diff File Tree"
description="Show or hide the file tree when opening combined diff views."
keywords={['diff', 'tree', 'file tree', 'combined diff', 'sidebar']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="min-w-0 flex-1 space-y-0.5">
<Label>Default Diff File Tree</Label>
<p className="text-xs text-muted-foreground">
Show or hide the file tree when opening combined diff views.
</p>
</div>
<SettingsSegmentedControl
ariaLabel="Default Diff File Tree"
value={settings.combinedDiffFileTreeVisibleByDefault ? 'shown' : 'hidden'}
onChange={(option) =>
updateSettings({ combinedDiffFileTreeVisibleByDefault: option === 'shown' })
}
options={[
{ value: 'shown', label: 'Shown' },
{ value: 'hidden', label: 'Hidden' }
]}
/>
</SearchableSetting>
<SearchableSetting
title="Minimap"
description="Show the minimap overview when editing a file."
keywords={['minimap', 'overview', 'code', 'scroll']}
>
<SettingsSwitchRow
label="Minimap"
description="Show the minimap overview when editing a file."
checked={settings.editorMinimapEnabled}
onChange={() => updateSettings({ editorMinimapEnabled: !settings.editorMinimapEnabled })}
/>
</SearchableSetting>
<SearchableSetting
title="Markdown Review Notes"
description="Show local markdown review note controls in rich editor mode."
keywords={['markdown', 'review', 'notes', 'annotations', 'agents']}
>
<SettingsSwitchRow
label="Markdown Review Notes"
description="Show local markdown note controls in rich editor mode and agent handoff actions."
checked={settings.markdownReviewToolsEnabled}
onChange={() =>
updateSettings({ markdownReviewToolsEnabled: !settings.markdownReviewToolsEnabled })
}
/>
</SearchableSetting>
</section>
)
}

View File

@ -0,0 +1,253 @@
import type React from 'react'
import { useState } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../../../shared/network-proxy'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSubsectionHeader } from './SettingsFormControls'
export type HttpProxyUrlDraftState = {
sourceValue: string
draft: string
error: string | null
}
export function createHttpProxyUrlDraftState(
httpProxyUrl: string | undefined
): HttpProxyUrlDraftState {
const sourceValue = httpProxyUrl ?? ''
return {
sourceValue,
draft: sourceValue,
error: null
}
}
function resolveHttpProxyUrlDraftState(
state: HttpProxyUrlDraftState,
httpProxyUrl: string | undefined
): HttpProxyUrlDraftState {
const sourceValue = httpProxyUrl ?? ''
return state.sourceValue === sourceValue ? state : createHttpProxyUrlDraftState(httpProxyUrl)
}
export function updateHttpProxyUrlDraftState(
state: HttpProxyUrlDraftState,
httpProxyUrl: string | undefined,
draft: string
): HttpProxyUrlDraftState {
return {
// Why: settings persistence is async, so edits after an external settings
// reload must build on the latest persisted proxy source.
...resolveHttpProxyUrlDraftState(state, httpProxyUrl),
draft,
error: null
}
}
export function setHttpProxyUrlDraftErrorState(
state: HttpProxyUrlDraftState,
httpProxyUrl: string | undefined,
error: string
): HttpProxyUrlDraftState {
return {
...resolveHttpProxyUrlDraftState(state, httpProxyUrl),
error
}
}
export type HttpProxyBypassRulesDraftState = {
sourceValue: string
draft: string
}
export function createHttpProxyBypassRulesDraftState(
httpProxyBypassRules: string | undefined
): HttpProxyBypassRulesDraftState {
const sourceValue = httpProxyBypassRules ?? ''
return {
sourceValue,
draft: sourceValue
}
}
function resolveHttpProxyBypassRulesDraftState(
state: HttpProxyBypassRulesDraftState,
httpProxyBypassRules: string | undefined
): HttpProxyBypassRulesDraftState {
const sourceValue = httpProxyBypassRules ?? ''
return state.sourceValue === sourceValue
? state
: createHttpProxyBypassRulesDraftState(httpProxyBypassRules)
}
export function updateHttpProxyBypassRulesDraftState(
state: HttpProxyBypassRulesDraftState,
httpProxyBypassRules: string | undefined,
draft: string
): HttpProxyBypassRulesDraftState {
return {
...resolveHttpProxyBypassRulesDraftState(state, httpProxyBypassRules),
draft
}
}
type GeneralNetworkSettingsSectionProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
export function GeneralNetworkSettingsSection({
settings,
updateSettings
}: GeneralNetworkSettingsSectionProps): React.JSX.Element {
const [httpProxyUrlDraftState, setHttpProxyUrlDraftState] = useState(() =>
createHttpProxyUrlDraftState(settings.httpProxyUrl)
)
const [httpProxyBypassRulesDraftState, setHttpProxyBypassRulesDraftState] = useState(() =>
createHttpProxyBypassRulesDraftState(settings.httpProxyBypassRules)
)
const resolvedHttpProxyUrlDraftState = resolveHttpProxyUrlDraftState(
httpProxyUrlDraftState,
settings.httpProxyUrl
)
if (resolvedHttpProxyUrlDraftState !== httpProxyUrlDraftState) {
// Why: Settings can change outside this pane; reconcile the proxy draft
// before paint so stale network values do not briefly appear.
setHttpProxyUrlDraftState(resolvedHttpProxyUrlDraftState)
}
const httpProxyUrlDraft = resolvedHttpProxyUrlDraftState.draft
const httpProxyUrlError = resolvedHttpProxyUrlDraftState.error
const resolvedHttpProxyBypassRulesDraftState = resolveHttpProxyBypassRulesDraftState(
httpProxyBypassRulesDraftState,
settings.httpProxyBypassRules
)
if (resolvedHttpProxyBypassRulesDraftState !== httpProxyBypassRulesDraftState) {
// Why: Proxy bypass rules are local input state, but settings reloads can
// replace their source while this pane is mounted.
setHttpProxyBypassRulesDraftState(resolvedHttpProxyBypassRulesDraftState)
}
const httpProxyBypassRulesDraft = resolvedHttpProxyBypassRulesDraftState.draft
const updateHttpProxyUrlDraft = (draft: string): void => {
setHttpProxyUrlDraftState((current) =>
updateHttpProxyUrlDraftState(current, settings.httpProxyUrl, draft)
)
}
const updateHttpProxyBypassRulesDraft = (draft: string): void => {
setHttpProxyBypassRulesDraftState((current) =>
updateHttpProxyBypassRulesDraftState(current, settings.httpProxyBypassRules, draft)
)
}
const commitHttpProxyUrl = (): void => {
const normalized = normalizeProxyUrl(httpProxyUrlDraft)
if (!normalized.ok) {
setHttpProxyUrlDraftState((current) =>
setHttpProxyUrlDraftErrorState(current, settings.httpProxyUrl, normalized.message)
)
return
}
setHttpProxyUrlDraftState((current) =>
updateHttpProxyUrlDraftState(current, settings.httpProxyUrl, normalized.value)
)
if (normalized.value !== (settings.httpProxyUrl ?? '')) {
updateSettings({ httpProxyUrl: normalized.value })
}
}
const commitHttpProxyBypassRules = (): void => {
const normalized = normalizeProxyBypassRules(httpProxyBypassRulesDraft)
setHttpProxyBypassRulesDraftState((current) =>
updateHttpProxyBypassRulesDraftState(current, settings.httpProxyBypassRules, normalized)
)
if (normalized !== (settings.httpProxyBypassRules ?? '')) {
updateSettings({ httpProxyBypassRules: normalized })
}
}
return (
<section key="network" className="space-y-4">
<SettingsSubsectionHeader
title="Network"
description="Configure app-level network routing."
/>
<SearchableSetting
title="HTTP Proxy"
description="Proxy URL for Orca network requests and local terminal children."
keywords={['proxy', 'http_proxy', 'https_proxy', 'network', 'dock', 'launchpad']}
className="space-y-3"
>
<div className="space-y-1">
<Label htmlFor="settings-http-proxy-url">HTTP Proxy</Label>
<p className="text-xs text-muted-foreground">
Leave empty to use system proxy settings and inherited proxy environment variables.
</p>
</div>
<Input
id="settings-http-proxy-url"
value={httpProxyUrlDraft}
onChange={(e) => {
updateHttpProxyUrlDraft(e.target.value)
}}
onBlur={commitHttpProxyUrl}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
}
}}
placeholder="http://proxy.example.com:8080"
autoCapitalize="none"
autoCorrect="off"
autoComplete="off"
spellCheck={false}
aria-invalid={httpProxyUrlError ? true : undefined}
className="font-mono text-xs"
/>
{httpProxyUrlError ? (
<p className="text-xs text-destructive">{httpProxyUrlError}</p>
) : (
<p className="text-xs text-muted-foreground">
Supports http, https, socks, socks4, and socks5 URLs.
</p>
)}
</SearchableSetting>
<SearchableSetting
title="Proxy Bypass Rules"
description="Hosts that should bypass the configured HTTP proxy."
keywords={['proxy', 'bypass', 'no_proxy', 'localhost', 'network']}
className="space-y-3"
>
<div className="space-y-1">
<Label htmlFor="settings-http-proxy-bypass-rules">Proxy Bypass Rules</Label>
<p className="text-xs text-muted-foreground">
Optional. Separate hosts with commas, semicolons, or new lines.
</p>
</div>
<Input
id="settings-http-proxy-bypass-rules"
value={httpProxyBypassRulesDraft}
onChange={(e) => updateHttpProxyBypassRulesDraft(e.target.value)}
onBlur={commitHttpProxyBypassRules}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
}
}}
placeholder="localhost, 127.0.0.1, *.internal"
autoCapitalize="none"
autoCorrect="off"
autoComplete="off"
spellCheck={false}
className="font-mono text-xs"
/>
</SearchableSetting>
</section>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,180 @@
import type React from 'react'
import { useEffect, useState } from 'react'
import { Loader2, Star } from 'lucide-react'
import { useMountedRef } from '@/hooks/useMountedRef'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSubsectionHeader } from './SettingsFormControls'
type SupportState = 'loading' | 'not-starred' | 'starring' | 'starred' | 'hidden' | 'error'
type GeneralSupportSectionProps = {
hasPrecedingSections: boolean
}
export function GeneralSupportSection({
hasPrecedingSections
}: GeneralSupportSectionProps): React.JSX.Element {
const mountedRef = useMountedRef()
// Why: the star state is derived from gh, not from settings, so it does not
// live in the global settings store. 'hidden' covers the gh-unavailable and
// already-starred-on-a-previous-session cases so the section drops out for
// users who can't or don't need to act.
//
// We start in 'loading' and render a placeholder at the exact same
// dimensions as the resolved section. When gh resolves to 'hidden', the
// placeholder collapses with a grid-rows transition so content above it
// doesn't shift; anything below (nothing today, but future-proof) eases up.
const [starState, setStarState] = useState<SupportState>('loading')
useEffect(() => {
let cancelled = false
void window.api.gh.checkOrcaStarred().then((result) => {
if (cancelled) {
return
}
if (result === null) {
setStarState('hidden')
} else {
setStarState(result ? 'starred' : 'not-starred')
}
})
return () => {
cancelled = true
}
}, [])
const handleStarClick = async (): Promise<void> => {
if (starState !== 'not-starred' && starState !== 'error') {
return
}
setStarState('starring')
const ok = await window.api.gh.starOrca('settings')
if (!ok) {
if (mountedRef.current) {
setStarState('error')
}
return
}
if (mountedRef.current) {
setStarState('starred')
}
// Why: clicking star anywhere should also permanently mute the
// threshold-based nag so the user isn't re-prompted via the popup.
await window.api.starNag.complete()
}
return (
<SupportSection
state={starState}
hasPrecedingSections={hasPrecedingSections}
onStarClick={handleStarClick}
/>
)
}
type SupportSectionProps = {
state: SupportState
hasPrecedingSections: boolean
onStarClick: () => void | Promise<void>
}
function SupportSection({
state,
hasPrecedingSections,
onStarClick
}: SupportSectionProps): React.JSX.Element {
// Why: 'hidden' means gh is unavailable or the user had already starred on a
// previous session. Collapse the whole section, including its leading
// Separator, so the settings pane doesn't carry an empty strip.
const collapsed = state === 'hidden'
return (
<section
className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
collapsed ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr] opacity-100'
}`}
aria-hidden={collapsed}
>
<div className="min-h-0 overflow-hidden">
<div className="space-y-8">
{hasPrecedingSections ? <Separator /> : null}
<div className="space-y-4">
<SettingsSubsectionHeader title="Support Orca" />
{state === 'loading' ? <SupportRowSkeleton /> : null}
{state !== 'loading' && state !== 'hidden' ? (
<SupportRow state={state} onStarClick={onStarClick} />
) : null}
</div>
</div>
</div>
</section>
)
}
function SupportRowSkeleton(): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-4 py-2" aria-hidden="true">
<div className="h-4 w-36 rounded bg-muted/50 animate-pulse" />
<div className="h-8 w-24 rounded-md bg-muted/50 animate-pulse" />
</div>
)
}
function SupportRow({
state,
onStarClick
}: {
state: 'not-starred' | 'starring' | 'starred' | 'error'
onStarClick: () => void | Promise<void>
}): React.JSX.Element {
// Why: the left-hand label is the setting's identity and must not change
// when the user clicks. The right-hand control is what changes: before
// starring it is a button; after success it becomes a small confirmation.
return (
<SearchableSetting
title="Star Orca on GitHub"
description="Support the project with a GitHub star via the gh CLI."
keywords={['star', 'github', 'support', 'feedback', 'like']}
className="flex items-center justify-between gap-4 py-2"
>
<Label>Star Orca on GitHub</Label>
{state === 'starred' ? (
<SupportRowThanks />
) : (
<Button
variant="default"
size="sm"
onClick={() => void onStarClick()}
disabled={state === 'starring'}
className="shrink-0 gap-1.5"
>
{state === 'starring' ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Star className="size-3.5" />
)}
{state === 'starring' ? 'Starring...' : state === 'error' ? 'Try Again' : 'Star'}
</Button>
)}
</SearchableSetting>
)
}
function SupportRowThanks(): React.JSX.Element {
// Why: match the size="sm" button's h-8 / gap-1.5 / px-3 dimensions so the
// row height stays identical when the button is swapped out.
return (
<div
className="shrink-0 inline-flex h-8 items-center gap-1.5 px-3 text-sm font-medium
text-amber-400/90 animate-in fade-in slide-in-from-right-1 duration-300"
role="status"
aria-live="polite"
>
<Star className="size-3.5 fill-amber-400/80 text-amber-400/80" aria-hidden="true" />
Thanks for the support!
</div>
)
}

View File

@ -0,0 +1,171 @@
import type React from 'react'
import { useEffect, useRef, useState } from 'react'
import { Download, Loader2, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useAppStore } from '../../store'
import { Button } from '../ui/button'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSubsectionHeader } from './SettingsFormControls'
export function GeneralUpdateSettingsSection(): React.JSX.Element {
const updateStatus = useAppStore((s) => s.updateStatus)
// Why: the 'error' variant of UpdateStatus does not carry a `version` field.
// The main process emits `{ state: 'error' }` for both check failures (no
// version known yet) and download/install failures (version was known from
// the preceding 'available'/'downloading'/'downloaded' state). Cache the
// last-known version so the error copy below can distinguish the two cases
// without adding IPC. Mirrors `versionRef` in UpdateCard.tsx.
const updateVersionRef = useRef<string | null>(null)
if (
(updateStatus.state === 'available' ||
updateStatus.state === 'downloading' ||
updateStatus.state === 'downloaded') &&
updateStatus.version
) {
updateVersionRef.current = updateStatus.version
} else if (
updateStatus.state === 'checking' ||
updateStatus.state === 'idle' ||
updateStatus.state === 'not-available'
) {
// Why: a new check cycle has started or completed cleanly. Clear the
// cached version so a subsequent check failure cannot be mis-classified
// as a download failure based on a stale version from a prior cycle.
updateVersionRef.current = null
}
const [appVersion, setAppVersion] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
void window.api.updater.getVersion().then((version) => {
if (!cancelled) {
setAppVersion(version)
}
})
return () => {
cancelled = true
}
}, [])
const handleRestartToUpdate = (): void => {
// Why: quitAndInstall resolves immediately (the actual quit happens in a
// deferred timer in the main process), so rejection here is only possible
// if the IPC channel itself breaks. Log defensively; the user will notice
// the app didn't restart and can retry.
void window.api.updater.quitAndInstall().catch(console.error)
}
return (
<section key="updates" className="space-y-4">
<SettingsSubsectionHeader
title="Updates"
description={`Current version: ${appVersion ?? '...'}`}
/>
<SearchableSetting
title="Check for Updates"
description="Check for app updates and install a newer Orca version."
keywords={['update', 'version', 'release notes', 'download']}
className="space-y-3"
>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
// Why: Shift-click opts this check into the release-candidate
// channel. Keep the affordance hidden; it's a power-user
// shortcut, not a discoverable toggle.
onClick={(event) =>
window.api.updater.check({
includePrerelease: event.shiftKey
})
}
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
className="gap-2"
>
{updateStatus.state === 'checking' ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
Check for Updates
</Button>
{updateStatus.state === 'available' ? (
<Button
variant="default"
size="sm"
onClick={() => {
void window.api.updater.download().catch((error) => {
toast.error('Could not start the update download.', {
description: String((error as Error)?.message ?? error)
})
})
}}
className="gap-2"
>
<Download className="size-3.5" />
Install Update ({updateStatus.version})
</Button>
) : updateStatus.state === 'downloaded' ? (
<Button variant="default" size="sm" onClick={handleRestartToUpdate} className="gap-2">
<Download className="size-3.5" />
Restart to Update ({updateStatus.version})
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{updateStatus.state === 'idle' && 'Updates are checked automatically on launch.'}
{updateStatus.state === 'checking' && 'Checking for updates...'}
{updateStatus.state === 'available' && (
<>
Version {updateStatus.version} is available. Click &quot;Install Update&quot; to
download and install it.{' '}
<a
href={
updateStatus.releaseUrl ??
`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`
}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Release notes
</a>
</>
)}
{updateStatus.state === 'not-available' && 'You\u2019re on the latest version.'}
{updateStatus.state === 'downloading' &&
`Downloading v${updateStatus.version}... ${updateStatus.percent}%`}
{updateStatus.state === 'downloaded' && (
<>
Version {updateStatus.version} is ready to install.{' '}
<a
href={
updateStatus.releaseUrl ??
`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`
}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Release notes
</a>
</>
)}
{updateStatus.state === 'error' &&
// Why: `{ state: 'error' }` is emitted for both check-time
// failures (no version cached) and download/install failures
// (version cached from a prior 'available'/'downloading'/
// 'downloaded' state). Label accordingly so a download failure
// isn't mislabeled as a "check" failure. Mirrors UpdateCard.tsx.
(updateVersionRef.current
? `Update error. ${updateStatus.message}`
: `Update check failed. ${updateStatus.message}`)}
</p>
</SearchableSetting>
</section>
)
}

View File

@ -0,0 +1,146 @@
import type React from 'react'
import { FolderOpen } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { OpenInMenuSetting } from './OpenInMenuSetting'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls'
type GeneralWorkspaceSettingsSectionProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
export function GeneralWorkspaceSettingsSection({
settings,
updateSettings
}: GeneralWorkspaceSettingsSectionProps): React.JSX.Element {
const handleBrowseWorkspace = async (): Promise<void> => {
const path = await window.api.repos.pickFolder()
if (path) {
updateSettings({ workspaceDir: path })
}
}
return (
<section key="workspace" className="space-y-4">
<SettingsSubsectionHeader
title="Workspace"
description="Configure where new workspaces are created."
/>
<SearchableSetting
title="Workspace Directory"
description="Root directory where workspace folders are created."
keywords={['workspace', 'folder', 'path', 'worktree']}
className="space-y-2"
>
<Label>Workspace Directory</Label>
<div className="flex gap-2">
<Input
value={settings.workspaceDir}
onChange={(e) => updateSettings({ workspaceDir: e.target.value })}
className="flex-1 text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={handleBrowseWorkspace}
className="shrink-0 gap-1.5"
>
<FolderOpen className="size-3.5" />
Browse
</Button>
</div>
<p className="text-xs text-muted-foreground">
Root directory where workspace folders are created.
</p>
</SearchableSetting>
<SearchableSetting
title="Nest Workspaces"
description="Create workspaces inside a repo-named subfolder."
keywords={['nested', 'subfolder', 'directory']}
>
<SettingsSwitchRow
label="Nest Workspaces"
description="Create workspaces inside a repo-named subfolder."
checked={settings.nestWorkspaces}
onChange={() => updateSettings({ nestWorkspaces: !settings.nestWorkspaces })}
/>
</SearchableSetting>
{/* Why: the "Don't ask again" toast in the delete-worktree dialog
deep-links here, so the wrapper id must stay stable. Renaming it
breaks that toast action even though this pane still renders fine. */}
<div id="general-skip-delete-worktree-confirm" className="scroll-mt-6">
<SearchableSetting
title="Ask Before Deleting Workspaces"
description="Show a confirmation dialog before deleting a workspace."
keywords={['delete', 'worktree', 'confirm', 'dialog', 'skip', 'prompt']}
>
<SettingsSwitchRow
label="Ask Before Deleting Workspaces"
description="Show a confirmation before deleting a workspace from the context menu. Failed deletes still surface a Force Delete fallback."
checked={!settings.skipDeleteWorktreeConfirm}
onChange={() =>
updateSettings({
skipDeleteWorktreeConfirm: !settings.skipDeleteWorktreeConfirm
})
}
/>
</SearchableSetting>
</div>
<div id="general-skip-delete-automation-confirm" className="scroll-mt-6">
<SearchableSetting
title="Ask Before Deleting Automations"
description="Show a confirmation dialog before deleting an automation and its run history."
keywords={['delete', 'automation', 'confirm', 'dialog', 'skip', 'prompt']}
>
<SettingsSwitchRow
label="Ask Before Deleting Automations"
description="Show a confirmation before deleting automations and their run history."
checked={!settings.skipDeleteAutomationConfirm}
onChange={() =>
updateSettings({
skipDeleteAutomationConfirm: !settings.skipDeleteAutomationConfirm
})
}
/>
</SearchableSetting>
</div>
<div
id="general-open-in-apps"
data-settings-section="general-open-in-apps"
className="scroll-mt-6"
>
<SearchableSetting
title="Open In Apps"
description="Choose apps available from a workspace's Open in menu."
keywords={[
'open in',
'open menu',
'editor',
'launcher',
'cursor',
'zed',
'command',
'vscode',
'finder',
'file explorer'
]}
className="space-y-3"
>
<OpenInMenuSetting
applications={settings.openInApplications}
updateSettings={updateSettings}
/>
</SearchableSetting>
</div>
</section>
)
}

View File

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
createPresetOpenInApplication,
shouldCommitOpenInApplicationsDraft
} from './OpenInMenuSetting'
import { isOpenInAppPresetAdded, OPEN_IN_APP_PRESETS } from '@/lib/open-in-app-catalog'
import type { OpenInAppPreset } from '@/lib/open-in-app-catalog'
function requirePreset(id: string): OpenInAppPreset {
const preset = OPEN_IN_APP_PRESETS.find((entry) => entry.id === id)
if (!preset) {
throw new Error(`Preset not found: ${id}`)
}
return preset
}
describe('OpenInMenuSetting presets', () => {
it('creates stable preset rows for known apps', () => {
const cursor = requirePreset('cursor')
expect(createPresetOpenInApplication(cursor)).toEqual({
id: 'cursor',
label: 'Cursor',
command: 'cursor'
})
})
it('recognizes legacy preset rows by command', () => {
const cursor = requirePreset('cursor')
expect(isOpenInAppPresetAdded([{ command: ' cursor ' }], cursor)).toBe(true)
})
})
describe('OpenInMenuSetting application drafts', () => {
it('does not commit rows until both label and command are present', () => {
expect(
shouldCommitOpenInApplicationsDraft([{ id: 'draft', label: 'Cursor', command: '' }])
).toBe(false)
expect(
shouldCommitOpenInApplicationsDraft([{ id: 'draft', label: '', command: 'cursor' }])
).toBe(false)
expect(
shouldCommitOpenInApplicationsDraft([{ id: 'draft', label: ' ', command: 'cursor' }])
).toBe(false)
expect(
shouldCommitOpenInApplicationsDraft([{ id: 'draft', label: 'Cursor', command: ' ' }])
).toBe(false)
})
it('allows commit when every draft row has a label and command', () => {
expect(shouldCommitOpenInApplicationsDraft([])).toBe(true)
expect(
shouldCommitOpenInApplicationsDraft([{ id: 'cursor', label: 'Cursor', command: 'cursor' }])
).toBe(true)
expect(
shouldCommitOpenInApplicationsDraft([
{ id: 'cursor', label: 'Cursor', command: 'cursor' },
{ id: 'zed', label: 'Zed', command: 'zed' }
])
).toBe(true)
})
})

View File

@ -0,0 +1,350 @@
import type React from 'react'
import { useState } from 'react'
import { Check, ChevronDown, Pencil, Trash2 } from 'lucide-react'
import type { GlobalSettings, OpenInApplication } from '../../../../shared/types'
import { OPEN_IN_APPLICATIONS_MAX } from '../../../../shared/open-in-applications'
import { Button } from '../ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { cn } from '@/lib/utils'
import {
getOpenInAppPreset,
isOpenInAppPresetAdded,
OpenInApplicationIcon,
OPEN_IN_APP_PRESETS,
type OpenInAppPreset
} from '@/lib/open-in-app-catalog'
type OpenInMenuSettingProps = {
applications: OpenInApplication[] | undefined
updateSettings: (updates: Partial<GlobalSettings>) => void
}
type OpenInApplicationsDraftState = {
sourceApplications: OpenInApplication[] | undefined
draft: OpenInApplication[]
}
function createOpenInApplication(): OpenInApplication {
return {
id:
globalThis.crypto?.randomUUID?.() ??
`open-in-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
label: '',
command: ''
}
}
export function createPresetOpenInApplication(preset: OpenInAppPreset): OpenInApplication {
return {
id: preset.id,
label: preset.label,
command: preset.command
}
}
function createOpenInApplicationsDraftState(
openInApplications: OpenInApplication[] | undefined
): OpenInApplicationsDraftState {
return {
sourceApplications: openInApplications,
draft: openInApplications ?? []
}
}
function resolveOpenInApplicationsDraftState(
state: OpenInApplicationsDraftState,
openInApplications: OpenInApplication[] | undefined
): OpenInApplicationsDraftState {
return state.sourceApplications === openInApplications
? state
: createOpenInApplicationsDraftState(openInApplications)
}
export function shouldCommitOpenInApplicationsDraft(applications: OpenInApplication[]): boolean {
return applications.every((application) => {
return application.label.trim() !== '' && application.command.trim() !== ''
})
}
function OpenInMenuRow({
application,
editing,
onEditToggle,
onRemove,
onChange,
onCommit
}: {
application: OpenInApplication
editing: boolean
onEditToggle: () => void
onRemove: () => void
onChange: (updates: Pick<OpenInApplication, 'label' | 'command'>) => void
onCommit: () => void
}): React.JSX.Element {
const preset = getOpenInAppPreset(application)
const isPreset =
preset !== null &&
(application.id === preset.id ||
application.label.trim().toLowerCase() === preset.label.toLowerCase())
return (
<div className="py-3">
<div className="flex flex-wrap items-start gap-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border/50 bg-background/50">
<OpenInApplicationIcon application={application} size={16} />
</div>
<div className="min-w-0 flex-1 sm:min-w-[12rem]">
<div className="flex items-center gap-2">
<span className="text-sm font-medium leading-none">
{application.label.trim() || 'New app'}
</span>
</div>
<div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
{application.command.trim() || 'Set command'}
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onEditToggle}
title={editing ? 'Collapse app details' : 'Edit app'}
aria-label={editing ? 'Collapse app details' : 'Edit app'}
aria-expanded={editing}
className={cn(
'size-7 text-muted-foreground hover:text-foreground',
editing && 'text-foreground'
)}
>
<Pencil className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRemove}
title="Remove app"
aria-label="Remove app"
className="size-7 text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
{editing && (
<div
className={cn(
'mt-3 grid grid-cols-1 gap-2 pl-10',
!isPreset && 'sm:grid-cols-[minmax(12rem,1fr)_minmax(12rem,1fr)]'
)}
>
{!isPreset && (
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">Menu label</Label>
<Input
value={application.label}
placeholder="App name"
onChange={(event) =>
onChange({ label: event.target.value, command: application.command })
}
onBlur={onCommit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onCommit()
event.currentTarget.blur()
}
}}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">Terminal command</Label>
<Input
value={application.command}
placeholder="cursor"
spellCheck={false}
className="font-mono text-xs"
onChange={(event) =>
onChange({ label: application.label, command: event.target.value })
}
onBlur={onCommit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onCommit()
event.currentTarget.blur()
}
}}
/>
<p className="text-[11px] text-muted-foreground">
The command you would type in Terminal to open this app.
</p>
</div>
</div>
)}
</div>
)
}
export function OpenInMenuSetting({
applications,
updateSettings
}: OpenInMenuSettingProps): React.JSX.Element {
const [draftState, setDraftState] = useState(() =>
createOpenInApplicationsDraftState(applications)
)
const [editingIds, setEditingIds] = useState<ReadonlySet<string>>(new Set())
const resolvedDraftState = resolveOpenInApplicationsDraftState(draftState, applications)
if (resolvedDraftState !== draftState) {
// Why: the Open menu rows are editable local drafts, but Settings can
// reload from persistence while this pane is mounted.
setDraftState(resolvedDraftState)
}
const draft = resolvedDraftState.draft
const isAtLimit = draft.length >= OPEN_IN_APPLICATIONS_MAX
const commit = (nextDraft: OpenInApplication[]): void => {
if (!shouldCommitOpenInApplicationsDraft(nextDraft)) {
return
}
updateSettings({ openInApplications: nextDraft })
}
const updateDraft = (nextDraft: OpenInApplication[]): void => {
setDraftState((current) => ({
...resolveOpenInApplicationsDraftState(current, applications),
draft: nextDraft
}))
}
const applyDraft = (nextDraft: OpenInApplication[]): void => {
updateDraft(nextDraft)
commit(nextDraft)
}
const addPreset = (preset: OpenInAppPreset): void => {
if (isAtLimit || isOpenInAppPresetAdded(draft, preset)) {
return
}
applyDraft([...draft, createPresetOpenInApplication(preset)])
}
const addCustomApp = (): void => {
if (isAtLimit) {
return
}
const application = createOpenInApplication()
updateDraft([...draft, application])
setEditingIds((current) => new Set([...current, application.id]))
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<Label>Open In Apps</Label>
<p className="text-xs text-muted-foreground">
Choose apps available from a workspace&apos;s Open in menu.
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
disabled={isAtLimit}
className="h-8 shrink-0 gap-1.5"
>
Add app
<ChevronDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{OPEN_IN_APP_PRESETS.map((preset) => {
const isAdded = isOpenInAppPresetAdded(draft, preset)
return (
<DropdownMenuItem
key={preset.id}
disabled={isAdded || isAtLimit}
onSelect={() => addPreset(preset)}
className="gap-2"
>
<OpenInApplicationIcon application={preset} size={14} />
<span className="min-w-0 truncate">{preset.label}</span>
{isAdded && (
<DropdownMenuShortcut className="inline-flex items-center gap-1">
<Check className="size-3" />
Added
</DropdownMenuShortcut>
)}
</DropdownMenuItem>
)
})}
<DropdownMenuItem disabled={isAtLimit} onSelect={addCustomApp} className="gap-2">
<OpenInApplicationIcon application={{ command: '' }} size={14} />
<span className="min-w-0 truncate">Custom app</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{draft.length > 0 && (
<div className="divide-y divide-border/40">
{draft.map((application, index) => {
const editing =
editingIds.has(application.id) ||
application.label.trim() === '' ||
application.command.trim() === ''
return (
<OpenInMenuRow
key={application.id}
application={application}
editing={editing}
onEditToggle={() =>
setEditingIds((current) => {
const next = new Set(current)
if (next.has(application.id)) {
next.delete(application.id)
} else {
next.add(application.id)
}
return next
})
}
onRemove={() => {
const next = draft.filter((entry) => entry.id !== application.id)
applyDraft(next)
setEditingIds((current) => {
const nextEditing = new Set(current)
nextEditing.delete(application.id)
return nextEditing
})
}}
onChange={(updates) => {
const next = [...draft]
next[index] = { ...application, ...updates }
updateDraft(next)
}}
onCommit={() => commit(draft)}
/>
)
})}
</div>
)}
</div>
)
}

View File

@ -22,9 +22,20 @@ export const GENERAL_WORKSPACE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
keywords: ['delete', 'automation', 'confirm', 'dialog', 'skip', 'prompt']
},
{
title: 'Open In Menu',
description: 'Add custom launchers to the workspace Open in menu.',
keywords: ['open in', 'editor', 'launcher', 'cursor', 'zed', 'command', 'vscode']
title: 'Open In Apps',
description: "Choose apps available from a workspace's Open in menu.",
keywords: [
'open in',
'open menu',
'editor',
'launcher',
'cursor',
'zed',
'command',
'vscode',
'finder',
'file explorer'
]
}
]

View File

@ -4,6 +4,7 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/components/ui/
import {
getWorktreeOpenInEntries,
getLocalFileManagerLabel,
openOpenInAppsSettings,
openWorktreePath,
WorktreeOpenInSubMenu
} from './WorktreeOpenInMenu'
@ -13,19 +14,26 @@ type ReactElementLike = {
props: Record<string, unknown>
}
const { mockState, openInExternalEditorMock, openInFileManagerMock, toastErrorMock } = vi.hoisted(
() => ({
mockState: {
settings: {
activeRuntimeEnvironmentId: null as string | null,
openInApplications: [] as { id: string; label: string; command: string }[]
}
},
openInExternalEditorMock: vi.fn(),
openInFileManagerMock: vi.fn(),
toastErrorMock: vi.fn()
})
)
const {
mockState,
openInExternalEditorMock,
openInFileManagerMock,
openSettingsPageMock,
openSettingsTargetMock,
toastErrorMock
} = vi.hoisted(() => ({
mockState: {
settings: {
activeRuntimeEnvironmentId: null as string | null,
openInApplications: [] as { id: string; label: string; command: string }[]
}
},
openInExternalEditorMock: vi.fn(),
openInFileManagerMock: vi.fn(),
openSettingsPageMock: vi.fn(),
openSettingsTargetMock: vi.fn(),
toastErrorMock: vi.fn()
}))
vi.mock('sonner', () => ({
toast: {
@ -38,7 +46,11 @@ vi.mock('@/store', () => {
(selector: (state: { settings: typeof mockState.settings }) => unknown) =>
selector({ settings: mockState.settings }),
{
getState: () => ({ settings: mockState.settings })
getState: () => ({
settings: mockState.settings,
openSettingsPage: openSettingsPageMock,
openSettingsTarget: openSettingsTargetMock
})
}
)
return { useAppStore }
@ -78,6 +90,8 @@ describe('WorktreeOpenInMenu', () => {
toastErrorMock.mockReset()
openInFileManagerMock.mockReset()
openInExternalEditorMock.mockReset()
openSettingsPageMock.mockReset()
openSettingsTargetMock.mockReset()
openInFileManagerMock.mockResolvedValue({ ok: true })
openInExternalEditorMock.mockResolvedValue({ ok: true })
Object.defineProperty(globalThis, 'window', {
@ -153,10 +167,11 @@ describe('WorktreeOpenInMenu', () => {
})
})
it('builds menu entries with VS Code first and file manager last', () => {
it('builds menu entries from configured launchers with file manager last', () => {
expect(
getWorktreeOpenInEntries(
[
{ id: 'vscode', label: 'VS Code', command: 'code' },
{ id: 'cursor', label: 'Cursor', command: 'cursor' },
{ id: 'zed', label: 'Zed', command: 'zed' }
],
@ -165,6 +180,17 @@ describe('WorktreeOpenInMenu', () => {
).toEqual(['VS Code', 'Cursor', 'Zed', 'File Manager'])
})
it('opens settings at the Open In Apps section', () => {
openOpenInAppsSettings()
expect(openSettingsTargetMock).toHaveBeenCalledWith({
pane: 'general',
repoId: null,
sectionId: 'general-open-in-apps'
})
expect(openSettingsPageMock).toHaveBeenCalled()
})
it('forwards the configured command when opening a configured launcher', async () => {
await openWorktreePath({
target: 'external-editor',

View File

@ -3,15 +3,20 @@ import { ExternalLink, FolderOpen } from 'lucide-react'
import { toast } from 'sonner'
import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { useAppStore } from '@/store'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { getLocalFileManagerLabel } from '@/lib/local-file-manager-label'
import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog'
import type { ShellOpenLocalPathFailureReason } from '../../../../shared/shell-open-types'
import type { OpenInApplication } from '../../../../shared/types'
export { getLocalFileManagerLabel } from '@/lib/local-file-manager-label'
type WorktreeOpenInMenuItemsProps = {
worktreePath: string
connectionId?: string | null
@ -26,24 +31,11 @@ type OpenInMenuEntry = {
command?: string
}
export function getLocalFileManagerLabel(userAgent?: string): string {
const resolvedUserAgent =
userAgent ?? (typeof navigator === 'undefined' ? '' : navigator.userAgent)
if (resolvedUserAgent.includes('Mac')) {
return 'Finder'
}
if (resolvedUserAgent.includes('Windows')) {
return 'File Explorer'
}
return 'File Manager'
}
export function getWorktreeOpenInEntries(
openInApplications: OpenInApplication[],
fileManagerLabel: string
): OpenInMenuEntry[] {
return [
{ id: 'vscode', label: 'VS Code', target: 'external-editor' },
...openInApplications.map((application) => ({
id: application.id,
label: application.label,
@ -74,6 +66,16 @@ function stopMenuPropagation(event: React.SyntheticEvent): void {
event.stopPropagation()
}
export function openOpenInAppsSettings(): void {
const store = useAppStore.getState()
store.openSettingsTarget({
pane: 'general',
repoId: null,
sectionId: 'general-open-in-apps'
})
store.openSettingsPage()
}
export async function openWorktreePath(args: {
target: 'file-manager' | 'external-editor'
worktreePath: string
@ -137,6 +139,8 @@ export function WorktreeOpenInMenuItems({
>
{entry.target === 'file-manager' ? (
<FolderOpen className="size-3.5" />
) : entry.command ? (
<OpenInApplicationIcon application={{ command: entry.command }} size={14} />
) : (
<ExternalLink className="size-3.5" />
)}
@ -169,6 +173,14 @@ export function WorktreeOpenInSubMenu({
connectionId={connectionId}
disabled={disabled}
/>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={stopMenuPropagation}
onSelect={openOpenInAppsSettings}
disabled={disabled}
>
Customize apps...
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)

View File

@ -0,0 +1,11 @@
export function getLocalFileManagerLabel(userAgent?: string): string {
const resolvedUserAgent =
userAgent ?? (typeof navigator === 'undefined' ? '' : navigator.userAgent)
if (resolvedUserAgent.includes('Mac')) {
return 'Finder'
}
if (resolvedUserAgent.includes('Windows')) {
return 'File Explorer'
}
return 'File Manager'
}

View File

@ -0,0 +1,60 @@
import type React from 'react'
import { AppWindow } from 'lucide-react'
import type { OpenInApplication } from '../../../shared/types'
export type OpenInAppPreset = {
id: string
label: string
command: string
faviconDomain: string
}
export const OPEN_IN_APP_PRESETS: OpenInAppPreset[] = [
{
id: 'vscode',
label: 'VS Code',
command: 'code',
faviconDomain: 'code.visualstudio.com'
},
{ id: 'cursor', label: 'Cursor', command: 'cursor', faviconDomain: 'cursor.com' },
{ id: 'zed', label: 'Zed', command: 'zed', faviconDomain: 'zed.dev' }
]
export function getOpenInAppPreset(
application: Pick<OpenInApplication, 'command'>
): OpenInAppPreset | null {
const command = application.command.trim().toLowerCase()
return OPEN_IN_APP_PRESETS.find((preset) => preset.command === command) ?? null
}
export function isOpenInAppPresetAdded(
applications: readonly Pick<OpenInApplication, 'command'>[],
preset: OpenInAppPreset
): boolean {
return applications.some(
(application) => application.command.trim().toLowerCase() === preset.command
)
}
export function OpenInApplicationIcon({
application,
size = 14
}: {
application: Pick<OpenInApplication, 'command'>
size?: number
}): React.JSX.Element {
const preset = getOpenInAppPreset(application)
if (preset) {
return (
<img
src={`https://www.google.com/s2/favicons?domain=${preset.faviconDomain}&sz=64`}
width={size}
height={size}
alt=""
aria-hidden
style={{ borderRadius: 2 }}
/>
)
}
return <AppWindow width={size} height={size} />
}

View File

@ -19,6 +19,7 @@ import { TASK_PROVIDERS } from './task-providers'
import { DEFAULT_WORKTREE_CARD_PROPERTIES } from './worktree-card-properties'
import { getDefaultSourceControlAiSettings } from './source-control-ai'
import { DEFAULT_APP_ICON_ID } from './app-icon'
import { DEFAULT_OPEN_IN_APPLICATIONS } from './open-in-applications'
export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults'
export {
@ -235,7 +236,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
httpProxyBypassRules: '',
electronHttp1CompatibilityMode: false,
openLinksInApp: true,
openInApplications: [],
openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS],
rightSidebarOpenByDefault: true,
showGitIgnoredFiles: true,
sourceControlViewMode: 'list',

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { normalizeOpenInApplications } from './open-in-applications'
import { DEFAULT_OPEN_IN_APPLICATIONS, normalizeOpenInApplications } from './open-in-applications'
describe('normalizeOpenInApplications', () => {
it('trims fields, drops invalid rows, keeps first duplicate id, and caps list', () => {
@ -44,4 +44,11 @@ describe('normalizeOpenInApplications', () => {
{ id: 'gen-2', label: 'Zed', command: 'zed' }
])
})
it('seeds defaults only when the persisted field is missing', () => {
expect(normalizeOpenInApplications(undefined, { seedDefaults: true })).toEqual(
DEFAULT_OPEN_IN_APPLICATIONS
)
expect(normalizeOpenInApplications([], { seedDefaults: true })).toEqual([])
})
})

View File

@ -1,9 +1,13 @@
import type { OpenInApplication } from './types'
export const OPEN_IN_APPLICATIONS_MAX = 8
export const DEFAULT_OPEN_IN_APPLICATIONS: OpenInApplication[] = [
{ id: 'vscode', label: 'VS Code', command: 'code' }
]
type NormalizeOpenInApplicationsOptions = {
createId?: () => string
seedDefaults?: boolean
}
function normalizeToken(value: unknown): string {
@ -19,7 +23,7 @@ export function normalizeOpenInApplications(
options: NormalizeOpenInApplicationsOptions = {}
): OpenInApplication[] {
if (!Array.isArray(value)) {
return []
return options.seedDefaults ? [...DEFAULT_OPEN_IN_APPLICATIONS] : []
}
const normalized: OpenInApplication[] = []