Improve Open In apps settings (#4668)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
a5437c16fa
commit
1cd6df9137
|
|
@ -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)
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 "Install Update" 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
Loading…
Reference in New Issue