mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Add default browser action for terminal links (#6129)
* Add terminal link browser context action * Polish terminal link default-browser action - Route Open in Default Browser through the shared openHttpLink funnel (forceSystemBrowser) so loopback dev-server links get the same worktree-label rewrite as shift+click's system-browser path. - Translate the new menu label in es/ja/ko/zh (was en-only). - Extract TerminalQuickCommandMenuItem to keep TerminalContextMenu under the max-lines lint after adding the link item. - Hit-test the link only when the menu actually opens (skip on Windows right-click copy/paste path). - Add scheme-safety unit tests (file:/javascript:/smb:/vscode: rejected) and a menu-item render/callback test; fix TerminalContextMenu.test props for the new required fields. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
co-authored by
Orca
Neil
parent
42de074152
commit
3614cbc40c
@@ -57,6 +57,8 @@ function renderMenu(overrides: Record<string, unknown> = {}): void {
|
||||
canClosePane: true,
|
||||
canExpandPane: true,
|
||||
menuPaneIsExpanded: false,
|
||||
linkUrl: null,
|
||||
onOpenLinkInDefaultBrowser: vi.fn(),
|
||||
onCopy: vi.fn(),
|
||||
onPaste: vi.fn(),
|
||||
onSplitRight: vi.fn(),
|
||||
@@ -114,6 +116,24 @@ describe('TerminalContextMenu', () => {
|
||||
expect(onForkAgentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows "Open in Default Browser" only when a link is under the cursor', () => {
|
||||
renderMenu({ linkUrl: null })
|
||||
expect(
|
||||
items.list.some((item) => childrenText(item.children) === 'Open in Default Browser')
|
||||
).toBe(false)
|
||||
|
||||
items.list = []
|
||||
const onOpenLinkInDefaultBrowser = vi.fn()
|
||||
renderMenu({ linkUrl: 'https://example.com/docs', onOpenLinkInDefaultBrowser })
|
||||
|
||||
const openLinkItem = items.list.find(
|
||||
(item) => childrenText(item.children) === 'Open in Default Browser'
|
||||
)
|
||||
expect(openLinkItem).toBeDefined()
|
||||
openLinkItem?.onSelect?.()
|
||||
expect(onOpenLinkInDefaultBrowser).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows one shortcut per terminal menu action on Windows', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ClipboardCopy,
|
||||
Copy,
|
||||
Eraser,
|
||||
ExternalLink,
|
||||
GitFork,
|
||||
Maximize2,
|
||||
MessageSquare,
|
||||
@@ -31,12 +32,11 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { shouldIgnoreTerminalMenuPointerDownOutside } from './terminal-context-menu-dismiss'
|
||||
import type { TerminalQuickCommand } from '../../../../shared/types'
|
||||
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
|
||||
import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import type { KeybindingOverrides } from '../../../../shared/keybindings'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isMacPlatform, nativeChatToggleShortcutLabel } from '../native-chat/native-chat-shortcut'
|
||||
import { TerminalQuickCommandMenuItem } from './TerminalQuickCommandMenuItem'
|
||||
|
||||
type TerminalContextMenuProps = {
|
||||
open: boolean
|
||||
@@ -46,6 +46,8 @@ type TerminalContextMenuProps = {
|
||||
canClosePane: boolean
|
||||
canExpandPane: boolean
|
||||
menuPaneIsExpanded: boolean
|
||||
linkUrl: string | null
|
||||
onOpenLinkInDefaultBrowser: () => void
|
||||
onCopy: () => void
|
||||
onPaste: () => void
|
||||
onSplitRight: () => void
|
||||
@@ -81,6 +83,8 @@ export default function TerminalContextMenu({
|
||||
canClosePane,
|
||||
canExpandPane,
|
||||
menuPaneIsExpanded,
|
||||
linkUrl,
|
||||
onOpenLinkInDefaultBrowser,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onSplitRight,
|
||||
@@ -128,27 +132,6 @@ export default function TerminalContextMenu({
|
||||
const showEqualizeShortcut = shortcuts.equalize !== 'Unassigned'
|
||||
const showSetTitleShortcut = shortcuts.setTitle !== 'Unassigned'
|
||||
const showClearPaneTitleShortcut = shortcuts.clearPaneTitle !== 'Unassigned'
|
||||
const renderQuickCommandItem = (command: TerminalQuickCommand): React.JSX.Element => (
|
||||
<DropdownMenuItem key={command.id} onSelect={() => onQuickCommand(command)}>
|
||||
{isTerminalAgentQuickCommand(command) ? (
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<AgentIcon agent={command.agent} size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<Play
|
||||
className="size-3.5 shrink-0 text-muted-foreground"
|
||||
fill="currentColor"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{command.label}</span>
|
||||
{!isTerminalAgentQuickCommand(command) && !command.appendEnter ? (
|
||||
<DropdownMenuShortcut className="shrink-0">
|
||||
{translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')}
|
||||
</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
@@ -194,6 +177,18 @@ export default function TerminalContextMenu({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{linkUrl ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onOpenLinkInDefaultBrowser}>
|
||||
<ExternalLink />
|
||||
{translate(
|
||||
'auto.components.terminal.pane.TerminalContextMenu.openLinkDefaultBrowser',
|
||||
'Open in Default Browser'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={onCopy}>
|
||||
<Copy />
|
||||
{translate('auto.components.terminal.pane.TerminalContextMenu.f3eeb1de13', 'Copy')}
|
||||
@@ -220,7 +215,13 @@ export default function TerminalContextMenu({
|
||||
<DropdownMenuLabel className="truncate">
|
||||
{quickCommandRepoLabel}
|
||||
</DropdownMenuLabel>
|
||||
{repoQuickCommands.map(renderQuickCommandItem)}
|
||||
{repoQuickCommands.map((command) => (
|
||||
<TerminalQuickCommandMenuItem
|
||||
key={command.id}
|
||||
command={command}
|
||||
onSelect={onQuickCommand}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{globalQuickCommands.length > 0 ? (
|
||||
@@ -234,7 +235,13 @@ export default function TerminalContextMenu({
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
) : null}
|
||||
{globalQuickCommands.map(renderQuickCommandItem)}
|
||||
{globalQuickCommands.map((command) => (
|
||||
<TerminalQuickCommandMenuItem
|
||||
key={command.id}
|
||||
command={command}
|
||||
onSelect={onQuickCommand}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -2971,6 +2971,8 @@ export default function TerminalPane({
|
||||
menuPaneIsExpanded={
|
||||
contextMenu.menuPaneId !== null && contextMenu.menuPaneId === expandedPaneId
|
||||
}
|
||||
linkUrl={contextMenu.menuLinkUrl}
|
||||
onOpenLinkInDefaultBrowser={contextMenu.onOpenLinkInDefaultBrowser}
|
||||
onCopy={() => void contextMenu.onCopy()}
|
||||
onPaste={() => void contextMenu.onPaste()}
|
||||
onSplitRight={contextMenu.onSplitRight}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Play } from 'lucide-react'
|
||||
import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { TerminalQuickCommand } from '../../../../shared/types'
|
||||
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
|
||||
|
||||
type TerminalQuickCommandMenuItemProps = {
|
||||
command: TerminalQuickCommand
|
||||
onSelect: (command: TerminalQuickCommand) => void
|
||||
}
|
||||
|
||||
export function TerminalQuickCommandMenuItem({
|
||||
command,
|
||||
onSelect
|
||||
}: TerminalQuickCommandMenuItemProps): React.JSX.Element {
|
||||
return (
|
||||
<DropdownMenuItem onSelect={() => onSelect(command)}>
|
||||
{isTerminalAgentQuickCommand(command) ? (
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<AgentIcon agent={command.agent} size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<Play
|
||||
className="size-3.5 shrink-0 text-muted-foreground"
|
||||
fill="currentColor"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{command.label}</span>
|
||||
{!isTerminalAgentQuickCommand(command) && !command.appendEnter ? (
|
||||
<DropdownMenuShortcut className="shrink-0">
|
||||
{translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')}
|
||||
</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
extractTerminalHttpLinks,
|
||||
getTerminalHttpLinkAtBufferPosition,
|
||||
getTerminalHttpLinkForMouseEvent,
|
||||
TERMINAL_HTTP_URL_MAX_LENGTH
|
||||
} from './terminal-url-link-hit-testing'
|
||||
|
||||
@@ -47,3 +49,127 @@ describe('extractTerminalHttpLinks', () => {
|
||||
expect(matchAllSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal HTTP link hit testing', () => {
|
||||
type TestBufferLine = {
|
||||
isWrapped: boolean
|
||||
length: number
|
||||
translateToString: (
|
||||
trimRight?: boolean,
|
||||
startColumn?: number,
|
||||
endColumn?: number,
|
||||
outColumns?: number[]
|
||||
) => string
|
||||
}
|
||||
|
||||
function defaultColumnsForText(text: string): number[] {
|
||||
return Array.from({ length: text.length + 1 }, (_value, index) => index)
|
||||
}
|
||||
|
||||
function makeBufferLine(
|
||||
text: string,
|
||||
options: { isWrapped?: boolean; columns?: number[] } = {}
|
||||
): TestBufferLine {
|
||||
const columns = options.columns ?? defaultColumnsForText(text)
|
||||
return {
|
||||
isWrapped: options.isWrapped ?? false,
|
||||
length: text.length,
|
||||
translateToString: (
|
||||
_trimRight?: boolean,
|
||||
startColumn = 0,
|
||||
endColumn = text.length,
|
||||
outColumns?: number[]
|
||||
) => {
|
||||
if (outColumns) {
|
||||
outColumns.length = 0
|
||||
for (let index = startColumn; index <= endColumn; index++) {
|
||||
outColumns.push(columns[index] ?? index)
|
||||
}
|
||||
}
|
||||
return text.slice(startColumn, endColumn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeBuffer(
|
||||
rows: TestBufferLine[]
|
||||
): Parameters<typeof getTerminalHttpLinkAtBufferPosition>[0] {
|
||||
return { getLine: (y: number) => rows[y] } as Parameters<
|
||||
typeof getTerminalHttpLinkAtBufferPosition
|
||||
>[0]
|
||||
}
|
||||
|
||||
it('returns the URL at a buffer position without opening it', () => {
|
||||
const line = 'Open https://example.com/docs?q=orca now'
|
||||
|
||||
expect(
|
||||
getTerminalHttpLinkAtBufferPosition(
|
||||
makeBuffer([makeBufferLine(line)]),
|
||||
{ x: line.indexOf('example') + 1, y: 1 },
|
||||
80
|
||||
)
|
||||
).toBe('https://example.com/docs?q=orca')
|
||||
})
|
||||
|
||||
it('returns null when the buffer position is outside the URL', () => {
|
||||
const line = 'Open https://example.com/docs now'
|
||||
|
||||
expect(
|
||||
getTerminalHttpLinkAtBufferPosition(makeBuffer([makeBufferLine(line)]), { x: 2, y: 1 }, 80)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
// Why: the context-menu "Open in Default Browser" item hands this URL to
|
||||
// shell.openExternal, so a dangerous scheme under the cursor must never be
|
||||
// surfaced as an openable link.
|
||||
it.each(['file:///etc/passwd', 'javascript:alert(1)', 'smb://host/share', 'vscode://x'])(
|
||||
'returns null for the non-http scheme %s',
|
||||
(dangerousUrl) => {
|
||||
const line = `Open ${dangerousUrl} now`
|
||||
|
||||
expect(
|
||||
getTerminalHttpLinkAtBufferPosition(
|
||||
makeBuffer([makeBufferLine(line)]),
|
||||
{ x: line.indexOf(dangerousUrl) + 1, y: 1 },
|
||||
80
|
||||
)
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('returns wrapped URLs from mouse coordinates', () => {
|
||||
const rows = [
|
||||
makeBufferLine('Visit https://exa'),
|
||||
makeBufferLine('mple.com/docs', { isWrapped: true })
|
||||
]
|
||||
const screen = {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 200,
|
||||
height: 40
|
||||
})
|
||||
}
|
||||
const terminal = {
|
||||
cols: 20,
|
||||
rows: 2,
|
||||
element: { querySelector: () => screen },
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY: 0,
|
||||
getLine: (y: number) => rows[y]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
getTerminalHttpLinkForMouseEvent(
|
||||
terminal as unknown as Parameters<typeof getTerminalHttpLinkForMouseEvent>[0],
|
||||
{
|
||||
clientX: 30,
|
||||
clientY: 45
|
||||
}
|
||||
)
|
||||
).toBe('https://example.com/docs')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -191,7 +191,7 @@ function getTerminalScreenElement(terminal: Terminal): HTMLElement | null {
|
||||
|
||||
function getBufferPositionForTerminalMouseEvent(
|
||||
terminal: Terminal,
|
||||
event: MouseEvent
|
||||
event: Pick<MouseEvent, 'clientX' | 'clientY'>
|
||||
): { x: number; y: number } | null {
|
||||
const screenElement = getTerminalScreenElement(terminal)
|
||||
if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) {
|
||||
@@ -260,9 +260,34 @@ export function openHttpLinkAtBufferPosition(
|
||||
terminalColumns: number,
|
||||
deps: UrlLinkHitTestDeps
|
||||
): boolean {
|
||||
const url = getTerminalHttpLinkAtBufferPosition(buffer, position, terminalColumns)
|
||||
if (!url) {
|
||||
return false
|
||||
}
|
||||
|
||||
openTerminalHttpLink(url, deps)
|
||||
return true
|
||||
}
|
||||
|
||||
export function getTerminalHttpLinkForMouseEvent(
|
||||
terminal: Terminal,
|
||||
event: Pick<MouseEvent, 'clientX' | 'clientY'>
|
||||
): string | null {
|
||||
const position = getBufferPositionForTerminalMouseEvent(terminal, event)
|
||||
if (!position) {
|
||||
return null
|
||||
}
|
||||
return getTerminalHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols)
|
||||
}
|
||||
|
||||
export function getTerminalHttpLinkAtBufferPosition(
|
||||
buffer: { getLine(y: number): IBufferLine | undefined },
|
||||
position: { x: number; y: number },
|
||||
terminalColumns: number
|
||||
): string | null {
|
||||
const logicalLines = buildCandidateLogicalLinesForBufferPosition(buffer, position.y)
|
||||
if (logicalLines.length === 0) {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
for (const logicalLine of logicalLines) {
|
||||
@@ -271,12 +296,11 @@ export function openHttpLinkAtBufferPosition(
|
||||
if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) {
|
||||
continue
|
||||
}
|
||||
openTerminalHttpLink(parsed.url, deps)
|
||||
return true
|
||||
return parsed.url
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void {
|
||||
|
||||
@@ -41,6 +41,8 @@ import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
|
||||
import { copyTerminalHandleForPane } from './terminal-handle-copy'
|
||||
import { getTerminalHttpLinkForMouseEvent } from './terminal-url-link-hit-testing'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
|
||||
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
|
||||
|
||||
@@ -81,6 +83,8 @@ type TerminalMenuState = {
|
||||
menuOpenedAtRef: React.RefObject<number>
|
||||
paneCount: number
|
||||
menuPaneId: number | null
|
||||
menuLinkUrl: string | null
|
||||
onOpenLinkInDefaultBrowser: () => void
|
||||
onContextMenuCapture: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
onPaneTitleContextMenu: (event: React.MouseEvent<HTMLElement>, paneId: number) => void
|
||||
onCopy: () => Promise<void>
|
||||
@@ -124,6 +128,7 @@ export function useTerminalPaneContextMenu({
|
||||
const menuOpenedAtRef = useRef(0)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [point, setPoint] = useState({ x: 0, y: 0 })
|
||||
const [menuLinkUrl, setMenuLinkUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const closeMenu = (): void => {
|
||||
@@ -182,6 +187,17 @@ export function useTerminalPaneContextMenu({
|
||||
pane.terminal.focus()
|
||||
}
|
||||
|
||||
const onOpenLinkInDefaultBrowser = (): void => {
|
||||
if (!menuLinkUrl) {
|
||||
return
|
||||
}
|
||||
// Why: route through the shared funnel (not shell.openUrl directly) so this
|
||||
// matches shift+click's system-browser path, including the loopback
|
||||
// worktree-label rewrite for local dev-server links.
|
||||
openHttpLink(menuLinkUrl, { worktreeId, forceSystemBrowser: true })
|
||||
resolveMenuPane()?.terminal.focus()
|
||||
}
|
||||
|
||||
const getShortcutPlatform = (): NodeJS.Platform => {
|
||||
if (navigator.userAgent.includes('Mac')) {
|
||||
return 'darwin'
|
||||
@@ -472,6 +488,7 @@ export function useTerminalPaneContextMenu({
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
contextPaneIdRef.current = null
|
||||
setMenuLinkUrl(null)
|
||||
return
|
||||
}
|
||||
const clickedPane =
|
||||
@@ -499,6 +516,11 @@ export function useTerminalPaneContextMenu({
|
||||
return
|
||||
}
|
||||
|
||||
// Why: only hit-test the link once the menu is actually opening; the Windows
|
||||
// copy/paste path above returns without a menu and needs no link lookup.
|
||||
setMenuLinkUrl(
|
||||
clickedPane ? getTerminalHttpLinkForMouseEvent(clickedPane.terminal, event.nativeEvent) : null
|
||||
)
|
||||
menuOpenedAtRef.current = Date.now()
|
||||
const bounds = boundsElement.getBoundingClientRect()
|
||||
setPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top })
|
||||
@@ -510,12 +532,14 @@ export function useTerminalPaneContextMenu({
|
||||
if (!manager) {
|
||||
event.preventDefault()
|
||||
contextPaneIdRef.current = null
|
||||
setMenuLinkUrl(null)
|
||||
return
|
||||
}
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) {
|
||||
event.preventDefault()
|
||||
contextPaneIdRef.current = null
|
||||
setMenuLinkUrl(null)
|
||||
return
|
||||
}
|
||||
const clickedPane = manager.getPanes().find((pane) => pane.container.contains(target)) ?? null
|
||||
@@ -544,6 +568,8 @@ export function useTerminalPaneContextMenu({
|
||||
menuOpenedAtRef,
|
||||
paneCount,
|
||||
menuPaneId,
|
||||
menuLinkUrl,
|
||||
onOpenLinkInDefaultBrowser,
|
||||
onContextMenuCapture,
|
||||
onPaneTitleContextMenu,
|
||||
onCopy,
|
||||
|
||||
@@ -2450,6 +2450,7 @@
|
||||
"0a917b591a": "Paste",
|
||||
"f3eeb1de13": "Copy",
|
||||
"c2f0b72b8d": "Insert",
|
||||
"openLinkDefaultBrowser": "Open in Default Browser",
|
||||
"925f49f210": "Expand Pane",
|
||||
"df766809e0": "Collapse Pane",
|
||||
"cff67afad1": "Copy Context"
|
||||
|
||||
@@ -2450,6 +2450,7 @@
|
||||
"0a917b591a": "Pasta",
|
||||
"f3eeb1de13": "Copiar",
|
||||
"c2f0b72b8d": "Insertar",
|
||||
"openLinkDefaultBrowser": "Abrir en el navegador predeterminado",
|
||||
"925f49f210": "Expandir panel",
|
||||
"df766809e0": "Contraer panel",
|
||||
"cff67afad1": "Copy Context"
|
||||
|
||||
@@ -2450,6 +2450,7 @@
|
||||
"0a917b591a": "ペースト",
|
||||
"f3eeb1de13": "コピー",
|
||||
"c2f0b72b8d": "入れる",
|
||||
"openLinkDefaultBrowser": "既定のブラウザで開く",
|
||||
"925f49f210": "ペインを展開する",
|
||||
"df766809e0": "ペインを折りたたむ",
|
||||
"cff67afad1": "Copy Context"
|
||||
|
||||
@@ -2450,6 +2450,7 @@
|
||||
"0a917b591a": "붙여넣기",
|
||||
"f3eeb1de13": "복사",
|
||||
"c2f0b72b8d": "삽입",
|
||||
"openLinkDefaultBrowser": "기본 브라우저에서 열기",
|
||||
"925f49f210": "창 확장",
|
||||
"df766809e0": "창 축소",
|
||||
"cff67afad1": "Copy Context"
|
||||
|
||||
@@ -2450,6 +2450,7 @@
|
||||
"0a917b591a": "粘贴",
|
||||
"f3eeb1de13": "复制",
|
||||
"c2f0b72b8d": "插入",
|
||||
"openLinkDefaultBrowser": "在默认浏览器中打开",
|
||||
"925f49f210": "展开窗格",
|
||||
"df766809e0": "折叠窗格",
|
||||
"cff67afad1": "Copy Context"
|
||||
|
||||
Reference in New Issue
Block a user