From 1cd6df9137c12f9e694c73c19486447bd0237b62 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:02:00 -0400 Subject: [PATCH] Improve Open In apps settings (#4668) Co-authored-by: Orca --- src/main/ipc/shell.test.ts | 1 + src/main/persistence.test.ts | 4 +- src/main/persistence.ts | 4 +- .../settings/GeneralCacheTimerSection.tsx | 88 ++ .../settings/GeneralEditorSettingsSection.tsx | 239 ++++ .../GeneralNetworkSettingsSection.tsx | 253 ++++ .../src/components/settings/GeneralPane.tsx | 1177 +---------------- .../settings/GeneralSupportSection.tsx | 180 +++ .../settings/GeneralUpdateSettingsSection.tsx | 171 +++ .../GeneralWorkspaceSettingsSection.tsx | 146 ++ .../settings/OpenInMenuSetting.test.ts | 63 + .../components/settings/OpenInMenuSetting.tsx | 350 +++++ .../src/components/settings/general-search.ts | 17 +- .../sidebar/WorktreeOpenInMenu.test.tsx | 56 +- .../components/sidebar/WorktreeOpenInMenu.tsx | 38 +- .../src/lib/local-file-manager-label.ts | 11 + src/renderer/src/lib/open-in-app-catalog.tsx | 60 + src/shared/constants.ts | 3 +- src/shared/open-in-applications.test.ts | 9 +- src/shared/open-in-applications.ts | 6 +- 20 files changed, 1710 insertions(+), 1166 deletions(-) create mode 100644 src/renderer/src/components/settings/GeneralCacheTimerSection.tsx create mode 100644 src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx create mode 100644 src/renderer/src/components/settings/GeneralNetworkSettingsSection.tsx create mode 100644 src/renderer/src/components/settings/GeneralSupportSection.tsx create mode 100644 src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx create mode 100644 src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx create mode 100644 src/renderer/src/components/settings/OpenInMenuSetting.test.ts create mode 100644 src/renderer/src/components/settings/OpenInMenuSetting.tsx create mode 100644 src/renderer/src/lib/local-file-manager-label.ts create mode 100644 src/renderer/src/lib/open-in-app-catalog.tsx diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index ca53ee48e..69004644b 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -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) ]) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index eb7ac5d39..bfe39970c 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index e43a637b3..36d5c9e7d 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 diff --git a/src/renderer/src/components/settings/GeneralCacheTimerSection.tsx b/src/renderer/src/components/settings/GeneralCacheTimerSection.tsx new file mode 100644 index 000000000..a6c2ce403 --- /dev/null +++ b/src/renderer/src/components/settings/GeneralCacheTimerSection.tsx @@ -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) => void +} + +export function GeneralCacheTimerSection({ + settings, + updateSettings +}: GeneralCacheTimerSectionProps): React.JSX.Element { + return ( +
+ + + [ + entry.title, + entry.description ?? '', + ...(entry.keywords ?? []) + ])} + className="flex items-center justify-between gap-4 py-2" + > +
+
+ + +
+

+ Show a countdown in the sidebar after a Claude agent becomes idle. +

+
+ { + const enabling = !settings.promptCacheTimerEnabled + updateSettings({ promptCacheTimerEnabled: enabling }) + if (enabling) { + useAppStore.getState().seedCacheTimersForIdleTabs() + } + }} + /> +
+ + {settings.promptCacheTimerEnabled && ( + +
+ +

+ Match this to your provider's cache TTL. The default is 5 minutes. +

+
+ +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx new file mode 100644 index 000000000..f177ef76b --- /dev/null +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -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) => 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 ( +
+ + + + updateSettings({ editorAutoSave: !settings.editorAutoSave })} + /> + + + +
+ +

+ How long Orca waits after your last edit before saving automatically. First launch + defaults to {DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS} ms. +

+
+
+ updateAutoSaveDelayDraft(e.target.value)} + onBlur={commitAutoSaveDelay} + onKeyDown={(e) => { + if (e.key === 'Enter') { + commitAutoSaveDelay() + } + }} + className="number-input-clean w-28 text-right tabular-nums" + /> + ms +
+
+ + +
+ +

+ Preferred presentation format for showing git diffs by default. +

+
+ updateSettings({ diffDefaultView: option })} + options={[ + { value: 'inline', label: 'Inline' }, + { value: 'side-by-side', label: 'Side-by-side' } + ]} + /> +
+ + +
+ +

+ Show or hide the file tree when opening combined diff views. +

+
+ + updateSettings({ combinedDiffFileTreeVisibleByDefault: option === 'shown' }) + } + options={[ + { value: 'shown', label: 'Shown' }, + { value: 'hidden', label: 'Hidden' } + ]} + /> +
+ + + updateSettings({ editorMinimapEnabled: !settings.editorMinimapEnabled })} + /> + + + + + updateSettings({ markdownReviewToolsEnabled: !settings.markdownReviewToolsEnabled }) + } + /> + +
+ ) +} diff --git a/src/renderer/src/components/settings/GeneralNetworkSettingsSection.tsx b/src/renderer/src/components/settings/GeneralNetworkSettingsSection.tsx new file mode 100644 index 000000000..3a4aebde3 --- /dev/null +++ b/src/renderer/src/components/settings/GeneralNetworkSettingsSection.tsx @@ -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) => 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 ( +
+ + + +
+ +

+ Leave empty to use system proxy settings and inherited proxy environment variables. +

+
+ { + 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 ? ( +

{httpProxyUrlError}

+ ) : ( +

+ Supports http, https, socks, socks4, and socks5 URLs. +

+ )} +
+ + +
+ +

+ Optional. Separate hosts with commas, semicolons, or new lines. +

+
+ 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" + /> +
+
+ ) +} diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 8ef743d04..a6aea7641 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -1,23 +1,14 @@ -/* eslint-disable max-lines -- Why: GeneralPane is the single owner of all general settings UI; - splitting individual settings into separate files would scatter related controls without a - meaningful abstraction boundary. */ -import { useEffect, useRef, useState } from 'react' -import type { GlobalSettings, OpenInApplication } from '../../../../shared/types' -import { Button } from '../ui/button' -import { Input } from '../ui/input' -import { Label } from '../ui/label' -import { Separator } from '../ui/separator' -import { Download, FolderOpen, Loader2, RefreshCw, Star, Timer } from 'lucide-react' +import type React from 'react' +import type { GlobalSettings } from '../../../../shared/types' import { useAppStore } from '../../store' +import { Separator } from '../ui/separator' import { CliSection } from './CliSection' -import { toast } from 'sonner' -import { - DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, - MAX_EDITOR_AUTO_SAVE_DELAY_MS, - MIN_EDITOR_AUTO_SAVE_DELAY_MS -} from '../../../../shared/constants' -import { OPEN_IN_APPLICATIONS_MAX } from '../../../../shared/open-in-applications' -import { clampNumber } from '@/lib/terminal-theme' +import { GeneralCacheTimerSection } from './GeneralCacheTimerSection' +import { GeneralEditorSettingsSection } from './GeneralEditorSettingsSection' +import { GeneralNetworkSettingsSection } from './GeneralNetworkSettingsSection' +import { GeneralSupportSection } from './GeneralSupportSection' +import { GeneralUpdateSettingsSection } from './GeneralUpdateSettingsSection' +import { GeneralWorkspaceSettingsSection } from './GeneralWorkspaceSettingsSection' import { GENERAL_CACHE_TIMER_SEARCH_ENTRIES, GENERAL_CLI_SEARCH_ENTRIES, @@ -29,42 +20,25 @@ import { GENERAL_UPDATE_SEARCH_ENTRIES, GENERAL_WORKSPACE_SEARCH_ENTRIES } from './general-search' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { RecentTabOrderControl } from './RecentTabOrderControl' -import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' -import { - SettingsSegmentedControl, - SettingsSubsectionHeader, - SettingsSwitch, - SettingsSwitchRow -} from './SettingsFormControls' -import { useMountedRef } from '@/hooks/useMountedRef' -import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../../../shared/network-proxy' +import { SettingsSubsectionHeader } from './SettingsFormControls' -function createOpenInApplication(): OpenInApplication { - return { - id: - globalThis.crypto?.randomUUID?.() ?? - `open-in-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, - label: '', - command: '' - } -} - -function createPresetOpenInApplication(label: string, command: string): OpenInApplication { - return { - ...createOpenInApplication(), - label, - command - } -} - -export function shouldCommitOpenInApplicationsDraft(applications: OpenInApplication[]): boolean { - return applications.every((application) => { - return application.label.trim() !== '' && application.command.trim() !== '' - }) -} +export { + createAutoSaveDelayDraftState, + updateAutoSaveDelayDraftState, + type AutoSaveDelayDraftState +} from './GeneralEditorSettingsSection' +export { + createHttpProxyBypassRulesDraftState, + createHttpProxyUrlDraftState, + setHttpProxyUrlDraftErrorState, + updateHttpProxyBypassRulesDraftState, + updateHttpProxyUrlDraftState, + type HttpProxyBypassRulesDraftState, + type HttpProxyUrlDraftState +} from './GeneralNetworkSettingsSection' +export { shouldCommitOpenInApplicationsDraft } from './OpenInMenuSetting' export function getDesktopPlatformFromUserAgent(userAgent: string): 'darwin' | 'win32' | 'other' { if (userAgent.includes('Mac')) { @@ -78,151 +52,6 @@ export function getDesktopPlatformFromUserAgent(userAgent: string): 'darwin' | ' export { GENERAL_PANE_SEARCH_ENTRIES } -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 - } -} - -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 OpenInApplicationsDraftState = { - sourceApplications: OpenInApplication[] | undefined - draft: OpenInApplication[] -} - -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) -} - type GeneralPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void @@ -239,249 +68,6 @@ export function GeneralPane({ wslCapabilitiesLoading }: GeneralPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) - const updateStatus = useAppStore((s) => s.updateStatus) - const mountedRef = useMountedRef() - // 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(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(null) - const [autoSaveDelayDraftState, setAutoSaveDelayDraftState] = useState(() => - createAutoSaveDelayDraftState(settings.editorAutoSaveDelayMs) - ) - const [httpProxyUrlDraftState, setHttpProxyUrlDraftState] = useState(() => - createHttpProxyUrlDraftState(settings.httpProxyUrl) - ) - const [httpProxyBypassRulesDraftState, setHttpProxyBypassRulesDraftState] = useState(() => - createHttpProxyBypassRulesDraftState(settings.httpProxyBypassRules) - ) - const [openInApplicationsDraftState, setOpenInApplicationsDraftState] = useState(() => - createOpenInApplicationsDraftState(settings.openInApplications) - ) - // 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< - 'loading' | 'not-starred' | 'starred' | 'starring' | 'hidden' | 'error' - >('loading') - - useEffect(() => { - let cancelled = false - void window.api.updater.getVersion().then((version) => { - if (!cancelled) { - setAppVersion(version) - } - }) - return () => { - cancelled = true - } - }, []) - - 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 => { - 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() - } - - 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 resolvedOpenInApplicationsDraftState = resolveOpenInApplicationsDraftState( - openInApplicationsDraftState, - settings.openInApplications - ) - if (resolvedOpenInApplicationsDraftState !== openInApplicationsDraftState) { - // Why: the Open In rows are a local draft, but Settings can reload them - // externally; sync before paint instead of after an Effect pass. - setOpenInApplicationsDraftState(resolvedOpenInApplicationsDraftState) - } - const openInApplicationsDraft = resolvedOpenInApplicationsDraftState.draft - const updateOpenInApplicationsDraft = (draft: OpenInApplication[]): void => { - setOpenInApplicationsDraftState((current) => ({ - ...resolveOpenInApplicationsDraftState(current, settings.openInApplications), - draft - })) - } - - 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 updateHttpProxyUrlDraft = (draft: string): void => { - setHttpProxyUrlDraftState((current) => - updateHttpProxyUrlDraftState(current, settings.httpProxyUrl, draft) - ) - } - - 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 updateHttpProxyBypassRulesDraft = (draft: string): void => { - setHttpProxyBypassRulesDraftState((current) => - updateHttpProxyBypassRulesDraftState(current, settings.httpProxyBypassRules, draft) - ) - } - - const commitOpenInApplications = (applications: OpenInApplication[]): void => { - if (!shouldCommitOpenInApplicationsDraft(applications)) { - return - } - updateSettings({ openInApplications: applications }) - } - - const applyOpenInApplicationsDraft = (applications: OpenInApplication[]): void => { - updateOpenInApplicationsDraft(applications) - commitOpenInApplications(applications) - } - - const handleBrowseWorkspace = async () => { - const path = await window.api.repos.pickFolder() - if (path) { - updateSettings({ workspaceDir: path }) - } - } - - 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)) - ) - } - - 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 }) - } - } - - 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) - } const visibleSections = [ matchesSettingsSearch(searchQuery, GENERAL_NAVIGATION_SEARCH_ENTRIES) ? ( @@ -499,408 +85,25 @@ export function GeneralPane({ ) : null, matchesSettingsSearch(searchQuery, GENERAL_WORKSPACE_SEARCH_ENTRIES) ? ( -
- - - - -
- updateSettings({ workspaceDir: e.target.value })} - className="flex-1 text-xs" - /> - -
-

- Root directory where workspace folders are created. -

-
- - - updateSettings({ nestWorkspaces: !settings.nestWorkspaces })} - /> - - - {/* 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. */} -
- - - updateSettings({ - skipDeleteWorktreeConfirm: !settings.skipDeleteWorktreeConfirm - }) - } - /> - -
- -
- - - updateSettings({ - skipDeleteAutomationConfirm: !settings.skipDeleteAutomationConfirm - }) - } - /> - -
- - -
- -

- VS Code is always included first. Add executables to show extra entries in each - workspace's Open in menu. -

-

- Commands are not shell-parsed. Use only an executable command name. For flags, use a - wrapper script. -

-
-
- - -
-
- {openInApplicationsDraft.map((app, index) => ( -
- { - const next = [...openInApplicationsDraft] - next[index] = { ...app, label: event.target.value } - updateOpenInApplicationsDraft(next) - }} - onBlur={() => commitOpenInApplications(openInApplicationsDraft)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - commitOpenInApplications(openInApplicationsDraft) - } - }} - /> - { - const next = [...openInApplicationsDraft] - next[index] = { ...app, command: event.target.value } - updateOpenInApplicationsDraft(next) - }} - onBlur={() => commitOpenInApplications(openInApplicationsDraft)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - commitOpenInApplications(openInApplicationsDraft) - } - }} - /> - -
- ))} -
- -
-
+ ) : null, matchesSettingsSearch(searchQuery, GENERAL_NETWORK_SEARCH_ENTRIES) ? ( -
- - - -
- -

- Leave empty to use system proxy settings and inherited proxy environment variables. -

-
- { - 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 ? ( -

{httpProxyUrlError}

- ) : ( -

- Supports http, https, socks, socks4, and socks5 URLs. -

- )} -
- - -
- -

- Optional. Separate hosts with commas, semicolons, or new lines. -

-
- 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" - /> -
-
+ ) : null, matchesSettingsSearch(searchQuery, GENERAL_EDITOR_SEARCH_ENTRIES) ? ( -
- - - - updateSettings({ editorAutoSave: !settings.editorAutoSave })} - /> - - - -
- -

- How long Orca waits after your last edit before saving automatically. First launch - defaults to {DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS} ms. -

-
-
- updateAutoSaveDelayDraft(e.target.value)} - onBlur={commitAutoSaveDelay} - onKeyDown={(e) => { - if (e.key === 'Enter') { - commitAutoSaveDelay() - } - }} - className="number-input-clean w-28 text-right tabular-nums" - /> - ms -
-
- - -
- -

- Preferred presentation format for showing git diffs by default. -

-
- updateSettings({ diffDefaultView: option })} - options={[ - { value: 'inline', label: 'Inline' }, - { value: 'side-by-side', label: 'Side-by-side' } - ]} - /> -
- - -
- -

- Show or hide the file tree when opening combined diff views. -

-
- - updateSettings({ combinedDiffFileTreeVisibleByDefault: option === 'shown' }) - } - options={[ - { value: 'shown', label: 'Shown' }, - { value: 'hidden', label: 'Hidden' } - ]} - /> -
- - - - updateSettings({ editorMinimapEnabled: !settings.editorMinimapEnabled }) - } - /> - - - - - updateSettings({ markdownReviewToolsEnabled: !settings.markdownReviewToolsEnabled }) - } - /> - -
+ ) : null, matchesSettingsSearch(searchQuery, GENERAL_CLI_SEARCH_ENTRIES) ? ( ) : null, matchesSettingsSearch(searchQuery, GENERAL_CACHE_TIMER_SEARCH_ENTRIES) ? ( -
- - - [ - entry.title, - entry.description ?? '', - ...(entry.keywords ?? []) - ])} - className="flex items-center justify-between gap-4 py-2" - > -
-
- - -
-

- Show a countdown in the sidebar after a Claude agent becomes idle. -

-
- { - const enabling = !settings.promptCacheTimerEnabled - updateSettings({ promptCacheTimerEnabled: enabling }) - if (enabling) { - useAppStore.getState().seedCacheTimersForIdleTabs() - } - }} - /> -
- - {settings.promptCacheTimerEnabled && ( - -
- -

- Match this to your provider's cache TTL. The default is 5 minutes. -

-
- -
- )} -
+ ) : null, matchesSettingsSearch(searchQuery, GENERAL_UPDATE_SEARCH_ENTRIES) ? ( -
- - - -
- - - {updateStatus.state === 'available' ? ( - - ) : updateStatus.state === 'downloaded' ? ( - - ) : null} -
- -

- {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.{' '} - - Release notes - - - )} - {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.{' '} - - Release notes - - - )} - {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}`)} -

-
-
+ ) : null // Note: the Support section is rendered outside this array so it can own // its own loading placeholder and its own collapsing Separator. Without @@ -1108,124 +141,8 @@ export function GeneralPane({ ))} {matchesSettingsSearch(searchQuery, GENERAL_SUPPORT_SEARCH_ENTRIES) ? ( - 0} - onStarClick={handleStarClick} - /> + 0} /> ) : null} ) } - -type SupportSectionProps = { - state: 'loading' | 'not-starred' | 'starring' | 'starred' | 'hidden' | 'error' - hasPrecedingSections: boolean - onStarClick: () => void | Promise -} - -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 — in both cases we collapse the entire section (including - // its leading Separator) so the settings pane doesn't carry an empty strip. - // For every other state we render the full row so the initial layout is - // stable: the skeleton-to-live swap happens in place and a post-click - // "Starred" confirmation does not shift anything above or below it. - const collapsed = state === 'hidden' - - return ( -
-
-
- {hasPrecedingSections ? : null} -
- - {state === 'loading' ? : null} - {state !== 'loading' && state !== 'hidden' ? ( - - ) : null} -
-
-
-
- ) -} - -function SupportRowSkeleton(): React.JSX.Element { - return ( -