Merge remote-tracking branch 'origin/main' into ethie/pm-clean
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
setDataUrlReadMaxMb
|
||||
} from '@/store/data-url-read-max'
|
||||
import { $disableF12, setDisableF12 } from '@/store/disable-f12'
|
||||
import { $alwaysExternalLinks, setAlwaysExternalLinks } from '@/store/external-links'
|
||||
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { normalizeProfileKey } from '@/store/profile'
|
||||
@@ -101,6 +102,7 @@ function ConfigSettingsInner({
|
||||
const c = t.settings.config
|
||||
const keepAwake = useStore($keepAwake)
|
||||
const disableF12 = useStore($disableF12)
|
||||
const alwaysExternalLinks = useStore($alwaysExternalLinks)
|
||||
// The editable draft is local (debounced autosave watches it), but it's seeded
|
||||
// from — and saved back through — the shared config cache, so edits are visible
|
||||
// in the MCP/model surfaces and reopening the page doesn't reload-flash.
|
||||
@@ -453,6 +455,12 @@ function ConfigSettingsInner({
|
||||
label={c.disableF12Title}
|
||||
onChange={setDisableF12}
|
||||
/>
|
||||
<ToggleRow
|
||||
checked={alwaysExternalLinks}
|
||||
description={c.alwaysExternalLinksDesc}
|
||||
label={c.alwaysExternalLinksTitle}
|
||||
onChange={setAlwaysExternalLinks}
|
||||
/>
|
||||
<PoolLimitsSetting />
|
||||
<QuickEntrySettings />
|
||||
</>
|
||||
|
||||
@@ -1313,6 +1313,9 @@ export const en: Translations = {
|
||||
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.',
|
||||
disableF12Title: 'Disable F12 DevTools',
|
||||
disableF12Desc: 'Block F12 from opening Developer Tools. Ctrl+Shift+I (or Cmd+Opt+I on Mac) still works.',
|
||||
alwaysExternalLinksTitle: 'Always open links in external browser',
|
||||
alwaysExternalLinksDesc:
|
||||
'Open every link you click in your system browser instead of the in-app browser. "Open in in-app browser" in the right-click menu still works.',
|
||||
attachmentSizeTitle: 'Max preview / image load size',
|
||||
attachmentSizeDesc:
|
||||
'How big a local file Desktop will load for previews and image attach, in MB. Default is 16. Remote non-image attach uses a separate 256 MB cap. Setting this very high loads the whole file into memory and can freeze or crash the app.',
|
||||
|
||||
@@ -1054,6 +1054,9 @@ export const ru = defineLocale({
|
||||
disableF12Title: 'Отключить F12 DevTools',
|
||||
disableF12Desc:
|
||||
'Блокирует открытие Developer Tools по F12. Ctrl+Shift+I (на Mac — Cmd+Opt+I) продолжает работать.',
|
||||
alwaysExternalLinksTitle: 'Всегда открывать ссылки во внешнем браузере',
|
||||
alwaysExternalLinksDesc:
|
||||
'Открывать каждую ссылку в системном браузере вместо встроенного. Пункт «Открыть во встроенном браузере» в контекстном меню продолжает работать.',
|
||||
attachmentSizeTitle: 'Макс. размер превью / загрузки изображений',
|
||||
attachmentSizeDesc:
|
||||
'Насколько большой локальный файл приложение будет загружать для превью и вложений, в МБ. По умолчанию 16. Для удалённых неграфических вложений действует отдельный лимит 256 МБ. Слишком большое значение загружает весь файл в память и может подвесить или уронить приложение.',
|
||||
|
||||
@@ -1092,6 +1092,8 @@ export interface Translations {
|
||||
keepAwakeDesc: string
|
||||
disableF12Title: string
|
||||
disableF12Desc: string
|
||||
alwaysExternalLinksTitle: string
|
||||
alwaysExternalLinksDesc: string
|
||||
attachmentSizeTitle: string
|
||||
attachmentSizeDesc: string
|
||||
attachmentSizeUnit: string
|
||||
|
||||
@@ -1277,6 +1277,9 @@ export const zh = defineLocale({
|
||||
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。',
|
||||
disableF12Title: '禁用 F12 开发者工具',
|
||||
disableF12Desc: '阻止 F12 打开开发者工具。Ctrl+Shift+I(Mac 上为 Cmd+Opt+I)仍然可用。',
|
||||
alwaysExternalLinksTitle: '始终在外部浏览器中打开链接',
|
||||
alwaysExternalLinksDesc:
|
||||
'点击的每个链接都在系统浏览器中打开,而不是应用内浏览器。右键菜单中的“在应用内浏览器中打开”仍然可用。',
|
||||
attachmentSizeTitle: '预览 / 图片加载大小上限',
|
||||
attachmentSizeDesc:
|
||||
'桌面端为预览和图片附件加载本地文件的大小上限(MB)。默认为 16。远程非图片附件使用单独的 256 MB 上限。设置过大会将整个文件读入内存,可能导致应用卡死或崩溃。',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { IS_MAC } from '@/lib/keybinds/combo'
|
||||
import { setAlwaysExternalLinks } from '@/store/external-links'
|
||||
import { $previewTabs, closeRightRail } from '@/store/preview'
|
||||
|
||||
import {
|
||||
@@ -41,6 +42,7 @@ function installTitleBridge(title: string) {
|
||||
afterEach(() => {
|
||||
__resetLinkTitleCache()
|
||||
closeRightRail()
|
||||
setAlwaysExternalLinks(false)
|
||||
vi.restoreAllMocks()
|
||||
cleanup()
|
||||
|
||||
@@ -136,6 +138,19 @@ describe('external link helpers', () => {
|
||||
expect($previewTabs.get()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sends a plain click to the OS browser when "always external" is on', () => {
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
|
||||
setAlwaysExternalLinks(true)
|
||||
|
||||
render(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Example link' }))
|
||||
|
||||
expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource')
|
||||
expect($previewTabs.get()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('treats only the HUD renderer as a native-link surface', () => {
|
||||
expect(hudForcesNativeLinks('')).toBe(false)
|
||||
expect(hudForcesNativeLinks('?win=secondary')).toBe(false)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { ArrowUpRight } from '@/lib/icons'
|
||||
import { IS_MAC } from '@/lib/keybinds/combo'
|
||||
import { $alwaysExternalLinks } from '@/store/external-links'
|
||||
|
||||
import { resolveBrandIcon } from './brand-icon'
|
||||
import { cn } from './utils'
|
||||
@@ -244,7 +245,8 @@ export function hudForcesNativeLinks(search = typeof window === 'undefined' ? ''
|
||||
*
|
||||
* Everything that ISN'T a web page — `mailto:`, `file:`, a custom scheme — has
|
||||
* no business in the webview and always hands off to the OS. The HUD has no
|
||||
* browser pane, so it always takes the OS path.
|
||||
* browser pane, so it always takes the OS path. The "Always open links in
|
||||
* external browser" setting (`$alwaysExternalLinks`) sends every click there.
|
||||
*/
|
||||
export function openLink(href: string, options: { native?: boolean } = {}): void {
|
||||
const target = normalizeExternalUrl(href)
|
||||
@@ -255,6 +257,7 @@ export function openLink(href: string, options: { native?: boolean } = {}): void
|
||||
|
||||
if (
|
||||
options.native ||
|
||||
$alwaysExternalLinks.get() ||
|
||||
isConnectorAuthorizationLink(target) ||
|
||||
hudForcesNativeLinks() ||
|
||||
!/^https?:$/i.test(parseUrl(target)?.protocol ?? '')
|
||||
|
||||
29
apps/desktop/src/store/external-links.test.ts
Normal file
29
apps/desktop/src/store/external-links.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { storedBoolean } from '@/lib/storage'
|
||||
|
||||
import { $alwaysExternalLinks, setAlwaysExternalLinks } from './external-links'
|
||||
|
||||
const KEY = 'hermes.desktop.alwaysExternalLinks.v1'
|
||||
|
||||
afterEach(() => {
|
||||
setAlwaysExternalLinks(false)
|
||||
})
|
||||
|
||||
describe('always-external-links store', () => {
|
||||
it('defaults off and persists the pref', () => {
|
||||
expect($alwaysExternalLinks.get()).toBe(false)
|
||||
|
||||
setAlwaysExternalLinks(true)
|
||||
expect(storedBoolean(KEY, false)).toBe(true)
|
||||
|
||||
setAlwaysExternalLinks(false)
|
||||
expect(storedBoolean(KEY, true)).toBe(false)
|
||||
})
|
||||
|
||||
it('follows a change made in another window', () => {
|
||||
window.localStorage.setItem(KEY, 'true')
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: KEY }))
|
||||
expect($alwaysExternalLinks.get()).toBe(true)
|
||||
})
|
||||
})
|
||||
34
apps/desktop/src/store/external-links.ts
Normal file
34
apps/desktop/src/store/external-links.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Always open links in the system browser — a device-local preference.
|
||||
*
|
||||
* Off (the default): a clicked web link opens in the in-app browser, and
|
||||
* ⌘/Ctrl-click or middle-click escapes to the OS browser. On: every clicked
|
||||
* link goes to the OS browser (see `openLink` in `@/lib/external-link`).
|
||||
*
|
||||
* Renderer-owned: it only decides where THIS machine's link clicks land. The
|
||||
* `storage` listener keeps every open window in step when one window flips it.
|
||||
* Explicit "Open in in-app browser" menu actions and agent-driven previews are
|
||||
* not link clicks and stay unaffected.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||
|
||||
const KEY = 'hermes.desktop.alwaysExternalLinks.v1'
|
||||
|
||||
export const $alwaysExternalLinks = atom<boolean>(typeof window === 'undefined' ? false : storedBoolean(KEY, false))
|
||||
|
||||
export function setAlwaysExternalLinks(on: boolean): void {
|
||||
$alwaysExternalLinks.set(on)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
$alwaysExternalLinks.subscribe(on => persistBoolean(KEY, on))
|
||||
|
||||
window.addEventListener('storage', event => {
|
||||
if (event.key === KEY) {
|
||||
$alwaysExternalLinks.set(storedBoolean(KEY, false))
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user