feat(ui): improve output actions and link handling
- Clear stale history chain metadata when workspace state changes - Centralize external URL opening behind a shared helper - Add copy-and-open AI platform actions with localized labels - Cover workspace and output action behavior with unit tests
This commit is contained in:
1 parent
b0680cda7e
commit
f765a4fd39
17 files changed
+842
-106
No files matched your search
@@ -173,6 +173,7 @@ import { getEnvVar } from '@prompt-optimizer/core'
|
||||
import { useToast } from '../composables/ui/useToast'
|
||||
import type { AppServices } from '../types/services'
|
||||
import { getI18nErrorMessage } from '../utils/error'
|
||||
import { openExternalUrl } from '../utils/open-external-url'
|
||||
import { parsePromptGardenImportInput } from '../utils/prompt-garden-import'
|
||||
import {
|
||||
importFavoriteResourcePackage,
|
||||
@@ -230,25 +231,8 @@ const handleFileChange = (options: UploadChangeParam) => {
|
||||
fileList.value = options.fileList.slice(0, 1)
|
||||
}
|
||||
|
||||
const openExternalUrl = async (url: string) => {
|
||||
if (!url) return
|
||||
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.shell) {
|
||||
try {
|
||||
await window.electronAPI.shell.openExternal(url)
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('[PromptGarden] Failed to open external URL in Electron:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePromptGardenDiscover = () => {
|
||||
void openExternalUrl(promptGardenBaseUrl.value)
|
||||
void openExternalUrl(promptGardenBaseUrl.value, { logPrefix: 'PromptGarden' })
|
||||
}
|
||||
|
||||
const readFileAsArrayBuffer = (file: File) =>
|
||||
|
||||
@@ -76,6 +76,7 @@ import { NLayout, NLayoutHeader, NLayoutContent, NFlex, NText } from 'naive-ui'
|
||||
import ToastUI from './Toast.vue'
|
||||
import logoImage from '../assets/logo.png'
|
||||
import AppPreviewImage from './media/AppPreviewImage.vue'
|
||||
import { openExternalUrl } from '../utils/open-external-url'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -125,20 +126,7 @@ const logoSize = computed(() => {
|
||||
})
|
||||
|
||||
const openBrandWebsite = async () => {
|
||||
const url = 'https://always200.com'
|
||||
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.shell) {
|
||||
try {
|
||||
await window.electronAPI.shell.openExternal(url)
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('Failed to open brand website in Electron:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
await openExternalUrl('https://always200.com', { logPrefix: 'MainLayout' })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
<!-- 右侧:操作按钮 -->
|
||||
<NFlex align="center" :size="6" :wrap="false" class="output-toolbar-actions">
|
||||
<slot name="toolbar-right-extra"></slot>
|
||||
<NButtonGroup class="output-toolbar-action-group">
|
||||
<NButton
|
||||
v-if="isActionEnabled('favorite')"
|
||||
:data-testid="testId ? `${testId}-favorite` : 'output-favorite'"
|
||||
@@ -55,21 +54,51 @@
|
||||
</NIcon>
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton
|
||||
<div
|
||||
v-if="isActionEnabled('copy')"
|
||||
@click="handleCopy('content')"
|
||||
size="small"
|
||||
quaternary
|
||||
circle
|
||||
class="output-copy-split-button"
|
||||
:class="{ 'is-disabled': !hasContent }"
|
||||
role="group"
|
||||
:aria-label="activeCopyActionTitle"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.03 1.125 0 1.13.094 1.976 1.057 1.976 2.192V7.5M8.25 7.5h7.5M8.25 7.5h-1.5a1.5 1.5 0 00-1.5 1.5v11.25c0 .828.672 1.5 1.5 1.5h10.5a1.5 1.5 0 001.5-1.5V9a1.5 1.5 0 00-1.5-1.5h-1.5" />
|
||||
</svg>
|
||||
</NIcon>
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton
|
||||
:data-testid="testId ? `${testId}-copy-action` : 'output-copy-action'"
|
||||
class="output-copy-split-primary"
|
||||
@click="handlePrimaryCopyAction"
|
||||
size="small"
|
||||
quaternary
|
||||
:disabled="!hasContent"
|
||||
:title="activeCopyActionTitle"
|
||||
:aria-label="activeCopyActionTitle"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<component :is="activeCopyActionIcon" />
|
||||
</NIcon>
|
||||
</template>
|
||||
</NButton>
|
||||
<NDropdown
|
||||
trigger="click"
|
||||
:options="copyActionOptions"
|
||||
@select="handleCopyActionSelect"
|
||||
>
|
||||
<NButton
|
||||
:data-testid="testId ? `${testId}-copy-action-menu` : 'output-copy-action-menu'"
|
||||
class="output-copy-split-menu"
|
||||
size="small"
|
||||
quaternary
|
||||
:disabled="!hasContent"
|
||||
:title="t('copyOpen.selectAction')"
|
||||
:aria-label="t('copyOpen.selectAction')"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<ChevronDown />
|
||||
</NIcon>
|
||||
</template>
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
</div>
|
||||
<NButton
|
||||
v-if="isActionEnabled('fullscreen')"
|
||||
@click="handleFullscreen"
|
||||
@@ -85,7 +114,6 @@
|
||||
</NIcon>
|
||||
</template>
|
||||
</NButton>
|
||||
</NButtonGroup>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
|
||||
@@ -193,15 +221,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick, onMounted, inject, type Ref } from 'vue'
|
||||
import { computed, ref, watch, nextTick, onMounted, inject, h, type Ref } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
NCard, NButton, NButtonGroup, NIcon, NCollapse, NCollapseItem,
|
||||
NInput, NEmpty, NSpin, NScrollbar, NFlex, NText, NSpace
|
||||
NInput, NEmpty, NSpin, NScrollbar, NFlex, NText, NSpace, NDropdown,
|
||||
type DropdownOption
|
||||
} from 'naive-ui'
|
||||
import { useToast } from '../composables/ui/useToast'
|
||||
import { Star } from '@vicons/tabler'
|
||||
import {
|
||||
ChevronDown,
|
||||
Star,
|
||||
} from '@vicons/tabler'
|
||||
import { useClipboard } from '../composables/ui/useClipboard'
|
||||
import MarkdownRenderer from './MarkdownRenderer.vue'
|
||||
import XmlRenderer from './XmlRenderer.vue'
|
||||
@@ -214,6 +246,17 @@ import { useVariableManager } from '../composables/prompt/useVariableManager'
|
||||
import type { AppServices } from '../types/services'
|
||||
import { router as routerInstance } from '../router'
|
||||
import { isValidXmlContent } from '../utils/xml-renderer'
|
||||
import {
|
||||
buildCopyOpenActionUrl,
|
||||
COPY_OPEN_ACTIONS,
|
||||
DEFAULT_COPY_OPEN_ACTION_ID,
|
||||
getCopyOpenAction,
|
||||
readCopyOpenActionFromSession,
|
||||
writeCopyOpenActionToSession,
|
||||
type CopyOpenActionId,
|
||||
} from '../utils/copy-open-action'
|
||||
import { copyOpenActionIconMap } from '../utils/copy-open-icons'
|
||||
import { openExternalUrl } from '../utils/open-external-url'
|
||||
|
||||
type ActionName = 'fullscreen' | 'diff' | 'copy' | 'edit' | 'reasoning' | 'favorite'
|
||||
|
||||
@@ -344,6 +387,10 @@ const hasToolbar = computed(() =>
|
||||
// 计算属性
|
||||
const displayContent = computed(() => (props.content || '').trim())
|
||||
const displayReasoning = computed(() => (props.reasoning || '').trim())
|
||||
const copyActionWorkspacePath = computed(() => routerInstance.currentRoute.value.path || '/')
|
||||
const activeCopyActionId = ref<CopyOpenActionId>(
|
||||
readCopyOpenActionFromSession(copyActionWorkspacePath.value),
|
||||
)
|
||||
|
||||
const renderContentType = computed<'markdown' | 'xml'>(() => {
|
||||
if (!displayContent.value) return 'markdown'
|
||||
@@ -368,6 +415,33 @@ const shouldShowReasoning = computed(() => {
|
||||
return hasReasoning.value
|
||||
})
|
||||
|
||||
const getCopyActionPlatformLabel = (actionId: CopyOpenActionId): string => {
|
||||
const platform = getCopyOpenAction(actionId).platform
|
||||
return platform ? t(`copyOpen.platforms.${actionId}`) : ''
|
||||
}
|
||||
|
||||
const activeCopyActionIcon = computed(() => copyOpenActionIconMap[activeCopyActionId.value])
|
||||
|
||||
const activeCopyActionTitle = computed(() => {
|
||||
if (activeCopyActionId.value === DEFAULT_COPY_OPEN_ACTION_ID) {
|
||||
return t('common.copy')
|
||||
}
|
||||
|
||||
return t('copyOpen.copyAndOpen', {
|
||||
platform: getCopyActionPlatformLabel(activeCopyActionId.value),
|
||||
})
|
||||
})
|
||||
|
||||
const copyActionOptions = computed<DropdownOption[]>(() =>
|
||||
COPY_OPEN_ACTIONS.map((action) => ({
|
||||
key: action.id,
|
||||
label: action.id === 'copy'
|
||||
? t('copyOpen.copyOnly')
|
||||
: t('copyOpen.copyAndOpen', { platform: getCopyActionPlatformLabel(action.id) }),
|
||||
icon: () => h(NIcon, null, { default: () => h(copyOpenActionIconMap[action.id]) }),
|
||||
})),
|
||||
)
|
||||
|
||||
// 推理展开/折叠状态的计算属性
|
||||
const isReasoningExpanded = computed({
|
||||
get: () => reasoningExpandedNames.value.includes('reasoning'),
|
||||
@@ -386,8 +460,25 @@ const handleSourceInput = (value: string) => {
|
||||
emit('update:content', value)
|
||||
}
|
||||
|
||||
const copyDisplayText = async (
|
||||
textToCopy: string,
|
||||
emitType: 'content' | 'reasoning' | 'all',
|
||||
): Promise<boolean> => {
|
||||
if (!textToCopy) return false
|
||||
|
||||
try {
|
||||
await copyText(textToCopy)
|
||||
emit('copy', textToCopy, emitType)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[OutputDisplayCore] Failed to copy content:', error)
|
||||
message.error(t('common.copyFailed'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 复制功能
|
||||
const handleCopy = (type: 'content' | 'reasoning' | 'all') => {
|
||||
const handleCopy = async (type: 'content' | 'reasoning' | 'all') => {
|
||||
let textToCopy = ''
|
||||
const emitType: 'content' | 'reasoning' | 'all' = type
|
||||
|
||||
@@ -405,13 +496,35 @@ const handleCopy = (type: 'content' | 'reasoning' | 'all') => {
|
||||
].filter(Boolean).join('\n\n')
|
||||
break
|
||||
}
|
||||
|
||||
if (textToCopy) {
|
||||
copyText(textToCopy)
|
||||
emit('copy', textToCopy, emitType)
|
||||
|
||||
await copyDisplayText(textToCopy, emitType)
|
||||
}
|
||||
|
||||
const handlePrimaryCopyAction = async () => {
|
||||
const textToCopy = displayContent.value
|
||||
const copied = await copyDisplayText(textToCopy, 'content')
|
||||
if (!copied || activeCopyActionId.value === DEFAULT_COPY_OPEN_ACTION_ID) return
|
||||
|
||||
const url = buildCopyOpenActionUrl(activeCopyActionId.value)
|
||||
if (!url) return
|
||||
|
||||
try {
|
||||
await openExternalUrl(url, { logPrefix: 'OutputDisplayCore' })
|
||||
} catch (error) {
|
||||
console.error('[OutputDisplayCore] Failed to open external AI platform:', error)
|
||||
message.error(t('copyOpen.openFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyActionSelect = async (key: string | number) => {
|
||||
const selected = COPY_OPEN_ACTIONS.find((action) => action.id === key)
|
||||
if (!selected) return
|
||||
|
||||
activeCopyActionId.value = selected.id
|
||||
writeCopyOpenActionToSession(copyActionWorkspacePath.value, selected.id)
|
||||
await handlePrimaryCopyAction()
|
||||
}
|
||||
|
||||
// 全屏功能
|
||||
const handleFullscreen = () => {
|
||||
emit('fullscreen')
|
||||
@@ -507,6 +620,10 @@ watch(() => props.content, (newContent, oldContent) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(copyActionWorkspacePath, (workspacePath) => {
|
||||
activeCopyActionId.value = readCopyOpenActionFromSession(workspacePath)
|
||||
})
|
||||
|
||||
// 监听推理折叠状态变化
|
||||
watch(reasoningExpandedNames, (newNames) => {
|
||||
const expanded = newNames.includes('reasoning')
|
||||
@@ -578,7 +695,59 @@ defineExpose({ resetReasoningState, forceRefreshContent, forceExitEditing })
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.output-toolbar-action-group {
|
||||
.output-copy-split-button {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.output-copy-split-button:not(.is-disabled):hover {
|
||||
border-color: color-mix(in srgb, currentColor 28%, transparent);
|
||||
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||
}
|
||||
|
||||
.output-copy-split-button.is-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.output-copy-split-button :deep(.n-button.output-copy-split-primary),
|
||||
.output-copy-split-button :deep(.n-button.output-copy-split-menu) {
|
||||
height: 26px;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.output-copy-split-button :deep(.n-button.output-copy-split-primary) {
|
||||
min-width: 29px;
|
||||
width: 29px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.output-copy-split-button :deep(.n-button.output-copy-split-menu) {
|
||||
position: relative;
|
||||
min-width: 21px;
|
||||
width: 21px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.output-copy-split-button :deep(.n-button.output-copy-split-menu)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 1px;
|
||||
background: color-mix(in srgb, currentColor 18%, transparent);
|
||||
}
|
||||
|
||||
.output-copy-split-button:not(.is-disabled) :deep(.n-button:hover) {
|
||||
background: color-mix(in srgb, currentColor 8%, transparent) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -247,6 +247,7 @@ import {
|
||||
resolveWorkspacePathFallback,
|
||||
} from '../../router/workspaceRoutes';
|
||||
import { createExternalDataLoadingGate } from '../../utils/external-data-loading'
|
||||
import { openExternalUrl } from '../../utils/open-external-url'
|
||||
import { registerOptionalIntegrations } from '../../integrations/registerOptionalIntegrations';
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
@@ -1976,19 +1977,6 @@ watch(
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
const openExternalUrl = async (url: string) => {
|
||||
if (typeof window !== "undefined" && window.electronAPI?.shell) {
|
||||
try {
|
||||
await window.electronAPI.shell.openExternal(url);
|
||||
} catch (error) {
|
||||
console.error("Failed to open external URL in Electron:", error);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
} else {
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
const appVersion = `v${rootPackageJson.version}`;
|
||||
|
||||
const openOfficialWebsite = async () => {
|
||||
|
||||
@@ -113,6 +113,7 @@ import { getEnvVar } from '@prompt-optimizer/core'
|
||||
import SourceAssetBadge from '../source/SourceAssetBadge.vue'
|
||||
import PromptGardenImportDialog from './PromptGardenImportDialog.vue'
|
||||
import ThemedTooltip from './ThemedTooltip.vue'
|
||||
import { openExternalUrl } from '../../utils/open-external-url'
|
||||
import type { PromptGardenImportRequest } from '../../utils/prompt-garden-import'
|
||||
import type { SourceAssetRef } from '../../utils/source-asset'
|
||||
|
||||
@@ -147,23 +148,6 @@ const promptGardenBaseUrl = computed(() => {
|
||||
return getEnvVar('VITE_PROMPT_GARDEN_BASE_URL').trim().replace(/\/$/, '')
|
||||
})
|
||||
|
||||
const openExternalUrl = async (url: string) => {
|
||||
if (!url) return
|
||||
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.shell) {
|
||||
try {
|
||||
await window.electronAPI.shell.openExternal(url)
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('[PromptGarden] Failed to open external URL in Electron:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const updateTriggerPlacement = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
@@ -252,7 +236,7 @@ const handleSelect = (key: string) => {
|
||||
|
||||
const handleGardenSelect = (key: string) => {
|
||||
if (key === 'discover') {
|
||||
void openExternalUrl(promptGardenBaseUrl.value)
|
||||
void openExternalUrl(promptGardenBaseUrl.value, { logPrefix: 'PromptGarden' })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -937,6 +937,7 @@ import { getI18nErrorMessage } from '../../utils/error'
|
||||
import { withHistorySourceBindingMetadata } from '../../utils/history-source-binding'
|
||||
import { resolveSourceAssetRef } from '../../utils/source-asset'
|
||||
import { downloadImageSource } from '../../utils/image-download'
|
||||
import { openExternalUrl } from '../../utils/open-external-url'
|
||||
import type { PromptGardenImportRequest } from '../../utils/prompt-garden-import'
|
||||
import {
|
||||
resolveReferencePromptPreview,
|
||||
@@ -1152,25 +1153,8 @@ const isPromptGardenGuideDisabled = computed(() =>
|
||||
isOptimizing.value || isIterating.value || isAnyVariantRunning.value || isExtractingFromImage.value,
|
||||
)
|
||||
|
||||
const openExternalUrl = async (url: string) => {
|
||||
if (!url) return
|
||||
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.shell) {
|
||||
try {
|
||||
await window.electronAPI.shell.openExternal(url)
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('[PromptGarden] Failed to open external URL in Electron:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePromptGardenDiscover = () => {
|
||||
void openExternalUrl(promptGardenBaseUrl.value)
|
||||
void openExternalUrl(promptGardenBaseUrl.value, { logPrefix: 'PromptGarden' })
|
||||
}
|
||||
|
||||
const handlePromptGardenImportConfirm = async (request: PromptGardenImportRequest) => {
|
||||
|
||||
@@ -28,6 +28,12 @@ import { getI18nErrorMessage } from '../../utils/error'
|
||||
import type { IteratePayload } from '../../types/workspace'
|
||||
import { withHistorySourceBindingMetadata } from '../../utils/history-source-binding'
|
||||
|
||||
const isHistoryRecordNotFoundError = (error: unknown): boolean => {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const code = 'code' in error ? (error as { code?: unknown }).code : undefined
|
||||
return code === 'error.history.record_not_found'
|
||||
}
|
||||
|
||||
type BasicSessionStore = {
|
||||
prompt: string
|
||||
optimizedPrompt: string
|
||||
@@ -547,6 +553,15 @@ export function useBasicWorkspaceLogic(options: UseBasicWorkspaceLogicOptions) {
|
||||
currentVersions.value = []
|
||||
currentChainId.value = ''
|
||||
currentVersionId.value = ''
|
||||
|
||||
if (isHistoryRecordNotFoundError(error)) {
|
||||
sessionStore.updateOptimizedResult({
|
||||
optimizedPrompt: sessionStore.optimizedPrompt || '',
|
||||
reasoning: sessionStore.reasoning || '',
|
||||
chainId: '',
|
||||
versionId: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,18 @@ const messages = {
|
||||
"copy": "Copy",
|
||||
"fullscreen": "Fullscreen"
|
||||
},
|
||||
"copyOpen": {
|
||||
"copyOnly": "Copy only",
|
||||
"copyAndOpen": "Copy and open {platform}",
|
||||
"selectAction": "Choose copy action",
|
||||
"openFailed": "Copied, but failed to open the platform",
|
||||
"platforms": {
|
||||
"chatgpt": "ChatGPT",
|
||||
"claude": "Claude",
|
||||
"gemini": "Gemini",
|
||||
"deepseek": "DeepSeek"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"dashboard": "Dashboard",
|
||||
|
||||
@@ -113,6 +113,18 @@ const messages = {
|
||||
"copy": "复制",
|
||||
"fullscreen": "全屏"
|
||||
},
|
||||
"copyOpen": {
|
||||
"copyOnly": "仅复制",
|
||||
"copyAndOpen": "复制并打开 {platform}",
|
||||
"selectAction": "选择复制动作",
|
||||
"openFailed": "已复制,但打开平台失败",
|
||||
"platforms": {
|
||||
"chatgpt": "ChatGPT",
|
||||
"claude": "Claude",
|
||||
"gemini": "Gemini",
|
||||
"deepseek": "DeepSeek"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"dashboard": "仪表盘",
|
||||
|
||||
@@ -113,6 +113,18 @@ const messages = {
|
||||
"copy": "複製",
|
||||
"fullscreen": "全螢幕"
|
||||
},
|
||||
"copyOpen": {
|
||||
"copyOnly": "僅複製",
|
||||
"copyAndOpen": "複製並開啟 {platform}",
|
||||
"selectAction": "選擇複製動作",
|
||||
"openFailed": "已複製,但開啟平台失敗",
|
||||
"platforms": {
|
||||
"chatgpt": "ChatGPT",
|
||||
"claude": "Claude",
|
||||
"gemini": "Gemini",
|
||||
"deepseek": "DeepSeek"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"home": "首頁",
|
||||
"dashboard": "儀表板",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
export type CopyOpenActionId = 'copy' | 'chatgpt' | 'claude' | 'gemini' | 'deepseek'
|
||||
|
||||
export interface CopyOpenAction {
|
||||
id: CopyOpenActionId
|
||||
platform?: 'ChatGPT' | 'Claude' | 'Gemini' | 'DeepSeek'
|
||||
homepage?: string
|
||||
}
|
||||
|
||||
const COPY_OPEN_ACTION_SESSION_KEY_PREFIX = 'prompt-optimizer:copy-action:'
|
||||
|
||||
export const COPY_OPEN_ACTIONS: CopyOpenAction[] = [
|
||||
{ id: 'copy' },
|
||||
{ id: 'chatgpt', platform: 'ChatGPT', homepage: 'https://chatgpt.com/' },
|
||||
{ id: 'claude', platform: 'Claude', homepage: 'https://claude.ai/new' },
|
||||
{ id: 'gemini', platform: 'Gemini', homepage: 'https://gemini.google.com/app' },
|
||||
{ id: 'deepseek', platform: 'DeepSeek', homepage: 'https://chat.deepseek.com/' },
|
||||
]
|
||||
|
||||
export const DEFAULT_COPY_OPEN_ACTION_ID: CopyOpenActionId = 'copy'
|
||||
|
||||
const COPY_OPEN_ACTION_IDS = new Set<CopyOpenActionId>(
|
||||
COPY_OPEN_ACTIONS.map((action) => action.id),
|
||||
)
|
||||
|
||||
export const isCopyOpenActionId = (value: unknown): value is CopyOpenActionId =>
|
||||
typeof value === 'string' && COPY_OPEN_ACTION_IDS.has(value as CopyOpenActionId)
|
||||
|
||||
export const getCopyOpenAction = (id: CopyOpenActionId): CopyOpenAction =>
|
||||
COPY_OPEN_ACTIONS.find((action) => action.id === id) ?? COPY_OPEN_ACTIONS[0]
|
||||
|
||||
export const getCopyOpenActionSessionKey = (workspacePath: string): string => {
|
||||
const normalizedPath = workspacePath.trim() || '/'
|
||||
return `${COPY_OPEN_ACTION_SESSION_KEY_PREFIX}${normalizedPath}`
|
||||
}
|
||||
|
||||
export const readCopyOpenActionFromSession = (workspacePath: string): CopyOpenActionId => {
|
||||
if (typeof window === 'undefined') return DEFAULT_COPY_OPEN_ACTION_ID
|
||||
|
||||
try {
|
||||
const stored = window.sessionStorage.getItem(getCopyOpenActionSessionKey(workspacePath))
|
||||
return isCopyOpenActionId(stored) ? stored : DEFAULT_COPY_OPEN_ACTION_ID
|
||||
} catch {
|
||||
return DEFAULT_COPY_OPEN_ACTION_ID
|
||||
}
|
||||
}
|
||||
|
||||
export const writeCopyOpenActionToSession = (
|
||||
workspacePath: string,
|
||||
actionId: CopyOpenActionId,
|
||||
): void => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(getCopyOpenActionSessionKey(workspacePath), actionId)
|
||||
} catch {
|
||||
// Session persistence is a convenience; the action can still run without it.
|
||||
}
|
||||
}
|
||||
|
||||
export const buildCopyOpenActionUrl = (actionId: CopyOpenActionId): string | null => {
|
||||
const action = getCopyOpenAction(actionId)
|
||||
if (action.id === 'copy') return null
|
||||
return action.homepage ?? null
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { h, type Component } from 'vue'
|
||||
import { BrandGoogle, Copy } from '@vicons/tabler'
|
||||
import type { CopyOpenActionId } from './copy-open-action'
|
||||
|
||||
const ChatGptMark = {
|
||||
name: 'ChatGptMark',
|
||||
render: () => h('svg', {
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '1.8',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
}, [
|
||||
h('path', { d: 'M12 3.4a3.3 3.3 0 0 1 3 1.8 3.35 3.35 0 0 1 4 4.1 3.3 3.3 0 0 1-1.7 3 3.35 3.35 0 0 1-.6 4.9 3.3 3.3 0 0 1-4.3.1 3.35 3.35 0 0 1-5 .5 3.3 3.3 0 0 1-1-4.1 3.35 3.35 0 0 1-1.8-4.8 3.3 3.3 0 0 1 3.8-1.5A3.35 3.35 0 0 1 12 3.4Z' }),
|
||||
h('path', { d: 'M8.5 7.1 15 10.8v7' }),
|
||||
h('path', { d: 'M15.5 5.9v7.3L9 16.9' }),
|
||||
h('path', { d: 'M18.4 11.8 12 15.4l-6.4-3.6' }),
|
||||
]),
|
||||
}
|
||||
|
||||
const ClaudeMark = {
|
||||
name: 'ClaudeMark',
|
||||
render: () => h('svg', {
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '1.8',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
}, [
|
||||
h('path', { d: 'm12 3 2.6 6.4L21 12l-6.4 2.6L12 21l-2.6-6.4L3 12l6.4-2.6L12 3Z' }),
|
||||
h('path', { d: 'm5.6 5.6 3.8 3.8' }),
|
||||
h('path', { d: 'm18.4 5.6-3.8 3.8' }),
|
||||
h('path', { d: 'm18.4 18.4-3.8-3.8' }),
|
||||
h('path', { d: 'm5.6 18.4 3.8-3.8' }),
|
||||
]),
|
||||
}
|
||||
|
||||
const DeepSeekMark = {
|
||||
name: 'DeepSeekMark',
|
||||
render: () => h('svg', {
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '1.8',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
}, [
|
||||
h('path', { d: 'M3.5 13.2c2.2-3.7 5.8-5.6 9.9-5.1 3.5.4 6.1 2.5 7.1 5.7-1.5-.9-3.1-1-4.5-.3-1.2.6-2 1.6-2.4 2.9-3.6.6-7.5-.5-10.1-3.2Z' }),
|
||||
h('path', { d: 'M3.6 13.4c1.5 3 4.8 5 8.7 5 2.7 0 5-.9 6.6-2.5' }),
|
||||
h('path', { d: 'M7.6 9.1c-.2-1.8-1.2-3.2-2.9-4.1 2.4-.2 4.2.7 5.4 2.6' }),
|
||||
h('path', { d: 'M9.7 8.2c.2-1.6 1.1-2.8 2.8-3.6-.1 1.7-.8 3-2.1 4' }),
|
||||
h('path', { d: 'M13.8 8.3c-.6 2-2.3 3.5-4.9 4.3' }),
|
||||
h('circle', { cx: '16.7', cy: '10.8', r: '0.8', fill: 'currentColor', stroke: 'none' }),
|
||||
]),
|
||||
}
|
||||
|
||||
export const copyOpenActionIconMap: Record<CopyOpenActionId, Component> = {
|
||||
copy: Copy,
|
||||
chatgpt: ChatGptMark,
|
||||
claude: ClaudeMark,
|
||||
gemini: BrandGoogle,
|
||||
deepseek: DeepSeekMark,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface OpenExternalUrlOptions {
|
||||
target?: string
|
||||
features?: string
|
||||
logPrefix?: string
|
||||
}
|
||||
|
||||
export const openExternalUrl = async (
|
||||
url: string,
|
||||
options: OpenExternalUrlOptions = {},
|
||||
): Promise<boolean> => {
|
||||
if (!url || typeof window === 'undefined') return false
|
||||
|
||||
const target = options.target ?? '_blank'
|
||||
const features = options.features ?? 'noopener,noreferrer'
|
||||
|
||||
if (window.electronAPI?.shell?.openExternal) {
|
||||
try {
|
||||
await window.electronAPI.shell.openExternal(url)
|
||||
return true
|
||||
} catch (error) {
|
||||
const prefix = options.logPrefix ?? 'openExternalUrl'
|
||||
console.error(`[${prefix}] Failed to open external URL in Electron:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
window.open(url, target, features)
|
||||
return true
|
||||
}
|
||||
@@ -286,4 +286,82 @@ describe('Basic workspace logic (smoke)', () => {
|
||||
expect(sessionStore.chainId).toBe('')
|
||||
expect(sessionStore.versionId).toBe('')
|
||||
})
|
||||
|
||||
it('clears stale chain metadata when loading versions for a missing history chain', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const sessionStore = reactive({
|
||||
prompt: 'original',
|
||||
optimizedPrompt: 'optimized',
|
||||
reasoning: 'reasoning',
|
||||
chainId: 'missing-chain',
|
||||
versionId: 'missing-version',
|
||||
testContent: 'input',
|
||||
selectedOptimizeModelKey: 'model-1',
|
||||
selectedTestModelKey: 'model-1',
|
||||
selectedTemplateId: 'template-1',
|
||||
selectedIterateTemplateId: null,
|
||||
isCompareMode: false,
|
||||
updatePrompt: (prompt: string) => {
|
||||
sessionStore.prompt = prompt
|
||||
},
|
||||
updateOptimizedResult: (payload: {
|
||||
optimizedPrompt: string
|
||||
reasoning?: string
|
||||
chainId: string
|
||||
versionId: string
|
||||
}) => {
|
||||
sessionStore.optimizedPrompt = payload.optimizedPrompt
|
||||
sessionStore.reasoning = payload.reasoning || ''
|
||||
sessionStore.chainId = payload.chainId
|
||||
sessionStore.versionId = payload.versionId
|
||||
},
|
||||
updateTestContent: (content: string) => {
|
||||
sessionStore.testContent = content
|
||||
},
|
||||
updateOptimizeModel: (key: string) => {
|
||||
sessionStore.selectedOptimizeModelKey = key
|
||||
},
|
||||
updateTestModel: (key: string) => {
|
||||
sessionStore.selectedTestModelKey = key
|
||||
},
|
||||
updateTemplate: (id: string | null) => {
|
||||
sessionStore.selectedTemplateId = id
|
||||
},
|
||||
updateIterateTemplate: (id: string | null) => {
|
||||
sessionStore.selectedIterateTemplateId = id
|
||||
}
|
||||
}) as any
|
||||
|
||||
const historyManager = {
|
||||
getChain: vi.fn(async () => {
|
||||
throw {
|
||||
code: 'error.history.record_not_found',
|
||||
message: 'Chain with ID missing-chain not found',
|
||||
}
|
||||
}),
|
||||
}
|
||||
const services = ref({
|
||||
historyManager,
|
||||
} as unknown as AppServices)
|
||||
|
||||
const logic = useBasicWorkspaceLogic({
|
||||
services,
|
||||
sessionStore,
|
||||
optimizationMode: 'system',
|
||||
promptRecordType: 'optimize'
|
||||
})
|
||||
|
||||
await logic.loadVersions()
|
||||
|
||||
expect(historyManager.getChain).toHaveBeenCalledWith('missing-chain')
|
||||
expect(sessionStore.optimizedPrompt).toBe('optimized')
|
||||
expect(sessionStore.reasoning).toBe('reasoning')
|
||||
expect(sessionStore.chainId).toBe('')
|
||||
expect(sessionStore.versionId).toBe('')
|
||||
expect(logic.currentChainId.value).toBe('')
|
||||
expect(logic.currentVersions.value).toEqual([])
|
||||
expect(logic.currentVersionId.value).toBe('')
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import OutputDisplayCore from '../../../src/components/OutputDisplayCore.vue'
|
||||
|
||||
const { copyTextMock, errorMock, currentRoute } = vi.hoisted(() => ({
|
||||
copyTextMock: vi.fn(),
|
||||
errorMock: vi.fn(),
|
||||
currentRoute: {
|
||||
value: {
|
||||
path: '/basic/system',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/router', () => ({
|
||||
router: {
|
||||
currentRoute,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/composables/ui/useClipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copyText: copyTextMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/composables/ui/useToast', () => ({
|
||||
useToast: () => ({
|
||||
error: errorMock,
|
||||
warning: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/composables/variable/useTemporaryVariables', () => ({
|
||||
useTemporaryVariables: () => ({
|
||||
temporaryVariables: [],
|
||||
setVariable: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/composables/variable/useVariableAwareInputBridge', () => ({
|
||||
useVariableAwareInputBridge: () => ({
|
||||
variableInputData: [],
|
||||
handleVariableExtracted: vi.fn(),
|
||||
handleAddMissingVariable: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/composables/prompt/useVariableManager', () => ({
|
||||
useVariableManager: () => ({
|
||||
isReady: true,
|
||||
customVariables: [],
|
||||
allVariables: [],
|
||||
addVariable: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const NButtonStub = defineComponent({
|
||||
name: 'NButton',
|
||||
props: {
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button v-bind="$attrs" :disabled="disabled" @click="$emit('click', $event)">
|
||||
<slot name="icon" />
|
||||
<slot />
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
|
||||
const NDropdownStub = defineComponent({
|
||||
name: 'NDropdown',
|
||||
props: {
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['select'],
|
||||
template: `
|
||||
<div>
|
||||
<slot />
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.key"
|
||||
:data-testid="'copy-action-option-' + option.key"
|
||||
@click="$emit('select', option.key)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
const passthroughStub = (name: string) => defineComponent({
|
||||
name,
|
||||
template: '<div><slot /><slot name="header" /><slot name="icon" /></div>',
|
||||
})
|
||||
|
||||
const mountOutputDisplayCore = (content = 'Optimized prompt') =>
|
||||
mount(OutputDisplayCore, {
|
||||
props: {
|
||||
content,
|
||||
mode: 'readonly',
|
||||
enabledActions: ['copy'],
|
||||
},
|
||||
global: {
|
||||
provide: {
|
||||
services: ref(null),
|
||||
},
|
||||
stubs: {
|
||||
NButton: NButtonStub,
|
||||
NDropdown: NDropdownStub,
|
||||
NButtonGroup: passthroughStub('NButtonGroup'),
|
||||
NCard: passthroughStub('NCard'),
|
||||
NFlex: passthroughStub('NFlex'),
|
||||
NIcon: passthroughStub('NIcon'),
|
||||
NCollapse: passthroughStub('NCollapse'),
|
||||
NCollapseItem: passthroughStub('NCollapseItem'),
|
||||
NScrollbar: passthroughStub('NScrollbar'),
|
||||
NText: passthroughStub('NText'),
|
||||
NSpace: passthroughStub('NSpace'),
|
||||
NEmpty: passthroughStub('NEmpty'),
|
||||
NInput: passthroughStub('NInput'),
|
||||
NSpin: passthroughStub('NSpin'),
|
||||
MarkdownRenderer: passthroughStub('MarkdownRenderer'),
|
||||
XmlRenderer: passthroughStub('XmlRenderer'),
|
||||
TextDiffUI: passthroughStub('TextDiffUI'),
|
||||
VariableAwareInput: passthroughStub('VariableAwareInput'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const selectCopyAction = async (
|
||||
wrapper: ReturnType<typeof mountOutputDisplayCore>,
|
||||
actionId: string,
|
||||
) => {
|
||||
const dropdownCandidates = [
|
||||
...wrapper.findAllComponents(NDropdownStub),
|
||||
...wrapper.findAllComponents({ name: 'NDropdown' }),
|
||||
...wrapper.findAllComponents({ name: 'Dropdown' }),
|
||||
]
|
||||
const dropdown = dropdownCandidates
|
||||
.find((component) => {
|
||||
const options = component.props('options') as Array<{ key: string }>
|
||||
return options.some((option) => option.key === actionId)
|
||||
})
|
||||
|
||||
expect(dropdown, wrapper.html()).toBeTruthy()
|
||||
dropdown?.vm.$emit('select', actionId)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('OutputDisplayCore copy and open action', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
let originalElectronAPI: unknown
|
||||
|
||||
beforeEach(() => {
|
||||
copyTextMock.mockResolvedValue(undefined)
|
||||
errorMock.mockClear()
|
||||
window.sessionStorage.clear()
|
||||
currentRoute.value = { path: '/basic/system' }
|
||||
originalElectronAPI = window.electronAPI
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
})
|
||||
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalElectronAPI,
|
||||
})
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('copies content by default without opening an AI platform', async () => {
|
||||
const wrapper = mountOutputDisplayCore('Hello platform')
|
||||
|
||||
await wrapper.find('[data-testid="output-copy-action"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(copyTextMock).toHaveBeenCalledWith('Hello platform')
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('copy')).toEqual([[expect.any(String), 'content']])
|
||||
})
|
||||
|
||||
it('stores Claude selection and immediately opens Claude after copying', async () => {
|
||||
const wrapper = mountOutputDisplayCore('Ask with this')
|
||||
|
||||
await selectCopyAction(wrapper, 'claude')
|
||||
await flushPromises()
|
||||
|
||||
expect(window.sessionStorage.getItem('prompt-optimizer:copy-action:/basic/system')).toBe('claude')
|
||||
expect(copyTextMock).toHaveBeenCalledWith('Ask with this')
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://claude.ai/new',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('stores copy selection and immediately copies without opening a platform', async () => {
|
||||
window.sessionStorage.setItem('prompt-optimizer:copy-action:/basic/system', 'claude')
|
||||
const wrapper = mountOutputDisplayCore('Copy only now')
|
||||
|
||||
await selectCopyAction(wrapper, 'copy')
|
||||
await flushPromises()
|
||||
|
||||
expect(window.sessionStorage.getItem('prompt-optimizer:copy-action:/basic/system')).toBe('copy')
|
||||
expect(copyTextMock).toHaveBeenCalledWith('Copy only now')
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the desktop shell bridge instead of window.open when available', async () => {
|
||||
const openExternalMock = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
shell: {
|
||||
openExternal: openExternalMock,
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mountOutputDisplayCore('Desktop route')
|
||||
|
||||
await selectCopyAction(wrapper, 'chatgpt')
|
||||
await flushPromises()
|
||||
|
||||
expect(openExternalMock).toHaveBeenCalledWith('https://chatgpt.com/')
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps copy action selections isolated by workspace path', async () => {
|
||||
const firstWrapper = mountOutputDisplayCore('Workspace one')
|
||||
|
||||
await selectCopyAction(firstWrapper, 'claude')
|
||||
firstWrapper.unmount()
|
||||
openSpy.mockClear()
|
||||
|
||||
currentRoute.value = { path: '/pro/multi' }
|
||||
const secondWrapper = mountOutputDisplayCore('Workspace two')
|
||||
|
||||
await secondWrapper.find('[data-testid="output-copy-action"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(window.sessionStorage.getItem('prompt-optimizer:copy-action:/basic/system')).toBe('claude')
|
||||
expect(window.sessionStorage.getItem('prompt-optimizer:copy-action:/pro/multi')).toBeNull()
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to copy when session storage contains an invalid action', async () => {
|
||||
window.sessionStorage.setItem('prompt-optimizer:copy-action:/basic/system', 'unknown-platform')
|
||||
const wrapper = mountOutputDisplayCore('Invalid storage')
|
||||
|
||||
await wrapper.find('[data-testid="output-copy-action"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(copyTextMock).toHaveBeenCalledWith('Invalid storage')
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the primary copy action when there is no content', () => {
|
||||
const wrapper = mountOutputDisplayCore('')
|
||||
|
||||
expect(wrapper.find('[data-testid="output-copy-action"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-testid="output-copy-action-menu"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -218,7 +218,7 @@ describe('WorkspaceUtilityMenu Prompt Garden actions', () => {
|
||||
|
||||
await wrapper.find('[data-dropdown-key="discover"]').trigger('click')
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith('https://garden.always200.com', '_blank')
|
||||
expect(window.open).toHaveBeenCalledWith('https://garden.always200.com', '_blank', 'noopener,noreferrer')
|
||||
})
|
||||
|
||||
it('applies pasted Garden import links through the current route query', async () => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { openExternalUrl } from '../../../src/utils/open-external-url'
|
||||
|
||||
describe('openExternalUrl', () => {
|
||||
let originalElectronAPI: unknown
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
originalElectronAPI = window.electronAPI
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
})
|
||||
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalElectronAPI,
|
||||
})
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('opens with the desktop shell bridge when available', async () => {
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { shell: { openExternal } },
|
||||
})
|
||||
|
||||
const result = await openExternalUrl('https://example.com')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(openExternal).toHaveBeenCalledWith('https://example.com')
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to window.open in web runtime', async () => {
|
||||
const result = await openExternalUrl('https://example.com')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to window.open when the desktop bridge fails', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const openExternal = vi.fn().mockRejectedValue(new Error('shell failed'))
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { shell: { openExternal } },
|
||||
})
|
||||
|
||||
const result = await openExternalUrl('https://example.com', { logPrefix: 'Test' })
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(openExternal).toHaveBeenCalledWith('https://example.com')
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Test] Failed to open external URL in Electron:',
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user