feat(prompt-garden): integrate import and favorite flow
Wire Prompt Garden imports into workspace and favorite flows, add the import dialog/panel affordances, persist Garden metadata/media into favorites, and document the external import contract.
This commit is contained in:
1 parent
98c85c64bc
commit
6d75528793
17 files changed
+499
-71
No files matched your search
@@ -6,7 +6,7 @@
|
||||
|
||||
- URL 只携带少量路由参数,由 Prompt Optimizer 自己去 Garden 拉完整内容
|
||||
- Prompt Optimizer 固定从 `VITE_PROMPT_GARDEN_BASE_URL` 拉取内容,不接受 URL 参数覆盖
|
||||
- Garden API 返回统一的 v1 schema,由 Prompt Optimizer 按子模式写入不同 session store
|
||||
- Garden API 返回统一的 v1 schema;普通导入会由 Prompt Optimizer 按子模式写入不同 session store
|
||||
- Garden 的扩展元数据和素材快照可以跟随收藏一起保存,便于后续预览和复用
|
||||
|
||||
## 1. Prompt Optimizer 侧:导入触发与 URL 参数
|
||||
@@ -21,19 +21,23 @@ Prompt Optimizer 在应用初始 session 恢复完成后检查当前路由 query
|
||||
|
||||
- `importCode`(必填)
|
||||
- 外部提示词的唯一标识,例如 `NB-001`
|
||||
- 可在导入码后追加示例选择后缀,例如 `NB-001@ex-2`
|
||||
- 追加后缀时,Prompt Optimizer 仍请求 `GET /api/prompt-source/NB-001`,并把 `ex-2` 当作本次导入的示例选择
|
||||
- `subModeKey`(可选)
|
||||
- 显式指定导入目标工作区
|
||||
- 若未提供,则优先使用 Garden 返回的 `optimizerTarget.subModeKey`
|
||||
- 若 Garden 返回值无效,则退回当前路由;当前路由也无效时默认落到 `basic-system`
|
||||
- `exampleId`(可选)
|
||||
- 指定使用哪一个示例
|
||||
- 若 `importCode` 同时带 `@exampleId` 后缀,显式 URL 参数 `exampleId` 优先
|
||||
- 若未提供,则默认使用 `assets.examples[0]`
|
||||
- 仅用于示例参数和 image2image 输入图回填,不改变 prompt 主体
|
||||
- `saveToFavorites`(可选)
|
||||
- 控制导入后是否联动收藏
|
||||
- 控制本次导入是否进入收藏流程
|
||||
- `1` / `true` / `auto` -> 自动保存到收藏
|
||||
- `confirm` / `dialog` / `manual` -> 打开“保存收藏”对话框,并带预填数据
|
||||
- 其它值或省略 -> 不触发收藏保存
|
||||
- 只要启用 `saveToFavorites`,本次导入只处理收藏,不写入或覆盖当前工作区
|
||||
- 其它值或省略 -> 作为“使用”导入,写入目标工作区,不触发收藏保存
|
||||
|
||||
### 1.2 `subModeKey` 支持值
|
||||
|
||||
@@ -52,7 +56,10 @@ Prompt Optimizer 在应用初始 session 恢复完成后检查当前路由 query
|
||||
- 导入到 `pro-multi`,并显式指定示例:
|
||||
- `https://prompt.example.com/#/pro/multi?importCode=NB-001&exampleId=ex-2`
|
||||
|
||||
- 导入到 `image-image2image`,并在导入后弹出保存收藏对话框:
|
||||
- 导入到 `pro-multi`,并通过导入码后缀指定示例:
|
||||
- `https://prompt.example.com/#/pro/multi?importCode=NB-001@ex-2`
|
||||
|
||||
- 从 `image-image2image` 类型的 Garden 提示词导入为收藏,并弹出保存收藏对话框:
|
||||
- `https://prompt.example.com/#/image/image2image?importCode=NB-001&saveToFavorites=confirm`
|
||||
|
||||
- 若希望 query 明确覆盖目标工作区:
|
||||
@@ -61,7 +68,8 @@ Prompt Optimizer 在应用初始 session 恢复完成后检查当前路由 query
|
||||
说明:
|
||||
|
||||
- 推荐 Garden 直接打开目标工作区路由,而不是总是打开根路由再依赖 `subModeKey`
|
||||
- `subModeKey` 只用于决定写入哪个工作区,不决定 API 返回结构
|
||||
- 不带 `saveToFavorites` 时,`subModeKey` 用于决定写入哪个工作区
|
||||
- 带 `saveToFavorites` 时,`subModeKey` 只用于收藏的模式预填充,不会触发工作区写入
|
||||
|
||||
## 2. Prompt Garden 侧:必须提供的 API
|
||||
|
||||
@@ -393,8 +401,9 @@ Prompt Optimizer 会调用:
|
||||
当 `saveToFavorites=1|true|auto` 时:
|
||||
|
||||
- Prompt Optimizer 会尝试自动保存到收藏
|
||||
- 收藏内容来自导入后的 prompt
|
||||
- 收藏内容来自 Garden 返回的 prompt
|
||||
- `meta` 和 `assets` 会作为 `gardenSnapshot` 一起写入收藏 metadata
|
||||
- 不会写入或覆盖当前工作区
|
||||
|
||||
### 8.2 确认保存
|
||||
|
||||
@@ -402,6 +411,7 @@ Prompt Optimizer 会调用:
|
||||
|
||||
- Prompt Optimizer 会打开“保存收藏”对话框
|
||||
- 自动带入标题、描述、标签、分类、模式信息和 `gardenSnapshot`
|
||||
- 不会写入或覆盖当前工作区
|
||||
|
||||
### 8.3 去重与更新规则
|
||||
|
||||
@@ -417,7 +427,7 @@ Garden 联动保存收藏时,不按收藏内容去重,而按下面的组合
|
||||
|
||||
## 9. 工作区写入时会被重置的状态
|
||||
|
||||
导入成功后,Prompt Optimizer 不只是更新 prompt,还会清理与旧工作区状态绑定的内容。
|
||||
当 URL 不带 `saveToFavorites` 时,导入成功后 Prompt Optimizer 不只是更新 prompt,还会清理与旧工作区状态绑定的内容。
|
||||
|
||||
例如:
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './errors';
|
||||
import { FAVORITE_ERROR_CODES } from '../../constants/error-codes'
|
||||
import { toErrorWithCode } from '../../utils/error'
|
||||
import { safeSerializeArgs } from '../../utils/ipc-serialization'
|
||||
|
||||
declare const window: {
|
||||
electronAPI: {
|
||||
@@ -39,7 +40,8 @@ export class FavoriteManagerElectronProxy implements IFavoriteManager {
|
||||
private async invokeMethod<T>(method: string, ...args: any[]): Promise<T> {
|
||||
this.ensureApiAvailable();
|
||||
try {
|
||||
return await (window.electronAPI.favoriteManager as any)[method](...args);
|
||||
const safeArgs = safeSerializeArgs(...args);
|
||||
return await (window.electronAPI.favoriteManager as any)[method](...safeArgs);
|
||||
} catch (error: any) {
|
||||
// New i18n-style structured errors: pass through as-is so UI can translate via `code + params`.
|
||||
if (typeof error?.code === 'string' && error.code.startsWith('error.')) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FavoriteManagerElectronProxy } from '../../../src/services/favorite/electron-proxy'
|
||||
|
||||
describe('FavoriteManagerElectronProxy', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
delete (globalThis as typeof globalThis & { window?: unknown }).window
|
||||
})
|
||||
|
||||
it('serializes favorite payloads before sending them over Electron IPC', async () => {
|
||||
const addFavorite = vi.fn().mockResolvedValue('favorite-1')
|
||||
;(globalThis as typeof globalThis & { window?: unknown }).window = {
|
||||
electronAPI: {
|
||||
favoriteManager: {
|
||||
addFavorite,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const nonCloneableMetadata = {
|
||||
media: {
|
||||
coverUrl: 'data:image/png;base64,abc',
|
||||
assetIds: ['asset-1'],
|
||||
},
|
||||
droppedCallback: () => 'not cloneable',
|
||||
}
|
||||
const proxiedMetadata = new Proxy(nonCloneableMetadata, {})
|
||||
|
||||
const manager = new FavoriteManagerElectronProxy()
|
||||
const id = await manager.addFavorite({
|
||||
title: 'Garden image prompt',
|
||||
content: 'A quiet botanical workspace',
|
||||
tags: ['garden'],
|
||||
functionMode: 'image',
|
||||
imageSubMode: 'text2image',
|
||||
metadata: proxiedMetadata,
|
||||
})
|
||||
|
||||
expect(id).toBe('favorite-1')
|
||||
expect(addFavorite).toHaveBeenCalledOnce()
|
||||
expect(addFavorite).toHaveBeenCalledWith({
|
||||
title: 'Garden image prompt',
|
||||
content: 'A quiet botanical workspace',
|
||||
tags: ['garden'],
|
||||
functionMode: 'image',
|
||||
imageSubMode: 'text2image',
|
||||
metadata: {
|
||||
media: {
|
||||
coverUrl: 'data:image/png;base64,abc',
|
||||
assetIds: ['asset-1'],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,9 @@
|
||||
<NSpace :size="16" align="center" wrap>
|
||||
<NRadio value="file">{{ t('favorites.manager.importDialog.sourceFile') }}</NRadio>
|
||||
<NRadio value="paste">{{ t('favorites.manager.importDialog.sourcePaste') }}</NRadio>
|
||||
<NRadio v-if="isPromptGardenEnabled" value="garden">
|
||||
{{ t('favorites.manager.importDialog.sourceGarden') }}
|
||||
</NRadio>
|
||||
</NSpace>
|
||||
</NRadioGroup>
|
||||
|
||||
@@ -65,6 +68,32 @@
|
||||
</NUpload>
|
||||
</div>
|
||||
|
||||
<div v-else-if="source === 'garden'" class="favorite-import-panel__garden">
|
||||
<div class="favorite-import-panel__garden-guide">
|
||||
<NIcon size="22">
|
||||
<Plant2 />
|
||||
</NIcon>
|
||||
<div class="favorite-import-panel__garden-copy">
|
||||
<NText strong>{{ t('favorites.manager.importDialog.gardenTitle') }}</NText>
|
||||
<NText depth="3">{{ t('favorites.manager.importDialog.gardenHint') }}</NText>
|
||||
</div>
|
||||
<NButton secondary size="small" @click="handlePromptGardenDiscover">
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<ExternalLink />
|
||||
</NIcon>
|
||||
</template>
|
||||
{{ t('favorites.manager.importDialog.gardenDiscover') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<NInput
|
||||
v-model:value="gardenImportInput"
|
||||
:placeholder="t('favorites.manager.importDialog.gardenPlaceholder')"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NInput
|
||||
v-else
|
||||
v-model:value="rawJson"
|
||||
@@ -76,6 +105,7 @@
|
||||
</NCard>
|
||||
|
||||
<NCard
|
||||
v-if="source !== 'garden'"
|
||||
size="small"
|
||||
:title="t('favorites.manager.importDialog.mergeStrategy')"
|
||||
:segmented="{ content: true }"
|
||||
@@ -119,6 +149,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, type Ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
NButton,
|
||||
@@ -135,12 +166,14 @@ import {
|
||||
NUploadDragger,
|
||||
type UploadFileInfo,
|
||||
} from 'naive-ui'
|
||||
import { Upload } from '@vicons/tabler'
|
||||
import { ExternalLink, Plant2, Upload } from '@vicons/tabler'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { getEnvVar } from '@prompt-optimizer/core'
|
||||
import { useToast } from '../composables/ui/useToast'
|
||||
import type { AppServices } from '../types/services'
|
||||
import { getI18nErrorMessage } from '../utils/error'
|
||||
import { normalizePromptGardenImportCode } from '../utils/prompt-garden-import'
|
||||
import {
|
||||
importFavoriteResourcePackage,
|
||||
looksLikeFavoriteZipPackage,
|
||||
@@ -148,6 +181,7 @@ import {
|
||||
} from '../utils/favorite-resource-package'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const message = useToast()
|
||||
const services = inject<Ref<AppServices | null> | null>('services', null)
|
||||
|
||||
@@ -156,13 +190,24 @@ const emit = defineEmits<{
|
||||
'imported': []
|
||||
}>()
|
||||
|
||||
const source = ref<'file' | 'paste'>('file')
|
||||
const source = ref<'file' | 'paste' | 'garden'>('file')
|
||||
const rawJson = ref('')
|
||||
const gardenImportInput = ref('')
|
||||
const mergeStrategy = ref<'skip' | 'overwrite' | 'merge'>('skip')
|
||||
const fileList = ref<UploadFileInfo[]>([])
|
||||
const importing = ref(false)
|
||||
|
||||
const selectedFile = computed(() => fileList.value[0] || null)
|
||||
const normalizedGardenImportCode = computed(() => normalizePromptGardenImportCode(gardenImportInput.value))
|
||||
|
||||
const isPromptGardenEnabled = computed(() => {
|
||||
const value = getEnvVar('VITE_ENABLE_PROMPT_GARDEN_IMPORT').trim().toLowerCase()
|
||||
return value === '1' || value === 'true'
|
||||
})
|
||||
|
||||
const promptGardenBaseUrl = computed(() => {
|
||||
return getEnvVar('VITE_PROMPT_GARDEN_BASE_URL').trim().replace(/\/$/, '')
|
||||
})
|
||||
|
||||
const mergeStrategyHint = computed(() => {
|
||||
if (mergeStrategy.value === 'overwrite') {
|
||||
@@ -184,6 +229,27 @@ 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)
|
||||
}
|
||||
|
||||
const readFileAsArrayBuffer = (file: File) =>
|
||||
new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
@@ -235,6 +301,33 @@ const buildPackageImportWarning = (result: FavoriteResourcePackageImportResult):
|
||||
}
|
||||
|
||||
const handleImportConfirm = async () => {
|
||||
if (source.value === 'garden') {
|
||||
const importCode = normalizedGardenImportCode.value
|
||||
if (!importCode) {
|
||||
message.warning(t('favorites.manager.importDialog.gardenCodeRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const currentRoute = router.currentRoute.value
|
||||
await router.push({
|
||||
path: currentRoute.path,
|
||||
query: {
|
||||
...currentRoute.query,
|
||||
importCode,
|
||||
saveToFavorites: 'confirm',
|
||||
},
|
||||
})
|
||||
emit('cancel')
|
||||
} catch (error) {
|
||||
message.error(buildErrorMessage(t('favorites.manager.importDialog.importFailed'), error))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const servicesValue = services?.value
|
||||
if (!servicesValue?.favoriteManager) {
|
||||
message.warning(t('favorites.manager.messages.unavailable'))
|
||||
@@ -330,10 +423,33 @@ const handleImportConfirm = async () => {
|
||||
}
|
||||
|
||||
.favorite-import-panel__section,
|
||||
.favorite-import-panel__garden,
|
||||
.favorite-import-panel__strategy-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.favorite-import-panel__garden {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.favorite-import-panel__garden-guide {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--n-border-color);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--n-success-color) 5%, transparent);
|
||||
}
|
||||
|
||||
.favorite-import-panel__garden-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.favorite-import-panel__upload {
|
||||
padding: 18px 12px;
|
||||
}
|
||||
@@ -376,5 +492,14 @@ const handleImportConfirm = async () => {
|
||||
.favorite-import-panel__actions {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.favorite-import-panel__garden-guide {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.favorite-import-panel__garden-guide .n-button {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +2,8 @@
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="dialog"
|
||||
:title="t('common.promptGarden.importTitle')"
|
||||
:positive-text="t('common.import')"
|
||||
:title="title || t('common.promptGarden.importTitle')"
|
||||
:positive-text="positiveText || t('common.import')"
|
||||
:negative-text="t('common.cancel')"
|
||||
:positive-button-props="{ disabled: !normalizedImportCode }"
|
||||
:show-icon="false"
|
||||
@@ -15,7 +15,7 @@
|
||||
>
|
||||
<div class="prompt-garden-import-dialog">
|
||||
<p class="prompt-garden-import-hint">
|
||||
{{ t('common.promptGarden.importHint') }}
|
||||
{{ hint || t('common.promptGarden.importHint') }}
|
||||
</p>
|
||||
<NInput
|
||||
v-model:value="inputValue"
|
||||
@@ -33,9 +33,13 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { NInput, NModal } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { normalizePromptGardenImportCode } from '../../utils/prompt-garden-import'
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
title?: string
|
||||
hint?: string
|
||||
positiveText?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -46,27 +50,6 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const inputValue = ref('')
|
||||
|
||||
const normalizePromptGardenImportCode = (value: string): string => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
const directImportCode = url.searchParams.get('importCode')?.trim()
|
||||
if (directImportCode) return directImportCode
|
||||
|
||||
const hashQueryIndex = url.hash.indexOf('?')
|
||||
if (hashQueryIndex >= 0) {
|
||||
const hashQuery = new URLSearchParams(url.hash.slice(hashQueryIndex + 1))
|
||||
return hashQuery.get('importCode')?.trim() || trimmed
|
||||
}
|
||||
} catch {
|
||||
// Plain import codes are expected; non-URL values are used as-is.
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const normalizedImportCode = computed(() =>
|
||||
normalizePromptGardenImportCode(inputValue.value)
|
||||
)
|
||||
|
||||
@@ -97,6 +97,8 @@
|
||||
|
||||
<PromptGardenImportDialog
|
||||
v-model:show="showPromptGardenImport"
|
||||
:title="promptGardenImportDialogTitle"
|
||||
:hint="promptGardenImportDialogHint"
|
||||
@confirm="handleConfirmPromptGardenImport"
|
||||
/>
|
||||
</template>
|
||||
@@ -104,8 +106,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, inject, nextTick, onMounted, onUnmounted, ref, type CSSProperties } from 'vue'
|
||||
import { routerKey } from 'vue-router'
|
||||
import type { LocationQueryRaw } from 'vue-router'
|
||||
import { NButton, NDropdown, NIcon, NModal, type DropdownOption } from 'naive-ui'
|
||||
import { ClearAll, DotsVertical, ExternalLink, Plant2, FileImport } from '@vicons/tabler'
|
||||
import { Bookmark, ClearAll, DotsVertical, ExternalLink, Plant2, FileImport } from '@vicons/tabler'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getEnvVar } from '@prompt-optimizer/core'
|
||||
import SourceAssetBadge from '../source/SourceAssetBadge.vue'
|
||||
@@ -131,6 +134,7 @@ const { t } = useI18n()
|
||||
const router = inject(routerKey, null)
|
||||
const showClearConfirm = ref(false)
|
||||
const showPromptGardenImport = ref(false)
|
||||
const promptGardenImportIntent = ref<'use' | 'favorite'>('use')
|
||||
const triggerStyle = ref<CSSProperties>({})
|
||||
let placementResizeObserver: ResizeObserver | null = null
|
||||
|
||||
@@ -221,8 +225,25 @@ const gardenMenuOptions = computed<DropdownOption[]>(() => [
|
||||
label: t('common.promptGarden.importPrompt'),
|
||||
icon: () => h(NIcon, null, { default: () => h(FileImport) }),
|
||||
},
|
||||
{
|
||||
key: 'import-favorite',
|
||||
label: t('common.promptGarden.importFavorite'),
|
||||
icon: () => h(NIcon, null, { default: () => h(Bookmark) }),
|
||||
},
|
||||
])
|
||||
|
||||
const promptGardenImportDialogTitle = computed(() =>
|
||||
promptGardenImportIntent.value === 'favorite'
|
||||
? t('common.promptGarden.importFavoriteTitle')
|
||||
: t('common.promptGarden.importTitle'),
|
||||
)
|
||||
|
||||
const promptGardenImportDialogHint = computed(() =>
|
||||
promptGardenImportIntent.value === 'favorite'
|
||||
? t('common.promptGarden.importFavoriteHint')
|
||||
: t('common.promptGarden.importHint'),
|
||||
)
|
||||
|
||||
const handleSelect = (key: string) => {
|
||||
if (key === 'clear-content') {
|
||||
showClearConfirm.value = true
|
||||
@@ -236,6 +257,12 @@ const handleGardenSelect = (key: string) => {
|
||||
}
|
||||
|
||||
if (key === 'import-code') {
|
||||
promptGardenImportIntent.value = 'use'
|
||||
showPromptGardenImport.value = true
|
||||
}
|
||||
|
||||
if (key === 'import-favorite') {
|
||||
promptGardenImportIntent.value = 'favorite'
|
||||
showPromptGardenImport.value = true
|
||||
}
|
||||
}
|
||||
@@ -248,12 +275,20 @@ const handleConfirmPromptGardenImport = async (importCode: string) => {
|
||||
if (!importCode || !router) return false
|
||||
|
||||
const currentRoute = router.currentRoute.value
|
||||
const query: LocationQueryRaw = {
|
||||
...currentRoute.query,
|
||||
importCode,
|
||||
}
|
||||
|
||||
if (promptGardenImportIntent.value === 'favorite') {
|
||||
query.saveToFavorites = 'confirm'
|
||||
} else {
|
||||
delete query.saveToFavorites
|
||||
}
|
||||
|
||||
await router.push({
|
||||
path: currentRoute.path,
|
||||
query: {
|
||||
...currentRoute.query,
|
||||
importCode,
|
||||
},
|
||||
query,
|
||||
})
|
||||
|
||||
showPromptGardenImport.value = false
|
||||
|
||||
@@ -60,6 +60,31 @@ const getQueryString = (query: LocationQuery, key: string): string | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
const parseImportCodeSelector = (value: string): { importCode: string; exampleId: string | null } => {
|
||||
const trimmed = value.trim()
|
||||
const separatorIndex = trimmed.lastIndexOf('@')
|
||||
if (separatorIndex <= 0 || separatorIndex === trimmed.length - 1) {
|
||||
return {
|
||||
importCode: trimmed,
|
||||
exampleId: null,
|
||||
}
|
||||
}
|
||||
|
||||
const importCode = trimmed.slice(0, separatorIndex).trim()
|
||||
const exampleId = trimmed.slice(separatorIndex + 1).trim()
|
||||
if (!importCode || !exampleId) {
|
||||
return {
|
||||
importCode: trimmed,
|
||||
exampleId: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
importCode,
|
||||
exampleId,
|
||||
}
|
||||
}
|
||||
|
||||
const omitKeys = (query: LocationQuery, keys: string[]): LocationQuery => {
|
||||
const next: Record<string, unknown> = { ...query }
|
||||
for (const k of keys) {
|
||||
@@ -1053,10 +1078,13 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions)
|
||||
const currentRoute = router.currentRoute.value
|
||||
const query = currentRoute.query
|
||||
|
||||
const importCode = getQueryString(query, 'importCode')
|
||||
const rawImportCode = getQueryString(query, 'importCode')
|
||||
const parsedImportCode = rawImportCode ? parseImportCodeSelector(rawImportCode) : null
|
||||
const importCode = parsedImportCode?.importCode
|
||||
if (!importCode) return
|
||||
|
||||
const exampleId = getQueryString(query, 'exampleId')
|
||||
const explicitExampleId = getQueryString(query, 'exampleId')?.trim() || null
|
||||
const exampleId = explicitExampleId ?? parsedImportCode.exampleId
|
||||
const saveToFavoritesMode = parseSaveToFavoritesMode(getQueryString(query, 'saveToFavorites'))
|
||||
|
||||
inFlight.value = true
|
||||
@@ -1071,6 +1099,76 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions)
|
||||
|
||||
const targetKey = resolveTargetKey(query, currentRoute.path, fetched.optimizerTargetKey)
|
||||
|
||||
if (saveToFavoritesMode !== 'none') {
|
||||
if (saveToFavoritesMode === 'auto') {
|
||||
const favoriteManager = getFavoriteManager?.() || null
|
||||
const imageStorageService =
|
||||
getFavoriteImageStorageService?.() || getImageStorageService?.() || null
|
||||
if (favoriteManager) {
|
||||
try {
|
||||
await saveImportedPromptToFavorites({
|
||||
manager: favoriteManager,
|
||||
imageStorageService,
|
||||
fetched,
|
||||
targetKey,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.warning(String(i18n.global.t('toast.warning.promptGardenFavoriteSaveFailed')))
|
||||
}
|
||||
} else {
|
||||
console.warn('[PromptGardenImport] Favorite manager unavailable, skip auto-save')
|
||||
}
|
||||
} else {
|
||||
const content = buildFavoriteContentFromFetchedPrompt(fetched)
|
||||
if (!content) {
|
||||
console.warn('[PromptGardenImport] Skip favorite dialog: imported content is empty')
|
||||
} else if (!openSaveFavoriteDialog) {
|
||||
console.warn('[PromptGardenImport] Favorite dialog callback unavailable, skip confirm flow')
|
||||
} else {
|
||||
const modeMapping = toFavoriteModeMapping(targetKey)
|
||||
const imageStorageService =
|
||||
getFavoriteImageStorageService?.() || getImageStorageService?.() || null
|
||||
|
||||
let snapshot = fetched.gardenSnapshot
|
||||
try {
|
||||
snapshot = await buildStorableGardenSnapshot(snapshot, imageStorageService, {
|
||||
allowImageFallback: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.info('[PromptGardenImport] Failed to persist snapshot assets for favorite dialog:', error)
|
||||
}
|
||||
|
||||
const media = buildFavoriteMediaFromSnapshot(snapshot)
|
||||
const metadata: Record<string, unknown> = {
|
||||
gardenSnapshot: snapshot,
|
||||
...(media ? { media } : {}),
|
||||
}
|
||||
|
||||
openSaveFavoriteDialog({
|
||||
content,
|
||||
originalContent: content,
|
||||
prefill: {
|
||||
title: deriveFavoriteTitle(fetched),
|
||||
description: deriveFavoriteDescription(fetched),
|
||||
category: deriveFavoriteCategory(fetched),
|
||||
tags: deriveFavoriteTags(fetched),
|
||||
functionMode: modeMapping.functionMode,
|
||||
optimizationMode: modeMapping.optimizationMode,
|
||||
imageSubMode: modeMapping.imageSubMode,
|
||||
metadata,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const cleanedQuery = omitKeys(query, ['importCode', 'subModeKey', 'exampleId', 'saveToFavorites'])
|
||||
await router.replace({ path: router.currentRoute.value.path, query: cleanedQuery })
|
||||
await nextTick()
|
||||
|
||||
toast.success(String(i18n.global.t('toast.success.promptGardenImportSuccess')))
|
||||
return
|
||||
}
|
||||
|
||||
// If caller opened the wrong workspace, navigate first.
|
||||
const targetPath = keyToPath(targetKey)
|
||||
if (router.currentRoute.value.path !== targetPath) {
|
||||
|
||||
@@ -41,8 +41,11 @@ const messages = {
|
||||
"title": "Prompt Garden",
|
||||
"discover": "Discover Garden Prompts",
|
||||
"importPrompt": "Import Garden Prompt",
|
||||
"importFavorite": "Import as Favorite",
|
||||
"importTitle": "Import Prompt Garden Prompt",
|
||||
"importHint": "Paste the import code copied from Prompt Garden, or paste a link that contains importCode.",
|
||||
"importFavoriteTitle": "Import Prompt Garden Favorite",
|
||||
"importFavoriteHint": "Paste a Prompt Garden import code or link. A save-favorite confirmation will open after import.",
|
||||
"importPlaceholder": "Enter import code",
|
||||
"text2ImageGuideTitle": "Discover text-to-image prompts in Prompt Garden",
|
||||
"text2ImageGuideHint": "When you need a starting point, browse Garden prompts or paste an import code into this workspace.",
|
||||
|
||||
@@ -64,15 +64,21 @@ const messages = {
|
||||
"emptyDescription": "No favorites yet",
|
||||
"importDialog": {
|
||||
"title": "Import Favorites",
|
||||
"sourceLabel": "Data Source",
|
||||
"sourceFile": "Upload File",
|
||||
"sourcePaste": "Paste JSON",
|
||||
"selectFile": "Select package or JSON file",
|
||||
"uploadHint": "Click or drag file to this area",
|
||||
"supportFormat": "Supports .po-favorites.zip packages and legacy .json files",
|
||||
"orPasteJson": "Or paste exported favorites JSON",
|
||||
"pastePlaceholder": "Paste favorites data...",
|
||||
"mergeStrategy": "Merge Strategy",
|
||||
"sourceLabel": "Data Source",
|
||||
"sourceFile": "Upload File",
|
||||
"sourcePaste": "Paste JSON",
|
||||
"sourceGarden": "Prompt Garden",
|
||||
"selectFile": "Select package or JSON file",
|
||||
"uploadHint": "Click or drag file to this area",
|
||||
"supportFormat": "Supports .po-favorites.zip packages and legacy .json files",
|
||||
"orPasteJson": "Or paste exported favorites JSON",
|
||||
"pastePlaceholder": "Paste favorites data...",
|
||||
"gardenTitle": "Import from Prompt Garden",
|
||||
"gardenHint": "Browse Prompt Garden for text-to-image prompts, then paste an import code or importCode link to save it as a favorite.",
|
||||
"gardenDiscover": "Discover Garden",
|
||||
"gardenPlaceholder": "Enter an import code, e.g. ZH-NB-001@ex-001",
|
||||
"gardenCodeRequired": "Paste a Prompt Garden import code first",
|
||||
"mergeStrategy": "Merge Strategy",
|
||||
"skipDuplicate": "Skip Duplicates",
|
||||
"overwriteDuplicate": "Overwrite Duplicates",
|
||||
"createCopy": "Create Copy",
|
||||
|
||||
@@ -39,8 +39,11 @@ const messages = {
|
||||
"title": "Prompt Garden",
|
||||
"discover": "发现花园提示词",
|
||||
"importPrompt": "导入花园提示词",
|
||||
"importFavorite": "导入为收藏",
|
||||
"importTitle": "导入 Prompt Garden 提示词",
|
||||
"importHint": "粘贴从 Prompt Garden 复制的导入码,也可以粘贴包含 importCode 的链接。",
|
||||
"importFavoriteTitle": "从 Prompt Garden 导入收藏",
|
||||
"importFavoriteHint": "粘贴 Prompt Garden 导入码或链接,导入后会打开收藏保存确认。",
|
||||
"importPlaceholder": "输入导入码",
|
||||
"text2ImageGuideTitle": "从 Prompt Garden 发现文生图提示词",
|
||||
"text2ImageGuideHint": "没有灵感时,可以先去花园挑一个提示词,或粘贴导入码直接带入当前工作区。",
|
||||
|
||||
@@ -64,15 +64,21 @@ const messages = {
|
||||
"emptyDescription": "还没有收藏任何提示词",
|
||||
"importDialog": {
|
||||
"title": "导入收藏",
|
||||
"sourceLabel": "数据来源",
|
||||
"sourceFile": "上传文件",
|
||||
"sourcePaste": "粘贴 JSON",
|
||||
"selectFile": "选择资源包或 JSON 文件",
|
||||
"uploadHint": "点击或拖拽文件到此区域",
|
||||
"supportFormat": "支持 .po-favorites.zip 资源包和历史 .json 文件",
|
||||
"orPasteJson": "或粘贴导出的收藏 JSON",
|
||||
"pastePlaceholder": "粘贴收藏数据...",
|
||||
"mergeStrategy": "合并策略",
|
||||
"sourceLabel": "数据来源",
|
||||
"sourceFile": "上传文件",
|
||||
"sourcePaste": "粘贴 JSON",
|
||||
"sourceGarden": "Prompt Garden",
|
||||
"selectFile": "选择资源包或 JSON 文件",
|
||||
"uploadHint": "点击或拖拽文件到此区域",
|
||||
"supportFormat": "支持 .po-favorites.zip 资源包和历史 .json 文件",
|
||||
"orPasteJson": "或粘贴导出的收藏 JSON",
|
||||
"pastePlaceholder": "粘贴收藏数据...",
|
||||
"gardenTitle": "从 Prompt Garden 导入收藏",
|
||||
"gardenHint": "可以先去 Garden 发现文生图提示词,再粘贴导入码或包含 importCode 的链接保存到收藏。",
|
||||
"gardenDiscover": "去 Garden 发现",
|
||||
"gardenPlaceholder": "输入导入码,例如 ZH-NB-001@ex-001",
|
||||
"gardenCodeRequired": "请先粘贴 Prompt Garden 导入码",
|
||||
"mergeStrategy": "合并策略",
|
||||
"skipDuplicate": "跳过重复",
|
||||
"overwriteDuplicate": "覆盖重复",
|
||||
"createCopy": "创建副本",
|
||||
|
||||
@@ -39,8 +39,11 @@ const messages = {
|
||||
"title": "Prompt Garden",
|
||||
"discover": "發現花園提示詞",
|
||||
"importPrompt": "匯入花園提示詞",
|
||||
"importFavorite": "匯入為收藏",
|
||||
"importTitle": "匯入 Prompt Garden 提示詞",
|
||||
"importHint": "貼上從 Prompt Garden 複製的匯入碼,也可以貼上包含 importCode 的連結。",
|
||||
"importFavoriteTitle": "從 Prompt Garden 匯入收藏",
|
||||
"importFavoriteHint": "貼上 Prompt Garden 匯入碼或連結,匯入後會開啟收藏儲存確認。",
|
||||
"importPlaceholder": "輸入匯入碼",
|
||||
"text2ImageGuideTitle": "從 Prompt Garden 發現文生圖提示詞",
|
||||
"text2ImageGuideHint": "沒有靈感時,可以先去花園挑一個提示詞,或貼上匯入碼直接帶入目前工作區。",
|
||||
|
||||
@@ -64,15 +64,21 @@ const messages = {
|
||||
"emptyDescription": "還沒有收藏任何提示詞",
|
||||
"importDialog": {
|
||||
"title": "匯入收藏",
|
||||
"sourceLabel": "資料來源",
|
||||
"sourceFile": "上傳檔案",
|
||||
"sourcePaste": "貼上 JSON",
|
||||
"selectFile": "選擇資源包或 JSON 檔案",
|
||||
"uploadHint": "點選或拖曳檔案到此區域",
|
||||
"supportFormat": "支援 .po-favorites.zip 資源包與歷史 .json 檔案",
|
||||
"orPasteJson": "或貼上匯出的收藏 JSON",
|
||||
"pastePlaceholder": "貼上收藏資料...",
|
||||
"mergeStrategy": "合併策略",
|
||||
"sourceLabel": "資料來源",
|
||||
"sourceFile": "上傳檔案",
|
||||
"sourcePaste": "貼上 JSON",
|
||||
"sourceGarden": "Prompt Garden",
|
||||
"selectFile": "選擇資源包或 JSON 檔案",
|
||||
"uploadHint": "點選或拖曳檔案到此區域",
|
||||
"supportFormat": "支援 .po-favorites.zip 資源包與歷史 .json 檔案",
|
||||
"orPasteJson": "或貼上匯出的收藏 JSON",
|
||||
"pastePlaceholder": "貼上收藏資料...",
|
||||
"gardenTitle": "從 Prompt Garden 匯入收藏",
|
||||
"gardenHint": "可以先去 Garden 發現文生圖提示詞,再貼上匯入碼或包含 importCode 的連結儲存到收藏。",
|
||||
"gardenDiscover": "去 Garden 發現",
|
||||
"gardenPlaceholder": "輸入匯入碼,例如 ZH-NB-001@ex-001",
|
||||
"gardenCodeRequired": "請先貼上 Prompt Garden 匯入碼",
|
||||
"mergeStrategy": "合併策略",
|
||||
"skipDuplicate": "跳過重複",
|
||||
"overwriteDuplicate": "覆蓋重複",
|
||||
"createCopy": "建立副本",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const normalizePromptGardenImportCode = (value: string): string => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
const directImportCode = url.searchParams.get('importCode')?.trim()
|
||||
if (directImportCode) return directImportCode
|
||||
|
||||
const hashQueryIndex = url.hash.indexOf('?')
|
||||
if (hashQueryIndex >= 0) {
|
||||
const hashQuery = new URLSearchParams(url.hash.slice(hashQueryIndex + 1))
|
||||
return hashQuery.get('importCode')?.trim() || trimmed
|
||||
}
|
||||
} catch {
|
||||
// Plain import codes are expected; non-URL values are used as-is.
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
@@ -13,10 +13,28 @@ const toastMock = vi.hoisted(() => ({
|
||||
info: vi.fn(),
|
||||
}))
|
||||
|
||||
const { pushMock, currentRoute } = vi.hoisted(() => {
|
||||
const pushMock = vi.fn()
|
||||
const currentRoute = {
|
||||
value: {
|
||||
path: '/favorites',
|
||||
query: { keep: '1' },
|
||||
},
|
||||
}
|
||||
return { pushMock, currentRoute }
|
||||
})
|
||||
|
||||
vi.mock('../../../src/composables/ui/useToast', () => ({
|
||||
useToast: () => toastMock,
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
currentRoute,
|
||||
push: pushMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
const naiveStubs = {
|
||||
NButton: {
|
||||
name: 'NButton',
|
||||
@@ -110,6 +128,11 @@ describe('FavoriteImportPanel', () => {
|
||||
toastMock.error.mockReset()
|
||||
toastMock.warning.mockReset()
|
||||
toastMock.info.mockReset()
|
||||
pushMock.mockReset()
|
||||
currentRoute.value = {
|
||||
path: '/favorites',
|
||||
query: { keep: '1' },
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps legacy JSON file import on the file path', async () => {
|
||||
@@ -193,4 +216,25 @@ describe('FavoriteImportPanel', () => {
|
||||
expect(order).toEqual(['save:cover-asset', 'import:favorites'])
|
||||
expect(wrapper.emitted('imported')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('routes Prompt Garden import codes to the save-favorite flow', async () => {
|
||||
const wrapper = mountPanel({})
|
||||
|
||||
;(wrapper.vm as unknown as { source: string; gardenImportInput: string }).source = 'garden'
|
||||
;(wrapper.vm as unknown as { source: string; gardenImportInput: string }).gardenImportInput =
|
||||
'https://prompt.local/#/image/text2image?importCode=ZH-NB-001@ex-001'
|
||||
|
||||
await (wrapper.vm as unknown as { handleImportConfirm: () => Promise<void> }).handleImportConfirm()
|
||||
await flushPromises()
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith({
|
||||
path: '/favorites',
|
||||
query: {
|
||||
keep: '1',
|
||||
importCode: 'ZH-NB-001@ex-001',
|
||||
saveToFavorites: 'confirm',
|
||||
},
|
||||
})
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -36,8 +36,11 @@ vi.mock('vue-i18n', () => ({
|
||||
'common.promptGarden.title': 'Prompt Garden',
|
||||
'common.promptGarden.discover': 'Discover Garden Prompts',
|
||||
'common.promptGarden.importPrompt': 'Import Garden Prompt',
|
||||
'common.promptGarden.importFavorite': 'Import as Favorite',
|
||||
'common.promptGarden.importTitle': 'Import Prompt Garden Prompt',
|
||||
'common.promptGarden.importHint': 'Paste import code',
|
||||
'common.promptGarden.importFavoriteTitle': 'Import Prompt Garden Favorite',
|
||||
'common.promptGarden.importFavoriteHint': 'Paste import code and save favorite',
|
||||
'common.promptGarden.importPlaceholder': 'Enter import code',
|
||||
'common.workspaceTools': 'Workspace Tools',
|
||||
'common.clearContent': 'Clear Content',
|
||||
@@ -211,6 +214,7 @@ describe('WorkspaceUtilityMenu Prompt Garden actions', () => {
|
||||
expect(wrapper.find('[data-testid="workspace-prompt-garden-menu"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Discover Garden Prompts')
|
||||
expect(wrapper.text()).toContain('Import Garden Prompt')
|
||||
expect(wrapper.text()).toContain('Import as Favorite')
|
||||
|
||||
await wrapper.find('[data-dropdown-key="discover"]').trigger('click')
|
||||
|
||||
@@ -234,4 +238,25 @@ describe('WorkspaceUtilityMenu Prompt Garden actions', () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('can import a pasted Garden link as a favorite through the route query', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
await wrapper.find('[data-dropdown-key="import-favorite"]').trigger('click')
|
||||
expect(wrapper.text()).toContain('Import Prompt Garden Favorite')
|
||||
|
||||
await wrapper.find('[data-testid="workspace-prompt-garden-import-code"]').setValue(
|
||||
'https://prompt.always200.com/#/image/text2image?importCode=garden_fav',
|
||||
)
|
||||
await wrapper.find('[data-testid="modal-positive"]').trigger('click')
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith({
|
||||
path: '/image/text2image',
|
||||
query: {
|
||||
keep: '1',
|
||||
importCode: 'garden_fav',
|
||||
saveToFavorites: 'confirm',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1005,7 +1005,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('applies selected exampleId parameters when provided', async () => {
|
||||
it('applies selected inline example suffix parameters when provided', async () => {
|
||||
const { pinia } = createTestPinia()
|
||||
|
||||
// Avoid console.warn from useToast (tests fail on console.warn).
|
||||
@@ -1031,8 +1031,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
const isLoadingExternalData = ref(false)
|
||||
|
||||
const query: LocationQuery = {
|
||||
importCode: 'NB-PVAR-EX-002',
|
||||
exampleId: 'ex-b',
|
||||
importCode: 'NB-PVAR-EX-002@ex-b',
|
||||
}
|
||||
|
||||
const currentRoute = ref<RouteLocationNormalizedLoaded>(makeRoute('/basic/system', query))
|
||||
@@ -1110,6 +1109,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
await waitForCondition(() => isLoadingExternalData.value === false)
|
||||
|
||||
expect(currentRoute.value.path).toBe('/pro/variable')
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('http://garden.local/api/prompt-source/NB-PVAR-EX-002')
|
||||
expect(proVariableSession.getTemporaryVariable('name')).toBe('Charlie')
|
||||
|
||||
// Import params removed from the URL.
|
||||
@@ -1747,6 +1747,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
})
|
||||
|
||||
const basicSystemSession = useBasicSystemSession(pinia)
|
||||
basicSystemSession.updatePrompt('KEEP WORKSPACE')
|
||||
const basicUserSession = useBasicUserSession(pinia)
|
||||
const proMultiMessageSession = useProMultiMessageSession(pinia)
|
||||
const proVariableSession = useProVariableSession(pinia)
|
||||
@@ -1849,6 +1850,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
await waitForCondition(() => isLoadingExternalData.value === false)
|
||||
|
||||
expect(openSaveFavoriteDialog).toHaveBeenCalledTimes(1)
|
||||
expect(basicSystemSession.prompt).toBe('KEEP WORKSPACE')
|
||||
const savedArg = openSaveFavoriteDialog.mock.calls[0]?.[0] as {
|
||||
content: string
|
||||
prefill?: {
|
||||
@@ -1894,6 +1896,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
})
|
||||
|
||||
const basicSystemSession = useBasicSystemSession(pinia)
|
||||
basicSystemSession.updatePrompt('KEEP WORKSPACE')
|
||||
const basicUserSession = useBasicUserSession(pinia)
|
||||
const proMultiMessageSession = useProMultiMessageSession(pinia)
|
||||
const proVariableSession = useProVariableSession(pinia)
|
||||
@@ -2006,6 +2009,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
await waitForCondition(() => isLoadingExternalData.value === false)
|
||||
|
||||
expect(favoriteManager.getFavorites).toHaveBeenCalledTimes(1)
|
||||
expect(basicSystemSession.prompt).toBe('KEEP WORKSPACE')
|
||||
expect(favoriteManager.addFavorite).toHaveBeenCalledTimes(1)
|
||||
expect(favoriteManager.updateFavorite).not.toHaveBeenCalled()
|
||||
const favoriteArg = favoriteManager.addFavorite.mock.calls[0]?.[0]
|
||||
|
||||
Reference in new issue
Block a user