fix: address review findings (#5275)
This commit is contained in:
parent
528a9292aa
commit
d2a3f12baa
|
|
@ -82,6 +82,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
localAccountRuntime: 'host',
|
||||
localAccountWslDistro: null,
|
||||
openLinksInApp: false,
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
rightSidebarOpenByDefault: true,
|
||||
sourceControlViewMode: 'list',
|
||||
showTitlebarAppName: true,
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
localAccountRuntime: 'host',
|
||||
localAccountWslDistro: null,
|
||||
openLinksInApp: false,
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
rightSidebarOpenByDefault: true,
|
||||
sourceControlViewMode: 'list',
|
||||
showTitlebarAppName: true,
|
||||
|
|
|
|||
|
|
@ -2229,6 +2229,14 @@ export class Store {
|
|||
parsed.settings?.disabledTuiAgents
|
||||
)
|
||||
const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings)
|
||||
const openLinksInAppWasPersisted = Object.prototype.hasOwnProperty.call(
|
||||
parsed.settings ?? {},
|
||||
'openLinksInApp'
|
||||
)
|
||||
const migratedOpenLinksInAppPreferencePrompted =
|
||||
typeof parsed.settings?.openLinksInAppPreferencePrompted === 'boolean'
|
||||
? parsed.settings.openLinksInAppPreferencePrompted
|
||||
: openLinksInAppWasPersisted
|
||||
if (
|
||||
parsed.settings?.agentYoloDefaultsMigrated !== true ||
|
||||
hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) ||
|
||||
|
|
@ -2245,6 +2253,12 @@ export class Store {
|
|||
if (!autoRenameBranchFromWorkDefaultedOn) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
if (
|
||||
parsed.settings?.openLinksInAppPreferencePrompted !==
|
||||
migratedOpenLinksInAppPreferencePrompted
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
const normalizedOnboarding = normalizeLoadedOnboardingState(
|
||||
parsed.onboarding,
|
||||
defaults.onboarding
|
||||
|
|
@ -2322,6 +2336,7 @@ export class Store {
|
|||
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, {
|
||||
seedDefaults: true
|
||||
}),
|
||||
openLinksInAppPreferencePrompted: migratedOpenLinksInAppPreferencePrompted,
|
||||
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
|
||||
sourceControlAi: migratedSourceControlAi,
|
||||
// Why: new builds read sourceControlAi, but rollback builds still
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import { WorkspacePortScanner } from './components/ports/WorkspacePortScanner'
|
|||
import { CrashReportDialog } from './components/crash-report/CrashReportDialog'
|
||||
import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary'
|
||||
import { ConfirmationDialogProvider } from './components/confirmation-dialog'
|
||||
import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog'
|
||||
import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher'
|
||||
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
|
||||
import { useEditorExternalWatch } from './hooks/useEditorExternalWatch'
|
||||
|
|
@ -1773,7 +1774,8 @@ function App(): React.JSX.Element {
|
|||
>
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<ConfirmationDialogProvider>
|
||||
<WorkspacePortScanner enabled={workspaceSessionReady} />
|
||||
<LinkRoutingPreferenceDialogProvider>
|
||||
<WorkspacePortScanner enabled={workspaceSessionReady} />
|
||||
{/* Why: leaf-mounted retention sync keeps agent-status retention
|
||||
subscriptions from re-rendering the App tree. */}
|
||||
<RetainedAgentsSyncGate />
|
||||
|
|
@ -2287,6 +2289,7 @@ function App(): React.JSX.Element {
|
|||
>
|
||||
<RecentTabSwitcher />
|
||||
</RecoverableRenderErrorBoundary>
|
||||
</LinkRoutingPreferenceDialogProvider>
|
||||
</ConfirmationDialogProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { ExternalLink, Settings } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type LinkRoutingPreferenceDialogOptions = {
|
||||
url?: string
|
||||
preview?: boolean
|
||||
openLinksInAppDefault?: boolean
|
||||
}
|
||||
|
||||
type LinkRoutingPreferenceDialogRequest = {
|
||||
id: number
|
||||
options: LinkRoutingPreferenceDialogOptions
|
||||
resolve: (openInOrca: boolean) => void
|
||||
}
|
||||
|
||||
type LinkRoutingPreferenceDialogContextValue = (
|
||||
options?: LinkRoutingPreferenceDialogOptions
|
||||
) => Promise<boolean>
|
||||
|
||||
const PREVIEW_STORAGE_KEY = 'orca.previewLinkRoutingPreferenceDialog'
|
||||
const PREVIEW_DEFAULT_STORAGE_KEY = `${PREVIEW_STORAGE_KEY}.default`
|
||||
const LinkRoutingPreferenceDialogContext =
|
||||
createContext<LinkRoutingPreferenceDialogContextValue | null>(null)
|
||||
|
||||
function displayHostForUrl(url: string | undefined): string | null {
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return new URL(url).host
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function LinkRoutingPreferenceDialogProvider({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const nextIdRef = useRef(0)
|
||||
const [queue, setQueue] = useState<LinkRoutingPreferenceDialogRequest[]>([])
|
||||
const activeRequest = queue[0] ?? null
|
||||
const activeRequestRef = useRef<LinkRoutingPreferenceDialogRequest | null>(activeRequest)
|
||||
const setContextualToursBlockingSurfaceVisible = useAppStore(
|
||||
(s) => s.setContextualToursBlockingSurfaceVisible
|
||||
)
|
||||
const lastDisplayedRequestRef = useRef<LinkRoutingPreferenceDialogRequest | null>(activeRequest)
|
||||
activeRequestRef.current = activeRequest
|
||||
if (activeRequest) {
|
||||
lastDisplayedRequestRef.current = activeRequest
|
||||
}
|
||||
// Why: Radix keeps dialog content mounted while closing; keep copy stable during exit animation.
|
||||
const displayedRequest = activeRequest ?? lastDisplayedRequestRef.current
|
||||
const displayHost = displayHostForUrl(displayedRequest?.options.url)
|
||||
const openLinksInAppDefault = displayedRequest?.options.openLinksInAppDefault === true
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const systemBrowserShortcutKeys = isMac ? ['⇧', '⌘'] : ['Shift', 'Ctrl']
|
||||
|
||||
useEffect(() => {
|
||||
setContextualToursBlockingSurfaceVisible(activeRequest !== null)
|
||||
return () => setContextualToursBlockingSurfaceVisible(false)
|
||||
}, [activeRequest, setContextualToursBlockingSurfaceVisible])
|
||||
|
||||
const requestPreference = useCallback<LinkRoutingPreferenceDialogContextValue>((options = {}) => {
|
||||
return new Promise((resolve) => {
|
||||
const request: LinkRoutingPreferenceDialogRequest = {
|
||||
id: nextIdRef.current,
|
||||
options,
|
||||
resolve
|
||||
}
|
||||
nextIdRef.current += 1
|
||||
setQueue((currentQueue) => [...currentQueue, request])
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
if (window.sessionStorage.getItem(PREVIEW_STORAGE_KEY) !== '1') {
|
||||
return
|
||||
}
|
||||
const previewDefault = window.sessionStorage.getItem(PREVIEW_DEFAULT_STORAGE_KEY)
|
||||
window.sessionStorage.removeItem(PREVIEW_STORAGE_KEY)
|
||||
window.sessionStorage.removeItem(PREVIEW_DEFAULT_STORAGE_KEY)
|
||||
void requestPreference({
|
||||
openLinksInAppDefault: previewDefault === 'orca',
|
||||
preview: true,
|
||||
url: 'https://github.com/stablyai/orca/pull/1234'
|
||||
})
|
||||
}, [requestPreference])
|
||||
|
||||
const settleActiveRequest = useCallback((openInOrca: boolean) => {
|
||||
const request = activeRequestRef.current
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
request.resolve(openInOrca)
|
||||
setQueue((currentQueue) => {
|
||||
if (currentQueue[0]?.id === request.id) {
|
||||
return currentQueue.slice(1)
|
||||
}
|
||||
return currentQueue.filter((queuedRequest) => queuedRequest.id !== request.id)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<LinkRoutingPreferenceDialogContext.Provider value={requestPreference}>
|
||||
{children}
|
||||
<Dialog
|
||||
open={activeRequest !== null}
|
||||
onOpenChange={(open) => !open && settleActiveRequest(false)}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
overlayClassName="!z-[140]"
|
||||
className="!z-[150] gap-4 p-0 sm:max-w-[520px]"
|
||||
>
|
||||
<div className="rounded-t-lg border-b border-border bg-muted/30 px-6 pt-5 pb-4">
|
||||
<DialogHeader className="gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Badge variant="outline" className="bg-background/70 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.link.routing.preference.dialog.badge',
|
||||
'Terminal link'
|
||||
)}
|
||||
</Badge>
|
||||
{displayedRequest?.options.preview ? (
|
||||
<Badge variant="secondary">
|
||||
{translate('auto.components.link.routing.preference.dialog.preview', 'Preview')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<DialogTitle className="text-xl leading-tight">
|
||||
{openLinksInAppDefault
|
||||
? translate(
|
||||
'auto.components.link.routing.preference.dialog.keep.title',
|
||||
"Keep terminal links in Orca's browser?"
|
||||
)
|
||||
: translate(
|
||||
'auto.components.link.routing.preference.dialog.title',
|
||||
"Open terminal links in Orca's browser?"
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-relaxed">
|
||||
{openLinksInAppDefault
|
||||
? translate(
|
||||
'auto.components.link.routing.preference.dialog.keep.description',
|
||||
'Or use your system browser by default.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.link.routing.preference.dialog.description',
|
||||
"Use Orca's browser for terminal links, or keep your system browser."
|
||||
)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 px-6">
|
||||
{displayHost ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{translate('auto.components.link.routing.preference.dialog.link.label', 'Link')}
|
||||
</span>
|
||||
<span className="rounded-md border border-border bg-muted/30 px-2 py-1 font-mono">
|
||||
{displayHost}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2 rounded-lg border border-border bg-muted/20 p-3 text-xs leading-relaxed text-muted-foreground">
|
||||
<Settings className="mt-0.5 size-3.5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
{translate(
|
||||
'auto.components.link.routing.preference.dialog.orca.note',
|
||||
'Orca can use imported cookies for logged-in sites.'
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{translate(
|
||||
'auto.components.link.routing.preference.dialog.settings.note',
|
||||
'Change this later in Settings → Browser.'
|
||||
)}
|
||||
</p>
|
||||
<p className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.link.routing.preference.dialog.shortcut.note.prefix',
|
||||
'When links open in Orca,'
|
||||
)}
|
||||
</span>
|
||||
<ShortcutKeyCombo
|
||||
keys={systemBrowserShortcutKeys}
|
||||
keyCapClassName="min-w-0 px-1 py-0 text-[10px] shadow-none"
|
||||
separatorClassName="text-[10px] text-muted-foreground"
|
||||
/>
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.link.routing.preference.dialog.shortcut.note.suffix',
|
||||
'click opens system browser once.'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t border-border bg-muted/20 px-6 py-4 sm:justify-between">
|
||||
<Button variant="outline" onClick={() => settleActiveRequest(false)}>
|
||||
<ExternalLink className="size-4" />
|
||||
{translate(
|
||||
'auto.components.link.routing.preference.dialog.system.button',
|
||||
'Use system browser'
|
||||
)}
|
||||
</Button>
|
||||
<Button autoFocus onClick={() => settleActiveRequest(true)}>
|
||||
{openLinksInAppDefault
|
||||
? translate(
|
||||
'auto.components.link.routing.preference.dialog.keep.orca.button',
|
||||
'Keep Orca'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.link.routing.preference.dialog.orca.button',
|
||||
'Open in Orca'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</LinkRoutingPreferenceDialogContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLinkRoutingPreferenceDialog(): LinkRoutingPreferenceDialogContextValue {
|
||||
const requestPreference = useContext(LinkRoutingPreferenceDialogContext)
|
||||
if (!requestPreference) {
|
||||
throw new Error(
|
||||
'useLinkRoutingPreferenceDialog must be used inside LinkRoutingPreferenceDialogProvider'
|
||||
)
|
||||
}
|
||||
return requestPreference
|
||||
}
|
||||
|
|
@ -42,7 +42,12 @@ export function BrowserLinkRoutingSetting({
|
|||
<button
|
||||
role="switch"
|
||||
aria-checked={settings.openLinksInApp}
|
||||
onClick={() => updateSettings({ openLinksInApp: !settings.openLinksInApp })}
|
||||
onClick={() =>
|
||||
updateSettings({
|
||||
openLinksInApp: !settings.openLinksInApp,
|
||||
openLinksInAppPreferencePrompted: true
|
||||
})
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
settings.openLinksInApp ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { X } from 'lucide-react'
|
|||
import { useAppStore } from '../../store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog'
|
||||
import { DaemonActionDialog, useDaemonActions } from '@/components/shared/useDaemonActions'
|
||||
import {
|
||||
DEFAULT_TERMINAL_DIVIDER_DARK,
|
||||
|
|
@ -400,6 +401,7 @@ export default function TerminalPane({
|
|||
const refreshWorkspaceSpace = useAppStore((store) => store.refreshWorkspaceSpace)
|
||||
const settings = useAppStore((store) => store.settings)
|
||||
const updateSettings = useAppStore((store) => store.updateSettings)
|
||||
const requestLinkRoutingPreference = useLinkRoutingPreferenceDialog()
|
||||
const keybindings = useAppStore((store) => store.keybindings)
|
||||
// Why: Windows is the only platform where bare right-click is repurposed as
|
||||
// a paste gesture; on macOS/Linux the terminal still owns right-click for the
|
||||
|
|
@ -483,6 +485,38 @@ export default function TerminalPane({
|
|||
|
||||
const settingsRef = useRef(settings)
|
||||
settingsRef.current = settings
|
||||
const openLinksInAppPreferencePromiseRef = useRef<Promise<boolean> | null>(null)
|
||||
|
||||
const requestOpenLinksInAppPreference = useCallback(
|
||||
(url: string): Promise<boolean> | null => {
|
||||
if (settingsRef.current?.openLinksInAppPreferencePrompted === true) {
|
||||
return null
|
||||
}
|
||||
if (!settingsRef.current) {
|
||||
return null
|
||||
}
|
||||
if (openLinksInAppPreferencePromiseRef.current) {
|
||||
return openLinksInAppPreferencePromiseRef.current
|
||||
}
|
||||
const preferencePromise = (async () => {
|
||||
const openInOrca = await requestLinkRoutingPreference({
|
||||
openLinksInAppDefault: settingsRef.current?.openLinksInApp === true,
|
||||
url
|
||||
})
|
||||
await updateSettings({
|
||||
openLinksInApp: openInOrca,
|
||||
openLinksInAppPreferencePrompted: true
|
||||
})
|
||||
return openInOrca
|
||||
})()
|
||||
openLinksInAppPreferencePromiseRef.current = preferencePromise
|
||||
void preferencePromise.finally(() => {
|
||||
openLinksInAppPreferencePromiseRef.current = null
|
||||
})
|
||||
return preferencePromise
|
||||
},
|
||||
[requestLinkRoutingPreference, updateSettings]
|
||||
)
|
||||
// Why: the persisted setting can be 'auto' (default) or one of the four
|
||||
// explicit modes. useEffectiveMacOptionAsAlt resolves 'auto' into
|
||||
// 'true' | 'false' based on the probe's current layout category (US → 'true',
|
||||
|
|
@ -781,6 +815,7 @@ export default function TerminalPane({
|
|||
systemPrefersDark,
|
||||
settings,
|
||||
settingsRef,
|
||||
requestOpenLinksInAppPreference,
|
||||
effectiveMacOptionAsAlt,
|
||||
effectiveMacOptionAsAltRef: macOptionAsAltRef,
|
||||
initialLayoutRef,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ const setPendingEditorRevealMock = vi.fn()
|
|||
const deps = { worktreeId: 'wt-1', worktreePath: '/tmp' }
|
||||
const storeState = {
|
||||
settings: undefined as
|
||||
| { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null }
|
||||
| {
|
||||
openLinksInApp?: boolean
|
||||
openLinksInAppPreferencePrompted?: boolean
|
||||
activeRuntimeEnvironmentId?: string | null
|
||||
}
|
||||
| undefined,
|
||||
setActiveWorktree: setActiveWorktreeMock,
|
||||
createBrowserTab: createBrowserTabMock,
|
||||
|
|
@ -178,16 +182,40 @@ describe('handleOscLink', () => {
|
|||
expect(stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults to Orca when settings have not hydrated yet', () => {
|
||||
it('defaults to the system browser when settings have not hydrated yet', () => {
|
||||
setPlatform('Macintosh')
|
||||
storeState.settings = undefined
|
||||
|
||||
handleOscLink('https://example.com', { metaKey: true, ctrlKey: false, shiftKey: false }, deps)
|
||||
|
||||
expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(setActiveWorktreeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for the first-use preference before routing terminal http links', async () => {
|
||||
setPlatform('Macintosh')
|
||||
storeState.settings = { openLinksInApp: false, openLinksInAppPreferencePrompted: false }
|
||||
const requestOpenLinksInAppPreference = vi.fn(async () => {
|
||||
storeState.settings = { openLinksInApp: true, openLinksInAppPreferencePrompted: true }
|
||||
return true
|
||||
})
|
||||
|
||||
handleOscLink(
|
||||
'https://example.com',
|
||||
{ metaKey: true, ctrlKey: false, shiftKey: false },
|
||||
{ ...deps, requestOpenLinksInAppPreference }
|
||||
)
|
||||
|
||||
expect(requestOpenLinksInAppPreference).toHaveBeenCalledWith('https://example.com/')
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
|
||||
activate: true
|
||||
})
|
||||
expect(setActiveWorktreeMock).toHaveBeenCalledWith('wt-1')
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -1367,6 +1395,55 @@ describe('createFilePathLinkProvider range bounds', () => {
|
|||
expect(element.removeEventListener).toHaveBeenCalledWith('mouseup', mouseUp)
|
||||
})
|
||||
|
||||
it('asks for the first-use preference from the direct URL click fallback', async () => {
|
||||
setPlatform('Macintosh')
|
||||
storeState.settings = { openLinksInApp: false, openLinksInAppPreferencePrompted: false }
|
||||
const rows = [
|
||||
makeBufferLine('PR opened: https://github.com/stablyai/orca-marketing-website/pull/82')
|
||||
]
|
||||
const requestOpenLinksInAppPreference = vi.fn(async () => {
|
||||
storeState.settings = { openLinksInApp: true, openLinksInAppPreferencePrompted: true }
|
||||
return true
|
||||
})
|
||||
const { terminal, element } = makeFallbackTerminal(rows)
|
||||
const disposable = installHttpLinkClickFallback(terminal, {
|
||||
worktreeId: 'wt-1',
|
||||
requestOpenLinksInAppPreference
|
||||
})
|
||||
const mouseUp = getRegisteredBubbleMouseUpHandler(element)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
mouseUp({
|
||||
button: 0,
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
defaultPrevented: false,
|
||||
clientX: 230,
|
||||
clientY: 25,
|
||||
preventDefault,
|
||||
stopPropagation: vi.fn()
|
||||
} as unknown as MouseEvent)
|
||||
|
||||
expect(requestOpenLinksInAppPreference).toHaveBeenCalledWith(
|
||||
'https://github.com/stablyai/orca-marketing-website/pull/82'
|
||||
)
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(createBrowserTabMock).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
'https://github.com/stablyai/orca-marketing-website/pull/82',
|
||||
{ activate: true }
|
||||
)
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(terminal.clearSelection).toHaveBeenCalled()
|
||||
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('does not double-open URLs when xterm already handled the mouseup', () => {
|
||||
setPlatform('Macintosh')
|
||||
storeState.settings = { openLinksInApp: false }
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { resolveTerminalFileLinkText } from '@/lib/terminal-links'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
|
||||
import type { LinkHandlerDeps } from './terminal-link-handlers'
|
||||
import { isTerminalLinkActivation } from './terminal-link-handlers'
|
||||
import { resolveTerminalFileUrlTarget } from './terminal-file-url-target'
|
||||
import { openDetectedFilePath } from './terminal-file-open-routing'
|
||||
import {
|
||||
openTerminalHttpLink,
|
||||
type TerminalLinkRoutingPreferenceRequester
|
||||
} from './terminal-url-link-hit-testing'
|
||||
|
||||
type TerminalLinkEvent = Pick<MouseEvent, 'metaKey' | 'ctrlKey'> &
|
||||
Partial<Pick<MouseEvent, 'shiftKey' | 'preventDefault' | 'stopPropagation'>>
|
||||
|
|
@ -13,7 +16,9 @@ export function handleOscLink(
|
|||
rawText: string,
|
||||
event: TerminalLinkEvent | undefined,
|
||||
deps: Pick<LinkHandlerDeps, 'worktreeId' | 'worktreePath'> &
|
||||
Partial<Pick<LinkHandlerDeps, 'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath'>>
|
||||
Partial<Pick<LinkHandlerDeps, 'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath'>> & {
|
||||
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
|
||||
}
|
||||
): void {
|
||||
if (!isTerminalLinkActivation(event)) {
|
||||
return
|
||||
|
|
@ -65,9 +70,10 @@ export function handleOscLink(
|
|||
}
|
||||
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
openHttpLink(parsed.toString(), {
|
||||
openTerminalHttpLink(parsed.toString(), {
|
||||
worktreeId: deps.worktreeId,
|
||||
forceSystemBrowser: Boolean(event?.shiftKey)
|
||||
forceSystemBrowser: Boolean(event?.shiftKey),
|
||||
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,18 @@ import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges'
|
|||
type UrlLinkHitTestDeps = {
|
||||
worktreeId: string
|
||||
forceSystemBrowser?: boolean
|
||||
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
|
||||
}
|
||||
|
||||
type UrlLinkClickFallbackDeps = {
|
||||
worktreeId: string
|
||||
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
|
||||
}
|
||||
|
||||
export type TerminalLinkRoutingPreferenceRequester = (
|
||||
url: string
|
||||
) => boolean | Promise<boolean> | null | undefined
|
||||
|
||||
type ParsedTerminalHttpLink = {
|
||||
url: string
|
||||
startIndex: number
|
||||
|
|
@ -100,7 +106,8 @@ export function installHttpLinkClickFallback(
|
|||
// that xterm already handled.
|
||||
const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, {
|
||||
worktreeId: deps.worktreeId,
|
||||
forceSystemBrowser: event.shiftKey
|
||||
forceSystemBrowser: event.shiftKey,
|
||||
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
|
||||
})
|
||||
if (opened) {
|
||||
event.preventDefault()
|
||||
|
|
@ -134,10 +141,7 @@ export function openHttpLinkAtBufferPosition(
|
|||
if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) {
|
||||
continue
|
||||
}
|
||||
openHttpLink(parsed.url, {
|
||||
worktreeId: deps.worktreeId,
|
||||
forceSystemBrowser: deps.forceSystemBrowser
|
||||
})
|
||||
openTerminalHttpLink(parsed.url, deps)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -145,6 +149,33 @@ export function openHttpLinkAtBufferPosition(
|
|||
return false
|
||||
}
|
||||
|
||||
export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void {
|
||||
if (deps.forceSystemBrowser) {
|
||||
openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true })
|
||||
return
|
||||
}
|
||||
|
||||
const preferenceDecision = deps.requestOpenLinksInAppPreference?.(url)
|
||||
if (preferenceDecision === null || preferenceDecision === undefined) {
|
||||
openHttpLink(url, { worktreeId: deps.worktreeId })
|
||||
return
|
||||
}
|
||||
|
||||
// Why: the first terminal link click may need an async preference dialog.
|
||||
// Suppress the browser's default link handling first, then route after the
|
||||
// persisted choice is available.
|
||||
void Promise.resolve(preferenceDecision)
|
||||
.then((openInOrca) => {
|
||||
openHttpLink(url, {
|
||||
worktreeId: deps.worktreeId,
|
||||
forceSystemBrowser: !openInOrca
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true })
|
||||
})
|
||||
}
|
||||
|
||||
function rangeContainsBufferPosition(
|
||||
range: IBufferRange,
|
||||
position: { x: number; y: number },
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ import {
|
|||
import { createTerminalHandleLinkProvider } from './terminal-handle-links'
|
||||
import type { LinkHandlerDeps } from './terminal-link-handlers'
|
||||
import { handleOscLink } from './terminal-osc-link-routing'
|
||||
import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing'
|
||||
import {
|
||||
installHttpLinkClickFallback,
|
||||
type TerminalLinkRoutingPreferenceRequester
|
||||
} from './terminal-url-link-hit-testing'
|
||||
import type {
|
||||
GlobalSettings,
|
||||
SetupSplitDirection,
|
||||
|
|
@ -133,6 +136,7 @@ type UseTerminalPaneLifecycleDeps = {
|
|||
systemPrefersDark: boolean
|
||||
settings: GlobalSettings | null | undefined
|
||||
settingsRef: React.RefObject<GlobalSettings | null | undefined>
|
||||
requestOpenLinksInAppPreference: TerminalLinkRoutingPreferenceRequester
|
||||
/** Resolved Option-as-Alt value: `'auto'` has already been mapped to
|
||||
* `'true' | 'false'` via the keyboard-layout probe. Passed separately
|
||||
* from `settings` because the probe lives outside the settings store. */
|
||||
|
|
@ -322,6 +326,7 @@ export function useTerminalPaneLifecycle({
|
|||
systemPrefersDark,
|
||||
settings,
|
||||
settingsRef,
|
||||
requestOpenLinksInAppPreference,
|
||||
effectiveMacOptionAsAlt,
|
||||
effectiveMacOptionAsAltRef,
|
||||
initialLayoutRef,
|
||||
|
|
@ -696,10 +701,10 @@ export function useTerminalPaneLifecycle({
|
|||
linkDeps
|
||||
)
|
||||
fileLinkClickFallbackDisposablesRef.current.set(pane.id, fileLinkClickFallbackDisposable)
|
||||
const httpLinkClickFallbackDisposable = installHttpLinkClickFallback(
|
||||
pane.terminal,
|
||||
linkDeps
|
||||
)
|
||||
const httpLinkClickFallbackDisposable = installHttpLinkClickFallback(pane.terminal, {
|
||||
...linkDeps,
|
||||
requestOpenLinksInAppPreference
|
||||
})
|
||||
httpLinkClickFallbackDisposables.set(pane.id, httpLinkClickFallbackDisposable)
|
||||
// Why: skip empty selections so clicking to deselect doesn't clobber
|
||||
// whatever the user last copied elsewhere.
|
||||
|
|
@ -766,7 +771,8 @@ export function useTerminalPaneLifecycle({
|
|||
activate: (event, text) => {
|
||||
handleOscLink(text, event as MouseEvent | undefined, {
|
||||
...linkDeps,
|
||||
runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null
|
||||
runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null,
|
||||
requestOpenLinksInAppPreference
|
||||
})
|
||||
// Why: Cmd/Ctrl+clicking a link activates Orca handling (open file,
|
||||
// new browser tab, system browser) which can steal focus from the
|
||||
|
|
@ -1033,7 +1039,8 @@ export function useTerminalPaneLifecycle({
|
|||
...linkDeps,
|
||||
runtimeEnvironmentId: activePane
|
||||
? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null)
|
||||
: null
|
||||
: null,
|
||||
requestOpenLinksInAppPreference
|
||||
})
|
||||
// Why: Cmd/Ctrl+click on a plain-text URL (WebLinksAddon) takes focus
|
||||
// away from the terminal before the click's mouseup reaches
|
||||
|
|
|
|||
|
|
@ -10517,6 +10517,44 @@
|
|||
"8388bdea2b": "Connect Jira site"
|
||||
}
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"routing": {
|
||||
"preference": {
|
||||
"dialog": {
|
||||
"badge": "Terminal link",
|
||||
"preview": "Preview",
|
||||
"title": "Open terminal links in Orca's browser?",
|
||||
"description": "Use Orca's browser for terminal links, or keep your system browser.",
|
||||
"orca": {
|
||||
"button": "Open in Orca",
|
||||
"note": "Orca can use imported cookies for logged-in sites."
|
||||
},
|
||||
"settings": {
|
||||
"note": "Change this later in Settings → Browser."
|
||||
},
|
||||
"system": {
|
||||
"button": "Use system browser"
|
||||
},
|
||||
"link": {
|
||||
"label": "Link"
|
||||
},
|
||||
"shortcut": {
|
||||
"note": {
|
||||
"prefix": "When links open in Orca,",
|
||||
"suffix": "click opens system browser once."
|
||||
}
|
||||
},
|
||||
"keep": {
|
||||
"title": "Keep terminal links in Orca's browser?",
|
||||
"description": "Or use your system browser by default.",
|
||||
"orca": {
|
||||
"button": "Keep Orca"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
|
|
|||
|
|
@ -10517,6 +10517,44 @@
|
|||
"8388bdea2b": "Connect Jira site"
|
||||
}
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"routing": {
|
||||
"preference": {
|
||||
"dialog": {
|
||||
"badge": "Enlace del terminal",
|
||||
"preview": "Vista previa",
|
||||
"title": "¿Abrir enlaces del terminal en el navegador de Orca?",
|
||||
"description": "Usa el navegador de Orca para los enlaces del terminal o conserva tu navegador del sistema.",
|
||||
"orca": {
|
||||
"button": "Abrir en Orca",
|
||||
"note": "Orca puede usar cookies importadas para sitios con sesión iniciada."
|
||||
},
|
||||
"settings": {
|
||||
"note": "Cámbialo después en Configuración → Navegador."
|
||||
},
|
||||
"system": {
|
||||
"button": "Usar navegador del sistema"
|
||||
},
|
||||
"link": {
|
||||
"label": "Enlace"
|
||||
},
|
||||
"shortcut": {
|
||||
"note": {
|
||||
"prefix": "Cuando los enlaces se abren en Orca,",
|
||||
"suffix": "clic abre el navegador del sistema una vez."
|
||||
}
|
||||
},
|
||||
"keep": {
|
||||
"title": "¿Mantener los enlaces del terminal en el navegador de Orca?",
|
||||
"description": "O usa tu navegador del sistema de forma predeterminada.",
|
||||
"orca": {
|
||||
"button": "Mantener Orca"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
|
|
|||
|
|
@ -10517,6 +10517,44 @@
|
|||
"8388bdea2b": "Connect Jira site"
|
||||
}
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"routing": {
|
||||
"preference": {
|
||||
"dialog": {
|
||||
"badge": "ターミナルリンク",
|
||||
"preview": "プレビュー",
|
||||
"title": "ターミナルのリンクを Orca のブラウザで開きますか?",
|
||||
"description": "ターミナルのリンクを Orca のブラウザで開くか、システムブラウザを使い続けます。",
|
||||
"orca": {
|
||||
"button": "Orca で開く",
|
||||
"note": "Orca はインポート済み Cookie を使ってログイン済みサイトを開けます。"
|
||||
},
|
||||
"settings": {
|
||||
"note": "後で 設定 → ブラウザ から変更できます。"
|
||||
},
|
||||
"system": {
|
||||
"button": "システムブラウザを使用"
|
||||
},
|
||||
"link": {
|
||||
"label": "リンク"
|
||||
},
|
||||
"shortcut": {
|
||||
"note": {
|
||||
"prefix": "リンクを Orca で開く場合、",
|
||||
"suffix": "クリックで今回だけシステムブラウザで開きます。"
|
||||
}
|
||||
},
|
||||
"keep": {
|
||||
"title": "ターミナルのリンクを Orca のブラウザで開き続けますか?",
|
||||
"description": "またはシステムブラウザを既定で使用します。",
|
||||
"orca": {
|
||||
"button": "Orca のままにする"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
|
|
|||
|
|
@ -10517,6 +10517,44 @@
|
|||
"8388bdea2b": "Connect Jira site"
|
||||
}
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"routing": {
|
||||
"preference": {
|
||||
"dialog": {
|
||||
"badge": "터미널 링크",
|
||||
"preview": "미리보기",
|
||||
"title": "터미널 링크를 Orca 브라우저에서 열까요?",
|
||||
"description": "터미널 링크를 Orca 브라우저에서 열거나 시스템 브라우저를 계속 사용합니다.",
|
||||
"orca": {
|
||||
"button": "Orca에서 열기",
|
||||
"note": "Orca는 가져온 쿠키를 사용해 로그인된 사이트를 열 수 있습니다."
|
||||
},
|
||||
"settings": {
|
||||
"note": "나중에 설정 → 브라우저에서 변경할 수 있습니다."
|
||||
},
|
||||
"system": {
|
||||
"button": "시스템 브라우저 사용"
|
||||
},
|
||||
"link": {
|
||||
"label": "링크"
|
||||
},
|
||||
"shortcut": {
|
||||
"note": {
|
||||
"prefix": "링크가 Orca에서 열릴 때,",
|
||||
"suffix": "클릭하면 한 번만 시스템 브라우저에서 열립니다."
|
||||
}
|
||||
},
|
||||
"keep": {
|
||||
"title": "터미널 링크를 Orca 브라우저에서 계속 열까요?",
|
||||
"description": "또는 시스템 브라우저를 기본값으로 사용합니다.",
|
||||
"orca": {
|
||||
"button": "Orca 유지"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
|
|
|||
|
|
@ -10517,6 +10517,44 @@
|
|||
"8388bdea2b": "Connect Jira site"
|
||||
}
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"routing": {
|
||||
"preference": {
|
||||
"dialog": {
|
||||
"badge": "终端链接",
|
||||
"preview": "预览",
|
||||
"title": "在 Orca 浏览器中打开终端链接?",
|
||||
"description": "用 Orca 浏览器打开终端链接,或继续使用系统浏览器。",
|
||||
"orca": {
|
||||
"button": "在 Orca 中打开",
|
||||
"note": "Orca 可以使用已导入的 Cookie 打开已登录的网站。"
|
||||
},
|
||||
"settings": {
|
||||
"note": "之后可在设置 → 浏览器中更改。"
|
||||
},
|
||||
"system": {
|
||||
"button": "使用系统浏览器"
|
||||
},
|
||||
"link": {
|
||||
"label": "链接"
|
||||
},
|
||||
"shortcut": {
|
||||
"note": {
|
||||
"prefix": "当链接在 Orca 中打开时,",
|
||||
"suffix": "点击可临时改用系统浏览器。"
|
||||
}
|
||||
},
|
||||
"keep": {
|
||||
"title": "继续在 Orca 浏览器中打开终端链接?",
|
||||
"description": "或默认使用系统浏览器。",
|
||||
"orca": {
|
||||
"button": "继续使用 Orca"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ const createBrowserTabMock = vi.fn()
|
|||
|
||||
const storeState = {
|
||||
settings: undefined as
|
||||
| { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null }
|
||||
| {
|
||||
openLinksInApp?: boolean
|
||||
openLinksInAppPreferencePrompted?: boolean
|
||||
activeRuntimeEnvironmentId?: string | null
|
||||
}
|
||||
| undefined,
|
||||
setActiveWorktree: setActiveWorktreeMock,
|
||||
createBrowserTab: createBrowserTabMock
|
||||
|
|
@ -44,13 +48,13 @@ describe('openHttpLink', () => {
|
|||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults to Orca routing when settings have not hydrated', () => {
|
||||
it('defaults to the system browser when settings have not hydrated', () => {
|
||||
storeState.settings = undefined
|
||||
|
||||
openHttpLink('https://example.com/', { worktreeId: 'wt-1' })
|
||||
|
||||
expect(createBrowserTabMock).toHaveBeenCalled()
|
||||
expect(openUrlMock).not.toHaveBeenCalled()
|
||||
expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes floating workspace links into Orca without changing the active repo worktree', () => {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
|
|||
!remoteRuntimeActive &&
|
||||
!forceSystemBrowser &&
|
||||
Boolean(worktreeId) &&
|
||||
state?.settings?.openLinksInApp !== false
|
||||
state?.settings?.openLinksInApp === true
|
||||
|
||||
if (routeToOrca && worktreeId && state) {
|
||||
// Why: http clicks from inside a worktree should not push a worktree-switch
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ function delay(ms: number): Promise<void> {
|
|||
export function shouldOpenWorkspacePortInOrcaBrowser(
|
||||
settings: { openLinksInApp?: boolean } | null | undefined
|
||||
): boolean {
|
||||
return settings?.openLinksInApp !== false
|
||||
return settings?.openLinksInApp === true
|
||||
}
|
||||
|
||||
export function workspacePortOwnerWorktreeId(port: WorkspacePort): string | null {
|
||||
|
|
|
|||
|
|
@ -245,7 +245,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
httpProxyUrl: '',
|
||||
httpProxyBypassRules: '',
|
||||
electronHttp1CompatibilityMode: false,
|
||||
openLinksInApp: true,
|
||||
openLinksInApp: false,
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS],
|
||||
rightSidebarOpenByDefault: true,
|
||||
showGitIgnoredFiles: true,
|
||||
|
|
|
|||
|
|
@ -2185,6 +2185,9 @@ export type GlobalSettings = {
|
|||
* The setting stays opt-in so existing workflows continue to use the system browser
|
||||
* until the user explicitly wants worktree-scoped in-app browsing. */
|
||||
openLinksInApp: boolean
|
||||
/** Why: terminal link routing asks once at first use instead of silently
|
||||
* changing where links open for new users. */
|
||||
openLinksInAppPreferencePrompted: boolean
|
||||
/** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */
|
||||
openInApplications?: OpenInApplication[]
|
||||
/** Deprecated: migration/backward-compat only. Use PersistedUIState.rightSidebarOpen. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue