Compare commits
10 Commits
v1.41.10
...
AddTemplat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8585563d15 | ||
|
|
bf63a5cc71 | ||
|
|
8361122586 | ||
|
|
55dea32e00 | ||
|
|
07d49cbe64 | ||
|
|
fdd963a630 | ||
|
|
b638e6a577 | ||
|
|
c7409b6830 | ||
|
|
ff972fbefb | ||
|
|
b18fd8e57a |
@@ -5,10 +5,3 @@ reviews:
|
||||
high_level_summary: false
|
||||
auto_review:
|
||||
drafts: true
|
||||
ignore_title_keywords:
|
||||
- '[release]'
|
||||
- '[backport'
|
||||
ignore_usernames:
|
||||
- comfy-pr-bot
|
||||
- github-actions
|
||||
- github-actions[bot]
|
||||
|
||||
110
.github/workflows/ci-perf-report.yaml
vendored
@@ -1,110 +0,0 @@
|
||||
name: 'CI: Performance Report'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, core/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
|
||||
concurrency:
|
||||
group: perf-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
perf-tests:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
|
||||
- name: Start ComfyUI server
|
||||
uses: ./.github/actions/start-comfyui-server
|
||||
|
||||
- name: Run performance tests
|
||||
id: perf
|
||||
continue-on-error: true
|
||||
run: pnpm exec playwright test --project=performance --workers=1
|
||||
|
||||
- name: Upload perf metrics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: perf-metrics
|
||||
path: test-results/perf-metrics.json
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
report:
|
||||
needs: perf-tests
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Download PR perf metrics
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: perf-metrics
|
||||
path: test-results/
|
||||
|
||||
- name: Download baseline perf metrics
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ github.event.pull_request.base.ref }}
|
||||
workflow: ci-perf-report.yaml
|
||||
event: push
|
||||
name: perf-metrics
|
||||
path: temp/perf-baseline/
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Generate perf report
|
||||
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
|
||||
|
||||
- name: Read perf report
|
||||
id: perf-report
|
||||
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
|
||||
with:
|
||||
path: ./perf-report.md
|
||||
|
||||
- name: Create or update PR comment
|
||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
${{ steps.perf-report.outputs.content }}
|
||||
<!-- COMFYUI_FRONTEND_PERF -->
|
||||
body-include: '<!-- COMFYUI_FRONTEND_PERF -->'
|
||||
45
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -1,45 +0,0 @@
|
||||
---
|
||||
# Dispatches a frontend-asset-build event to the cloud repo on push to
|
||||
# cloud/* branches and main. The cloud repo handles the actual build,
|
||||
# GCS upload, and secret management (Sentry, Algolia, GCS creds).
|
||||
#
|
||||
# This is fire-and-forget — it does NOT wait for the cloud workflow to
|
||||
# complete. Status is visible in the cloud repo's Actions tab.
|
||||
|
||||
name: Cloud Frontend Build Dispatch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'cloud/*'
|
||||
- 'main'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: cloud-dispatch-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
# Fork guard: prevent forks from dispatching to the cloud repo
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
id: payload
|
||||
run: |
|
||||
payload="$(jq -nc \
|
||||
--arg ref "${GITHUB_SHA}" \
|
||||
--arg branch "${GITHUB_REF_NAME}" \
|
||||
'{ref: $ref, branch: $branch}')"
|
||||
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Dispatch to cloud repo
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
|
||||
repository: Comfy-Org/cloud
|
||||
event-type: frontend-asset-build
|
||||
client-payload: ${{ steps.payload.outputs.json }}
|
||||
10
.github/workflows/release-draft-create.yaml
vendored
@@ -53,13 +53,7 @@ jobs:
|
||||
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Desktop-specific release artifact with desktop distribution flags.
|
||||
DISTRIBUTION=desktop pnpm build
|
||||
pnpm zipdist ./dist ./dist-desktop.zip
|
||||
|
||||
# Default release artifact for core/PyPI.
|
||||
NX_SKIP_NX_CACHE=true pnpm build
|
||||
pnpm build
|
||||
pnpm zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -68,7 +62,6 @@ jobs:
|
||||
path: |
|
||||
dist/
|
||||
dist.zip
|
||||
dist-desktop.zip
|
||||
|
||||
draft_release:
|
||||
needs: build
|
||||
@@ -86,7 +79,6 @@ jobs:
|
||||
with:
|
||||
files: |
|
||||
dist.zip
|
||||
dist-desktop.zip
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
target_commitish: ${{ github.event.pull_request.base.ref }}
|
||||
make_latest: >-
|
||||
|
||||
@@ -40,12 +40,12 @@
|
||||
"block-no-empty": true,
|
||||
"no-descending-specificity": null,
|
||||
"no-duplicate-at-import-rules": true,
|
||||
"at-rule-disallowed-list": ["apply"],
|
||||
"at-rule-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreAtRules": [
|
||||
"tailwind",
|
||||
"apply",
|
||||
"layer",
|
||||
"config",
|
||||
"theme",
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"command": "vite build --config apps/desktop-ui/vite.config.mts"
|
||||
"cwd": "apps/desktop-ui",
|
||||
"command": "vite build --config vite.config.mts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
|
||||
@@ -4,39 +4,3 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.p-button-secondary {
|
||||
border: none;
|
||||
background-color: var(--color-neutral-600);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.p-button-secondary:hover {
|
||||
background-color: var(--color-neutral-550);
|
||||
}
|
||||
|
||||
.p-button-secondary:active {
|
||||
background-color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
background-color: var(--color-coral-red-600);
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
background-color: var(--color-coral-red-500);
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
background-color: var(--color-coral-red-400);
|
||||
}
|
||||
|
||||
.task-div .p-card {
|
||||
transition: opacity var(--default-transition-duration);
|
||||
--p-card-background: var(--p-button-secondary-background);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.task-div .p-card:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -101,15 +101,13 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* xterm renders its internal DOM outside Vue templates, so :deep selectors are
|
||||
* required to style those generated nodes.
|
||||
*/
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-terminal) .xterm {
|
||||
overflow: hidden;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
overflow: hidden;
|
||||
background-color: var(--color-neutral-900);
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
option-value="value"
|
||||
:disabled="isSwitching"
|
||||
:pt="dropdownPt"
|
||||
:size="size"
|
||||
:size="props.size"
|
||||
class="language-selector"
|
||||
@change="onLocaleChange"
|
||||
>
|
||||
@@ -36,10 +36,16 @@ import { i18n, loadLocale, st } from '@/i18n'
|
||||
type VariantKey = 'dark' | 'light'
|
||||
type SizeKey = 'small' | 'large'
|
||||
|
||||
const { variant = 'dark', size = 'small' } = defineProps<{
|
||||
variant?: VariantKey
|
||||
size?: SizeKey
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: VariantKey
|
||||
size?: SizeKey
|
||||
}>(),
|
||||
{
|
||||
variant: 'dark',
|
||||
size: 'small'
|
||||
}
|
||||
)
|
||||
|
||||
const dropdownId = `language-select-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
@@ -98,8 +104,10 @@ const VARIANT_PRESETS = {
|
||||
const selectedLocale = ref<string>(i18n.global.locale.value)
|
||||
const isSwitching = ref(false)
|
||||
|
||||
const sizePreset = computed(() => SIZE_PRESETS[size])
|
||||
const variantPreset = computed(() => VARIANT_PRESETS[variant])
|
||||
const sizePreset = computed(() => SIZE_PRESETS[props.size as SizeKey])
|
||||
const variantPreset = computed(
|
||||
() => VARIANT_PRESETS[props.variant as VariantKey]
|
||||
)
|
||||
|
||||
const dropdownPt = computed(() => ({
|
||||
root: {
|
||||
@@ -187,17 +195,13 @@ async function onLocaleChange(event: SelectChangeEvent) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-dropdown-panel .p-dropdown-item) {
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-duration: var(--default-transition-duration);
|
||||
@apply transition-colors;
|
||||
}
|
||||
|
||||
:deep(.p-dropdown) {
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-neutral-900),
|
||||
0 0 0 4px color-mix(in srgb, var(--color-brand-yellow) 60%, transparent);
|
||||
}
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -269,43 +269,26 @@ const onFocus = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.location-picker-accordion) {
|
||||
padding-inline: calc(var(--spacing) * 12);
|
||||
@apply px-12;
|
||||
|
||||
.p-accordionpanel {
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
@apply border-0 bg-transparent;
|
||||
}
|
||||
|
||||
.p-accordionheader {
|
||||
margin-top: calc(var(--spacing) * 2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-800) 50%,
|
||||
transparent
|
||||
);
|
||||
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-radius 0.5s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-700) 50%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* When panel is expanded, adjust header border radius */
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader {
|
||||
border-top-left-radius: var(--radius-xl);
|
||||
border-top-right-radius: var(--radius-xl);
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
@apply rounded-t-xl rounded-b-none;
|
||||
}
|
||||
|
||||
.p-accordionheader-toggle-icon {
|
||||
@@ -316,24 +299,11 @@ const onFocus = async () => {
|
||||
}
|
||||
|
||||
.p-accordioncontent {
|
||||
border: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: var(--radius-xl);
|
||||
border-bottom-left-radius: var(--radius-xl);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-neutral-800) 50%,
|
||||
transparent
|
||||
);
|
||||
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
|
||||
}
|
||||
|
||||
.p-accordioncontent-content {
|
||||
background-color: transparent;
|
||||
padding-top: calc(var(--spacing) * 3);
|
||||
padding-right: calc(var(--spacing) * 5);
|
||||
padding-bottom: calc(var(--spacing) * 5);
|
||||
padding-left: calc(var(--spacing) * 5);
|
||||
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
|
||||
}
|
||||
|
||||
/* Override default chevron icons to use up/down */
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'task-div group/task-card relative grid min-h-52 max-w-48',
|
||||
isLoading && 'opacity-75'
|
||||
)
|
||||
"
|
||||
class="task-div relative grid min-h-52 max-w-48"
|
||||
:class="{ 'opacity-75': isLoading }"
|
||||
>
|
||||
<Card
|
||||
:class="
|
||||
cn(
|
||||
'relative h-full max-w-48 overflow-hidden',
|
||||
runner.state !== 'error' && 'opacity-65'
|
||||
)
|
||||
"
|
||||
:pt="cardPt"
|
||||
class="relative h-full max-w-48 overflow-hidden"
|
||||
:class="{ 'opacity-65': runner.state !== 'error' }"
|
||||
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
|
||||
>
|
||||
<template #header>
|
||||
@@ -52,7 +43,7 @@
|
||||
|
||||
<i
|
||||
v-if="!isLoading && runner.state === 'OK'"
|
||||
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 col-span-full row-span-full z-10 text-[4rem] text-green-500 opacity-100 transition-opacity group-hover/task-card:opacity-20 [text-shadow:0.25rem_0_0.5rem_black]"
|
||||
class="task-card-ok pi pi-check"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -64,7 +55,6 @@ import { computed } from 'vue'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
@@ -93,9 +83,51 @@ const reactiveExecuting = computed(() => !!runner.value.executing)
|
||||
|
||||
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
|
||||
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
|
||||
|
||||
const cardPt = {
|
||||
header: { class: 'z-0' },
|
||||
body: { class: 'z-[1] grow justify-between' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.task-card-ok {
|
||||
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
|
||||
|
||||
font-size: 4rem;
|
||||
text-shadow: 0.25rem 0 0.5rem black;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.p-card {
|
||||
@apply transition-opacity;
|
||||
|
||||
--p-card-background: var(--p-button-secondary-background);
|
||||
opacity: 0.9;
|
||||
|
||||
&.opacity-65 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.p-card-header) {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
:deep(.p-card-body) {
|
||||
z-index: 1;
|
||||
flex-grow: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.task-div {
|
||||
> i {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover > i {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
|
||||
<h1 class="font-inter font-semibold text-xl m-0 italic">
|
||||
{{ $t(`desktopDialogs.${id}.title`, title) }}
|
||||
{{ t(`desktopDialogs.${id}.title`, title) }}
|
||||
</h1>
|
||||
<p class="whitespace-pre-wrap">
|
||||
{{ $t(`desktopDialogs.${id}.message`, message) }}
|
||||
{{ t(`desktopDialogs.${id}.message`, message) }}
|
||||
</p>
|
||||
<div class="flex w-full gap-2">
|
||||
<Button
|
||||
@@ -12,7 +12,7 @@
|
||||
:key="button.label"
|
||||
class="rounded-lg first:mr-auto"
|
||||
:label="
|
||||
$t(
|
||||
t(
|
||||
`desktopDialogs.${id}.buttons.${normalizeI18nKey(button.label)}`,
|
||||
button.label
|
||||
)
|
||||
@@ -31,6 +31,7 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import { getDialog } from '@/constants/desktopDialogs'
|
||||
import type { DialogAction } from '@/constants/desktopDialogs'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -40,3 +41,31 @@ const handleButtonClick = async (button: DialogAction) => {
|
||||
await electronAPI().Dialog.clickButton(button.returnValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.p-button-secondary {
|
||||
@apply text-white border-none bg-neutral-600;
|
||||
}
|
||||
|
||||
.p-button-secondary:hover {
|
||||
@apply bg-neutral-550;
|
||||
}
|
||||
|
||||
.p-button-secondary:active {
|
||||
@apply bg-neutral-500;
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
@apply bg-coral-red-600;
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
@apply bg-coral-red-500;
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
@apply bg-coral-red-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<div class="relative m-8 text-center">
|
||||
<!-- Header -->
|
||||
<h1 class="download-bg pi-download text-4xl font-bold">
|
||||
{{ $t('desktopUpdate.title') }}
|
||||
{{ t('desktopUpdate.title') }}
|
||||
</h1>
|
||||
|
||||
<div class="m-8">
|
||||
<span>{{ $t('desktopUpdate.description') }}</span>
|
||||
<span>{{ t('desktopUpdate.description') }}</span>
|
||||
</div>
|
||||
|
||||
<ProgressSpinner class="m-8 w-48 h-48" />
|
||||
@@ -19,7 +19,7 @@
|
||||
<Button
|
||||
style="transform: translateX(-50%)"
|
||||
class="fixed bottom-0 left-1/2 my-8"
|
||||
:label="$t('maintenance.consoleLogs')"
|
||||
:label="t('maintenance.consoleLogs')"
|
||||
icon="pi pi-desktop"
|
||||
icon-pos="left"
|
||||
severity="secondary"
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
<TerminalOutputDrawer
|
||||
v-model="terminalVisible"
|
||||
:header="$t('g.terminal')"
|
||||
:default-message="$t('desktopUpdate.terminalDefaultMessage')"
|
||||
:header="t('g.terminal')"
|
||||
:default-message="t('desktopUpdate.terminalDefaultMessage')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,6 +44,7 @@ import Toast from 'primevue/toast'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
import BaseViewTemplate from './templates/BaseViewTemplate.vue'
|
||||
@@ -60,10 +61,10 @@ onUnmounted(() => electron.Validation.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.download-bg::before {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
@apply m-0 absolute text-muted;
|
||||
font-family: 'primeicons', sans-serif;
|
||||
top: -2rem;
|
||||
right: 2rem;
|
||||
|
||||
@@ -183,37 +183,33 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-steppanel) {
|
||||
margin-top: calc(var(--spacing) * 8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
@apply mt-8 flex justify-center bg-transparent;
|
||||
}
|
||||
|
||||
/* Remove default padding/margin from StepPanels to make scrollbar flush */
|
||||
:deep(.p-steppanels) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@apply p-0 m-0;
|
||||
}
|
||||
|
||||
/* Ensure StepPanel content container has no top/bottom padding */
|
||||
:deep(.p-steppanel-content) {
|
||||
padding: 0;
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
/* Custom overlay scrollbar for WebKit browsers (Electron, Chrome) */
|
||||
:deep(.p-steppanels::-webkit-scrollbar) {
|
||||
width: calc(var(--spacing) * 4);
|
||||
@apply w-4;
|
||||
}
|
||||
|
||||
:deep(.p-steppanels::-webkit-scrollbar-track) {
|
||||
background-color: transparent;
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
:deep(.p-steppanels::-webkit-scrollbar-thumb) {
|
||||
border: 4px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: color-mix(in srgb, var(--color-white) 20%, transparent);
|
||||
@apply bg-white/20 rounded-lg border-[4px] border-transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -114,12 +114,12 @@ import Tag from 'primevue/tag'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import RefreshButton from '@/components/common/RefreshButton.vue'
|
||||
import StatusTag from '@/components/maintenance/StatusTag.vue'
|
||||
import TaskListPanel from '@/components/maintenance/TaskListPanel.vue'
|
||||
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceFilter } from '@/types/desktop/maintenanceTypes'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
@@ -129,7 +129,6 @@ import BaseViewTemplate from './templates/BaseViewTemplate.vue'
|
||||
|
||||
const electron = electronAPI()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
const { clearResolved, processUpdate, refreshDesktopTasks } = taskStore
|
||||
|
||||
@@ -221,14 +220,14 @@ onUnmounted(() => electron.Validation.dispose())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-tag) {
|
||||
--p-tag-gap: 0.375rem;
|
||||
}
|
||||
|
||||
.backspan::before {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
@apply m-0 absolute text-muted;
|
||||
font-family: 'primeicons', sans-serif;
|
||||
top: -2rem;
|
||||
right: -2rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseViewTemplate>
|
||||
<div class="sad-container grid items-center justify-evenly">
|
||||
<div class="sad-container">
|
||||
<!-- Right side image -->
|
||||
<img
|
||||
class="sad-girl"
|
||||
@@ -79,7 +79,10 @@ const continueToInstall = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.sad-container {
|
||||
@apply grid items-center justify-evenly;
|
||||
grid-template-columns: 25rem 1fr;
|
||||
|
||||
& > * {
|
||||
|
||||
@@ -232,6 +232,8 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
/* Hide the xterm scrollbar completely */
|
||||
:deep(.p-terminal) .xterm-viewport {
|
||||
overflow: hidden !important;
|
||||
|
||||
@@ -1,760 +0,0 @@
|
||||
{
|
||||
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 18,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewAny",
|
||||
"pos": [1031, 434],
|
||||
"size": [250, 178],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
},
|
||||
"widgets_values": [null, null, null]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
|
||||
"pos": [788, 433.5],
|
||||
"size": [225, 380],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [5]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["3", "string_a"],
|
||||
["4", "value"],
|
||||
["6", "value"],
|
||||
["6", "value_1"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [548, 451],
|
||||
"size": [225, 142],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"title": "Outer",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Outer\n"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[4, 1, 0, 5, 0, "STRING"],
|
||||
[5, 5, 0, 2, 0, "STRING"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 18,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Sub 0",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [351, 432.5, 120, 120]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1352, 294.5, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"localized_name": "string_a",
|
||||
"pos": [451, 452.5]
|
||||
},
|
||||
{
|
||||
"id": "5fb3dcf7-9bfd-4b3c-a1b9-750b4f3edf19",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [13],
|
||||
"pos": [451, 472.5]
|
||||
},
|
||||
{
|
||||
"id": "55d24b8a-7c82-4b02-8e3d-ff31ffb8aa13",
|
||||
"name": "value_1",
|
||||
"type": "STRING",
|
||||
"linkIds": [16],
|
||||
"pos": [451, 492.5]
|
||||
},
|
||||
{
|
||||
"id": "c1fe7cc3-547e-4fb0-b763-61888558d4bd",
|
||||
"name": "value_1_1",
|
||||
"type": "STRING",
|
||||
"linkIds": [18],
|
||||
"pos": [451, 512.5]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [9],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1372, 314.5]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [504, 437],
|
||||
"size": [210, 88],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"title": "Inner 1",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 1\n"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [743, 325],
|
||||
"size": [347, 231],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
|
||||
"pos": [1115, 301],
|
||||
"size": [210, 196],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 16
|
||||
},
|
||||
{
|
||||
"name": "value_1",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value_1"
|
||||
},
|
||||
"link": 18
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["5", "string_a"],
|
||||
["11", "value"],
|
||||
["9", "value"],
|
||||
["10", "string_a"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": 6,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 4,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 6,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 6,
|
||||
"target_slot": 2,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 18,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Sub 1",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [180, 739, 120, 100]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1246, 612, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "string_a",
|
||||
"pos": [280, 759]
|
||||
},
|
||||
{
|
||||
"id": "d50f6a62-0185-43d4-a174-a8a94bd8f6e7",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [14],
|
||||
"pos": [280, 779]
|
||||
},
|
||||
{
|
||||
"id": "6b78450e-5986-49cd-b743-c933e5a34a69",
|
||||
"name": "value_1",
|
||||
"type": "STRING",
|
||||
"linkIds": [17],
|
||||
"pos": [280, 799]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [12],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1266, 632]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [334, 742],
|
||||
"size": [210, 88],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 14
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"title": "Inner 2",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 2\n"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [581, 637],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
|
||||
"pos": [1004, 613],
|
||||
"size": [210, 142],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 17
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["7", "string_a"],
|
||||
["8", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 11,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": 10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 9,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": 9,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": 9,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 9,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 18,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Sub 2",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [262, 1222, 120, 80]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1123.089999999999, 1125.1999999999998, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [9],
|
||||
"localized_name": "string_a",
|
||||
"pos": [362, 1242]
|
||||
},
|
||||
{
|
||||
"id": "3a545207-7202-42a9-a82f-3b62e1b0f459",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [15],
|
||||
"pos": [362, 1262]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1143.089999999999, 1145.1999999999998]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 8,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [412.96000000000004, 1228.2399999999996],
|
||||
"size": [210, 88],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"title": "Inner 3",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 3\n"]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [686.08, 1132.38],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 8,
|
||||
"origin_id": 8,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": 7,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 8,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [-412, 11]
|
||||
},
|
||||
"frontendVersion": "1.41.7"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { PerformanceHelper } from './helpers/PerformanceHelper'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
@@ -186,7 +185,6 @@ export class ComfyPage {
|
||||
public readonly dragDrop: DragDropHelper
|
||||
public readonly command: CommandHelper
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly perf: PerformanceHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -231,7 +229,6 @@ export class ComfyPage {
|
||||
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
|
||||
this.command = new CommandHelper(page)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
@@ -439,13 +436,7 @@ export const comfyPageFixture = base.extend<{
|
||||
}
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
const isPerf = testInfo.tags.includes('@perf')
|
||||
if (isPerf) await comfyPage.perf.init()
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
if (isPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
const comfyMouse = new ComfyMouse(comfyPage)
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { CDPSession, Page } from '@playwright/test'
|
||||
|
||||
interface PerfSnapshot {
|
||||
RecalcStyleCount: number
|
||||
RecalcStyleDuration: number
|
||||
LayoutCount: number
|
||||
LayoutDuration: number
|
||||
TaskDuration: number
|
||||
JSHeapUsedSize: number
|
||||
Timestamp: number
|
||||
}
|
||||
|
||||
export interface PerfMeasurement {
|
||||
name: string
|
||||
durationMs: number
|
||||
styleRecalcs: number
|
||||
styleRecalcDurationMs: number
|
||||
layouts: number
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
}
|
||||
|
||||
export class PerformanceHelper {
|
||||
private cdp: CDPSession | null = null
|
||||
private snapshot: PerfSnapshot | null = null
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.cdp = await this.page.context().newCDPSession(this.page)
|
||||
await this.cdp.send('Performance.enable')
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.snapshot = null
|
||||
if (this.cdp) {
|
||||
try {
|
||||
await this.cdp.send('Performance.disable')
|
||||
} finally {
|
||||
await this.cdp.detach()
|
||||
this.cdp = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getSnapshot(): Promise<PerfSnapshot> {
|
||||
if (!this.cdp) throw new Error('PerformanceHelper not initialized')
|
||||
const { metrics } = (await this.cdp.send('Performance.getMetrics')) as {
|
||||
metrics: { name: string; value: number }[]
|
||||
}
|
||||
function get(name: string): number {
|
||||
return metrics.find((m) => m.name === name)?.value ?? 0
|
||||
}
|
||||
return {
|
||||
RecalcStyleCount: get('RecalcStyleCount'),
|
||||
RecalcStyleDuration: get('RecalcStyleDuration'),
|
||||
LayoutCount: get('LayoutCount'),
|
||||
LayoutDuration: get('LayoutDuration'),
|
||||
TaskDuration: get('TaskDuration'),
|
||||
JSHeapUsedSize: get('JSHeapUsedSize'),
|
||||
Timestamp: get('Timestamp')
|
||||
}
|
||||
}
|
||||
|
||||
async startMeasuring(): Promise<void> {
|
||||
if (this.snapshot) {
|
||||
throw new Error(
|
||||
'Measurement already in progress — call stopMeasuring() first'
|
||||
)
|
||||
}
|
||||
this.snapshot = await this.getSnapshot()
|
||||
}
|
||||
|
||||
async stopMeasuring(name: string): Promise<PerfMeasurement> {
|
||||
if (!this.snapshot) throw new Error('Call startMeasuring() first')
|
||||
const after = await this.getSnapshot()
|
||||
const before = this.snapshot
|
||||
this.snapshot = null
|
||||
|
||||
function delta(key: keyof PerfSnapshot): number {
|
||||
return after[key] - before[key]
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
durationMs: delta('Timestamp') * 1000,
|
||||
styleRecalcs: delta('RecalcStyleCount'),
|
||||
styleRecalcDurationMs: delta('RecalcStyleDuration') * 1000,
|
||||
layouts: delta('LayoutCount'),
|
||||
layoutDurationMs: delta('LayoutDuration') * 1000,
|
||||
taskDurationMs: delta('TaskDuration') * 1000,
|
||||
heapDeltaBytes: delta('JSHeapUsedSize')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,6 @@ export const TestIds = {
|
||||
node: {
|
||||
titleInput: 'node-title-input'
|
||||
},
|
||||
selectionToolbox: {
|
||||
colorPickerButton: 'color-picker-button',
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red'
|
||||
},
|
||||
widgets: {
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
@@ -80,7 +74,6 @@ export type TestIdValue =
|
||||
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
|
||||
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
|
||||
| (typeof TestIds.node)[keyof typeof TestIds.node]
|
||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { writePerfReport } from './helpers/perfReporter'
|
||||
import { restorePath } from './utils/backupUtils'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
export default function globalTeardown(_config: FullConfig) {
|
||||
writePerfReport()
|
||||
|
||||
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
|
||||
restorePath([process.env.TEST_COMFYUI_DIR, 'user'])
|
||||
restorePath([process.env.TEST_COMFYUI_DIR, 'models'])
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
import type { PerfMeasurement } from '../fixtures/helpers/PerformanceHelper'
|
||||
|
||||
export interface PerfReport {
|
||||
timestamp: string
|
||||
gitSha: string
|
||||
branch: string
|
||||
measurements: PerfMeasurement[]
|
||||
}
|
||||
|
||||
const TEMP_DIR = join('test-results', 'perf-temp')
|
||||
|
||||
export function recordMeasurement(m: PerfMeasurement) {
|
||||
mkdirSync(TEMP_DIR, { recursive: true })
|
||||
const filename = `${m.name}-${Date.now()}.json`
|
||||
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
|
||||
}
|
||||
|
||||
export function writePerfReport(
|
||||
gitSha = process.env.GITHUB_SHA ?? 'local',
|
||||
branch = process.env.GITHUB_HEAD_REF ?? 'local'
|
||||
) {
|
||||
if (!readdirSync('test-results', { withFileTypes: true }).length) return
|
||||
|
||||
let tempFiles: string[]
|
||||
try {
|
||||
tempFiles = readdirSync(TEMP_DIR).filter((f) => f.endsWith('.json'))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (tempFiles.length === 0) return
|
||||
|
||||
const measurements: PerfMeasurement[] = tempFiles.map((f) =>
|
||||
JSON.parse(readFileSync(join(TEMP_DIR, f), 'utf-8'))
|
||||
)
|
||||
|
||||
const report: PerfReport = {
|
||||
timestamp: new Date().toISOString(),
|
||||
gitSha,
|
||||
branch,
|
||||
measurements
|
||||
}
|
||||
writeFileSync(
|
||||
join('test-results', 'perf-metrics.json'),
|
||||
JSON.stringify(report, null, 2)
|
||||
)
|
||||
}
|
||||
@@ -104,13 +104,15 @@ test.describe('Missing models warning', () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
const downloadButton = missingModelsWarning.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
|
||||
// Check that the copy URL button is also visible for Desktop environment
|
||||
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
|
||||
await expect(copyUrlButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display a warning when missing models are found in node properties', async ({
|
||||
@@ -121,13 +123,15 @@ test.describe('Missing models warning', () => {
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
const downloadButton = missingModelsWarning.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
|
||||
// Check that the copy URL button is also visible for Desktop environment
|
||||
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
|
||||
await expect(copyUrlButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display a warning when no missing models are found', async ({
|
||||
@@ -168,10 +172,8 @@ test.describe('Missing models warning', () => {
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Should not display warning when model metadata exists but widget values have changed', async ({
|
||||
@@ -184,10 +186,8 @@ test.describe('Missing models warning', () => {
|
||||
)
|
||||
|
||||
// The missing models warning should NOT appear
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
@@ -199,15 +199,13 @@ test.describe('Missing models warning', () => {
|
||||
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).toBeVisible()
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
|
||||
const downloadAllButton = comfyPage.page.getByText('Download all')
|
||||
await expect(downloadAllButton).toBeVisible()
|
||||
const downloadButton = comfyPage.page.getByText('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await downloadAllButton.click()
|
||||
await downloadButton.click()
|
||||
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
|
||||
@@ -231,13 +229,12 @@ test.describe('Missing models warning', () => {
|
||||
test('Should disable warning dialog when checkbox is checked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await checkbox.click()
|
||||
const changeSettingPromise = comfyPage.page.waitForRequest(
|
||||
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
await checkbox.click()
|
||||
await changeSettingPromise
|
||||
|
||||
await closeButton.click()
|
||||
await changeSettingPromise
|
||||
|
||||
const settingValue = await comfyPage.settings.getSetting(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
@@ -171,7 +171,6 @@ test.describe('Node Interaction', () => {
|
||||
|
||||
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.dragTextEncodeNode2()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 93 KiB |
@@ -1,70 +0,0 @@
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { recordMeasurement } from '../helpers/perfReporter'
|
||||
|
||||
test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
test('canvas idle style recalculations', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
// Let the canvas idle for 2 seconds — no user interaction.
|
||||
// Measures baseline style recalcs from reactive state + render loop.
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('canvas-idle')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Canvas idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
|
||||
)
|
||||
})
|
||||
|
||||
test('canvas mouse interaction style recalculations', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
const canvas = comfyPage.canvas
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
|
||||
// Sweep mouse across the canvas — crosses nodes, empty space, slots
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + (box.width * i) / 100,
|
||||
box.y + (box.height * (i % 3)) / 3
|
||||
)
|
||||
}
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('canvas-mouse-sweep')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Mouse sweep: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
|
||||
)
|
||||
})
|
||||
|
||||
test('DOM widget clipping during node selection', async ({ comfyPage }) => {
|
||||
// Load default workflow which has DOM widgets (text inputs, combos)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
// Select and deselect nodes rapidly to trigger clipping recalculation
|
||||
const canvas = comfyPage.canvas
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
// Click on canvas area (nodes occupy various positions)
|
||||
await comfyPage.page.mouse.click(
|
||||
box.x + box.width / 3 + (i % 5) * 30,
|
||||
box.y + box.height / 3 + (i % 4) * 30
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('dom-widget-clipping')
|
||||
recordMeasurement(m)
|
||||
console.log(`Clipping: ${m.layouts} forced layouts`)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -1,8 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
@@ -12,17 +10,6 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
const BLUE_COLOR = 'rgb(51, 51, 85)'
|
||||
const RED_COLOR = 'rgb(85, 51, 51)'
|
||||
|
||||
const getColorPickerButton = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
|
||||
|
||||
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
|
||||
|
||||
const getColorPickerGroup = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByRole('group').filter({
|
||||
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
@@ -145,24 +132,28 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Color picker button should be visible
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
await expect(colorPickerButton).toBeVisible()
|
||||
|
||||
// Click color picker button
|
||||
await colorPickerButton.click()
|
||||
|
||||
// Color picker dropdown should be visible
|
||||
const colorPickerGroup = getColorPickerGroup(comfyPage)
|
||||
await expect(colorPickerGroup).toBeVisible()
|
||||
const colorPickerDropdown = comfyPage.page.locator(
|
||||
'.color-picker-container'
|
||||
)
|
||||
await expect(colorPickerDropdown).toBeVisible()
|
||||
|
||||
// Select a color (e.g., blue)
|
||||
const blueColorOption = colorPickerGroup.getByTestId(
|
||||
TestIds.selectionToolbox.colorBlue
|
||||
const blueColorOption = colorPickerDropdown.locator(
|
||||
'i[data-testid="blue"]'
|
||||
)
|
||||
await blueColorOption.click()
|
||||
|
||||
// Dropdown should close after selection
|
||||
await expect(colorPickerGroup).not.toBeVisible()
|
||||
await expect(colorPickerDropdown).not.toBeVisible()
|
||||
|
||||
// Node should have the selected color class/style
|
||||
// Note: Exact verification method depends on how color is applied to nodes
|
||||
@@ -181,21 +172,22 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
'CLIP Text Encode (Prompt)'
|
||||
])
|
||||
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
|
||||
// Initially should show default color
|
||||
await expect(colorPickerButton).not.toHaveAttribute('color')
|
||||
|
||||
// Click color picker and select a color
|
||||
await colorPickerButton.click()
|
||||
const redColorOption = getColorPickerGroup(comfyPage).getByTestId(
|
||||
TestIds.selectionToolbox.colorRed
|
||||
const redColorOption = comfyPage.page.locator(
|
||||
'.color-picker-container i[data-testid="red"]'
|
||||
)
|
||||
await redColorOption.click()
|
||||
|
||||
// Button should now show the selected color
|
||||
await expect(colorPickerCurrentColor).toHaveCSS('color', RED_COLOR)
|
||||
await expect(colorPickerButton).toHaveCSS('color', RED_COLOR)
|
||||
})
|
||||
|
||||
test('color picker shows mixed state for differently colored selections', async ({
|
||||
@@ -203,17 +195,17 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// Select first node and color it
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
.click()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Select second node and color it differently
|
||||
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorRed)
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="red"]')
|
||||
.click()
|
||||
|
||||
// Select both nodes
|
||||
@@ -223,7 +215,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
])
|
||||
|
||||
// Color picker should show null/mixed state
|
||||
const colorPickerButton = getColorPickerButton(comfyPage)
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
await expect(colorPickerButton).not.toHaveAttribute('color')
|
||||
})
|
||||
|
||||
@@ -232,9 +226,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// First color a node
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
.click()
|
||||
|
||||
// Clear selection
|
||||
@@ -244,8 +238,10 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
|
||||
// Color picker button should show the correct color
|
||||
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
|
||||
await expect(colorPickerCurrentColor).toHaveCSS('color', BLUE_COLOR)
|
||||
const colorPickerButton = comfyPage.page.locator(
|
||||
'.selection-toolbox .pi-circle-fill'
|
||||
)
|
||||
await expect(colorPickerButton).toHaveCSS('color', BLUE_COLOR)
|
||||
})
|
||||
|
||||
test('colorization via color picker can be undone', async ({
|
||||
@@ -253,9 +249,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// Select a node and color it
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await getColorPickerButton(comfyPage).click()
|
||||
await getColorPickerGroup(comfyPage)
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
.click()
|
||||
|
||||
// Undo the colorization
|
||||
|
||||
@@ -555,74 +555,6 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Nested Promoted Widget Disabled State', () => {
|
||||
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
|
||||
// slot connected externally from the Outer node, so it should be
|
||||
// disabled. The remaining promoted textarea widgets (value, value_1)
|
||||
// are unlinked and should be enabled.
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
expect(promotedNames).toContain('string_a')
|
||||
expect(promotedNames).toContain('value')
|
||||
|
||||
const disabledState = await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('5')
|
||||
return (node?.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
disabled: !!w.computedDisabled
|
||||
}))
|
||||
})
|
||||
|
||||
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
|
||||
expect(linkedWidget?.disabled).toBe(true)
|
||||
|
||||
const unlinkedWidgets = disabledState.filter(
|
||||
(w) => w.name !== 'string_a'
|
||||
)
|
||||
for (const w of unlinkedWidgets) {
|
||||
expect(w.disabled).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The promoted textareas that are NOT externally linked should be
|
||||
// fully opaque and interactive.
|
||||
const textareas = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textareas.first()).toBeVisible()
|
||||
|
||||
const count = await textareas.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const textarea = textareas.nth(i)
|
||||
const wrapper = textarea.locator('..')
|
||||
const opacity = await wrapper.evaluate(
|
||||
(el) => getComputedStyle(el).opacity
|
||||
)
|
||||
|
||||
if (opacity === '1' && (await textarea.isEditable())) {
|
||||
const testContent = `nested-promotion-edit-${i}`
|
||||
await textarea.fill(testContent)
|
||||
await expect(textarea).toHaveValue(testContent)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promotion Cleanup', () => {
|
||||
test('Removing subgraph node clears promotion store entries', async ({
|
||||
comfyPage
|
||||
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
@@ -2,7 +2,6 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../fixtures/selectors'
|
||||
|
||||
test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -20,16 +19,10 @@ test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
|
||||
})
|
||||
await loadCheckpointNode.getByText('Load Checkpoint').click()
|
||||
|
||||
const colorPickerButton = comfyPage.page.getByTestId(
|
||||
TestIds.selectionToolbox.colorPickerButton
|
||||
)
|
||||
await colorPickerButton.click()
|
||||
|
||||
const colorPickerGroup = comfyPage.page.getByRole('group').filter({
|
||||
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
})
|
||||
await colorPickerGroup
|
||||
.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container')
|
||||
.locator('i[data-testid="blue"]')
|
||||
.click()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
|
||||
@@ -39,10 +39,6 @@ Prefer Vue native options when available:
|
||||
- Use inline Tailwind CSS only (no `<style>` blocks)
|
||||
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
|
||||
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
|
||||
- Exception: when third-party libraries render runtime DOM outside Vue templates
|
||||
(for example xterm internals inside PrimeVue terminal wrappers), scoped
|
||||
`:deep()` selectors are allowed. Add a brief inline comment explaining why the
|
||||
exception is required.
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -22,9 +22,7 @@ const extraFileExtensions = ['.vue']
|
||||
|
||||
const commonGlobals = {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly',
|
||||
__DISTRIBUTION__: 'readonly',
|
||||
__IS_NIGHTLY__: 'readonly'
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly'
|
||||
} as const
|
||||
|
||||
const settings = {
|
||||
|
||||
@@ -41,9 +41,7 @@ const config: KnipConfig = {
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Workflow files contain license names that knip misinterprets as binaries
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue'
|
||||
'.github/workflows/ci-oss-assets-validation.yaml'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
@@ -4,11 +4,6 @@ export default {
|
||||
'tests-ui/**': () =>
|
||||
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
|
||||
|
||||
'./**/*.{css,vue}': (stagedFiles: string[]) => {
|
||||
const joinedPaths = toJoinedRelativePaths(stagedFiles)
|
||||
return [`pnpm exec stylelint --allow-empty-input ${joinedPaths}`]
|
||||
},
|
||||
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
|
||||
@@ -27,17 +22,12 @@ export default {
|
||||
}
|
||||
|
||||
function formatAndEslint(fileNames: string[]) {
|
||||
const joinedPaths = toJoinedRelativePaths(fileNames)
|
||||
// Convert absolute paths to relative paths for better ESLint resolution
|
||||
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
||||
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
return [
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||
function toJoinedRelativePaths(fileNames: string[]) {
|
||||
const relativePaths = fileNames.map((f) =>
|
||||
path.relative(process.cwd(), f).replace(/\\/g, '/')
|
||||
)
|
||||
return relativePaths.map((p) => `"${p}"`).join(' ')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.10",
|
||||
"version": "1.46.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -29,10 +29,10 @@
|
||||
"knip": "knip --cache",
|
||||
"lint:fix:no-cache": "oxlint src --type-aware --fix && eslint src --fix",
|
||||
"lint:fix": "oxlint src --type-aware --fix && eslint src --cache --fix",
|
||||
"lint:no-cache": "pnpm exec stylelint '{apps,packages,src}/**/*.{css,vue}' && oxlint src --type-aware && eslint src",
|
||||
"lint:no-cache": "oxlint src --type-aware && eslint src",
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint": "pnpm stylelint && oxlint src --type-aware && eslint src --cache",
|
||||
"lint": "oxlint src --type-aware && eslint src --cache",
|
||||
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
|
||||
"locale": "lobe-i18n locale",
|
||||
"oxlint": "oxlint src --type-aware",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
|
||||
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,clip-text-encode,get-video-components,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
|
||||
|
||||
@custom-variant touch (@media (hover: none));
|
||||
|
||||
@@ -156,7 +156,6 @@
|
||||
:root {
|
||||
--fg-color: #000;
|
||||
--bg-color: #fff;
|
||||
--default-transition-duration: 0.1s;
|
||||
--comfy-menu-bg: #353535;
|
||||
--comfy-menu-secondary-bg: #292929;
|
||||
--comfy-topbar-height: 2.5rem;
|
||||
@@ -634,18 +633,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-subscription-gradient {
|
||||
background: var(--color-subscription-button-gradient);
|
||||
}
|
||||
|
||||
@utility highlight {
|
||||
background-color: color-mix(in srgb, currentColor 20%, transparent);
|
||||
font-weight: 700;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0 0.125rem;
|
||||
margin: -0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
@utility scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 9C12.7761 9 13 9.22386 13 9.5V20C13 20.2761 13.2239 20.5 13.5 20.5H28C28.2761 20.5 28.5 20.7239 28.5 21C28.5 21.2761 28.2761 21.5 28 21.5H13.5C12.6716 21.5 12 20.8284 12 20V9.5C12 9.22386 12.2239 9 12.5 9ZM14.5 7C14.7761 7 15 7.22386 15 7.5V18C15 18.2761 15.2239 18.5 15.5 18.5H30C30.2761 18.5 30.5 18.7239 30.5 19C30.5 19.2761 30.2761 19.5 30 19.5H15.5C14.6716 19.5 14 18.8284 14 18V7.5C14 7.22386 14.2239 7 14.5 7ZM16.5 5C16.7761 5 17 5.22386 17 5.5V16C17 16.2761 17.2239 16.5 17.5 16.5H32C32.2761 16.5 32.5 16.7239 32.5 17C32.5 17.2761 32.2761 17.5 32 17.5H17.5C16.6716 17.5 16 16.8284 16 16V5.5C16 5.22386 16.2239 5 16.5 5ZM33.7061 2.5C34.4126 2.5 34.9999 3.08968 35 3.7998V14.2002C34.9999 14.9103 34.4126 15.5 33.7061 15.5H19.2939C18.5874 15.5 18.0001 14.9103 18 14.2002V3.7998C18.0001 3.08968 18.5874 2.5 19.2939 2.5H33.7061ZM19.1084 12.2676V14.2002C19.1085 14.3124 19.1814 14.3856 19.293 14.3857H33.7061C33.8179 14.3857 33.8915 14.3125 33.8916 14.2002V12.6094L30.7207 10.0615L28.1055 11.873C27.9107 12.005 27.6299 11.9923 27.4473 11.8438L23.8896 8.95312L19.1084 12.2676ZM19.2939 3.61426C19.1821 3.61426 19.1085 3.68744 19.1084 3.7998V10.9092L23.5957 7.79883C23.6707 7.74519 23.7587 7.71107 23.8496 7.7002C23.9954 7.68428 24.1465 7.72944 24.2598 7.82227L27.8164 10.7178L30.4385 8.90723C30.6334 8.7753 30.9141 8.78784 31.0967 8.93652L33.8916 11.1826V3.7998C33.8915 3.68747 33.8179 3.61426 33.7061 3.61426H19.2939ZM27.7939 5.09961C28.7054 5.09987 29.4561 5.8554 29.4561 6.77148C29.456 7.68754 28.7054 8.44213 27.7939 8.44238C26.8823 8.44238 26.1309 7.6877 26.1309 6.77148C26.1309 5.85524 26.8823 5.09961 27.7939 5.09961ZM27.7939 6.21387C27.4814 6.21387 27.2393 6.45737 27.2393 6.77148C27.2393 7.08557 27.4814 7.32812 27.7939 7.32812C28.1062 7.32788 28.3476 7.08542 28.3477 6.77148C28.3477 6.45752 28.1063 6.21411 27.7939 6.21387Z" fill="#8A8A8A"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 652 B After Width: | Height: | Size: 652 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M25.12 4.65979C26.0073 3.75304 27.4985 3.73975 28.3787 4.65979L31.3397 7.62073H31.3387C32.2523 8.49397 32.253 10.0028 31.3182 10.8785L31.3192 10.8795L23.62 18.5797L22.5096 19.6891V7.27112L22.7 7.08069L25.12 4.65979Z" stroke="#8A8A8A" stroke-width="1.3"/>
|
||||
<path d="M32.3396 13.8499C33.6177 13.8499 34.65 14.8804 34.6501 16.1594V20.3401C34.6501 21.6199 33.618 22.6506 32.3396 22.6506H20.3503L21.4597 21.5403L29.1501 13.8499H32.3396Z" stroke="#8A8A8A" stroke-width="1.3"/>
|
||||
<path d="M17.7604 17.2496C17.1991 17.2496 16.7498 17.6986 16.7497 18.2594C16.7497 18.8208 17.1995 19.2701 17.7604 19.2701C18.3065 19.2699 18.7702 18.8157 18.7702 18.2594C18.7701 17.6982 18.3211 17.2499 17.7604 17.2496ZM22.1706 18.2399C22.1706 20.6987 20.2192 22.6498 17.7604 22.65C15.2992 22.65 13.3493 20.677 13.3493 18.2399V3.65979C13.3494 2.38005 14.3815 1.34933 15.6598 1.34924H19.8405C21.1222 1.34934 22.1421 2.38132 22.1706 3.64514V18.2399Z" stroke="#8A8A8A" stroke-width="1.3"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.8593 13C35.4827 13 36.0007 13.4988 36.0009 14.0996V22.9004C36.0007 23.5012 35.4827 24 34.8593 24H22.1425C21.5191 24 21.0011 23.5012 21.0009 22.9004V14.0996C21.0011 13.4988 21.5191 13 22.1425 13H34.8593ZM21.9794 21.2646V22.9004C21.9796 22.9953 22.0439 23.0566 22.1425 23.0566H34.8593C34.9579 23.0566 35.0222 22.9953 35.0224 22.9004V21.5547L32.2255 19.3984L29.9179 20.9307C29.746 21.0424 29.498 21.032 29.3369 20.9062L26.1982 18.4609L21.9794 21.2646ZM16.5009 10.5C16.777 10.5001 17.0009 10.7239 17.0009 11V17.5C17.001 18.3283 17.6727 18.9998 18.5009 19H18.7089L18.0615 18.3535C17.8665 18.1583 17.8665 17.8417 18.0615 17.6465C18.2567 17.4512 18.5742 17.4512 18.7695 17.6465L20.1835 19.0605C20.3785 19.2557 20.3784 19.5723 20.1835 19.7676L18.7695 21.1816C18.5742 21.3769 18.2567 21.3768 18.0615 21.1816C17.8666 20.9864 17.8664 20.6697 18.0615 20.4746L18.5361 20H18.5009C17.1204 19.9998 16.001 18.8806 16.0009 17.5V11C16.001 10.724 16.2249 10.5002 16.5009 10.5ZM22.1425 13.9424C22.0439 13.9424 21.9796 14.0047 21.9794 14.0996V20.1152L25.9384 17.4834C26.0045 17.4381 26.082 17.4096 26.162 17.4004C26.2907 17.3869 26.4244 17.4244 26.5244 17.5029L29.663 19.9531L31.9755 18.4219C32.1475 18.3102 32.3954 18.3204 32.5566 18.4463L35.0224 20.3467V14.0996C35.0222 14.0047 34.9579 13.9424 34.8593 13.9424H22.1425ZM29.6425 15.2002C30.4468 15.2003 31.1093 15.839 31.1093 16.6143C31.1093 17.3895 30.4469 18.0283 29.6425 18.0283C28.8381 18.0283 28.1747 17.3895 28.1747 16.6143C28.1748 15.839 28.8381 15.2002 29.6425 15.2002ZM29.6425 16.1426C29.3668 16.1426 29.1533 16.3485 29.1533 16.6143C29.1533 16.8801 29.3667 17.0859 29.6425 17.0859C29.9182 17.0859 30.1318 16.88 30.1318 16.6143C30.1318 16.3485 29.9182 16.1426 29.6425 16.1426ZM22.0917 0C23.6924 0.000102997 25.0009 1.29808 25.0009 2.91016V7.08984C25.0009 8.70192 23.6924 9.9999 22.0917 10H14.9111C13.3103 10 12.0009 8.70198 12.0009 7.08984V2.91016C12.0009 1.29802 13.3103 0 14.9111 0H22.0917ZM14.9111 1.04199C13.8598 1.04199 13.0331 1.87561 13.0331 2.91016V7.08984C13.0331 8.12439 13.8598 8.95801 14.9111 8.95801H22.0917C23.1429 8.95791 23.9697 8.12432 23.9697 7.08984V2.91016C23.9697 1.87568 23.1429 1.04209 22.0917 1.04199H14.9111ZM17.0146 2.36523C17.1026 2.36806 17.189 2.39596 17.2636 2.44531L20.5556 4.53613C20.7284 4.64278 20.7919 4.83988 20.7919 5C20.7919 5.16007 20.7283 5.35719 20.5556 5.46387L17.2646 7.55469C17.1075 7.65858 16.9024 7.66034 16.7441 7.56055L16.7431 7.55957C16.5867 7.45933 16.4941 7.27149 16.499 7.08398V2.91016C16.4953 2.64423 16.6989 2.38047 16.9755 2.36621L17.0146 2.36523ZM17.5068 6.1416L19.3095 5L17.5068 3.85449V6.1416ZM20.4999 5.22559L20.5234 5.19434C20.5303 5.1833 20.5364 5.17121 20.5419 5.15918C20.5308 5.1833 20.5167 5.20593 20.4999 5.22559Z" fill="#8A8A8A"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -3952,7 +3952,7 @@ export interface components {
|
||||
* @description The subscription tier level
|
||||
* @enum {string}
|
||||
*/
|
||||
SubscriptionTier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
|
||||
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
|
||||
/**
|
||||
* @description The subscription billing duration
|
||||
* @enum {string}
|
||||
|
||||
@@ -36,18 +36,7 @@ export default defineConfig({
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
timeout: 15000,
|
||||
grepInvert: /@mobile|@perf/ // Run all tests except those tagged with @mobile or @perf
|
||||
},
|
||||
|
||||
{
|
||||
name: 'performance',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
trace: 'retain-on-failure'
|
||||
},
|
||||
timeout: 60_000,
|
||||
grep: /@perf/,
|
||||
fullyParallel: false
|
||||
grepInvert: /@mobile/ // Run all tests except those tagged with @mobile
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
interface PerfMeasurement {
|
||||
name: string
|
||||
durationMs: number
|
||||
styleRecalcs: number
|
||||
styleRecalcDurationMs: number
|
||||
layouts: number
|
||||
layoutDurationMs: number
|
||||
taskDurationMs: number
|
||||
heapDeltaBytes: number
|
||||
}
|
||||
|
||||
interface PerfReport {
|
||||
timestamp: string
|
||||
gitSha: string
|
||||
branch: string
|
||||
measurements: PerfMeasurement[]
|
||||
}
|
||||
|
||||
const CURRENT_PATH = 'test-results/perf-metrics.json'
|
||||
const BASELINE_PATH = 'temp/perf-baseline/perf-metrics.json'
|
||||
|
||||
function formatDelta(pct: number): string {
|
||||
if (pct >= 20) return `+${pct.toFixed(0)}% 🔴`
|
||||
if (pct >= 10) return `+${pct.toFixed(0)}% 🟠`
|
||||
if (pct > -10) return `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}% ⚪`
|
||||
return `${pct.toFixed(0)}% 🟢`
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (Math.abs(bytes) < 1024) return `${bytes} B`
|
||||
if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function calcDelta(
|
||||
baseline: number,
|
||||
current: number
|
||||
): { pct: number; isNew: boolean } {
|
||||
if (baseline > 0) {
|
||||
return { pct: ((current - baseline) / baseline) * 100, isNew: false }
|
||||
}
|
||||
return current > 0 ? { pct: Infinity, isNew: true } : { pct: 0, isNew: false }
|
||||
}
|
||||
|
||||
function formatDeltaCell(delta: { pct: number; isNew: boolean }): string {
|
||||
return delta.isNew ? 'new 🔴' : formatDelta(delta.pct)
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!existsSync(CURRENT_PATH)) {
|
||||
process.stdout.write(
|
||||
'## ⚡ Performance Report\n\nNo perf metrics found. Perf tests may not have run.\n'
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const current: PerfReport = JSON.parse(readFileSync(CURRENT_PATH, 'utf-8'))
|
||||
|
||||
const baseline: PerfReport | null = existsSync(BASELINE_PATH)
|
||||
? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'))
|
||||
: null
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('## ⚡ Performance Report\n')
|
||||
|
||||
if (baseline) {
|
||||
lines.push(
|
||||
'| Metric | Baseline | PR | Δ |',
|
||||
'|--------|----------|-----|---|'
|
||||
)
|
||||
|
||||
for (const m of current.measurements) {
|
||||
const base = baseline.measurements.find((b) => b.name === m.name)
|
||||
if (!base) {
|
||||
lines.push(`| ${m.name}: style recalcs | — | ${m.styleRecalcs} | new |`)
|
||||
lines.push(`| ${m.name}: layouts | — | ${m.layouts} | new |`)
|
||||
lines.push(
|
||||
`| ${m.name}: task duration | — | ${m.taskDurationMs.toFixed(0)}ms | new |`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const recalcDelta = calcDelta(base.styleRecalcs, m.styleRecalcs)
|
||||
lines.push(
|
||||
`| ${m.name}: style recalcs | ${base.styleRecalcs} | ${m.styleRecalcs} | ${formatDeltaCell(recalcDelta)} |`
|
||||
)
|
||||
|
||||
const layoutDelta = calcDelta(base.layouts, m.layouts)
|
||||
lines.push(
|
||||
`| ${m.name}: layouts | ${base.layouts} | ${m.layouts} | ${formatDeltaCell(layoutDelta)} |`
|
||||
)
|
||||
|
||||
const taskDelta = calcDelta(base.taskDurationMs, m.taskDurationMs)
|
||||
lines.push(
|
||||
`| ${m.name}: task duration | ${base.taskDurationMs.toFixed(0)}ms | ${m.taskDurationMs.toFixed(0)}ms | ${formatDeltaCell(taskDelta)} |`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
lines.push(
|
||||
'No baseline found — showing absolute values.\n',
|
||||
'| Metric | Value |',
|
||||
'|--------|-------|'
|
||||
)
|
||||
for (const m of current.measurements) {
|
||||
lines.push(`| ${m.name}: style recalcs | ${m.styleRecalcs} |`)
|
||||
lines.push(`| ${m.name}: layouts | ${m.layouts} |`)
|
||||
lines.push(
|
||||
`| ${m.name}: task duration | ${m.taskDurationMs.toFixed(0)}ms |`
|
||||
)
|
||||
lines.push(`| ${m.name}: heap delta | ${formatBytes(m.heapDeltaBytes)} |`)
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('\n<details><summary>Raw data</summary>\n')
|
||||
lines.push('```json')
|
||||
lines.push(JSON.stringify(current, null, 2))
|
||||
lines.push('```')
|
||||
lines.push('\n</details>')
|
||||
|
||||
process.stdout.write(lines.join('\n') + '\n')
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,14 +1,9 @@
|
||||
import zipdir from 'zip-dir'
|
||||
|
||||
const sourceDir = process.argv[2] || './dist'
|
||||
const outputPath = process.argv[3] || './dist.zip'
|
||||
|
||||
zipdir(sourceDir, { saveTo: outputPath }, function (err, buffer) {
|
||||
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
|
||||
if (err) {
|
||||
console.error(`Error zipping "${sourceDir}" directory:`, err)
|
||||
console.error('Error zipping "dist" directory:', err)
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`Successfully zipped "${sourceDir}" directory to "${outputPath}".\n`
|
||||
)
|
||||
console.log('Successfully zipped "dist" directory.')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -51,6 +51,7 @@ onMounted(() => {
|
||||
// See: https://vite.dev/guide/build#load-error-handling
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault()
|
||||
// eslint-disable-next-line no-undef
|
||||
if (__DISTRIBUTION__ === 'cloud') {
|
||||
captureException(event.payload, {
|
||||
tags: { error_type: 'vite_preload_error' }
|
||||
|
||||
@@ -2,28 +2,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
downloadFile,
|
||||
extractFilenameFromContentDisposition,
|
||||
openFileInNewTab
|
||||
extractFilenameFromContentDisposition
|
||||
} from '@/base/common/downloadUtil'
|
||||
|
||||
const { mockIsCloud } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: false }
|
||||
}))
|
||||
let mockIsCloud = false
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
return mockIsCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({ addAlert: vi.fn() }))
|
||||
}))
|
||||
|
||||
// Global stubs
|
||||
const createObjectURLSpy = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
@@ -37,7 +26,7 @@ describe('downloadUtil', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = false
|
||||
mockIsCloud = false
|
||||
fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
|
||||
@@ -165,7 +154,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('streams downloads via blob when running in cloud', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -184,7 +173,6 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -195,7 +183,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('logs an error when cloud fetch fails', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchMock.mockResolvedValue({
|
||||
@@ -209,15 +197,14 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob throw
|
||||
await Promise.resolve() // let .catch handler run
|
||||
await Promise.resolve()
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses filename from Content-Disposition header in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -236,7 +223,6 @@ describe('downloadUtil', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -245,7 +231,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('uses RFC 5987 filename from Content-Disposition header', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -267,7 +253,6 @@ describe('downloadUtil', () => {
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -275,7 +260,7 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
|
||||
it('falls back to provided filename when Content-Disposition is missing', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
@@ -293,7 +278,6 @@ describe('downloadUtil', () => {
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
await Promise.resolve() // let fetchAsBlob return
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
@@ -301,99 +285,6 @@ describe('downloadUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('openFileInNewTab', () => {
|
||||
let windowOpenSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens URL directly when not in cloud mode', async () => {
|
||||
mockIsCloud.value = false
|
||||
const testUrl = 'https://example.com/image.png'
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(testUrl, '_blank')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens blank tab synchronously then navigates to blob URL in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith('', '_blank')
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
|
||||
expect(mockTab.location.href).toBe('blob:mock-url')
|
||||
})
|
||||
|
||||
it('revokes blob URL after timeout in cloud mode', async () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('closes blank tab and logs error when cloud fetch fails', async () => {
|
||||
mockIsCloud.value = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab(testUrl)
|
||||
|
||||
expect(mockTab.close).toHaveBeenCalled()
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('revokes blob URL immediately if tab was closed by user', async () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: vi.fn().mockResolvedValue(blob)
|
||||
} as unknown as Response)
|
||||
|
||||
await openFileInNewTab('https://example.com/image.png')
|
||||
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
expect(mockTab.location.href).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFilenameFromContentDisposition', () => {
|
||||
it('returns null for null header', () => {
|
||||
expect(extractFilenameFromContentDisposition(null)).toBeNull()
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* Utility functions for downloading files
|
||||
*/
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
// Constants
|
||||
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
|
||||
@@ -114,23 +112,14 @@ export function extractFilenameFromContentDisposition(
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a URL and return its body as a Blob.
|
||||
* Shared by download and open-in-new-tab cloud paths.
|
||||
*/
|
||||
async function fetchAsBlob(url: string): Promise<Response> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status}`)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function downloadViaBlobFetch(
|
||||
const downloadViaBlobFetch = async (
|
||||
href: string,
|
||||
fallbackFilename: string
|
||||
): Promise<void> {
|
||||
const response = await fetchAsBlob(href)
|
||||
): Promise<void> => {
|
||||
const response = await fetch(href)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${href}: ${response.status}`)
|
||||
}
|
||||
|
||||
// Try to get filename from Content-Disposition header (set by backend)
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
@@ -140,44 +129,3 @@ async function downloadViaBlobFetch(
|
||||
const blob = await response.blob()
|
||||
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a file URL in a new browser tab.
|
||||
* On cloud, fetches the resource as a blob first to avoid GCS redirects
|
||||
* that would trigger an auto-download instead of displaying the file.
|
||||
*
|
||||
* Opens the tab synchronously to preserve the user-gesture context
|
||||
* (browsers block window.open after an await), then navigates it to
|
||||
* the blob URL once the fetch completes.
|
||||
*/
|
||||
export async function openFileInNewTab(url: string): Promise<void> {
|
||||
if (!isCloud) {
|
||||
window.open(url, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
// Open immediately to preserve user-gesture activation.
|
||||
const tab = window.open('', '_blank')
|
||||
|
||||
try {
|
||||
const response = await fetchAsBlob(url)
|
||||
const blob = await response.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
if (tab && !tab.closed) {
|
||||
tab.location.href = blobUrl
|
||||
// Revoke after the tab has had time to load the blob.
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
|
||||
} else {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
tab?.close()
|
||||
console.error('Failed to open image:', error)
|
||||
useToastStore().addAlert(
|
||||
t('toastMessages.errorOpenImage', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
|
||||
:state-key="isSelectMode ? 'builder-splitter' : sidebarStateKey"
|
||||
:state-key="sidebarStateKey"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="
|
||||
!focusMode && (sidebarLocation === 'left' || showOffsideSplitter)
|
||||
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
|
||||
"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
@@ -35,10 +35,8 @@
|
||||
)
|
||||
: 'bg-comfy-menu-bg pointer-events-auto'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="sidebarLocation === 'left' ? 10 : 15"
|
||||
:size="20"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
@@ -56,7 +54,7 @@
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="CENTER_PANEL_SIZE" class="flex flex-col">
|
||||
<SplitterPanel :size="80" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
@@ -87,7 +85,7 @@
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="
|
||||
!focusMode && (sidebarLocation === 'right' || showOffsideSplitter)
|
||||
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
|
||||
"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
@@ -97,10 +95,8 @@
|
||||
)
|
||||
: 'bg-comfy-menu-bg pointer-events-auto'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="sidebarLocation === 'right' ? 10 : 15"
|
||||
:size="20"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
@@ -127,13 +123,6 @@ import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import {
|
||||
BUILDER_MIN_SIZE,
|
||||
CENTER_PANEL_SIZE,
|
||||
SIDEBAR_MIN_SIZE,
|
||||
SIDE_PANEL_SIZE
|
||||
} from '@/constants/splitterConstants'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -155,17 +144,11 @@ const unifiedWidth = computed(() =>
|
||||
|
||||
const { focusMode } = storeToRefs(workspaceStore)
|
||||
|
||||
const { isSelectMode, isBuilderMode } = useAppMode()
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||
const showOffsideSplitter = computed(
|
||||
() => rightSidePanelVisible.value || isSelectMode.value
|
||||
)
|
||||
|
||||
const sidebarPanelVisible = computed(
|
||||
() => activeSidebarTab.value !== null && !isBuilderMode.value
|
||||
)
|
||||
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
|
||||
|
||||
const sidebarStateKey = computed(() => {
|
||||
return unifiedWidth.value
|
||||
@@ -186,7 +169,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
|
||||
* to recalculate the width and panel order
|
||||
*/
|
||||
const splitterRefreshKey = computed(() => {
|
||||
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}${isSelectMode.value ? '-builder' : ''}-${sidebarLocation.value}`
|
||||
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
|
||||
})
|
||||
|
||||
const firstPanelStyle = computed(() => {
|
||||
|
||||
@@ -262,7 +262,7 @@ describe('TopMenuSection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the job history sidebar tab when QPO V2 is enabled', async () => {
|
||||
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
@@ -273,10 +273,10 @@ describe('TopMenuSection', () => {
|
||||
|
||||
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
||||
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
||||
})
|
||||
|
||||
it('toggles the job history sidebar tab when QPO V2 is enabled', async () => {
|
||||
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
@@ -287,7 +287,7 @@ describe('TopMenuSection', () => {
|
||||
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
|
||||
await toggleButton.trigger('click')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
||||
|
||||
await toggleButton.trigger('click')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
|
||||
|
||||
@@ -56,6 +56,43 @@
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="destructive"
|
||||
size="md"
|
||||
:aria-pressed="
|
||||
isQueuePanelV2Enabled
|
||||
? activeSidebarTabId === 'assets'
|
||||
: isQueueProgressOverlayEnabled
|
||||
? isQueueOverlayExpanded
|
||||
: undefined
|
||||
"
|
||||
class="relative px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<StatusBadge
|
||||
v-if="activeJobsCount > 0"
|
||||
data-testid="active-jobs-indicator"
|
||||
variant="dot"
|
||||
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
|
||||
/>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
isQueuePanelV2Enabled
|
||||
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
|
||||
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
<ContextMenu
|
||||
ref="queueContextMenu"
|
||||
:model="queueContextMenuItems"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
@@ -90,15 +127,13 @@
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
|
||||
>
|
||||
<QueueInlineProgressSummary
|
||||
:hidden="shouldHideInlineProgressSummary"
|
||||
/>
|
||||
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
|
||||
</div>
|
||||
</Teleport>
|
||||
<QueueInlineProgressSummary
|
||||
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
|
||||
class="pr-1"
|
||||
:hidden="shouldHideInlineProgressSummary"
|
||||
:hidden="isQueueOverlayExpanded"
|
||||
/>
|
||||
<QueueNotificationBannerHost
|
||||
v-if="shouldShowQueueNotificationBanners"
|
||||
@@ -111,11 +146,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
@@ -129,9 +167,12 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
@@ -144,11 +185,17 @@ const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { t } = useI18n()
|
||||
const { t, n } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
const isTopMenuHovered = ref(false)
|
||||
@@ -161,6 +208,14 @@ const isActionbarEnabled = computed(
|
||||
const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobsShort',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
@@ -186,12 +241,24 @@ const inlineProgressSummaryTarget = computed(() => {
|
||||
}
|
||||
return progressTarget.value
|
||||
})
|
||||
const shouldHideInlineProgressSummary = computed(
|
||||
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.manageExtensions'))
|
||||
)
|
||||
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
||||
icon: 'icon-[lucide--list-x] text-destructive-background',
|
||||
class: '*:text-destructive-background',
|
||||
disabled: queueStore.pendingTasks.length === 0,
|
||||
command: () => {
|
||||
void handleClearQueue()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
return shouldShowConflictRedDot.value
|
||||
@@ -214,6 +281,27 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
if (isQueuePanelV2Enabled.value) {
|
||||
sidebarTabStore.toggleSidebarTab('assets')
|
||||
return
|
||||
}
|
||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
}
|
||||
|
||||
const showQueueContextMenu = (event: MouseEvent) => {
|
||||
queueContextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
}
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
|
||||
@@ -42,44 +42,12 @@
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-pressed="
|
||||
isQueuePanelV2Enabled
|
||||
? activeSidebarTabId === 'job-history'
|
||||
: queueOverlayExpanded
|
||||
"
|
||||
class="relative px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<StatusBadge
|
||||
v-if="activeJobsCount > 0"
|
||||
data-testid="active-jobs-indicator"
|
||||
variant="dot"
|
||||
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
|
||||
/>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
isQueuePanelV2Enabled
|
||||
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
|
||||
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
|
||||
<QueueInlineProgress
|
||||
:hidden="shouldHideInlineProgress"
|
||||
:hidden="queueOverlayExpanded"
|
||||
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
|
||||
data-testid="queue-inline-progress"
|
||||
/>
|
||||
@@ -97,14 +65,11 @@ import {
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
@@ -112,8 +77,6 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
@@ -129,13 +92,8 @@ const emit = defineEmits<{
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const queueStore = useQueueStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const { t, n } = useI18n()
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||
const { t } = useI18n()
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
@@ -329,9 +287,6 @@ const inlineProgressTarget = computed(() => {
|
||||
if (isDocked.value) return topMenuContainer ?? null
|
||||
return panelElement.value
|
||||
})
|
||||
const shouldHideInlineProgress = computed(
|
||||
() => !isQueuePanelV2Enabled.value && queueOverlayExpanded
|
||||
)
|
||||
watch(
|
||||
panelElement,
|
||||
(target) => {
|
||||
@@ -360,52 +315,11 @@ watch(isDragging, (dragging) => {
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobsShort',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
||||
icon: 'icon-[lucide--list-x] text-destructive-background',
|
||||
class: '*:text-destructive-background',
|
||||
disabled: queueStore.pendingTasks.length === 0,
|
||||
command: () => {
|
||||
void handleClearQueue()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const cancelCurrentJob = async () => {
|
||||
if (isExecutionIdle.value) return
|
||||
await commandStore.execute('Comfy.Interrupt')
|
||||
}
|
||||
const toggleQueueOverlay = () => {
|
||||
if (isQueuePanelV2Enabled.value) {
|
||||
sidebarTabStore.toggleSidebarTab('job-history')
|
||||
return
|
||||
}
|
||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
}
|
||||
const showQueueContextMenu = (event: MouseEvent) => {
|
||||
queueContextMenu.value?.show(event)
|
||||
}
|
||||
const handleClearQueue = async () => {
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
}
|
||||
|
||||
const actionbarClass = computed(() =>
|
||||
cn(
|
||||
|
||||
@@ -8,14 +8,12 @@ import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemp
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { enableAppBuilder } = useAppMode()
|
||||
const { enterBuilder } = useAppModeStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
const isAssetsActive = computed(
|
||||
@@ -25,6 +23,10 @@ const isWorkflowsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'workflows'
|
||||
)
|
||||
|
||||
function enterBuilderMode() {
|
||||
appModeStore.setMode('builder:select')
|
||||
}
|
||||
|
||||
function openAssets() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
|
||||
}
|
||||
@@ -41,7 +43,7 @@ function openTemplates() {
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 pointer-events-auto">
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar">
|
||||
<template #button="{ hasUnseenItems }">
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.labels.menu'),
|
||||
@@ -50,21 +52,16 @@ function openTemplates() {
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.labels.menu')"
|
||||
class="relative h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
class="h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
</WorkflowActionsDropdown>
|
||||
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-if="appModeStore.enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
@@ -73,7 +70,7 @@ function openTemplates() {
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
@click="enterBuilderMode"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -103,12 +103,13 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-terminal) .xterm {
|
||||
overflow: hidden;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
overflow: hidden;
|
||||
background-color: var(--color-neutral-900);
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="subgraph-breadcrumb"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center -mt-4 pt-4"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
@@ -195,6 +195,8 @@ onUpdated(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb:not(:empty) {
|
||||
flex: auto;
|
||||
flex-shrink: 10000;
|
||||
@@ -203,7 +205,7 @@ onUpdated(() => {
|
||||
|
||||
.subgraph-breadcrumb,
|
||||
:deep(.p-breadcrumb) {
|
||||
overflow: hidden;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb) {
|
||||
@@ -212,10 +214,7 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
height: calc(var(--spacing) * 8);
|
||||
@apply flex items-center overflow-hidden h-8;
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
@@ -237,7 +236,7 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:hover) {
|
||||
border-radius: var(--radius-lg);
|
||||
@apply rounded-lg;
|
||||
border-color: var(--interface-stroke);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
@@ -271,16 +270,18 @@ onUpdated(() => {
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
|
||||
.p-breadcrumb-item,
|
||||
.p-breadcrumb-separator {
|
||||
display: none;
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item:nth-last-child(3),
|
||||
.p-breadcrumb-separator:nth-last-child(2),
|
||||
.p-breadcrumb-item:nth-last-child(1) {
|
||||
display: flex;
|
||||
@apply flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,7 +78,9 @@ interface Props {
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const { item, isActive = false } = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
@@ -101,7 +103,7 @@ const rename = async (
|
||||
) => {
|
||||
if (newName && newName !== initialName) {
|
||||
// Synchronize the node titles with the new name
|
||||
item.updateTitle?.(newName)
|
||||
props.item.updateTitle?.(newName)
|
||||
|
||||
if (workflowStore.activeSubgraph) {
|
||||
workflowStore.activeSubgraph.name = newName
|
||||
@@ -125,13 +127,13 @@ const rename = async (
|
||||
}
|
||||
}
|
||||
|
||||
const isRoot = item.key === 'root'
|
||||
const isRoot = props.item.key === 'root'
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (hasMissingNodes.value && isRoot) {
|
||||
return t('breadcrumbsMenu.missingNodesWarning')
|
||||
}
|
||||
return item.label
|
||||
return props.item.label
|
||||
})
|
||||
|
||||
const startRename = async () => {
|
||||
@@ -143,7 +145,7 @@ const startRename = async () => {
|
||||
}
|
||||
|
||||
isEditing.value = true
|
||||
itemLabel.value = item.label as string
|
||||
itemLabel.value = props.item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
@@ -163,12 +165,12 @@ const handleClick = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
if (event.detail === 1) {
|
||||
if (isActive) {
|
||||
if (props.isActive) {
|
||||
menu.value?.toggle(event)
|
||||
} else {
|
||||
item.command?.({ item: item, originalEvent: event })
|
||||
props.item.command?.({ item: props.item, originalEvent: event })
|
||||
}
|
||||
} else if (isActive && event.detail === 2) {
|
||||
} else if (props.isActive && event.detail === 2) {
|
||||
menu.value?.hide()
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
@@ -178,7 +180,7 @@ const handleClick = (event: MouseEvent) => {
|
||||
|
||||
const inputBlur = async (doRename: boolean) => {
|
||||
if (doRename) {
|
||||
await rename(itemLabel.value, item.label as string)
|
||||
await rename(itemLabel.value, props.item.label as string)
|
||||
}
|
||||
|
||||
isEditing.value = false
|
||||
@@ -186,19 +188,19 @@ const inputBlur = async (doRename: boolean) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
user-select: none;
|
||||
@apply select-none;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-link {
|
||||
overflow: hidden;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@apply whitespace-nowrap text-ellipsis overflow-hidden;
|
||||
}
|
||||
|
||||
.active-breadcrumb-item {
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { remove } from 'es-toolkit'
|
||||
import { computed, provide, ref, toValue, watchEffect } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { t } = useI18n()
|
||||
const canvas: LGraphCanvas = canvasStore.getCanvas()
|
||||
|
||||
const { isSelectMode, isArrangeMode } = useAppMode()
|
||||
const hoveringSelectable = ref(false)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
|
||||
function resolveNode(nodeId: NodeId) {
|
||||
return (
|
||||
app.rootGraph.getNodeById(nodeId) ??
|
||||
[...app.rootGraph.subgraphs.values()]
|
||||
.flatMap((sg) => sg.nodes)
|
||||
.find((n) => n.id == nodeId)
|
||||
)
|
||||
}
|
||||
|
||||
// Prune stale entries whose node/widget no longer exists, so the
|
||||
// DraggableList model always matches the rendered items.
|
||||
watchEffect(() => {
|
||||
const valid = appModeStore.selectedInputs.filter(([nodeId, widgetName]) =>
|
||||
resolveNode(nodeId)?.widgets?.some((w) => w.name === widgetName)
|
||||
)
|
||||
if (valid.length < appModeStore.selectedInputs.length) {
|
||||
appModeStore.selectedInputs = valid
|
||||
}
|
||||
})
|
||||
|
||||
const arrangeInputs = computed(() =>
|
||||
appModeStore.selectedInputs
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const node = resolveNode(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (!node || !widget) return null
|
||||
return { nodeId, widgetName, node, widget }
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
)
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const node = resolveNode(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (!node || !widget) return { nodeId, widgetName }
|
||||
|
||||
const input = node.inputs.find((i) => i.widget?.name === widget.name)
|
||||
const rename = input && (() => renameWidget(widget, input))
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
rename
|
||||
}
|
||||
})
|
||||
)
|
||||
const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
appModeStore.selectedOutputs.map((nodeId) => [
|
||||
nodeId,
|
||||
app.rootGraph.getNodeById(nodeId)?.title ?? String(nodeId)
|
||||
])
|
||||
)
|
||||
|
||||
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
|
||||
const newLabel = await useDialogService().prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt'),
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
if (newLabel === null) return
|
||||
widget.label = newLabel || undefined
|
||||
input.label = newLabel || undefined
|
||||
widget.callback?.(widget.value)
|
||||
useCanvasStore().canvas?.setDirty(true)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
const { graph } = canvas
|
||||
if (!canvas || !graph) return
|
||||
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
if (!e) return
|
||||
|
||||
canvas.adjustMouseEvent(e)
|
||||
const node = graph.getNodeOnPos(e.canvasX, e.canvasY)
|
||||
if (!node) return
|
||||
|
||||
const widget = node.getWidgetOnPos(e.canvasX, e.canvasY, false)
|
||||
|
||||
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
|
||||
}
|
||||
|
||||
function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
if (!node) return
|
||||
|
||||
const titleOffset =
|
||||
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
|
||||
|
||||
if (!widgetName)
|
||||
return {
|
||||
width: `${node.size[0]}px`,
|
||||
height: `${node.size[1] + titleOffset}px`,
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) return
|
||||
|
||||
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
|
||||
const marginX = margin ?? BaseWidget.margin
|
||||
const height =
|
||||
(widget.computedHeight !== undefined
|
||||
? widget.computedHeight - 4
|
||||
: LiteGraph.NODE_WIDGET_HEIGHT) - (margin ? 2 * margin - 4 : 0)
|
||||
return {
|
||||
width: `${node.size[0] - marginX * 2}px`,
|
||||
height: `${height}px`,
|
||||
left: `${node.pos[0] + marginX}px`,
|
||||
top: `${node.pos[1] + widget.y + (margin ?? 0)}px`
|
||||
}
|
||||
}
|
||||
|
||||
function handleDown(e: MouseEvent) {
|
||||
const [node] = getHovered(e) ?? []
|
||||
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
|
||||
}
|
||||
function handleClick(e: MouseEvent) {
|
||||
const [node, widget] = getHovered(e) ?? []
|
||||
if (!node) return canvasInteractions.forwardEventToCanvas(e)
|
||||
|
||||
if (!widget) {
|
||||
if (!node.constructor.nodeData?.output_node)
|
||||
return canvasInteractions.forwardEventToCanvas(e)
|
||||
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
|
||||
if (index === -1) appModeStore.selectedOutputs.push(node.id)
|
||||
else appModeStore.selectedOutputs.splice(index, 1)
|
||||
return
|
||||
}
|
||||
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
|
||||
)
|
||||
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
function nodeToDisplayTuple(
|
||||
n: LGraphNode
|
||||
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
|
||||
return [
|
||||
n.id,
|
||||
getBounding(n.id),
|
||||
appModeStore.selectedOutputs.some((id) => n.id === id)
|
||||
]
|
||||
}
|
||||
|
||||
const renderedOutputs = computed(() => {
|
||||
void appModeStore.selectedOutputs.length
|
||||
return canvas
|
||||
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
|
||||
.map(nodeToDisplayTuple)
|
||||
})
|
||||
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
|
||||
`${nodeId}: ${widgetName}`,
|
||||
getBounding(nodeId, widgetName)
|
||||
])
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
|
||||
{{
|
||||
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
||||
}}
|
||||
</div>
|
||||
<DraggableList
|
||||
v-if="isArrangeMode"
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<div
|
||||
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'p-2 my-2 pointer-events-auto')"
|
||||
:aria-label="`${widget.label ?? widgetName} — ${node.title}`"
|
||||
>
|
||||
<div class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
show-node-name
|
||||
hidden-widget-actions
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableList>
|
||||
<PropertiesAccordionItem
|
||||
v-else
|
||||
:label="t('nodeHelpPage.inputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedInputs.length"
|
||||
class="border-border-subtle border-b"
|
||||
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.inputs') }}
|
||||
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
|
||||
<IoItem
|
||||
v-for="{
|
||||
nodeId,
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
rename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'bg-primary-background/30 p-2 my-2 rounded-lg')"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:rename
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
appModeStore.selectedInputs,
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
v-if="!isArrangeMode"
|
||||
:label="t('nodeHelpPage.outputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedOutputs.length"
|
||||
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.outputs') }}
|
||||
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
<DraggableList
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedOutputs"
|
||||
>
|
||||
<IoItem
|
||||
v-for="([key, title], index) in outputsWithState"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
dragClass,
|
||||
'bg-warning-background/40 p-2 my-2 rounded-lg',
|
||||
index === 0 && 'ring-warning-background ring-2'
|
||||
)
|
||||
"
|
||||
:title
|
||||
:sub-title="String(key)"
|
||||
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<Teleport
|
||||
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
|
||||
to="body"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'absolute w-full h-full pointer-events-auto',
|
||||
hoveringSelectable ? 'cursor-pointer' : 'cursor-grab'
|
||||
)
|
||||
"
|
||||
@pointerdown="handleDown"
|
||||
@pointermove="hoveringSelectable = !!getHovered($event)"
|
||||
@click="handleClick"
|
||||
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||
>
|
||||
<TransformPane :canvas="canvasStore.getCanvas()">
|
||||
<div
|
||||
v-for="[key, style] in renderedInputs"
|
||||
:key
|
||||
:style="toValue(style)"
|
||||
class="fixed bg-primary-background/30 rounded-lg"
|
||||
/>
|
||||
<div
|
||||
v-for="[key, style, isSelected] in renderedOutputs"
|
||||
:key
|
||||
:style="toValue(style)"
|
||||
:class="
|
||||
cn(
|
||||
'fixed ring-warning-background ring-5 rounded-2xl',
|
||||
!isSelected && 'ring-warning-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="absolute top-0 right-0 size-8">
|
||||
<div
|
||||
v-if="isSelected"
|
||||
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
|
||||
@click.stop="
|
||||
remove(appModeStore.selectedOutputs, (k) => k == key)
|
||||
"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<i class="icon-[lucide--check] bg-text-foreground size-full" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
|
||||
@click.stop="appModeStore.selectedOutputs.push(key)"
|
||||
@pointerdown.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TransformPane>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
>
|
||||
<Button size="lg" @click="onExitBuilder">
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === 'Escape' &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
dialogStore.dialogStack.length === 0 &&
|
||||
isBuilderMode.value
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onExitBuilder()
|
||||
}
|
||||
})
|
||||
|
||||
function onExitBuilder() {
|
||||
void appModeStore.exitBuilder()
|
||||
}
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<Popover :show-arrow="false" class="min-w-56 p-3">
|
||||
<template #button>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'absolute left-4 top-[calc(var(--workflow-tabs-height)+16px)] z-1000 inline-flex h-10 cursor-pointer items-center gap-2.5 rounded-lg py-2 pr-2 pl-3 shadow-interface transition-colors border-none',
|
||||
'bg-secondary-background hover:bg-secondary-background-hover',
|
||||
'data-[state=open]:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
<span class="text-sm font-medium">
|
||||
{{ t('linearMode.appModeToolbar.appBuilder') }}
|
||||
</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
</button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none',
|
||||
hasOutputs
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'opacity-50 pointer-events-none'
|
||||
)
|
||||
"
|
||||
:disabled="!hasOutputs"
|
||||
@click="onSave(close)"
|
||||
>
|
||||
<i class="icon-[lucide--save] size-4" />
|
||||
{{ t('builderMenu.saveApp') }}
|
||||
</button>
|
||||
<div class="my-1 border-t border-border-default" />
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none hover:bg-secondary-background-hover"
|
||||
@click="onExitBuilder(close)"
|
||||
>
|
||||
<i class="icon-[lucide--square-pen] size-4" />
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</button>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { setSaving } = useBuilderSave()
|
||||
|
||||
function onSave(close: () => void) {
|
||||
setSaving(true)
|
||||
close()
|
||||
}
|
||||
|
||||
function onExitBuilder(close: () => void) {
|
||||
void appModeStore.exitBuilder()
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<nav
|
||||
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-1000 -translate-x-1/2"
|
||||
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-[1000] -translate-x-1/2"
|
||||
:aria-label="t('builderToolbar.label')"
|
||||
>
|
||||
<div
|
||||
@@ -20,7 +20,7 @@
|
||||
)
|
||||
"
|
||||
:aria-current="activeStep === step.id ? 'step' : undefined"
|
||||
@click="setMode(step.id)"
|
||||
@click="appModeStore.setMode(step.id)"
|
||||
>
|
||||
<StepBadge :step :index :model-value="activeStep" />
|
||||
<StepLabel :step />
|
||||
@@ -31,9 +31,9 @@
|
||||
|
||||
<!-- Save -->
|
||||
<ConnectOutputPopover
|
||||
v-if="!hasOutputs"
|
||||
v-if="!appModeStore.hasOutputs"
|
||||
:is-select-active="activeStep === 'builder:select'"
|
||||
@switch="setMode('builder:select')"
|
||||
@switch="appModeStore.setMode('builder:select')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
@@ -50,7 +50,7 @@
|
||||
: 'hover:bg-secondary-background bg-transparent'
|
||||
)
|
||||
"
|
||||
@click="setSaving(true)"
|
||||
@click="appModeStore.setBuilderSaving(true)"
|
||||
>
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
<StepLabel :step="saveStep" />
|
||||
@@ -63,24 +63,21 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { AppMode } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import StepBadge from './StepBadge.vue'
|
||||
import StepLabel from './StepLabel.vue'
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { mode, setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
const { saving, setSaving } = useBuilderSave()
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const activeStep = computed(() => (saving.value ? 'save' : mode.value))
|
||||
const activeStep = computed(() =>
|
||||
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
|
||||
)
|
||||
|
||||
const stepClasses =
|
||||
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { rename, remove } = defineProps<{
|
||||
title: string
|
||||
subTitle?: string
|
||||
rename?: () => void
|
||||
remove?: () => void
|
||||
}>()
|
||||
|
||||
const entries = computed(() => {
|
||||
const items = []
|
||||
if (rename)
|
||||
items.push({
|
||||
label: t('g.rename'),
|
||||
command: rename,
|
||||
icon: 'icon-[lucide--pencil]'
|
||||
})
|
||||
if (remove)
|
||||
items.push({
|
||||
label: t('g.delete'),
|
||||
command: remove,
|
||||
icon: 'icon-[lucide--trash-2]'
|
||||
})
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="p-2 my-2 rounded-lg flex items-center-safe">
|
||||
<span class="mr-auto truncate shrink-1" v-text="title" />
|
||||
<span
|
||||
class="text-muted-foreground mr-2 text-end truncate shrink-3"
|
||||
v-text="subTitle"
|
||||
/>
|
||||
<Popover :entries>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly">
|
||||
<i class="icon-[lucide--ellipsis]" />
|
||||
</Button>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,36 +1,30 @@
|
||||
import { ref } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
|
||||
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
|
||||
import { whenever } from '@vueuse/core'
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
export function useBuilderSave() {
|
||||
const { setMode } = useAppMode()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const appModeStore = useAppModeStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const dialogService = useDialogService()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
whenever(saving, onBuilderSave)
|
||||
|
||||
function setSaving(value: boolean) {
|
||||
saving.value = value
|
||||
}
|
||||
watch(
|
||||
() => appModeStore.isBuilderSaving,
|
||||
(saving) => {
|
||||
if (saving) void onBuilderSave()
|
||||
}
|
||||
)
|
||||
|
||||
async function onBuilderSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
@@ -39,14 +33,15 @@ export function useBuilderSave() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!workflow.isTemporary && workflow.initialMode != null) {
|
||||
// Re-save with the previously chosen mode — no dialog needed.
|
||||
// TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before.
|
||||
// If they have saved app mode before, just save the workflow, but use the initial app mode state not current.
|
||||
|
||||
if (!workflow.isTemporary) {
|
||||
try {
|
||||
appModeStore.flushSelections()
|
||||
workflow.changeTracker?.checkState()
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
showSuccessDialog(workflow.filename, workflow.initialMode === 'app')
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
|
||||
} catch {
|
||||
resetSaving()
|
||||
}
|
||||
return
|
||||
@@ -80,19 +75,16 @@ export function useBuilderSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
appModeStore.flushSelections()
|
||||
const mode = openAsApp ? 'app' : 'graph'
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename,
|
||||
initialMode: mode
|
||||
openAsApp
|
||||
})
|
||||
|
||||
if (!saved) return
|
||||
|
||||
closeSaveDialog()
|
||||
showSuccessDialog(filename, openAsApp)
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
} catch {
|
||||
closeSaveDialog()
|
||||
resetSaving()
|
||||
}
|
||||
@@ -106,7 +98,7 @@ export function useBuilderSave() {
|
||||
workflowName,
|
||||
savedAsApp,
|
||||
onViewApp: () => {
|
||||
setMode('app')
|
||||
appModeStore.setMode('app')
|
||||
closeSuccessDialog()
|
||||
},
|
||||
onClose: closeSuccessDialog
|
||||
@@ -127,8 +119,6 @@ export function useBuilderSave() {
|
||||
}
|
||||
|
||||
function resetSaving() {
|
||||
saving.value = false
|
||||
appModeStore.setBuilderSaving(false)
|
||||
}
|
||||
|
||||
return { saving, setSaving }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs',
|
||||
textColorClass
|
||||
)
|
||||
"
|
||||
class="flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs"
|
||||
:class="textColorClass"
|
||||
:style="customStyle"
|
||||
>
|
||||
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { onBeforeUnmount, ref, useTemplateRef, watchPostEffect } from 'vue'
|
||||
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
|
||||
const modelValue = defineModel<T[]>({ required: true })
|
||||
const draggableList = ref<DraggableList>()
|
||||
const draggableItems = useTemplateRef('draggableItems')
|
||||
|
||||
watchPostEffect(() => {
|
||||
void modelValue.value.length
|
||||
draggableList.value?.dispose()
|
||||
if (!draggableItems.value?.children?.length) return
|
||||
draggableList.value = new DraggableList(
|
||||
draggableItems.value,
|
||||
'.draggable-item'
|
||||
)
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem
|
||||
}
|
||||
}
|
||||
const newPosition = reorderedItems.indexOf(this.draggableItem)
|
||||
const itemList = modelValue.value
|
||||
const [item] = itemList.splice(oldPosition, 1)
|
||||
itemList.splice(newPosition, 0, item)
|
||||
modelValue.value = [...itemList]
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
|
||||
<slot
|
||||
drag-class="draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
148
src/components/common/ElectronFileDownload.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<!-- A Electron-backed download button with a label, size hint and progress bar -->
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
|
||||
<div class="file-info">
|
||||
<div class="file-details">
|
||||
<span class="file-type" :title="hint">{{ label }}</span>
|
||||
</div>
|
||||
<div v-if="props.error" class="file-error">
|
||||
{{ props.error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-action flex flex-row items-center gap-2">
|
||||
<Button
|
||||
v-if="status === null || status === 'error'"
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerDownload"
|
||||
>
|
||||
<i class="pi pi-download" />
|
||||
{{ $t('g.downloadWithSize', { size: fileSize }) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="(status === null || status === 'error') && !!props.url"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="copyURL"
|
||||
>
|
||||
{{ $t('g.copyURL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="status === 'in_progress' || status === 'paused'"
|
||||
class="flex flex-row items-center gap-2"
|
||||
>
|
||||
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
|
||||
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
|
||||
-->
|
||||
<ProgressBar
|
||||
class="flex-1"
|
||||
:value="downloadProgress"
|
||||
:show-value="downloadProgress > 10"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="status === 'in_progress'"
|
||||
v-tooltip.top="t('electronFileDownload.pause')"
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerPauseDownload"
|
||||
>
|
||||
<i class="pi pi-pause-circle" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="status === 'paused'"
|
||||
v-tooltip.top="t('electronFileDownload.resume')"
|
||||
class="file-action-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:aria-label="t('electronFileDownload.resume')"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerResumeDownload"
|
||||
>
|
||||
<i class="pi pi-play-circle" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.top="t('electronFileDownload.cancel')"
|
||||
class="file-action-button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:aria-label="t('electronFileDownload.cancel')"
|
||||
:disabled="!!props.error"
|
||||
@click="triggerCancelDownload"
|
||||
>
|
||||
<i class="pi pi-times-circle" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
hint?: string
|
||||
label?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const downloadProgress = ref<number>(0)
|
||||
const status = ref<string | null>(null)
|
||||
const fileSize = computed(() =>
|
||||
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
|
||||
)
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const electronDownloadStore = useElectronDownloadStore()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const [savePath, filename] = props.label.split('/')
|
||||
|
||||
electronDownloadStore.$subscribe((_, { downloads }) => {
|
||||
const download = downloads.find((download) => props.url === download.url)
|
||||
|
||||
if (download) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
downloadProgress.value = Number((download.progress * 100).toFixed(1))
|
||||
// @ts-expect-error fixme ts strict error
|
||||
status.value = download.status
|
||||
}
|
||||
})
|
||||
|
||||
const triggerDownload = async () => {
|
||||
await electronDownloadStore.start({
|
||||
url: props.url,
|
||||
savePath: savePath.trim(),
|
||||
filename: filename.trim()
|
||||
})
|
||||
}
|
||||
|
||||
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
|
||||
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
|
||||
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
|
||||
|
||||
const copyURL = async () => {
|
||||
await copyToClipboard(props.url)
|
||||
}
|
||||
</script>
|
||||
69
src/components/common/FileDownload.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<!-- A file download button with a label and a size hint -->
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div>
|
||||
<div>
|
||||
<span :title="hint">{{ label }}</span>
|
||||
</div>
|
||||
<Message
|
||||
v-if="props.error"
|
||||
severity="error"
|
||||
icon="pi pi-exclamation-triangle"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
class="my-2 h-min max-w-xs px-1"
|
||||
:title="props.error"
|
||||
:pt="{
|
||||
text: { class: 'overflow-hidden text-ellipsis' }
|
||||
}"
|
||||
>
|
||||
{{ props.error }}
|
||||
</Message>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:disabled="!!props.error"
|
||||
:title="props.url"
|
||||
@click="download.triggerBrowserDownload"
|
||||
>
|
||||
{{ $t('g.downloadWithSize', { size: fileSize }) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
|
||||
{{ $t('g.copyURL') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
hint?: string
|
||||
label?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const fileSize = computed(() =>
|
||||
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
|
||||
)
|
||||
const copyURL = async () => {
|
||||
await copyToClipboard(props.url)
|
||||
}
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
@@ -117,18 +117,20 @@ function getFormComponent(item: FormItem): Component {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.form-input :deep(.input-slider) .p-inputnumber input,
|
||||
.form-input :deep(.input-slider) .slider-part {
|
||||
width: 5rem;
|
||||
@apply w-20;
|
||||
}
|
||||
|
||||
.form-input :deep(.input-knob) .p-inputnumber input,
|
||||
.form-input :deep(.input-knob) .knob-part {
|
||||
width: 8rem;
|
||||
@apply w-32;
|
||||
}
|
||||
|
||||
.form-input :deep(.p-inputtext),
|
||||
.form-input :deep(.p-select) {
|
||||
width: 11rem;
|
||||
@apply w-44;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -133,6 +133,8 @@ const wrapperStyle = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-inputtext) {
|
||||
--p-form-field-padding-x: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Chip removable @remove="emit('remove', $event)">
|
||||
<Badge size="small" :class="semanticBadgeClass">
|
||||
<Chip removable @remove="$emit('remove', $event)">
|
||||
<Badge size="small" :class="badgeClass">
|
||||
{{ badge }}
|
||||
</Badge>
|
||||
{{ text }}
|
||||
@@ -10,7 +10,6 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from 'primevue/badge'
|
||||
import Chip from 'primevue/chip'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface SearchFilter {
|
||||
text: string
|
||||
@@ -19,19 +18,26 @@ export interface SearchFilter {
|
||||
id: string | number
|
||||
}
|
||||
|
||||
const semanticClassMap: Record<string, string> = {
|
||||
'i-badge': 'bg-green-500 text-white',
|
||||
'o-badge': 'bg-red-500 text-white',
|
||||
'c-badge': 'bg-blue-500 text-white',
|
||||
's-badge': 'bg-yellow-500'
|
||||
defineProps<Omit<SearchFilter, 'id'>>()
|
||||
defineEmits(['remove'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.i-badge) {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
|
||||
const props = defineProps<Omit<SearchFilter, 'id'>>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'remove', event: Event): void
|
||||
}>()
|
||||
:deep(.o-badge) {
|
||||
@apply bg-red-500 text-white;
|
||||
}
|
||||
|
||||
const semanticBadgeClass = computed(() => {
|
||||
return semanticClassMap[props.badgeClass] ?? props.badgeClass
|
||||
})
|
||||
</script>
|
||||
:deep(.c-badge) {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
|
||||
:deep(.s-badge) {
|
||||
@apply bg-yellow-500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { mount } from '@vue/test-utils'
|
||||
import type { FlattenedItem } from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
@@ -10,25 +9,12 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn().mockReturnValue('left')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeBookmarkStore', () => ({
|
||||
useNodeBookmarkStore: () => ({
|
||||
isBookmarked: vi.fn().mockReturnValue(false),
|
||||
toggleBookmark: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}))
|
||||
@@ -92,7 +78,6 @@ describe('TreeExplorerV2Node', () => {
|
||||
return {
|
||||
wrapper: mount(TreeExplorerV2Node, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
TreeItem: treeItemStub.stub,
|
||||
Teleport: { template: '<div />' }
|
||||
|
||||
@@ -24,25 +24,6 @@
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'flex size-6 shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted-foreground hover:text-foreground',
|
||||
'opacity-0 group-hover/tree-node:opacity-100'
|
||||
)
|
||||
"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
|
||||
'text-xs'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Folder -->
|
||||
@@ -52,15 +33,6 @@
|
||||
:style="rowStyle"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
>
|
||||
<i
|
||||
v-if="item.hasChildren"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
!isExpanded && '-rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<i
|
||||
:class="cn(item.value.icon, 'size-4 shrink-0 text-muted-foreground')"
|
||||
/>
|
||||
@@ -69,6 +41,15 @@
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
<i
|
||||
v-if="item.hasChildren"
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-down] mr-4 size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
!isExpanded && '-rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</TreeItem>
|
||||
|
||||
@@ -92,7 +73,6 @@ import { computed, inject } from 'vue'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
@@ -113,21 +93,9 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const contextMenuNode = inject(InjectKeyContextMenuNode)
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
const nodeDef = computed(() => item.value.data)
|
||||
|
||||
const isBookmarked = computed(() => {
|
||||
if (!nodeDef.value) return false
|
||||
return nodeBookmarkStore.isBookmarked(nodeDef.value)
|
||||
})
|
||||
|
||||
function toggleBookmark() {
|
||||
if (nodeDef.value) {
|
||||
nodeBookmarkStore.toggleBookmark(nodeDef.value)
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
previewRef,
|
||||
showPreview,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -28,13 +27,8 @@ const { menuItems } = useWorkflowActionsMenu(
|
||||
{ isRoot: true }
|
||||
)
|
||||
|
||||
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
|
||||
() => menuItems.value
|
||||
)
|
||||
|
||||
function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source
|
||||
})
|
||||
@@ -45,7 +39,7 @@ function handleOpen(open: boolean) {
|
||||
<template>
|
||||
<DropdownMenuRoot @update:open="handleOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button" :has-unseen-items="hasUnseenItems">
|
||||
<slot name="button">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
@@ -55,7 +49,7 @@ function handleOpen(open: boolean) {
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
class="relative h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
class="h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<i
|
||||
class="size-4"
|
||||
@@ -66,11 +60,6 @@ function handleOpen(open: boolean) {
|
||||
"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</slot>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -17,7 +17,7 @@ function createWrapper(items: WorkflowMenuItem[]) {
|
||||
describe('WorkflowActionsList', () => {
|
||||
it('renders action items with label and icon', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() }
|
||||
{ label: 'Save', icon: 'pi pi-save', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
@@ -28,9 +28,9 @@ describe('WorkflowActionsList', () => {
|
||||
|
||||
it('renders separator items', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ id: 'before', label: 'Before', icon: 'pi pi-a', command: vi.fn() },
|
||||
{ label: 'Before', icon: 'pi pi-a', command: vi.fn() },
|
||||
{ separator: true },
|
||||
{ id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() }
|
||||
{ label: 'After', icon: 'pi pi-b', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
@@ -44,7 +44,7 @@ describe('WorkflowActionsList', () => {
|
||||
it('dispatches command on select', async () => {
|
||||
const command = vi.fn()
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ id: 'action', label: 'Action', icon: 'pi pi-play', command }
|
||||
{ label: 'Action', icon: 'pi pi-play', command }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
@@ -57,7 +57,6 @@ describe('WorkflowActionsList', () => {
|
||||
it('renders badge when present', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{
|
||||
id: 'new-feature',
|
||||
label: 'New Feature',
|
||||
icon: 'pi pi-star',
|
||||
command: vi.fn(),
|
||||
@@ -72,7 +71,7 @@ describe('WorkflowActionsList', () => {
|
||||
|
||||
it('does not render badge when absent', () => {
|
||||
const items: WorkflowMenuAction[] = [
|
||||
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
|
||||
{ label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
function mountEditor(points: CurvePoint[], extraProps = {}) {
|
||||
return mount(CurveEditor, {
|
||||
props: { modelValue: points, ...extraProps }
|
||||
})
|
||||
}
|
||||
|
||||
function getCurvePath(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.find('[data-testid="curve-path"]')
|
||||
}
|
||||
|
||||
describe('CurveEditor', () => {
|
||||
it('renders SVG with curve path', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
const curvePath = getCurvePath(wrapper)
|
||||
expect(curvePath.exists()).toBe(true)
|
||||
expect(curvePath.attributes('d')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a circle for each control point', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[0.5, 0.7],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.findAll('circle')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders histogram path when provided', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const wrapper = mountEditor(
|
||||
[
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
{ histogram }
|
||||
)
|
||||
const histogramPath = wrapper.find('[data-testid="histogram-path"]')
|
||||
expect(histogramPath.exists()).toBe(true)
|
||||
expect(histogramPath.attributes('d')).toContain('M0,1')
|
||||
})
|
||||
|
||||
it('does not render histogram path when not provided', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns empty path with fewer than 2 points', () => {
|
||||
const wrapper = mountEditor([[0.5, 0.5]])
|
||||
expect(getCurvePath(wrapper).attributes('d')).toBe('')
|
||||
})
|
||||
|
||||
it('generates path starting with M and containing L segments', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
])
|
||||
const d = getCurvePath(wrapper).attributes('d')!
|
||||
expect(d).toMatch(/^M/)
|
||||
expect(d).toContain('L')
|
||||
})
|
||||
|
||||
it('curve path only spans the x-range of control points', () => {
|
||||
const wrapper = mountEditor([
|
||||
[0.2, 0.3],
|
||||
[0.8, 0.9]
|
||||
])
|
||||
const d = getCurvePath(wrapper).attributes('d')!
|
||||
const xValues = d
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[0]))
|
||||
expect(Math.min(...xValues)).toBeCloseTo(0.2, 2)
|
||||
expect(Math.max(...xValues)).toBeCloseTo(0.8, 2)
|
||||
})
|
||||
|
||||
it('deletes a point on right-click but keeps minimum 2', async () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
[1, 1]
|
||||
]
|
||||
const wrapper = mountEditor(points)
|
||||
expect(wrapper.findAll('circle')).toHaveLength(3)
|
||||
|
||||
await wrapper.findAll('circle')[1].trigger('pointerdown', {
|
||||
button: 2,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(wrapper.findAll('circle')).toHaveLength(2)
|
||||
|
||||
await wrapper.findAll('circle')[0].trigger('pointerdown', {
|
||||
button: 2,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(wrapper.findAll('circle')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -1,104 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
ref="svgRef"
|
||||
viewBox="-0.04 -0.04 1.08 1.08"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
|
||||
@pointerdown.stop="handleSvgPointerDown"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<line
|
||||
v-for="v in [0.25, 0.5, 0.75]"
|
||||
:key="'h' + v"
|
||||
:x1="0"
|
||||
:y1="v"
|
||||
:x2="1"
|
||||
:y2="v"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.1"
|
||||
stroke-width="0.003"
|
||||
/>
|
||||
<line
|
||||
v-for="v in [0.25, 0.5, 0.75]"
|
||||
:key="'v' + v"
|
||||
:x1="v"
|
||||
:y1="0"
|
||||
:x2="v"
|
||||
:y2="1"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.1"
|
||||
stroke-width="0.003"
|
||||
/>
|
||||
|
||||
<line
|
||||
x1="0"
|
||||
y1="1"
|
||||
x2="1"
|
||||
y2="0"
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.15"
|
||||
stroke-width="0.003"
|
||||
/>
|
||||
|
||||
<path
|
||||
v-if="histogramPath"
|
||||
data-testid="histogram-path"
|
||||
:d="histogramPath"
|
||||
:fill="curveColor"
|
||||
fill-opacity="0.15"
|
||||
stroke="none"
|
||||
/>
|
||||
|
||||
<path
|
||||
data-testid="curve-path"
|
||||
:d="curvePath"
|
||||
fill="none"
|
||||
:stroke="curveColor"
|
||||
stroke-width="0.008"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<circle
|
||||
v-for="(point, i) in modelValue"
|
||||
:key="i"
|
||||
:cx="point[0]"
|
||||
:cy="1 - point[1]"
|
||||
r="0.02"
|
||||
:fill="curveColor"
|
||||
stroke="white"
|
||||
stroke-width="0.004"
|
||||
class="cursor-grab"
|
||||
@pointerdown.stop="startDrag(i, $event)"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import { useCurveEditor } from '@/composables/useCurveEditor'
|
||||
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import { histogramToPath } from './curveUtils'
|
||||
|
||||
const { curveColor = 'white', histogram } = defineProps<{
|
||||
curveColor?: string
|
||||
histogram?: Uint32Array | null
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
required: true
|
||||
})
|
||||
|
||||
const svgRef = useTemplateRef<SVGSVGElement>('svgRef')
|
||||
|
||||
const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
|
||||
svgRef,
|
||||
modelValue
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
histogram ? histogramToPath(histogram) : ''
|
||||
)
|
||||
</script>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<CurveEditor v-model="modelValue" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
const modelValue = defineModel<CurvePoint[]>({
|
||||
default: () => [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import {
|
||||
createMonotoneInterpolator,
|
||||
curvesToLUT,
|
||||
histogramToPath
|
||||
} from './curveUtils'
|
||||
|
||||
describe('createMonotoneInterpolator', () => {
|
||||
it('returns 0 for empty points', () => {
|
||||
const interpolate = createMonotoneInterpolator([])
|
||||
expect(interpolate(0.5)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns constant for single point', () => {
|
||||
const interpolate = createMonotoneInterpolator([[0.5, 0.7]])
|
||||
expect(interpolate(0)).toBe(0.7)
|
||||
expect(interpolate(1)).toBe(0.7)
|
||||
})
|
||||
|
||||
it('passes through control points exactly', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.8, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
|
||||
it('clamps to endpoint values outside range', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0.2, 0.3],
|
||||
[0.8, 0.9]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBe(0.3)
|
||||
expect(interpolate(1)).toBe(0.9)
|
||||
})
|
||||
|
||||
it('produces monotone output for monotone input', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[0, 0],
|
||||
[0.25, 0.2],
|
||||
[0.5, 0.5],
|
||||
[0.75, 0.8],
|
||||
[1, 1]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
|
||||
let prev = -Infinity
|
||||
for (let x = 0; x <= 1; x += 0.01) {
|
||||
const y = interpolate(x)
|
||||
expect(y).toBeGreaterThanOrEqual(prev)
|
||||
prev = y
|
||||
}
|
||||
})
|
||||
|
||||
it('handles unsorted input points', () => {
|
||||
const points: CurvePoint[] = [
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
[0.5, 0.5]
|
||||
]
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
expect(interpolate(0)).toBeCloseTo(0, 5)
|
||||
expect(interpolate(0.5)).toBeCloseTo(0.5, 5)
|
||||
expect(interpolate(1)).toBeCloseTo(1, 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('curvesToLUT', () => {
|
||||
it('returns a 256-entry Uint8Array', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(lut).toBeInstanceOf(Uint8Array)
|
||||
expect(lut.length).toBe(256)
|
||||
})
|
||||
|
||||
it('produces identity LUT for diagonal curve', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeCloseTo(i, 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('clamps output to [0, 255]', () => {
|
||||
const lut = curvesToLUT([
|
||||
[0, 0],
|
||||
[0.5, 1.5],
|
||||
[1, 1]
|
||||
])
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(lut[i]).toBeGreaterThanOrEqual(0)
|
||||
expect(lut[i]).toBeLessThanOrEqual(255)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('histogramToPath', () => {
|
||||
it('returns empty string for empty histogram', () => {
|
||||
expect(histogramToPath(new Uint32Array(0))).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string when all bins are zero', () => {
|
||||
expect(histogramToPath(new Uint32Array(256))).toBe('')
|
||||
})
|
||||
|
||||
it('returns a closed SVG path for valid histogram', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const path = histogramToPath(histogram)
|
||||
expect(path).toMatch(/^M0,1/)
|
||||
expect(path).toMatch(/L1,1 Z$/)
|
||||
})
|
||||
|
||||
it('normalizes using 99.5th percentile to suppress outliers', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = 100
|
||||
histogram[255] = 100000
|
||||
const path = histogramToPath(histogram)
|
||||
// Most bins should map to y=0 (1 - 100/100 = 0) since
|
||||
// the 99.5th percentile is 100, not the outlier 100000
|
||||
const yValues = path
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[1]))
|
||||
.filter((y) => !isNaN(y))
|
||||
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
|
||||
expect(nearZero.length).toBeGreaterThan(200)
|
||||
})
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
/**
|
||||
* Monotone cubic Hermite interpolation.
|
||||
* Produces a smooth curve that passes through all control points
|
||||
* without overshooting (monotone property).
|
||||
*
|
||||
* Returns a function that evaluates y for any x in [0, 1].
|
||||
*/
|
||||
export function createMonotoneInterpolator(
|
||||
points: CurvePoint[]
|
||||
): (x: number) => number {
|
||||
if (points.length === 0) return () => 0
|
||||
if (points.length === 1) return () => points[0][1]
|
||||
|
||||
const sorted = [...points].sort((a, b) => a[0] - b[0])
|
||||
const n = sorted.length
|
||||
const xs = sorted.map((p) => p[0])
|
||||
const ys = sorted.map((p) => p[1])
|
||||
|
||||
const deltas: number[] = []
|
||||
const slopes: number[] = []
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const dx = xs[i + 1] - xs[i]
|
||||
deltas.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx)
|
||||
}
|
||||
|
||||
slopes.push(deltas[0] ?? 0)
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
if (deltas[i - 1] * deltas[i] <= 0) {
|
||||
slopes.push(0)
|
||||
} else {
|
||||
slopes.push((deltas[i - 1] + deltas[i]) / 2)
|
||||
}
|
||||
}
|
||||
slopes.push(deltas[n - 2] ?? 0)
|
||||
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
if (deltas[i] === 0) {
|
||||
slopes[i] = 0
|
||||
slopes[i + 1] = 0
|
||||
} else {
|
||||
const alpha = slopes[i] / deltas[i]
|
||||
const beta = slopes[i + 1] / deltas[i]
|
||||
const s = alpha * alpha + beta * beta
|
||||
if (s > 9) {
|
||||
const t = 3 / Math.sqrt(s)
|
||||
slopes[i] = t * alpha * deltas[i]
|
||||
slopes[i + 1] = t * beta * deltas[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (x: number): number => {
|
||||
if (x <= xs[0]) return ys[0]
|
||||
if (x >= xs[n - 1]) return ys[n - 1]
|
||||
|
||||
let lo = 0
|
||||
let hi = n - 1
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (xs[mid] <= x) lo = mid
|
||||
else hi = mid
|
||||
}
|
||||
|
||||
const dx = xs[hi] - xs[lo]
|
||||
if (dx === 0) return ys[lo]
|
||||
|
||||
const t = (x - xs[lo]) / dx
|
||||
const t2 = t * t
|
||||
const t3 = t2 * t
|
||||
|
||||
const h00 = 2 * t3 - 3 * t2 + 1
|
||||
const h10 = t3 - 2 * t2 + t
|
||||
const h01 = -2 * t3 + 3 * t2
|
||||
const h11 = t3 - t2
|
||||
|
||||
return (
|
||||
h00 * ys[lo] +
|
||||
h10 * dx * slopes[lo] +
|
||||
h01 * ys[hi] +
|
||||
h11 * dx * slopes[hi]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 256-bin histogram into an SVG path string.
|
||||
* Normalizes using the 99.5th percentile to avoid outlier spikes.
|
||||
*/
|
||||
export function histogramToPath(histogram: Uint32Array): string {
|
||||
if (!histogram.length) return ''
|
||||
|
||||
const sorted = Array.from(histogram).sort((a, b) => a - b)
|
||||
const max = sorted[Math.floor(255 * 0.995)]
|
||||
if (max === 0) return ''
|
||||
|
||||
const invMax = 1 / max
|
||||
const parts: string[] = ['M0,1']
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = i / 255
|
||||
const y = 1 - Math.min(1, histogram[i] * invMax)
|
||||
parts.push(`L${x},${y}`)
|
||||
}
|
||||
parts.push('L1,1 Z')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
|
||||
const lut = new Uint8Array(256)
|
||||
const interpolate = createMonotoneInterpolator(points)
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = i / 255
|
||||
const y = interpolate(x)
|
||||
lut[i] = Math.max(0, Math.min(255, Math.round(y * 255)))
|
||||
}
|
||||
|
||||
return lut
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export type CurvePoint = [x: number, y: number]
|
||||
@@ -267,6 +267,16 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="template.vram" #top-left>
|
||||
<SquareChip
|
||||
:label="formatSize(template.vram)"
|
||||
:title="t('templateWorkflows.vramEstimateTooltip')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--cpu] h-3 w-3" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<template v-if="template.tags && template.tags.length > 0">
|
||||
<SquareChip
|
||||
@@ -387,6 +397,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -438,6 +449,7 @@ onMounted(() => {
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const distributions = computed(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
switch (__DISTRIBUTION__) {
|
||||
case 'cloud':
|
||||
return [TemplateIncludeOnDistributionEnum.Cloud]
|
||||
@@ -723,6 +735,10 @@ const sortOptions = computed(() => [
|
||||
name: t('templateWorkflows.sort.recommended', 'Recommended'),
|
||||
value: 'recommended'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.similarToCurrent', 'Similar to Current'),
|
||||
value: 'similar-to-current'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.popular', 'Popular'),
|
||||
value: 'popular'
|
||||
|
||||
367
src/components/developerProfile/DeveloperProfileDialog.test.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
DeveloperProfile,
|
||||
MarketplaceTemplate,
|
||||
TemplateReview,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
watchDebounced: vi.fn((source: unknown, cb: unknown, opts: unknown) => {
|
||||
const typedActual = actual as {
|
||||
watchDebounced: (...args: unknown[]) => unknown
|
||||
}
|
||||
return typedActual.watchDebounced(source, cb, {
|
||||
...(opts as object),
|
||||
debounce: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const stubProfile: DeveloperProfile = {
|
||||
username: '@StoneCypher',
|
||||
displayName: 'Stone Cypher',
|
||||
avatarUrl: undefined,
|
||||
bannerUrl: undefined,
|
||||
bio: 'Workflow designer',
|
||||
isVerified: true,
|
||||
monetizationEnabled: true,
|
||||
joinedAt: new Date('2024-03-15'),
|
||||
dependencies: 371,
|
||||
totalDownloads: 1000,
|
||||
totalFavorites: 50,
|
||||
averageRating: 4.2,
|
||||
templateCount: 2
|
||||
}
|
||||
|
||||
const stubReviews: TemplateReview[] = [
|
||||
{
|
||||
id: 'rev-1',
|
||||
authorName: 'Reviewer',
|
||||
rating: 4.5,
|
||||
text: 'Great work!',
|
||||
createdAt: new Date('2025-10-01'),
|
||||
templateId: 'tpl-1'
|
||||
}
|
||||
]
|
||||
|
||||
const stubTemplate: MarketplaceTemplate = {
|
||||
id: 'tpl-1',
|
||||
title: 'Test Template',
|
||||
description: 'Desc',
|
||||
shortDescription: 'Short',
|
||||
author: {
|
||||
id: 'usr-1',
|
||||
name: 'Stone Cypher',
|
||||
isVerified: true,
|
||||
profileUrl: '/u'
|
||||
},
|
||||
categories: [],
|
||||
tags: [],
|
||||
difficulty: 'beginner',
|
||||
requiredModels: [],
|
||||
requiredNodes: [],
|
||||
requiresCustomNodes: [],
|
||||
vramRequirement: 0,
|
||||
thumbnail: '',
|
||||
gallery: [],
|
||||
workflowPreview: '',
|
||||
license: 'mit',
|
||||
version: '1.0.0',
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
stats: {
|
||||
downloads: 500,
|
||||
favorites: 30,
|
||||
rating: 4,
|
||||
reviewCount: 5,
|
||||
weeklyTrend: 1
|
||||
}
|
||||
}
|
||||
|
||||
const stubRevenue: TemplateRevenue[] = [
|
||||
{
|
||||
templateId: 'tpl-1',
|
||||
totalRevenue: 5000,
|
||||
monthlyRevenue: 500,
|
||||
currency: 'USD'
|
||||
}
|
||||
]
|
||||
|
||||
const mockService = vi.hoisted(() => ({
|
||||
getCurrentUsername: vi.fn(() => '@StoneCypher'),
|
||||
fetchDeveloperProfile: vi.fn(() => Promise.resolve({ ...stubProfile })),
|
||||
fetchDeveloperReviews: vi.fn(() => Promise.resolve([...stubReviews])),
|
||||
fetchPublishedTemplates: vi.fn(() => Promise.resolve([{ ...stubTemplate }])),
|
||||
fetchTemplateRevenue: vi.fn(() => Promise.resolve([...stubRevenue])),
|
||||
fetchDownloadHistory: vi.fn(() => Promise.resolve([])),
|
||||
unpublishTemplate: vi.fn(() => Promise.resolve()),
|
||||
saveDeveloperProfile: vi.fn((p: Partial<DeveloperProfile>) =>
|
||||
Promise.resolve({ ...stubProfile, ...p })
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/services/developerProfileService', () => mockService)
|
||||
|
||||
import DeveloperProfileDialog from './DeveloperProfileDialog.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
developerProfile: {
|
||||
dialogTitle: 'Developer Profile',
|
||||
username: 'Username',
|
||||
bio: 'Bio',
|
||||
reviews: 'Reviews',
|
||||
publishedTemplates: 'Published Templates',
|
||||
dependencies: 'Dependencies',
|
||||
totalDownloads: 'Downloads',
|
||||
totalFavorites: 'Favorites',
|
||||
averageRating: 'Avg. Rating',
|
||||
templateCount: 'Templates',
|
||||
revenue: 'Revenue',
|
||||
monthlyRevenue: 'Monthly',
|
||||
totalRevenue: 'Total',
|
||||
noReviews: 'No reviews yet',
|
||||
noTemplates: 'No published templates yet',
|
||||
unpublish: 'Unpublish',
|
||||
save: 'Save Profile',
|
||||
saving: 'Saving...',
|
||||
verified: 'Verified',
|
||||
quickActions: 'Quick Actions',
|
||||
bannerPlaceholder: 'Banner image',
|
||||
editUsername: 'Edit username',
|
||||
editBio: 'Edit bio',
|
||||
lookupHandle: 'Enter developer handle\u2026',
|
||||
downloads: 'Downloads',
|
||||
favorites: 'Favorites',
|
||||
rating: 'Rating'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountDialog(props?: { username?: string }) {
|
||||
return mount(DeveloperProfileDialog, {
|
||||
props: {
|
||||
onClose: vi.fn(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
BaseModalLayout: {
|
||||
template: `
|
||||
<div data-testid="modal">
|
||||
<div data-testid="header"><slot name="header" /></div>
|
||||
<div data-testid="header-right"><slot name="header-right-area" /></div>
|
||||
<div data-testid="content"><slot name="content" /></div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
ReviewCard: {
|
||||
template: '<div data-testid="review-card" />',
|
||||
props: ['review']
|
||||
},
|
||||
TemplateListItem: {
|
||||
template:
|
||||
'<div data-testid="template-list-item" :data-show-revenue="showRevenue" :data-is-current-user="isCurrentUser" />',
|
||||
props: ['template', 'revenue', 'showRevenue', 'isCurrentUser']
|
||||
},
|
||||
DownloadHistoryChart: {
|
||||
template: '<div data-testid="download-history-chart" />',
|
||||
props: ['entries']
|
||||
},
|
||||
Button: {
|
||||
template: '<button><slot /></button>',
|
||||
props: ['variant', 'size', 'disabled']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('DeveloperProfileDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockService.getCurrentUsername.mockReturnValue('@StoneCypher')
|
||||
mockService.fetchDeveloperProfile.mockResolvedValue({ ...stubProfile })
|
||||
mockService.fetchDeveloperReviews.mockResolvedValue([...stubReviews])
|
||||
mockService.fetchPublishedTemplates.mockResolvedValue([{ ...stubTemplate }])
|
||||
mockService.fetchTemplateRevenue.mockResolvedValue([...stubRevenue])
|
||||
})
|
||||
|
||||
it('renders the banner section', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="banner-section"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows username input when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="username-input"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="username-text"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows username text when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="username-text"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="username-input"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows bio input when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="bio-input"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows bio text when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="bio-text"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders review cards', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-testid="review-card"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders template list items', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-testid="template-list-item"]')).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('passes showRevenue=true when current user with monetization', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-testid="template-list-item"]')
|
||||
expect(item.attributes('data-show-revenue')).toBe('true')
|
||||
})
|
||||
|
||||
it('passes showRevenue=false when not current user', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
const item = wrapper.find('[data-testid="template-list-item"]')
|
||||
expect(item.attributes('data-show-revenue')).toBe('false')
|
||||
})
|
||||
|
||||
it('shows quick actions when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="quick-actions"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides quick actions when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="quick-actions"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows save button when viewing own profile', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
expect(headerRight.text()).toContain('Save Profile')
|
||||
})
|
||||
|
||||
it('hides save button when viewing another profile', async () => {
|
||||
const wrapper = mountDialog({ username: '@OtherUser' })
|
||||
await flushPromises()
|
||||
const headerRight = wrapper.find('[data-testid="header-right"]')
|
||||
expect(headerRight.text()).not.toContain('Save Profile')
|
||||
})
|
||||
|
||||
it('renders summary stats', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const stats = wrapper.find('[data-testid="summary-stats"]')
|
||||
expect(stats.exists()).toBe(true)
|
||||
expect(stats.text()).toContain('371')
|
||||
expect(stats.text()).toContain('1,000')
|
||||
expect(stats.text()).toContain('50')
|
||||
})
|
||||
|
||||
it('renders the handle input with the default username', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
const handleInput = wrapper.find('[data-testid="handle-input"]')
|
||||
expect(handleInput.exists()).toBe(true)
|
||||
expect((handleInput.element as HTMLInputElement).value).toBe('@StoneCypher')
|
||||
})
|
||||
|
||||
it('reloads data when the handle input changes', async () => {
|
||||
const otherProfile: DeveloperProfile = {
|
||||
...stubProfile,
|
||||
username: '@OtherDev',
|
||||
displayName: 'Other Dev',
|
||||
bio: 'Another developer',
|
||||
isVerified: false,
|
||||
monetizationEnabled: false,
|
||||
totalDownloads: 42
|
||||
}
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
// Initial load
|
||||
expect(mockService.fetchDeveloperProfile).toHaveBeenCalledWith(
|
||||
'@StoneCypher'
|
||||
)
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockService.fetchDeveloperProfile.mockResolvedValue(otherProfile)
|
||||
mockService.fetchDeveloperReviews.mockResolvedValue([])
|
||||
mockService.fetchPublishedTemplates.mockResolvedValue([])
|
||||
|
||||
const handleInput = wrapper.find('[data-testid="handle-input"]')
|
||||
await handleInput.setValue('@OtherDev')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockService.fetchDeveloperProfile).toHaveBeenCalledWith('@OtherDev')
|
||||
expect(wrapper.find('[data-testid="username-text"]').text()).toBe(
|
||||
'Other Dev'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears revenue when switching to a non-current-user handle', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await flushPromises()
|
||||
|
||||
// Revenue was loaded for current user
|
||||
expect(mockService.fetchTemplateRevenue).toHaveBeenCalled()
|
||||
vi.clearAllMocks()
|
||||
|
||||
const otherProfile: DeveloperProfile = {
|
||||
...stubProfile,
|
||||
username: '@Someone',
|
||||
monetizationEnabled: false
|
||||
}
|
||||
mockService.fetchDeveloperProfile.mockResolvedValue(otherProfile)
|
||||
mockService.fetchDeveloperReviews.mockResolvedValue([])
|
||||
mockService.fetchPublishedTemplates.mockResolvedValue([])
|
||||
|
||||
const handleInput = wrapper.find('[data-testid="handle-input"]')
|
||||
await handleInput.setValue('@Someone')
|
||||
await flushPromises()
|
||||
|
||||
// Revenue should NOT be fetched for other user
|
||||
expect(mockService.fetchTemplateRevenue).not.toHaveBeenCalled()
|
||||
// showRevenue should be false
|
||||
const item = wrapper.find('[data-testid="template-list-item"]')
|
||||
expect(item.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
322
src/components/developerProfile/DeveloperProfileDialog.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<BaseModalLayout :content-title="t('developerProfile.dialogTitle')" size="sm">
|
||||
<template #header>
|
||||
<input
|
||||
v-model="viewedUsername"
|
||||
type="text"
|
||||
:placeholder="t('developerProfile.lookupHandle')"
|
||||
class="h-8 w-48 rounded border border-border-default bg-secondary-background px-2 text-sm text-muted-foreground focus:outline-none"
|
||||
data-testid="handle-input"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="isCurrentUser" #header-right-area>
|
||||
<div class="mr-6">
|
||||
<Button size="lg" :disabled="isSaving" @click="saveProfile">
|
||||
{{
|
||||
isSaving ? t('developerProfile.saving') : t('developerProfile.save')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Banner Image -->
|
||||
<div
|
||||
class="h-48 w-full overflow-hidden rounded-lg bg-secondary-background"
|
||||
data-testid="banner-section"
|
||||
>
|
||||
<img
|
||||
v-if="profile?.bannerUrl"
|
||||
:src="profile.bannerUrl"
|
||||
:alt="t('developerProfile.bannerPlaceholder')"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex size-full items-center justify-center">
|
||||
<i
|
||||
class="icon-[lucide--image] size-10 text-muted-foreground opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar + Username + Bio -->
|
||||
<div class="flex items-start gap-4" data-testid="identity-section">
|
||||
<div
|
||||
class="flex size-16 shrink-0 items-center justify-center rounded-full bg-modal-panel-background"
|
||||
>
|
||||
<i class="icon-[lucide--user] size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="isCurrentUser">
|
||||
<input
|
||||
v-model="editableUsername"
|
||||
type="text"
|
||||
:placeholder="t('developerProfile.editUsername')"
|
||||
class="h-8 rounded border border-border-default bg-secondary-background px-2 text-sm focus:outline-none"
|
||||
data-testid="username-input"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-lg font-semibold" data-testid="username-text">
|
||||
{{ profile?.displayName ?? viewedUsername }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="profile?.isVerified"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2 py-0.5 text-xs text-blue-400"
|
||||
>
|
||||
<i class="icon-[lucide--badge-check] size-3" />
|
||||
{{ t('developerProfile.verified') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="isCurrentUser">
|
||||
<textarea
|
||||
v-model="editableBio"
|
||||
:placeholder="t('developerProfile.editBio')"
|
||||
rows="2"
|
||||
class="resize-none rounded border border-border-default bg-secondary-background px-2 py-1 text-sm text-muted-foreground focus:outline-none"
|
||||
data-testid="bio-input"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p
|
||||
v-if="profile?.bio"
|
||||
class="m-0 text-sm text-muted-foreground"
|
||||
data-testid="bio-text"
|
||||
>
|
||||
{{ profile.bio }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-5 gap-3" data-testid="summary-stats">
|
||||
<div
|
||||
v-for="stat in summaryStats"
|
||||
:key="stat.label"
|
||||
class="flex flex-col items-center rounded-lg bg-secondary-background p-3"
|
||||
>
|
||||
<span class="text-lg font-semibold">{{ stat.value }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ stat.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download History Chart -->
|
||||
<DownloadHistoryChart
|
||||
v-if="downloadHistory.length > 0"
|
||||
:entries="downloadHistory"
|
||||
/>
|
||||
|
||||
<!-- Quick Actions (current user only) -->
|
||||
<div
|
||||
v-if="isCurrentUser"
|
||||
class="rounded-lg border border-border-default p-4"
|
||||
data-testid="quick-actions"
|
||||
>
|
||||
<h3 class="m-0 mb-3 text-sm font-semibold">
|
||||
{{ t('developerProfile.quickActions') }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
variant="destructive-textonly"
|
||||
size="sm"
|
||||
@click="handleUnpublish(tpl.id)"
|
||||
>
|
||||
{{ t('developerProfile.unpublish') }}: {{ tpl.title }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reviews Section -->
|
||||
<div data-testid="reviews-section">
|
||||
<h3 class="m-0 mb-3 text-sm font-semibold">
|
||||
{{ t('developerProfile.reviews') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="reviews.length === 0"
|
||||
class="py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('developerProfile.noReviews') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex max-h-80 flex-col gap-2 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<ReviewCard
|
||||
v-for="review in reviews"
|
||||
:key="review.id"
|
||||
:review="review"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Published Templates Section -->
|
||||
<div data-testid="templates-section">
|
||||
<h3 class="m-0 mb-3 text-sm font-semibold">
|
||||
{{ t('developerProfile.publishedTemplates') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="templates.length === 0"
|
||||
class="py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('developerProfile.noTemplates') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex max-h-96 flex-col gap-2 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<TemplateListItem
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
:template="tpl"
|
||||
:revenue="revenueByTemplateId[tpl.id]"
|
||||
:show-revenue="showRevenueColumn"
|
||||
:is-current-user="isCurrentUser"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import {
|
||||
fetchDeveloperProfile,
|
||||
fetchDeveloperReviews,
|
||||
fetchDownloadHistory,
|
||||
fetchPublishedTemplates,
|
||||
fetchTemplateRevenue,
|
||||
getCurrentUsername,
|
||||
saveDeveloperProfile,
|
||||
unpublishTemplate
|
||||
} from '@/services/developerProfileService'
|
||||
import type {
|
||||
DeveloperProfile,
|
||||
DownloadHistoryEntry,
|
||||
MarketplaceTemplate,
|
||||
TemplateReview,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
import DownloadHistoryChart from './DownloadHistoryChart.vue'
|
||||
import ReviewCard from './ReviewCard.vue'
|
||||
import TemplateListItem from './TemplateListItem.vue'
|
||||
|
||||
const { onClose, username } = defineProps<{
|
||||
onClose: () => void
|
||||
username?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
const viewedUsername = ref(username ?? getCurrentUsername())
|
||||
const isCurrentUser = computed(
|
||||
() => viewedUsername.value === getCurrentUsername()
|
||||
)
|
||||
|
||||
const profile = ref<DeveloperProfile | null>(null)
|
||||
const reviews = ref<TemplateReview[]>([])
|
||||
const templates = ref<MarketplaceTemplate[]>([])
|
||||
const revenue = ref<TemplateRevenue[]>([])
|
||||
const downloadHistory = ref<DownloadHistoryEntry[]>([])
|
||||
const isSaving = ref(false)
|
||||
|
||||
const editableUsername = ref('')
|
||||
const editableBio = ref('')
|
||||
|
||||
const revenueByTemplateId = computed(() => {
|
||||
const map: Record<string, TemplateRevenue> = {}
|
||||
for (const entry of revenue.value) {
|
||||
map[entry.templateId] = entry
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const showRevenueColumn = computed(
|
||||
() => isCurrentUser.value && (profile.value?.monetizationEnabled ?? false)
|
||||
)
|
||||
|
||||
const summaryStats = computed(() => [
|
||||
{
|
||||
label: t('developerProfile.dependencies'),
|
||||
value: (profile.value?.dependencies ?? 371).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.totalDownloads'),
|
||||
value: (profile.value?.totalDownloads ?? 0).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.totalFavorites'),
|
||||
value: (profile.value?.totalFavorites ?? 0).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.averageRating'),
|
||||
value: (profile.value?.averageRating ?? 0).toFixed(1)
|
||||
},
|
||||
{
|
||||
label: t('developerProfile.templateCount'),
|
||||
value: String(profile.value?.templateCount ?? 0)
|
||||
}
|
||||
])
|
||||
|
||||
watchDebounced(viewedUsername, () => void loadData(), { debounce: 500 })
|
||||
|
||||
async function loadData() {
|
||||
const handle = viewedUsername.value
|
||||
const [profileData, reviewsData, templatesData, historyData] =
|
||||
await Promise.all([
|
||||
fetchDeveloperProfile(handle),
|
||||
fetchDeveloperReviews(handle),
|
||||
fetchPublishedTemplates(handle),
|
||||
fetchDownloadHistory(handle)
|
||||
])
|
||||
profile.value = profileData
|
||||
reviews.value = reviewsData
|
||||
templates.value = templatesData
|
||||
downloadHistory.value = historyData
|
||||
editableUsername.value = profileData.displayName
|
||||
editableBio.value = profileData.bio ?? ''
|
||||
|
||||
if (isCurrentUser.value && profileData.monetizationEnabled) {
|
||||
revenue.value = await fetchTemplateRevenue(handle)
|
||||
} else {
|
||||
revenue.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
profile.value = await saveDeveloperProfile({
|
||||
...profile.value,
|
||||
displayName: editableUsername.value,
|
||||
bio: editableBio.value
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnpublish(templateId: string) {
|
||||
await unpublishTemplate(templateId)
|
||||
templates.value = templates.value.filter((t) => t.id !== templateId)
|
||||
}
|
||||
|
||||
void loadData()
|
||||
</script>
|
||||
217
src/components/developerProfile/DownloadHistoryChart.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DownloadHistoryEntry } from '@/types/templateMarketplace'
|
||||
|
||||
const { MockChart } = vi.hoisted(() => {
|
||||
const mockDestroyFn = vi.fn()
|
||||
|
||||
class MockChartClass {
|
||||
static register = vi.fn()
|
||||
static instances: MockChartClass[] = []
|
||||
type: string
|
||||
data: unknown
|
||||
destroy = mockDestroyFn
|
||||
|
||||
constructor(_canvas: unknown, config: { type: string; data: unknown }) {
|
||||
this.type = config.type
|
||||
this.data = config.data
|
||||
MockChartClass.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
return { MockChart: MockChartClass, mockDestroyFn }
|
||||
})
|
||||
|
||||
vi.mock('chart.js', () => ({
|
||||
Chart: MockChart,
|
||||
BarController: {},
|
||||
BarElement: {},
|
||||
CategoryScale: {},
|
||||
Filler: {},
|
||||
LineController: {},
|
||||
LineElement: {},
|
||||
LinearScale: {},
|
||||
PointElement: {},
|
||||
Tooltip: {}
|
||||
}))
|
||||
|
||||
import DownloadHistoryChart from './DownloadHistoryChart.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
developerProfile: {
|
||||
downloadHistory: 'Download History',
|
||||
range: {
|
||||
week: 'Week',
|
||||
month: 'Month',
|
||||
year: 'Year',
|
||||
allTime: 'All Time'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeEntries(count: number): DownloadHistoryEntry[] {
|
||||
const entries: DownloadHistoryEntry[] = []
|
||||
const now = new Date()
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const date = new Date(now)
|
||||
date.setDate(date.getDate() - i)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
entries.push({ date, downloads: 10 + i })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
async function mountChart(entries?: DownloadHistoryEntry[]) {
|
||||
const wrapper = mount(DownloadHistoryChart, {
|
||||
props: { entries: entries ?? makeEntries(730) },
|
||||
global: { plugins: [i18n] },
|
||||
attachTo: document.createElement('div')
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function lastInstance() {
|
||||
return MockChart.instances.at(-1)
|
||||
}
|
||||
|
||||
describe('DownloadHistoryChart', () => {
|
||||
beforeEach(() => {
|
||||
MockChart.instances = []
|
||||
})
|
||||
|
||||
it('renders all four range buttons', async () => {
|
||||
const wrapper = await mountChart()
|
||||
const buttons = wrapper.find('[data-testid="range-buttons"]')
|
||||
expect(buttons.exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="range-btn-week"]').text()).toBe('Week')
|
||||
expect(wrapper.find('[data-testid="range-btn-month"]').text()).toBe('Month')
|
||||
expect(wrapper.find('[data-testid="range-btn-year"]').text()).toBe('Year')
|
||||
expect(wrapper.find('[data-testid="range-btn-allTime"]').text()).toBe(
|
||||
'All Time'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to week range with active styling', async () => {
|
||||
const wrapper = await mountChart()
|
||||
const weekBtn = wrapper.find('[data-testid="range-btn-week"]')
|
||||
expect(weekBtn.classes()).toContain('font-semibold')
|
||||
})
|
||||
|
||||
it('creates a bar chart for week range', async () => {
|
||||
await mountChart()
|
||||
expect(lastInstance()?.type).toBe('bar')
|
||||
})
|
||||
|
||||
it('switches to month and creates a bar chart', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
expect(lastInstance()?.type).toBe('bar')
|
||||
})
|
||||
|
||||
it('switches to year and creates a line chart', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-year"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
expect(lastInstance()?.type).toBe('line')
|
||||
})
|
||||
|
||||
it('switches to allTime and creates a line chart', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
expect(lastInstance()?.type).toBe('line')
|
||||
})
|
||||
|
||||
it('destroys previous chart when switching ranges', async () => {
|
||||
const wrapper = await mountChart()
|
||||
const firstInstance = lastInstance()!
|
||||
|
||||
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(firstInstance.destroy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the heading text', async () => {
|
||||
const wrapper = await mountChart()
|
||||
expect(wrapper.text()).toContain('Download History')
|
||||
})
|
||||
|
||||
it('passes 7 data points for week range', async () => {
|
||||
await mountChart()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
expect(labels).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('passes 31 data points for month range', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-month"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
expect(labels).toHaveLength(31)
|
||||
})
|
||||
|
||||
it('downsamples year range to weekly buckets', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-year"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
// 365 days / 7 per bucket = 52 full + 1 partial = 53
|
||||
expect(labels).toHaveLength(Math.ceil(365 / 7))
|
||||
})
|
||||
|
||||
it('downsamples allTime range to monthly buckets', async () => {
|
||||
const wrapper = await mountChart()
|
||||
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
const chart = lastInstance()!
|
||||
const labels = (chart.data as { labels: string[] }).labels
|
||||
// 730 days / 30 per bucket = 24 full + 1 partial = 25
|
||||
expect(labels).toHaveLength(Math.ceil(730 / 30))
|
||||
})
|
||||
|
||||
it('sums downloads within each aggregated bucket', async () => {
|
||||
// 14 entries with downloads = 1 each, aggregated by 7 → 2 buckets of 7
|
||||
const entries = makeEntries(14).map((e) => ({ ...e, downloads: 1 }))
|
||||
const wrapper = mount(DownloadHistoryChart, {
|
||||
props: { entries },
|
||||
global: { plugins: [i18n] },
|
||||
attachTo: document.createElement('div')
|
||||
})
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
await wrapper.find('[data-testid="range-btn-allTime"]').trigger('click')
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
const chart = lastInstance()!
|
||||
const datasets = (chart.data as { datasets: { data: number[] }[] }).datasets
|
||||
// 14 / 30 per bucket → 1 bucket with all 14 summed
|
||||
expect(datasets[0].data).toEqual([14])
|
||||
})
|
||||
})
|
||||
209
src/components/developerProfile/DownloadHistoryChart.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-lg bg-secondary-background p-4"
|
||||
data-testid="download-history-chart"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="m-0 text-sm font-semibold">
|
||||
{{ t('developerProfile.downloadHistory') }}
|
||||
</h3>
|
||||
<div
|
||||
class="flex gap-1 rounded-md bg-modal-panel-background p-0.5"
|
||||
data-testid="range-buttons"
|
||||
>
|
||||
<button
|
||||
v-for="range in RANGES"
|
||||
:key="range"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded border-none px-2 py-1 text-xs transition-colors',
|
||||
selectedRange === range
|
||||
? 'bg-secondary-background font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)
|
||||
"
|
||||
:data-testid="`range-btn-${range}`"
|
||||
@click="selectedRange = range"
|
||||
>
|
||||
{{ t(`developerProfile.range.${range}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-62.5">
|
||||
<canvas ref="canvasRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
/** * Download history chart for the developer profile dashboard. * * Renders
|
||||
daily download counts using Chart.js, with a toggle group in the * upper-right
|
||||
corner that switches between four time ranges: * * - **Week** (7 bars) and
|
||||
**Month** (31 bars) render as bar charts. * - **Year** (weekly buckets) and
|
||||
**All Time** (monthly buckets) render as * filled area charts, with entries
|
||||
aggregated into summed buckets to keep * the point count manageable. * * @prop
|
||||
entries - Chronologically-ordered daily download history produced by * {@link
|
||||
fetchDownloadHistory}. */
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Filler,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
DownloadHistoryEntry,
|
||||
DownloadHistoryRange
|
||||
} from '@/types/templateMarketplace'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
Chart.register(
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Tooltip
|
||||
)
|
||||
|
||||
const RANGES: DownloadHistoryRange[] = ['week', 'month', 'year', 'allTime']
|
||||
|
||||
const BAR_COLOR = '#185A8B'
|
||||
|
||||
const { entries } = defineProps<{
|
||||
entries: DownloadHistoryEntry[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const selectedRange = ref<DownloadHistoryRange>('week')
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const chartInstance = shallowRef<Chart | null>(null)
|
||||
|
||||
/**
|
||||
* Aggregates entries into buckets by summing downloads and using the last
|
||||
* date in each bucket for the label.
|
||||
*/
|
||||
function aggregate(
|
||||
source: DownloadHistoryEntry[],
|
||||
bucketSize: number
|
||||
): DownloadHistoryEntry[] {
|
||||
const result: DownloadHistoryEntry[] = []
|
||||
for (let i = 0; i < source.length; i += bucketSize) {
|
||||
const bucket = source.slice(i, i + bucketSize)
|
||||
const downloads = bucket.reduce((sum, e) => sum + e.downloads, 0)
|
||||
result.push({ date: bucket[bucket.length - 1].date, downloads })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tail slice of entries matching the selected range, downsampled
|
||||
* for larger views, along with formatted date labels.
|
||||
*/
|
||||
function sliceEntries(range: DownloadHistoryRange): {
|
||||
labels: string[]
|
||||
data: number[]
|
||||
} {
|
||||
const count =
|
||||
range === 'week' ? 7 : range === 'month' ? 31 : range === 'year' ? 365 : 0
|
||||
|
||||
const sliced = count > 0 ? entries.slice(-count) : entries
|
||||
const sampled =
|
||||
range === 'year'
|
||||
? aggregate(sliced, 7)
|
||||
: range === 'allTime'
|
||||
? aggregate(sliced, 30)
|
||||
: sliced
|
||||
|
||||
const labels = sampled.map((e) => {
|
||||
const d = e.date
|
||||
if (range === 'week')
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short' })
|
||||
if (range === 'month') return String(d.getDate())
|
||||
if (range === 'year')
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' })
|
||||
})
|
||||
|
||||
return { labels, data: sampled.map((e) => e.downloads) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds or replaces the Chart.js instance on the canvas whenever range
|
||||
* or data changes.
|
||||
*/
|
||||
function renderChart() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
chartInstance.value?.destroy()
|
||||
|
||||
const range = selectedRange.value
|
||||
const isBar = range === 'week' || range === 'month'
|
||||
const { labels, data } = sliceEntries(range)
|
||||
|
||||
chartInstance.value = new Chart(canvas, {
|
||||
type: isBar ? 'bar' : 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data,
|
||||
backgroundColor: isBar ? BAR_COLOR : `${BAR_COLOR}33`,
|
||||
borderColor: BAR_COLOR,
|
||||
borderWidth: isBar ? 0 : 2,
|
||||
borderRadius: isBar ? { topLeft: 4, topRight: 4 } : undefined,
|
||||
fill: !isBar,
|
||||
tension: 0.3,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
transitions: { active: { animation: { duration: 0 } } },
|
||||
plugins: { legend: { display: false }, tooltip: { mode: 'index' } },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: '#9FA2BD',
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: isBar ? undefined : 12
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#9FA2BD22' },
|
||||
ticks: { color: '#9FA2BD' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch([selectedRange, () => entries], renderChart, { flush: 'post' })
|
||||
|
||||
watch(canvasRef, (el) => {
|
||||
if (el) renderChart()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chartInstance.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
63
src/components/developerProfile/ReviewCard.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
import type { TemplateReview } from '@/types/templateMarketplace'
|
||||
|
||||
import ReviewCard from './ReviewCard.vue'
|
||||
|
||||
function makeReview(overrides?: Partial<TemplateReview>): TemplateReview {
|
||||
return {
|
||||
id: 'rev-1',
|
||||
authorName: 'TestUser',
|
||||
authorAvatarUrl: undefined,
|
||||
rating: 4,
|
||||
text: 'Great template!',
|
||||
createdAt: new Date('2025-10-15'),
|
||||
templateId: 'tpl-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function mountCard(review: TemplateReview) {
|
||||
return mount(ReviewCard, {
|
||||
props: { review },
|
||||
global: {
|
||||
stubs: {
|
||||
StarRating: {
|
||||
template: '<span data-testid="star-rating" :data-rating="rating" />',
|
||||
props: ['rating', 'size']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ReviewCard', () => {
|
||||
it('renders the author name', () => {
|
||||
const wrapper = mountCard(makeReview({ authorName: 'PixelWizard' }))
|
||||
expect(wrapper.text()).toContain('PixelWizard')
|
||||
})
|
||||
|
||||
it('renders the review text', () => {
|
||||
const wrapper = mountCard(makeReview({ text: 'Awesome workflow!' }))
|
||||
expect(wrapper.text()).toContain('Awesome workflow!')
|
||||
})
|
||||
|
||||
it('passes the rating to StarRating', () => {
|
||||
const wrapper = mountCard(makeReview({ rating: 3.5 }))
|
||||
const starRating = wrapper.find('[data-testid="star-rating"]')
|
||||
expect(starRating.exists()).toBe(true)
|
||||
expect(starRating.attributes('data-rating')).toBe('3.5')
|
||||
})
|
||||
|
||||
it('renders a formatted date', () => {
|
||||
const wrapper = mountCard(makeReview({ createdAt: new Date('2025-10-15') }))
|
||||
expect(wrapper.text()).toContain('2025')
|
||||
})
|
||||
})
|
||||
38
src/components/developerProfile/ReviewCard.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 rounded-lg bg-secondary-background p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-modal-panel-background"
|
||||
>
|
||||
<i class="icon-[lucide--user] size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<span class="text-sm font-medium">{{ review.authorName }}</span>
|
||||
<StarRating :rating="review.rating" size="sm" />
|
||||
<span class="ml-auto text-xs text-muted-foreground">
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-muted-foreground">{{ review.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { TemplateReview } from '@/types/templateMarketplace'
|
||||
|
||||
import StarRating from './StarRating.vue'
|
||||
|
||||
const { review } = defineProps<{
|
||||
/** The review to display. */
|
||||
review: TemplateReview
|
||||
}>()
|
||||
|
||||
const formattedDate = computed(() =>
|
||||
review.createdAt.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
)
|
||||
</script>
|
||||
72
src/components/developerProfile/StarRating.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
import StarRating from './StarRating.vue'
|
||||
|
||||
function mountRating(rating: number, size?: 'sm' | 'md') {
|
||||
return mount(StarRating, {
|
||||
props: { rating, size }
|
||||
})
|
||||
}
|
||||
|
||||
describe('StarRating', () => {
|
||||
it('renders five star containers', () => {
|
||||
const wrapper = mountRating(3)
|
||||
const starContainers = wrapper.findAll('[role="img"] > div')
|
||||
expect(starContainers).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('fills all stars for a rating of 5', () => {
|
||||
const wrapper = mountRating(5)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(5)
|
||||
for (const fill of fills) {
|
||||
expect(fill.attributes('style')).toContain('width: 100%')
|
||||
}
|
||||
})
|
||||
|
||||
it('fills no stars for a rating of 0', () => {
|
||||
const wrapper = mountRating(0)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders correct fills for a half-star rating of 3.5', () => {
|
||||
const wrapper = mountRating(3.5)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(4)
|
||||
|
||||
expect(fills[0].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[1].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[2].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[3].attributes('style')).toContain('width: 50%')
|
||||
})
|
||||
|
||||
it('renders correct fills for a half-star rating of 2.5', () => {
|
||||
const wrapper = mountRating(2.5)
|
||||
const fills = wrapper.findAll('[role="img"] > div > div')
|
||||
expect(fills).toHaveLength(3)
|
||||
|
||||
expect(fills[0].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[1].attributes('style')).toContain('width: 100%')
|
||||
expect(fills[2].attributes('style')).toContain('width: 50%')
|
||||
})
|
||||
|
||||
it('uses smaller size class when size is sm', () => {
|
||||
const wrapper = mountRating(3, 'sm')
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('size-3.5')
|
||||
})
|
||||
|
||||
it('uses default size class when size is md', () => {
|
||||
const wrapper = mountRating(3, 'md')
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('size-4')
|
||||
})
|
||||
})
|
||||
56
src/components/developerProfile/StarRating.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-0.5"
|
||||
role="img"
|
||||
:aria-label="ariaLabel"
|
||||
>
|
||||
<div v-for="i in 5" :key="i" class="relative" :class="starSizeClass">
|
||||
<i
|
||||
:class="
|
||||
cn('icon-[lucide--star]', starSizeClass, 'text-muted-foreground')
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="fillWidth(i) > 0"
|
||||
class="absolute inset-0 overflow-hidden"
|
||||
:style="{ width: `${fillWidth(i)}%` }"
|
||||
>
|
||||
<i
|
||||
:class="cn('icon-[lucide--star]', starSizeClass, 'text-amber-400')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { rating, size = 'md' } = defineProps<{
|
||||
/** Star rating value from 0 to 5, supporting 0.5 increments. */
|
||||
rating: number
|
||||
/** Visual size variant. */
|
||||
size?: 'sm' | 'md'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const starSizeClass = computed(() => (size === 'sm' ? 'size-3.5' : 'size-4'))
|
||||
|
||||
const ariaLabel = computed(
|
||||
() => t('developerProfile.rating') + ': ' + String(rating) + '/5'
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns the fill percentage (0, 50, or 100) for the star at position `i`.
|
||||
* @param i - 1-indexed star position.
|
||||
*/
|
||||
function fillWidth(i: number): number {
|
||||
if (rating >= i) return 100
|
||||
if (rating >= i - 0.5) return 50
|
||||
return 0
|
||||
}
|
||||
</script>
|
||||
154
src/components/developerProfile/TemplateListItem.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
import type {
|
||||
MarketplaceTemplate,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
import TemplateListItem from './TemplateListItem.vue'
|
||||
|
||||
function makeTemplate(
|
||||
overrides?: Partial<MarketplaceTemplate>
|
||||
): MarketplaceTemplate {
|
||||
return {
|
||||
id: 'tpl-1',
|
||||
title: 'Test Template',
|
||||
description: 'Full description',
|
||||
shortDescription: 'Short desc',
|
||||
author: {
|
||||
id: 'usr-1',
|
||||
name: 'Author',
|
||||
isVerified: true,
|
||||
profileUrl: '/author'
|
||||
},
|
||||
categories: [],
|
||||
tags: [],
|
||||
difficulty: 'beginner',
|
||||
requiredModels: [],
|
||||
requiredNodes: [],
|
||||
requiresCustomNodes: [],
|
||||
vramRequirement: 0,
|
||||
thumbnail: '',
|
||||
gallery: [],
|
||||
workflowPreview: '',
|
||||
license: 'mit',
|
||||
version: '1.0.0',
|
||||
status: 'approved',
|
||||
updatedAt: new Date(),
|
||||
stats: {
|
||||
downloads: 1000,
|
||||
favorites: 50,
|
||||
rating: 4.5,
|
||||
reviewCount: 10,
|
||||
weeklyTrend: 2
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const stubRevenue: TemplateRevenue = {
|
||||
templateId: 'tpl-1',
|
||||
totalRevenue: 10_000,
|
||||
monthlyRevenue: 1_500,
|
||||
currency: 'USD'
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
template?: MarketplaceTemplate
|
||||
revenue?: TemplateRevenue
|
||||
showRevenue?: boolean
|
||||
isCurrentUser?: boolean
|
||||
}
|
||||
|
||||
function mountItem(options: MountOptions = {}) {
|
||||
return mount(TemplateListItem, {
|
||||
props: {
|
||||
template: options.template ?? makeTemplate(),
|
||||
revenue: options.revenue,
|
||||
showRevenue: options.showRevenue ?? false,
|
||||
isCurrentUser: options.isCurrentUser ?? false
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
StarRating: {
|
||||
template: '<span data-testid="star-rating" />',
|
||||
props: ['rating', 'size']
|
||||
},
|
||||
Button: {
|
||||
template: '<button data-testid="unpublish-button"><slot /></button>',
|
||||
props: ['variant', 'size']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TemplateListItem', () => {
|
||||
it('renders the template title and description', () => {
|
||||
const wrapper = mountItem({
|
||||
template: makeTemplate({
|
||||
title: 'My Workflow',
|
||||
shortDescription: 'A cool workflow'
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('My Workflow')
|
||||
expect(wrapper.text()).toContain('A cool workflow')
|
||||
})
|
||||
|
||||
it('renders download and favorite stats', () => {
|
||||
const wrapper = mountItem({
|
||||
template: makeTemplate({
|
||||
stats: {
|
||||
downloads: 5_000,
|
||||
favorites: 200,
|
||||
rating: 4,
|
||||
reviewCount: 15,
|
||||
weeklyTrend: 1
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('5,000')
|
||||
expect(wrapper.text()).toContain('200')
|
||||
})
|
||||
|
||||
it('hides revenue column when showRevenue is false', () => {
|
||||
const wrapper = mountItem({
|
||||
revenue: stubRevenue,
|
||||
showRevenue: false
|
||||
})
|
||||
expect(wrapper.find('[data-testid="revenue-column"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows revenue column when showRevenue is true', () => {
|
||||
const wrapper = mountItem({
|
||||
revenue: stubRevenue,
|
||||
showRevenue: true
|
||||
})
|
||||
expect(wrapper.find('[data-testid="revenue-column"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides unpublish button when isCurrentUser is false', () => {
|
||||
const wrapper = mountItem({ isCurrentUser: false })
|
||||
expect(wrapper.find('[data-testid="unpublish-button"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('shows unpublish button when isCurrentUser is true', () => {
|
||||
const wrapper = mountItem({ isCurrentUser: true })
|
||||
expect(wrapper.find('[data-testid="unpublish-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits unpublish event with template ID when button is clicked', async () => {
|
||||
const wrapper = mountItem({ isCurrentUser: true })
|
||||
await wrapper.find('[data-testid="unpublish-button"]').trigger('click')
|
||||
expect(wrapper.emitted('unpublish')).toEqual([['tpl-1']])
|
||||
})
|
||||
})
|
||||
114
src/components/developerProfile/TemplateListItem.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-4 rounded-lg bg-secondary-background p-3"
|
||||
data-testid="template-list-item"
|
||||
>
|
||||
<div
|
||||
class="size-12 shrink-0 overflow-hidden rounded bg-modal-panel-background"
|
||||
>
|
||||
<img
|
||||
v-if="template.thumbnail"
|
||||
:src="template.thumbnail"
|
||||
:alt="template.title"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex size-full items-center justify-center">
|
||||
<i class="icon-[lucide--image] size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="m-0 truncate text-sm font-medium">{{ template.title }}</h4>
|
||||
<p class="m-0 truncate text-xs text-muted-foreground">
|
||||
{{ template.shortDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-4 text-xs text-muted-foreground">
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
:title="t('developerProfile.downloads')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-3.5" />
|
||||
{{ template.stats.downloads.toLocaleString() }}
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
:title="t('developerProfile.favorites')"
|
||||
>
|
||||
<i class="icon-[lucide--heart] size-3.5" />
|
||||
{{ template.stats.favorites.toLocaleString() }}
|
||||
</span>
|
||||
<StarRating :rating="template.stats.rating" size="sm" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showRevenue && revenue"
|
||||
class="shrink-0 text-right text-xs"
|
||||
data-testid="revenue-column"
|
||||
>
|
||||
<div class="font-medium">{{ formatCurrency(revenue.totalRevenue) }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ formatCurrency(revenue.monthlyRevenue) }}/{{
|
||||
t('developerProfile.monthlyRevenue').toLowerCase()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="isCurrentUser"
|
||||
variant="destructive-textonly"
|
||||
size="sm"
|
||||
data-testid="unpublish-button"
|
||||
@click="emit('unpublish', template.id)"
|
||||
>
|
||||
{{ t('developerProfile.unpublish') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
MarketplaceTemplate,
|
||||
TemplateRevenue
|
||||
} from '@/types/templateMarketplace'
|
||||
|
||||
import StarRating from './StarRating.vue'
|
||||
|
||||
const {
|
||||
template,
|
||||
revenue,
|
||||
showRevenue = false,
|
||||
isCurrentUser = false
|
||||
} = defineProps<{
|
||||
/** The template to display. */
|
||||
template: MarketplaceTemplate
|
||||
/** Revenue data for this template, shown when showRevenue is true. */
|
||||
revenue?: TemplateRevenue
|
||||
/** Whether to display the revenue column. */
|
||||
showRevenue?: boolean
|
||||
/** Whether the profile being viewed belongs to the current user. */
|
||||
isCurrentUser?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Emitted when the unpublish button is clicked. */
|
||||
unpublish: [templateId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* Formats a value in cents as a currency string.
|
||||
* @param cents - Amount in cents.
|
||||
*/
|
||||
function formatCurrency(cents: number): string {
|
||||
return (cents / 100).toLocaleString(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -71,30 +71,20 @@ function getDialogPt(item: {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.global-dialog {
|
||||
max-width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-header {
|
||||
padding: calc(var(--spacing) * 2);
|
||||
padding-bottom: 0;
|
||||
@apply p-2 2xl:p-[var(--p-dialog-header-padding)];
|
||||
@apply pb-0;
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-content {
|
||||
padding: calc(var(--spacing) * 2);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.global-dialog .p-dialog-header {
|
||||
padding: var(--p-dialog-header-padding);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.global-dialog .p-dialog-content {
|
||||
padding: var(--p-dialog-content-padding);
|
||||
padding-top: 0;
|
||||
}
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
/* Workspace mode: wider settings dialog */
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[490px] flex-col border-t border-border-default"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<p class="m-0 text-sm leading-5 text-muted-foreground">
|
||||
{{ $t('missingModelsDialog.description') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex max-h-[300px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
|
||||
>
|
||||
<div
|
||||
v-for="model in processedModels"
|
||||
:key="model.name"
|
||||
class="flex items-center justify-between px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<span
|
||||
class="min-w-0 truncate text-sm text-foreground"
|
||||
:title="model.name"
|
||||
>
|
||||
{{ model.name }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex h-4 shrink-0 items-center rounded-full bg-muted-foreground/20 px-1.5 text-xxxs font-semibold uppercase text-muted-foreground"
|
||||
>
|
||||
{{ model.badgeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
v-if="model.isDownloadable && fileSizes.get(model.url)"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatSize(fileSizes.get(model.url)) }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="model.isDownloadable"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.download')"
|
||||
@click="downloadModel(model, paths)"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.copyURL')"
|
||||
@click="void copyToClipboard(model.url)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="totalDownloadSize > 0"
|
||||
class="sticky bottom-0 flex items-center justify-between border-t border-border-default bg-secondary-background px-3 py-2"
|
||||
>
|
||||
<span class="text-xs font-medium text-muted-foreground">
|
||||
{{ $t('missingModelsDialog.totalSize') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ formatSize(totalDownloadSize) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="m-0 text-xs leading-5 text-muted-foreground whitespace-pre-line"
|
||||
>
|
||||
{{ $t('missingModelsDialog.footerDescription') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="hasCustomModels"
|
||||
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p
|
||||
class="m-0 text-xs font-semibold leading-5 text-warning-background"
|
||||
>
|
||||
{{ $t('missingModelsDialog.customModelsWarning') }}
|
||||
</p>
|
||||
<p class="m-0 text-xs leading-5 text-warning-background">
|
||||
{{ $t('missingModelsDialog.customModelsInstruction') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
import type { ModelWithUrl } from './missingModelsUtils'
|
||||
import {
|
||||
downloadModel,
|
||||
getBadgeLabel,
|
||||
hasValidDirectory,
|
||||
isModelDownloadable
|
||||
} from './missingModelsUtils'
|
||||
|
||||
const { missingModels, paths } = defineProps<{
|
||||
missingModels: ModelWithUrl[]
|
||||
paths: Record<string, string[]>
|
||||
}>()
|
||||
|
||||
interface ProcessedModel {
|
||||
name: string
|
||||
url: string
|
||||
directory: string
|
||||
badgeLabel: string
|
||||
isDownloadable: boolean
|
||||
}
|
||||
|
||||
const processedModels = computed<ProcessedModel[]>(() =>
|
||||
missingModels.map((model) => ({
|
||||
name: model.name,
|
||||
url: model.url,
|
||||
directory: model.directory,
|
||||
badgeLabel: getBadgeLabel(model.directory),
|
||||
isDownloadable:
|
||||
hasValidDirectory(model, paths) && isModelDownloadable(model)
|
||||
}))
|
||||
)
|
||||
|
||||
const hasCustomModels = computed(() =>
|
||||
processedModels.value.some((m) => !m.isDownloadable)
|
||||
)
|
||||
|
||||
const fileSizes = reactive(new Map<string, number>())
|
||||
|
||||
const totalDownloadSize = computed(() =>
|
||||
processedModels.value
|
||||
.filter((model) => model.isDownloadable)
|
||||
.reduce((total, model) => total + (fileSizes.get(model.url) ?? 0), 0)
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const downloadableUrls = processedModels.value
|
||||
.filter((m) => m.isDownloadable)
|
||||
.map((m) => m.url)
|
||||
|
||||
await Promise.allSettled(
|
||||
downloadableUrls.map(async (url) => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (!response.ok) return
|
||||
const size = response.headers.get('content-length')
|
||||
if (size) fileSizes.set(url, parseInt(size, 10))
|
||||
} catch {
|
||||
// Silently skip size fetch failures
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
@@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-2 px-4 py-2">
|
||||
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
id="doNotAskAgainModels"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgainModels">{{
|
||||
$t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="ml-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="cursor-pointer p-0 text-sm text-muted-foreground underline hover:bg-transparent"
|
||||
@click="openShowMissingModelsSetting"
|
||||
>
|
||||
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button variant="secondary" size="md" @click="handleAction">
|
||||
{{ buttonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import type { ModelWithUrl } from './missingModelsUtils'
|
||||
import {
|
||||
downloadModel,
|
||||
hasValidDirectory,
|
||||
isModelDownloadable
|
||||
} from './missingModelsUtils'
|
||||
|
||||
const { missingModels, paths } = defineProps<{
|
||||
missingModels: ModelWithUrl[]
|
||||
paths: Record<string, string[]>
|
||||
}>()
|
||||
|
||||
const DIALOG_KEY = 'global-missing-models-warning'
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
watch(doNotAskAgain, (value) => {
|
||||
void useSettingStore().set('Comfy.Workflow.ShowMissingModelsWarning', !value)
|
||||
})
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
|
||||
}
|
||||
|
||||
const downloadableModels = computed(() =>
|
||||
missingModels.filter(
|
||||
(model) => hasValidDirectory(model, paths) && isModelDownloadable(model)
|
||||
)
|
||||
)
|
||||
|
||||
const hasDownloadable = computed(() => downloadableModels.value.length > 0)
|
||||
|
||||
const hasCustom = computed(
|
||||
() => downloadableModels.value.length < missingModels.length
|
||||
)
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (hasDownloadable.value && hasCustom.value)
|
||||
return t('missingModelsDialog.downloadAvailable')
|
||||
if (hasDownloadable.value) return t('missingModelsDialog.downloadAll')
|
||||
return t('missingModelsDialog.gotIt')
|
||||
})
|
||||
|
||||
function handleAction() {
|
||||
if (hasDownloadable.value) {
|
||||
for (const model of downloadableModels.value) {
|
||||
downloadModel(model, paths)
|
||||
}
|
||||
}
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
</script>
|
||||