mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-23 00:04:06 +00:00
Decouple Desktop UI into monorepo app (#5912)
## Summary Extracts desktop UI into apps/desktop-ui package with minimal changes. ## Changes - **What**: - Separates desktop-specific code into standalone package with independent Vite config, router, and i18n - Drastically simplifies the main app router by removing all desktop routes - Adds a some code duplication, most due to the existing design - Some duplication can be refactored to be *simpler* on either side - no need to split things by `isElectron()` - Rudimentary storybook support has been added - **Breaking**: Stacked PR for publishing must be merged before this PR makes it to stable core (but publishing _could_ be done manually) - #5915 - **Dependencies**: Takes full advantage of pnpm catalog. No additional dependencies added. ## Review Focus - Should be no changes to normal frontend operation - Scripts added to root package.json are acceptable - The duplication in this PR is copied as is, wherever possible. Any corrections or fix-ups beyond the scope of simply migrating the functionality as-is, can be addressed in later PRs. That said, if any changes are made, it instantly becomes more difficult to separate the duplicated code out into a shared utility. - Tracking issue to address concerns: #5925 ### i18n Fixing i18n is out of scope for this PR. It is a larger task that we should consider carefully and implement properly. Attempting to isolate the desktop i18n and duplicate the _current_ localisation scripts would be wasted energy.
This commit is contained in:
@@ -1,52 +0,0 @@
|
||||
<!--
|
||||
A refresh button that disables and shows a progress spinner whilst active.
|
||||
|
||||
Usage:
|
||||
```vue
|
||||
<RefreshButton
|
||||
v-model="isRefreshing"
|
||||
:outlined="false"
|
||||
@refresh="refresh"
|
||||
/>
|
||||
```
|
||||
-->
|
||||
<template>
|
||||
<Button
|
||||
class="relative p-button-icon-only"
|
||||
:outlined="outlined"
|
||||
:severity="severity"
|
||||
:disabled="active || disabled"
|
||||
@click="(event) => $emit('refresh', event)"
|
||||
>
|
||||
<span
|
||||
class="p-button-icon pi pi-refresh transition-all"
|
||||
:class="{ 'opacity-0': active }"
|
||||
data-pc-section="icon"
|
||||
/>
|
||||
<span class="p-button-label" data-pc-section="label"> </span>
|
||||
<ProgressSpinner v-show="active" class="absolute w-1/2 h-1/2" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
|
||||
import type { PrimeVueSeverity } from '@/types/primeVueTypes'
|
||||
|
||||
const {
|
||||
disabled,
|
||||
outlined = true,
|
||||
severity = 'secondary'
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
outlined?: boolean
|
||||
severity?: PrimeVueSeverity
|
||||
}>()
|
||||
|
||||
// Model
|
||||
const active = defineModel<boolean>({ required: true })
|
||||
|
||||
// Emits
|
||||
defineEmits(['refresh'])
|
||||
</script>
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<div class="grid grid-rows-2 gap-8">
|
||||
<!-- Top container: Logo -->
|
||||
<div class="flex items-end justify-center">
|
||||
<img
|
||||
src="/assets/images/comfy-brand-mark.svg"
|
||||
:alt="t('g.logoAlt')"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
<!-- Bottom container: Progress and text -->
|
||||
<div class="flex flex-col items-center justify-center gap-4">
|
||||
<ProgressBar
|
||||
v-if="!hideProgress"
|
||||
:mode="progressMode"
|
||||
:value="progressPercentage ?? 0"
|
||||
:show-value="false"
|
||||
class="w-90 h-2 mt-8"
|
||||
:pt="{ value: { class: 'bg-brand-yellow' } }"
|
||||
/>
|
||||
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="statusText" class="text-lg text-neutral-400">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** Props for the StartupDisplay component */
|
||||
interface StartupDisplayProps {
|
||||
/** Progress: 0-100 for determinate, undefined for indeterminate */
|
||||
progressPercentage?: number
|
||||
/** Main title text */
|
||||
title?: string
|
||||
/** Status text shown below the title */
|
||||
statusText?: string
|
||||
/** Hide the progress bar */
|
||||
hideProgress?: boolean
|
||||
/** Use full screen wrapper (default: true) */
|
||||
fullScreen?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
progressPercentage,
|
||||
title,
|
||||
statusText,
|
||||
hideProgress = false,
|
||||
fullScreen = true
|
||||
} = defineProps<StartupDisplayProps>()
|
||||
|
||||
const progressMode = computed(() =>
|
||||
progressPercentage === undefined ? 'indeterminate' : 'determinate'
|
||||
)
|
||||
|
||||
const wrapperClass = computed(() =>
|
||||
fullScreen
|
||||
? 'flex items-center justify-center min-h-screen'
|
||||
: 'flex items-center justify-center'
|
||||
)
|
||||
</script>
|
||||
@@ -1,133 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 w-[600px]">
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-2xl font-semibold text-neutral-100">
|
||||
{{ $t('install.desktopAppSettings') }}
|
||||
</h2>
|
||||
|
||||
<p class="text-neutral-400 my-0">
|
||||
{{ $t('install.desktopAppSettingsDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg text-sm">
|
||||
<!-- Auto Update Setting -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium text-neutral-100">
|
||||
{{ $t('install.settings.autoUpdate') }}
|
||||
</h3>
|
||||
<p class="text-neutral-400 mt-1">
|
||||
{{ $t('install.settings.autoUpdateDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="autoUpdate" />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<!-- Metrics Collection Setting -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium text-neutral-100">
|
||||
{{ $t('install.settings.allowMetrics') }}
|
||||
</h3>
|
||||
<p class="text-neutral-400">
|
||||
{{ $t('install.settings.allowMetricsDescription') }}
|
||||
</p>
|
||||
<a href="#" @click.prevent="showMetricsInfo">
|
||||
{{ $t('install.settings.learnMoreAboutData') }}
|
||||
</a>
|
||||
</div>
|
||||
<ToggleSwitch v-model="allowMetrics" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="showDialog"
|
||||
modal
|
||||
dismissable-mask
|
||||
:header="$t('install.settings.dataCollectionDialog.title')"
|
||||
class="select-none"
|
||||
>
|
||||
<div class="text-neutral-300">
|
||||
<h4 class="font-medium mb-2">
|
||||
{{ $t('install.settings.dataCollectionDialog.whatWeCollect') }}
|
||||
</h4>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
<li>
|
||||
{{
|
||||
$t('install.settings.dataCollectionDialog.collect.errorReports')
|
||||
}}
|
||||
</li>
|
||||
<li>
|
||||
{{ $t('install.settings.dataCollectionDialog.collect.systemInfo') }}
|
||||
</li>
|
||||
<li>
|
||||
{{
|
||||
$t(
|
||||
'install.settings.dataCollectionDialog.collect.userJourneyEvents'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="font-medium mt-4 mb-2">
|
||||
{{ $t('install.settings.dataCollectionDialog.whatWeDoNotCollect') }}
|
||||
</h4>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
<li>
|
||||
{{
|
||||
$t(
|
||||
'install.settings.dataCollectionDialog.doNotCollect.personalInformation'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li>
|
||||
{{
|
||||
$t(
|
||||
'install.settings.dataCollectionDialog.doNotCollect.workflowContents'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li>
|
||||
{{
|
||||
$t(
|
||||
'install.settings.dataCollectionDialog.doNotCollect.fileSystemInformation'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li>
|
||||
{{
|
||||
$t(
|
||||
'install.settings.dataCollectionDialog.doNotCollect.customNodeConfigurations'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="https://comfy.org/privacy" target="_blank">
|
||||
{{ $t('install.settings.dataCollectionDialog.viewFullPolicy') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Divider from 'primevue/divider'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showDialog = ref(false)
|
||||
const autoUpdate = defineModel<boolean>('autoUpdate', { required: true })
|
||||
const allowMetrics = defineModel<boolean>('allowMetrics', { required: true })
|
||||
|
||||
const showMetricsInfo = () => {
|
||||
showDialog.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-rows-[1fr_auto_auto_1fr] w-full max-w-3xl mx-auto h-[40rem] select-none"
|
||||
>
|
||||
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
|
||||
{{ $t('install.gpuPicker.title') }}
|
||||
</h2>
|
||||
|
||||
<!-- GPU Selection buttons - takes up remaining space and centers content -->
|
||||
<div class="flex-1 flex gap-8 justify-center items-center">
|
||||
<!-- Apple Metal / NVIDIA -->
|
||||
<HardwareOption
|
||||
v-if="platform === 'darwin'"
|
||||
:image-path="'/assets/images/apple-mps-logo.png'"
|
||||
placeholder-text="Apple Metal"
|
||||
subtitle="Apple Metal"
|
||||
:value="'mps'"
|
||||
:selected="selected === 'mps'"
|
||||
:recommended="true"
|
||||
@click="pickGpu('mps')"
|
||||
/>
|
||||
<HardwareOption
|
||||
v-else
|
||||
:image-path="'/assets/images/nvidia-logo-square.jpg'"
|
||||
placeholder-text="NVIDIA"
|
||||
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
|
||||
:value="'nvidia'"
|
||||
:selected="selected === 'nvidia'"
|
||||
:recommended="true"
|
||||
@click="pickGpu('nvidia')"
|
||||
/>
|
||||
<!-- CPU -->
|
||||
<HardwareOption
|
||||
placeholder-text="CPU"
|
||||
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
|
||||
:value="'cpu'"
|
||||
:selected="selected === 'cpu'"
|
||||
@click="pickGpu('cpu')"
|
||||
/>
|
||||
<!-- Manual Install -->
|
||||
<HardwareOption
|
||||
placeholder-text="Manual Install"
|
||||
:subtitle="$t('install.gpuPicker.manualSubtitle')"
|
||||
:value="'unsupported'"
|
||||
:selected="selected === 'unsupported'"
|
||||
@click="pickGpu('unsupported')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-12 px-24 h-16">
|
||||
<div v-show="showRecommendedBadge" class="flex items-center gap-2">
|
||||
<Tag
|
||||
:value="$t('install.gpuPicker.recommended')"
|
||||
class="bg-neutral-300 text-neutral-900 rounded-full text-sm font-bold px-2 py-[1px]"
|
||||
/>
|
||||
<i-lucide:badge-check class="text-neutral-300 text-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-neutral-300 px-24">
|
||||
<p v-show="descriptionText" class="leading-relaxed">
|
||||
{{ descriptionText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import HardwareOption from '@/components/install/HardwareOption.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const selected = defineModel<TorchDeviceType | null>('device', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const electron = electronAPI()
|
||||
const platform = electron.getPlatform()
|
||||
|
||||
const showRecommendedBadge = computed(
|
||||
() => selected.value === 'mps' || selected.value === 'nvidia'
|
||||
)
|
||||
|
||||
const descriptionKeys = {
|
||||
mps: 'appleMetal',
|
||||
nvidia: 'nvidia',
|
||||
cpu: 'cpu',
|
||||
unsupported: 'manual'
|
||||
} as const
|
||||
|
||||
const descriptionText = computed(() => {
|
||||
const key = selected.value ? descriptionKeys[selected.value] : undefined
|
||||
return st(`install.gpuPicker.${key}Description`, '')
|
||||
})
|
||||
|
||||
const pickGpu = (value: TorchDeviceType) => {
|
||||
selected.value = value
|
||||
}
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
|
||||
import HardwareOption from './HardwareOption.vue'
|
||||
|
||||
const meta: Meta<typeof HardwareOption> = {
|
||||
title: 'Desktop/Components/HardwareOption',
|
||||
component: HardwareOption,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [{ name: 'dark', value: '#1a1a1a' }]
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
selected: { control: 'boolean' },
|
||||
imagePath: { control: 'text' },
|
||||
placeholderText: { control: 'text' },
|
||||
subtitle: { control: 'text' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const AppleMetalSelected: Story = {
|
||||
args: {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
|
||||
export const AppleMetalUnselected: Story = {
|
||||
args: {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
export const CPUOption: Story = {
|
||||
args: {
|
||||
placeholderText: 'CPU',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'cpu',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
export const ManualInstall: Story = {
|
||||
args: {
|
||||
placeholderText: 'Manual Install',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'unsupported',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
export const NvidiaSelected: Story = {
|
||||
args: {
|
||||
imagePath: '/assets/images/nvidia-logo-square.jpg',
|
||||
placeholderText: 'NVIDIA',
|
||||
subtitle: 'NVIDIA',
|
||||
value: 'nvidia',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Recommended Badge -->
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
|
||||
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
|
||||
)
|
||||
"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<!-- Icon/Logo Area - Rounded square container -->
|
||||
<div
|
||||
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<img
|
||||
v-if="imagePath"
|
||||
:src="imagePath"
|
||||
:alt="placeholderText"
|
||||
class="w-full h-full object-cover"
|
||||
style="object-position: 57% center"
|
||||
draggable="false"
|
||||
/>
|
||||
<span v-else class="text-xl font-medium text-neutral-400">
|
||||
{{ placeholderText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Text Content -->
|
||||
<div v-if="subtitle" class="text-center mt-4">
|
||||
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface Props {
|
||||
imagePath?: string
|
||||
placeholderText: string
|
||||
subtitle?: string
|
||||
value: TorchDeviceType
|
||||
selected?: boolean
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
@@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-4">
|
||||
<!-- Back button -->
|
||||
<Button
|
||||
v-if="currentStep !== '1'"
|
||||
:label="$t('g.back')"
|
||||
severity="secondary"
|
||||
icon="pi pi-arrow-left"
|
||||
class="font-inter rounded-lg border-0 px-6 py-2 justify-self-start"
|
||||
@click="$emit('previous')"
|
||||
/>
|
||||
<div v-else></div>
|
||||
|
||||
<!-- Step indicators in center -->
|
||||
<StepList class="flex justify-center items-center gap-3 select-none">
|
||||
<Step value="1" :pt="stepPassthrough">
|
||||
{{ $t('install.gpu') }}
|
||||
</Step>
|
||||
<Step value="2" :disabled="disableLocationStep" :pt="stepPassthrough">
|
||||
{{ $t('install.installLocation') }}
|
||||
</Step>
|
||||
<Step value="3" :disabled="disableSettingsStep" :pt="stepPassthrough">
|
||||
{{ $t('install.desktopSettings') }}
|
||||
</Step>
|
||||
</StepList>
|
||||
|
||||
<!-- Next/Install button -->
|
||||
<Button
|
||||
:label="currentStep !== '3' ? $t('g.next') : $t('g.install')"
|
||||
class="px-8 py-2 bg-brand-yellow hover:bg-brand-yellow/90 font-inter rounded-lg border-0 transition-colors justify-self-end"
|
||||
:pt="{
|
||||
label: { class: 'text-neutral-900 font-inter font-black' }
|
||||
}"
|
||||
:disabled="!canProceed"
|
||||
@click="currentStep !== '3' ? $emit('next') : $emit('install')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PassThrough } from '@primevue/core'
|
||||
import Button from 'primevue/button'
|
||||
import Step, { type StepPassThroughOptions } from 'primevue/step'
|
||||
import StepList from 'primevue/steplist'
|
||||
|
||||
defineProps<{
|
||||
/** Current step index as string ('1', '2', '3', '4') */
|
||||
currentStep: string
|
||||
/** Whether the user can proceed to the next step */
|
||||
canProceed: boolean
|
||||
/** Whether the location step should be disabled */
|
||||
disableLocationStep: boolean
|
||||
/** Whether the migration step should be disabled */
|
||||
disableMigrationStep: boolean
|
||||
/** Whether the settings step should be disabled */
|
||||
disableSettingsStep: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
previous: []
|
||||
next: []
|
||||
install: []
|
||||
}>()
|
||||
|
||||
const stepPassthrough: PassThrough<StepPassThroughOptions> = {
|
||||
root: { class: 'flex-none p-0 m-0' },
|
||||
header: ({ context }) => ({
|
||||
class: [
|
||||
'h-2.5 p-0 m-0 border-0 rounded-full transition-all duration-300',
|
||||
context.active
|
||||
? 'bg-brand-yellow w-8 rounded-sm'
|
||||
: 'bg-neutral-700 w-2.5',
|
||||
context.disabled ? 'opacity-60 cursor-not-allowed' : ''
|
||||
].join(' ')
|
||||
}),
|
||||
number: { class: 'hidden' },
|
||||
title: { class: 'hidden' }
|
||||
}
|
||||
</script>
|
||||
@@ -1,148 +0,0 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import InstallLocationPicker from './InstallLocationPicker.vue'
|
||||
|
||||
const meta: Meta<typeof InstallLocationPicker> = {
|
||||
title: 'Desktop/Components/InstallLocationPicker',
|
||||
component: InstallLocationPicker,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' },
|
||||
{ name: 'neutral-950', value: '#0a0a0a' }
|
||||
]
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => {
|
||||
// Mock electron API
|
||||
;(window as any).electronAPI = {
|
||||
getSystemPaths: () =>
|
||||
Promise.resolve({
|
||||
defaultInstallPath: '/Users/username/ComfyUI'
|
||||
}),
|
||||
validateInstallPath: () =>
|
||||
Promise.resolve({
|
||||
isValid: true,
|
||||
exists: false,
|
||||
canWrite: true,
|
||||
freeSpace: 100000000000,
|
||||
requiredSpace: 10000000000,
|
||||
isNonDefaultDrive: false
|
||||
}),
|
||||
validateComfyUISource: () =>
|
||||
Promise.resolve({
|
||||
isValid: true
|
||||
}),
|
||||
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
|
||||
}
|
||||
return { template: '<story />' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Default story with accordion expanded
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { InstallLocationPicker },
|
||||
setup() {
|
||||
const installPath = ref('/Users/username/ComfyUI')
|
||||
const pathError = ref('')
|
||||
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
|
||||
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
|
||||
|
||||
return {
|
||||
args,
|
||||
installPath,
|
||||
pathError,
|
||||
migrationSourcePath,
|
||||
migrationItemIds
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-950 p-8">
|
||||
<InstallLocationPicker
|
||||
v-model:installPath="installPath"
|
||||
v-model:pathError="pathError"
|
||||
v-model:migrationSourcePath="migrationSourcePath"
|
||||
v-model:migrationItemIds="migrationItemIds"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// Story with different background to test transparency
|
||||
export const OnNeutral900: Story = {
|
||||
render: (args) => ({
|
||||
components: { InstallLocationPicker },
|
||||
setup() {
|
||||
const installPath = ref('/Users/username/ComfyUI')
|
||||
const pathError = ref('')
|
||||
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
|
||||
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
|
||||
|
||||
return {
|
||||
args,
|
||||
installPath,
|
||||
pathError,
|
||||
migrationSourcePath,
|
||||
migrationItemIds
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-900 p-8">
|
||||
<InstallLocationPicker
|
||||
v-model:installPath="installPath"
|
||||
v-model:pathError="pathError"
|
||||
v-model:migrationSourcePath="migrationSourcePath"
|
||||
v-model:migrationItemIds="migrationItemIds"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// Story with debug overlay showing background colors
|
||||
export const DebugBackgrounds: Story = {
|
||||
render: (args) => ({
|
||||
components: { InstallLocationPicker },
|
||||
setup() {
|
||||
const installPath = ref('/Users/username/ComfyUI')
|
||||
const pathError = ref('')
|
||||
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
|
||||
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
|
||||
|
||||
return {
|
||||
args,
|
||||
installPath,
|
||||
pathError,
|
||||
migrationSourcePath,
|
||||
migrationItemIds
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-950 p-8 relative">
|
||||
<div class="absolute top-4 right-4 text-white text-xs space-y-2 z-50">
|
||||
<div>Parent bg: neutral-950 (#0a0a0a)</div>
|
||||
<div>Accordion content: bg-transparent</div>
|
||||
<div>Migration options: bg-transparent + p-4 rounded-lg</div>
|
||||
</div>
|
||||
<InstallLocationPicker
|
||||
v-model:installPath="installPath"
|
||||
v-model:pathError="pathError"
|
||||
v-model:migrationSourcePath="migrationSourcePath"
|
||||
v-model:migrationItemIds="migrationItemIds"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
|
||||
<!-- Installation Path Section -->
|
||||
<div class="grow flex flex-col gap-6 text-neutral-300">
|
||||
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
|
||||
{{ $t('install.locationPicker.title') }}
|
||||
</h2>
|
||||
|
||||
<p class="text-center text-neutral-400 px-12">
|
||||
{{ $t('install.locationPicker.subtitle') }}
|
||||
</p>
|
||||
|
||||
<!-- Path Input -->
|
||||
<div class="flex gap-2 px-12">
|
||||
<InputText
|
||||
v-model="installPath"
|
||||
:placeholder="$t('install.locationPicker.pathPlaceholder')"
|
||||
class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
|
||||
:class="{ 'p-invalid': pathError }"
|
||||
@update:model-value="validatePath"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-folder-open"
|
||||
severity="secondary"
|
||||
class="bg-neutral-700 hover:bg-neutral-600 border-0"
|
||||
@click="browsePath"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<div v-if="pathError || pathExists || nonDefaultDrive" class="px-12">
|
||||
<Message
|
||||
v-if="pathError"
|
||||
severity="error"
|
||||
class="whitespace-pre-line w-full"
|
||||
>
|
||||
{{ pathError }}
|
||||
</Message>
|
||||
<Message v-if="pathExists" severity="warn" class="w-full">
|
||||
{{ $t('install.pathExists') }}
|
||||
</Message>
|
||||
<Message v-if="nonDefaultDrive" severity="warn" class="w-full">
|
||||
{{ $t('install.nonDefaultDrive') }}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Sections using PrimeVue Accordion -->
|
||||
<Accordion
|
||||
v-model:value="activeAccordionIndex"
|
||||
:multiple="true"
|
||||
class="location-picker-accordion"
|
||||
:pt="{
|
||||
root: 'bg-transparent border-0',
|
||||
panel: {
|
||||
root: 'border-0 mb-0'
|
||||
},
|
||||
header: {
|
||||
root: 'border-0',
|
||||
content:
|
||||
'text-neutral-400 hover:text-neutral-300 px-4 py-2 flex items-center gap-3',
|
||||
toggleicon: 'text-xs order-first mr-0'
|
||||
},
|
||||
content: {
|
||||
root: 'bg-transparent border-0',
|
||||
content: 'text-neutral-500 text-sm pl-11 pb-3 pt-0'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader>
|
||||
{{ $t('install.locationPicker.migrateFromExisting') }}
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<MigrationPicker
|
||||
v-model:source-path="migrationSourcePath"
|
||||
v-model:migration-item-ids="migrationItemIds"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<AccordionPanel value="1">
|
||||
<AccordionHeader>
|
||||
{{ $t('install.locationPicker.chooseDownloadServers') }}
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<template
|
||||
v-for="([item, modelValue], index) in mirrors"
|
||||
:key="item.settingId + item.mirror"
|
||||
>
|
||||
<Divider v-if="index > 0" class="my-8" />
|
||||
|
||||
<MirrorItem
|
||||
v-model="modelValue.value"
|
||||
:item="item"
|
||||
@state-change="validationStates[index] = $event"
|
||||
/>
|
||||
</template>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
|
||||
import Accordion from 'primevue/accordion'
|
||||
import AccordionContent from 'primevue/accordioncontent'
|
||||
import AccordionHeader from 'primevue/accordionheader'
|
||||
import AccordionPanel from 'primevue/accordionpanel'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { type ModelRef, computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MigrationPicker from '@/components/install/MigrationPicker.vue'
|
||||
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
|
||||
import {
|
||||
PYPI_MIRROR,
|
||||
PYTHON_MIRROR,
|
||||
type UVMirror
|
||||
} from '@/constants/uvMirrors'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const installPath = defineModel<string>('installPath', { required: true })
|
||||
const pathError = defineModel<string>('pathError', { required: true })
|
||||
const migrationSourcePath = defineModel<string>('migrationSourcePath')
|
||||
const migrationItemIds = defineModel<string[]>('migrationItemIds')
|
||||
const pythonMirror = defineModel<string>('pythonMirror', {
|
||||
default: ''
|
||||
})
|
||||
const pypiMirror = defineModel<string>('pypiMirror', {
|
||||
default: ''
|
||||
})
|
||||
const torchMirror = defineModel<string>('torchMirror', {
|
||||
default: ''
|
||||
})
|
||||
|
||||
const { device } = defineProps<{ device: TorchDeviceType | null }>()
|
||||
|
||||
const pathExists = ref(false)
|
||||
const nonDefaultDrive = ref(false)
|
||||
const inputTouched = ref(false)
|
||||
|
||||
// Accordion state - array of active panel values
|
||||
const activeAccordionIndex = ref<string[] | undefined>(undefined)
|
||||
|
||||
const electron = electronAPI()
|
||||
|
||||
// Mirror configuration logic
|
||||
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
|
||||
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
|
||||
switch (device) {
|
||||
case 'mps':
|
||||
return {
|
||||
settingId,
|
||||
mirror: TorchMirrorUrl.NightlyCpu,
|
||||
fallbackMirror: TorchMirrorUrl.NightlyCpu
|
||||
}
|
||||
case 'nvidia':
|
||||
return {
|
||||
settingId,
|
||||
mirror: TorchMirrorUrl.Cuda,
|
||||
fallbackMirror: TorchMirrorUrl.Cuda
|
||||
}
|
||||
case 'cpu':
|
||||
default:
|
||||
return {
|
||||
settingId,
|
||||
mirror: PYPI_MIRROR.mirror,
|
||||
fallbackMirror: PYPI_MIRROR.fallbackMirror
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userIsInChina = ref(false)
|
||||
const useFallbackMirror = (mirror: UVMirror) => ({
|
||||
...mirror,
|
||||
mirror: mirror.fallbackMirror
|
||||
})
|
||||
|
||||
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
|
||||
(
|
||||
[
|
||||
[PYTHON_MIRROR, pythonMirror],
|
||||
[PYPI_MIRROR, pypiMirror],
|
||||
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
|
||||
] as [UVMirror, ModelRef<string>][]
|
||||
).map(([item, modelValue]) => [
|
||||
userIsInChina.value ? useFallbackMirror(item) : item,
|
||||
modelValue
|
||||
])
|
||||
)
|
||||
|
||||
const validationStates = ref<ValidationState[]>(
|
||||
mirrors.value.map(() => ValidationState.IDLE)
|
||||
)
|
||||
|
||||
// Get default install path on component mount
|
||||
onMounted(async () => {
|
||||
const paths = await electron.getSystemPaths()
|
||||
installPath.value = paths.defaultInstallPath
|
||||
await validatePath(paths.defaultInstallPath)
|
||||
userIsInChina.value = await isInChina()
|
||||
})
|
||||
|
||||
const validatePath = async (path: string | undefined) => {
|
||||
try {
|
||||
pathError.value = ''
|
||||
pathExists.value = false
|
||||
nonDefaultDrive.value = false
|
||||
const validation = await electron.validateInstallPath(path ?? '')
|
||||
|
||||
// Create a pre-formatted list of errors
|
||||
if (!validation.isValid) {
|
||||
const errors: string[] = []
|
||||
if (validation.cannotWrite) errors.push(t('install.cannotWrite'))
|
||||
if (validation.freeSpace < validation.requiredSpace) {
|
||||
const requiredGB = validation.requiredSpace / 1024 / 1024 / 1024
|
||||
errors.push(`${t('install.insufficientFreeSpace')}: ${requiredGB} GB`)
|
||||
}
|
||||
if (validation.parentMissing) errors.push(t('install.parentMissing'))
|
||||
if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
|
||||
|
||||
if (validation.error)
|
||||
errors.push(`${t('install.unhandledError')}: ${validation.error}`)
|
||||
pathError.value = errors.join('\n')
|
||||
}
|
||||
|
||||
if (validation.isNonDefaultDrive) nonDefaultDrive.value = true
|
||||
if (validation.exists) pathExists.value = true
|
||||
} catch (error) {
|
||||
pathError.value = t('install.pathValidationFailed')
|
||||
}
|
||||
}
|
||||
|
||||
const browsePath = async () => {
|
||||
try {
|
||||
const result = await electron.showDirectoryPicker()
|
||||
if (result) {
|
||||
installPath.value = result
|
||||
await validatePath(result)
|
||||
}
|
||||
} catch (error) {
|
||||
pathError.value = t('install.failedToSelectDirectory')
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = async () => {
|
||||
if (!inputTouched.value) {
|
||||
inputTouched.value = true
|
||||
return
|
||||
}
|
||||
// Refresh validation on re-focus
|
||||
await validatePath(installPath.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.location-picker-accordion) {
|
||||
@apply px-12;
|
||||
|
||||
.p-accordionpanel {
|
||||
@apply border-0 bg-transparent;
|
||||
}
|
||||
|
||||
.p-accordionheader {
|
||||
@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;
|
||||
}
|
||||
|
||||
/* When panel is expanded, adjust header border radius */
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader {
|
||||
@apply rounded-t-xl rounded-b-none;
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordioncontent {
|
||||
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
|
||||
}
|
||||
|
||||
.p-accordioncontent-content {
|
||||
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
|
||||
}
|
||||
|
||||
/* Override default chevron icons to use up/down */
|
||||
.p-accordionheader-toggle-icon {
|
||||
&::before {
|
||||
content: '\e933';
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader-toggle-icon {
|
||||
&::before {
|
||||
content: '\e902';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,45 +0,0 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MigrationPicker from './MigrationPicker.vue'
|
||||
|
||||
const meta: Meta<typeof MigrationPicker> = {
|
||||
title: 'Desktop/Components/MigrationPicker',
|
||||
component: MigrationPicker,
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' }
|
||||
]
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => {
|
||||
;(window as any).electronAPI = {
|
||||
validateComfyUISource: () => Promise.resolve({ isValid: true }),
|
||||
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
|
||||
}
|
||||
|
||||
return { template: '<story />' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { MigrationPicker },
|
||||
setup() {
|
||||
const sourcePath = ref('')
|
||||
const migrationItemIds = ref<string[]>([])
|
||||
return { sourcePath, migrationItemIds }
|
||||
},
|
||||
template:
|
||||
'<MigrationPicker v-model:sourcePath="sourcePath" v-model:migrationItemIds="migrationItemIds" />'
|
||||
})
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 w-[600px]">
|
||||
<!-- Source Location Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-neutral-400 my-0">
|
||||
{{ $t('install.migrationSourcePathDescription') }}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<InputText
|
||||
v-model="sourcePath"
|
||||
:placeholder="$t('install.locationPicker.migrationPathPlaceholder')"
|
||||
class="flex-1"
|
||||
:class="{ 'p-invalid': pathError }"
|
||||
@update:model-value="validateSource"
|
||||
/>
|
||||
<Button icon="pi pi-folder" class="w-12" @click="browsePath" />
|
||||
</div>
|
||||
|
||||
<Message v-if="pathError" severity="error">
|
||||
{{ pathError }}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<!-- Migration Options -->
|
||||
<div v-if="isValidSource" class="flex flex-col gap-4 p-4 rounded-lg">
|
||||
<h3 class="text-lg mt-0 font-medium text-neutral-100">
|
||||
{{ $t('install.selectItemsToMigrate') }}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="item in migrationItems"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-3 p-2 hover:bg-neutral-700 rounded"
|
||||
@click="item.selected = !item.selected"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="item.selected"
|
||||
:input-id="item.id"
|
||||
:binary="true"
|
||||
@click.stop
|
||||
/>
|
||||
<div>
|
||||
<label :for="item.id" class="text-neutral-200 font-medium">
|
||||
{{ item.label }}
|
||||
</label>
|
||||
<p class="text-sm text-neutral-400 my-1">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skip Migration -->
|
||||
<div v-else class="text-neutral-400 italic">
|
||||
{{ $t('install.migrationOptional') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MigrationItems } from '@comfyorg/comfyui-electron-types'
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const electron = electronAPI()
|
||||
|
||||
const sourcePath = defineModel<string>('sourcePath', { required: false })
|
||||
const migrationItemIds = defineModel<string[]>('migrationItemIds', {
|
||||
required: false
|
||||
})
|
||||
|
||||
const migrationItems = ref(
|
||||
MigrationItems.map((item) => ({
|
||||
...item,
|
||||
selected: true
|
||||
}))
|
||||
)
|
||||
|
||||
const pathError = ref('')
|
||||
const isValidSource = computed(
|
||||
() => sourcePath.value !== '' && pathError.value === ''
|
||||
)
|
||||
|
||||
const validateSource = async (sourcePath: string | undefined) => {
|
||||
if (!sourcePath) {
|
||||
pathError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
pathError.value = ''
|
||||
const validation = await electron.validateComfyUISource(sourcePath)
|
||||
|
||||
if (!validation.isValid) pathError.value = validation.error ?? 'ERROR'
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
pathError.value = t('install.pathValidationFailed')
|
||||
}
|
||||
}
|
||||
|
||||
const browsePath = async () => {
|
||||
try {
|
||||
const result = await electron.showDirectoryPicker()
|
||||
if (result) {
|
||||
sourcePath.value = result
|
||||
await validateSource(result)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
pathError.value = t('install.failedToSelectDirectory')
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
migrationItemIds.value = migrationItems.value
|
||||
.filter((item) => item.selected)
|
||||
.map((item) => item.id)
|
||||
})
|
||||
</script>
|
||||
@@ -1,109 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 text-neutral-400 text-sm">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-neutral-100 mb-3 mt-0">
|
||||
{{ $t(`settings.${normalizedSettingId}.name`) }}
|
||||
</h3>
|
||||
<p class="my-1">
|
||||
{{ $t(`settings.${normalizedSettingId}.tooltip`) }}
|
||||
</p>
|
||||
</div>
|
||||
<UrlInput
|
||||
v-model="modelValue"
|
||||
:validate-url-fn="
|
||||
(mirror: string) =>
|
||||
checkMirrorReachable(mirror + (item.validationPathSuffix ?? ''))
|
||||
"
|
||||
@state-change="validationState = $event"
|
||||
/>
|
||||
<div v-if="secondParagraph" class="mt-2">
|
||||
<a href="#" @click.prevent="showDialog = true">
|
||||
{{ $t('g.learnMore') }}
|
||||
</a>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="showDialog"
|
||||
modal
|
||||
dismissable-mask
|
||||
:header="$t(`settings.${normalizedSettingId}.urlFormatTitle`)"
|
||||
class="select-none max-w-3xl"
|
||||
>
|
||||
<div class="text-neutral-300">
|
||||
<p class="mt-1 whitespace-pre-wrap">{{ secondParagraph }}</p>
|
||||
<div class="mt-2 break-all">
|
||||
<span class="text-neutral-300 font-semibold">
|
||||
{{ EXAMPLE_URL_FIRST_PART }}
|
||||
</span>
|
||||
<span>{{ EXAMPLE_URL_SECOND_PART }}</span>
|
||||
</div>
|
||||
<Divider />
|
||||
<p>
|
||||
{{ $t(`settings.${normalizedSettingId}.fileUrlDescription`) }}
|
||||
</p>
|
||||
<span class="text-neutral-300 font-semibold">
|
||||
{{ FILE_URL_SCHEME }}
|
||||
</span>
|
||||
<span>
|
||||
{{ EXAMPLE_FILE_URL }}
|
||||
</span>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Divider from 'primevue/divider'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
import type { UVMirror } from '@/constants/uvMirrors'
|
||||
import { st } from '@/i18n'
|
||||
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const FILE_URL_SCHEME = 'file://'
|
||||
const EXAMPLE_FILE_URL = '/C:/MyPythonInstallers/'
|
||||
const EXAMPLE_URL_FIRST_PART =
|
||||
'https://github.com/astral-sh/python-build-standalone/releases/download'
|
||||
const EXAMPLE_URL_SECOND_PART =
|
||||
'/20250902/cpython-3.12.11+20250902-x86_64-pc-windows-msvc-install_only.tar.gz'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: UVMirror
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'state-change': [state: ValidationState]
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>('modelValue', { required: true })
|
||||
const validationState = ref<ValidationState>(ValidationState.IDLE)
|
||||
const showDialog = ref(false)
|
||||
|
||||
const normalizedSettingId = computed(() => {
|
||||
return normalizeI18nKey(item.settingId)
|
||||
})
|
||||
|
||||
const secondParagraph = computed(() =>
|
||||
st(`settings.${normalizedSettingId.value}.urlDescription`, '')
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
modelValue.value = item.mirror
|
||||
})
|
||||
|
||||
watch(validationState, (newState) => {
|
||||
emit('state-change', newState)
|
||||
|
||||
// Set fallback mirror if default mirror is invalid
|
||||
if (
|
||||
newState === ValidationState.INVALID &&
|
||||
modelValue.value === item.mirror
|
||||
) {
|
||||
modelValue.value = item.fallbackMirror
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,36 +0,0 @@
|
||||
<template>
|
||||
<Tag :icon :severity :value />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PrimeIcons } from '@primevue/core/api'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
// Properties
|
||||
const props = defineProps<{
|
||||
error: boolean
|
||||
refreshing?: boolean
|
||||
}>()
|
||||
|
||||
// Bindings
|
||||
const icon = computed(() => {
|
||||
if (props.refreshing) return PrimeIcons.QUESTION
|
||||
if (props.error) return PrimeIcons.TIMES
|
||||
return PrimeIcons.CHECK
|
||||
})
|
||||
|
||||
const severity = computed(() => {
|
||||
if (props.refreshing) return 'info'
|
||||
if (props.error) return 'danger'
|
||||
return 'success'
|
||||
})
|
||||
|
||||
const value = computed(() => {
|
||||
if (props.refreshing) return t('maintenance.refreshing')
|
||||
if (props.error) return t('g.error')
|
||||
return t('maintenance.OK')
|
||||
})
|
||||
</script>
|
||||
@@ -1,133 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="task-div max-w-48 min-h-52 grid relative"
|
||||
:class="{ 'opacity-75': isLoading }"
|
||||
>
|
||||
<Card
|
||||
class="max-w-48 relative h-full overflow-hidden"
|
||||
:class="{ 'opacity-65': runner.state !== 'error' }"
|
||||
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
|
||||
>
|
||||
<template #header>
|
||||
<i
|
||||
v-if="runner.state === 'error'"
|
||||
class="pi pi-exclamation-triangle text-red-500 absolute m-2 top-0 -right-14 opacity-15"
|
||||
style="font-size: 10rem"
|
||||
/>
|
||||
<img
|
||||
v-if="task.headerImg"
|
||||
:src="task.headerImg"
|
||||
class="object-contain w-full h-full opacity-25 pt-4 px-4"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
{{ task.name }}
|
||||
</template>
|
||||
<template #content>
|
||||
{{ description }}
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex gap-4 mt-1">
|
||||
<Button
|
||||
:icon="task.button?.icon"
|
||||
:label="task.button?.text"
|
||||
class="w-full"
|
||||
raised
|
||||
icon-pos="right"
|
||||
:loading="isExecuting"
|
||||
@click="(event) => $emit('execute', event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<i
|
||||
v-if="!isLoading && runner.state === 'OK'"
|
||||
class="task-card-ok pi pi-check"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Card from 'primevue/card'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
const runner = computed(() => taskStore.getRunner(props.task))
|
||||
|
||||
// Properties
|
||||
const props = defineProps<{
|
||||
task: MaintenanceTask
|
||||
}>()
|
||||
|
||||
// Events
|
||||
defineEmits<{
|
||||
execute: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
// Bindings
|
||||
const description = computed(() =>
|
||||
runner.value.state === 'error'
|
||||
? props.task.errorDescription ?? props.task.shortDescription
|
||||
: props.task.shortDescription
|
||||
)
|
||||
|
||||
// Use a minimum run time to ensure tasks "feel" like they have run
|
||||
const reactiveLoading = computed(() => !!runner.value.refreshing)
|
||||
const reactiveExecuting = computed(() => !!runner.value.executing)
|
||||
|
||||
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
|
||||
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
|
||||
</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,88 +0,0 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-neutral-700 border-solid border-y"
|
||||
:class="{
|
||||
'opacity-50': runner.resolved,
|
||||
'opacity-75': isLoading && runner.resolved
|
||||
}"
|
||||
>
|
||||
<td class="text-center w-16">
|
||||
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
|
||||
</td>
|
||||
<td>
|
||||
<p class="inline-block">
|
||||
{{ task.name }}
|
||||
</p>
|
||||
<Button
|
||||
class="inline-block mx-2"
|
||||
type="button"
|
||||
:icon="PrimeIcons.INFO_CIRCLE"
|
||||
severity="secondary"
|
||||
:text="true"
|
||||
@click="toggle"
|
||||
/>
|
||||
|
||||
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
|
||||
<span class="whitespace-pre-line">{{ task.description }}</span>
|
||||
</Popover>
|
||||
</td>
|
||||
<td class="text-right px-4">
|
||||
<Button
|
||||
:icon="task.button?.icon"
|
||||
:label="task.button?.text"
|
||||
:severity
|
||||
icon-pos="right"
|
||||
:loading="isExecuting"
|
||||
@click="(event) => $emit('execute', event)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PrimeIcons } from '@primevue/core/api'
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import type { PrimeVueSeverity } from '@/types/primeVueTypes'
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
import TaskListStatusIcon from './TaskListStatusIcon.vue'
|
||||
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
const runner = computed(() => taskStore.getRunner(props.task))
|
||||
|
||||
// Properties
|
||||
const props = defineProps<{
|
||||
task: MaintenanceTask
|
||||
}>()
|
||||
|
||||
// Events
|
||||
defineEmits<{
|
||||
execute: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
// Binding
|
||||
const severity = computed<PrimeVueSeverity>(() =>
|
||||
runner.value.state === 'error' || runner.value.state === 'warning'
|
||||
? 'primary'
|
||||
: 'secondary'
|
||||
)
|
||||
|
||||
// Use a minimum run time to ensure tasks "feel" like they have run
|
||||
const reactiveLoading = computed(() => !!runner.value.refreshing)
|
||||
const reactiveExecuting = computed(() => !!runner.value.executing)
|
||||
|
||||
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
|
||||
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
|
||||
|
||||
// Popover
|
||||
const infoPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
infoPopover.value?.toggle(event)
|
||||
}
|
||||
</script>
|
||||
@@ -1,115 +0,0 @@
|
||||
<template>
|
||||
<!-- Tasks -->
|
||||
<section class="my-4">
|
||||
<template v-if="filter.tasks.length === 0">
|
||||
<!-- Empty filter -->
|
||||
<Divider />
|
||||
<p class="text-neutral-400 w-full text-center">
|
||||
{{ $t('maintenance.allOk') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Display: List -->
|
||||
<table
|
||||
v-if="displayAsList === PrimeIcons.LIST"
|
||||
class="w-full border-collapse border-hidden"
|
||||
>
|
||||
<TaskListItem
|
||||
v-for="task in filter.tasks"
|
||||
:key="task.id"
|
||||
:task
|
||||
@execute="(event) => confirmButton(event, task)"
|
||||
/>
|
||||
</table>
|
||||
|
||||
<!-- Display: Cards -->
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
|
||||
<TaskCard
|
||||
v-for="task in filter.tasks"
|
||||
:key="task.id"
|
||||
:task
|
||||
@execute="(event) => confirmButton(event, task)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<ConfirmPopup />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PrimeIcons } from '@primevue/core/api'
|
||||
import { useConfirm, useToast } from 'primevue'
|
||||
import ConfirmPopup from 'primevue/confirmpopup'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type {
|
||||
MaintenanceFilter,
|
||||
MaintenanceTask
|
||||
} from '@/types/desktop/maintenanceTypes'
|
||||
|
||||
import TaskCard from './TaskCard.vue'
|
||||
import TaskListItem from './TaskListItem.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
|
||||
// Properties
|
||||
defineProps<{
|
||||
displayAsList: string
|
||||
filter: MaintenanceFilter
|
||||
isRefreshing: boolean
|
||||
}>()
|
||||
|
||||
const executeTask = async (task: MaintenanceTask) => {
|
||||
let message: string | undefined
|
||||
|
||||
try {
|
||||
// Success
|
||||
if ((await taskStore.execute(task)) === true) return
|
||||
|
||||
message = t('maintenance.error.taskFailed')
|
||||
} catch (error) {
|
||||
message = (error as Error)?.message
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('maintenance.error.toastTitle'),
|
||||
detail: message ?? t('maintenance.error.defaultDescription'),
|
||||
life: 10_000
|
||||
})
|
||||
}
|
||||
|
||||
// Commands
|
||||
const confirmButton = async (event: MouseEvent, task: MaintenanceTask) => {
|
||||
if (!task.requireConfirm) {
|
||||
await executeTask(task)
|
||||
return
|
||||
}
|
||||
|
||||
confirm.require({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
message: task.confirmText ?? t('maintenance.confirmTitle'),
|
||||
icon: 'pi pi-exclamation-circle',
|
||||
rejectProps: {
|
||||
label: t('g.cancel'),
|
||||
severity: 'secondary',
|
||||
outlined: true
|
||||
},
|
||||
acceptProps: {
|
||||
label: task.button?.text ?? t('g.save'),
|
||||
severity: task.severity ?? 'primary'
|
||||
},
|
||||
// TODO: Not awaited.
|
||||
accept: async () => {
|
||||
await executeTask(task)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<ProgressSpinner v-if="!state || loading" class="h-8 w-8" />
|
||||
<template v-else>
|
||||
<i v-tooltip.top="{ value: tooltip, showDelay: 250 }" :class="cssClasses" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PrimeIcons } from '@primevue/core/api'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
// Properties
|
||||
const tooltip = computed(() => {
|
||||
if (props.state === 'error') {
|
||||
return t('g.error')
|
||||
} else if (props.state === 'OK') {
|
||||
return t('maintenance.OK')
|
||||
} else {
|
||||
return t('maintenance.Skipped')
|
||||
}
|
||||
})
|
||||
|
||||
const cssClasses = computed(() => {
|
||||
let classes: string
|
||||
if (props.state === 'error') {
|
||||
classes = `${PrimeIcons.EXCLAMATION_TRIANGLE} text-red-500`
|
||||
} else if (props.state === 'OK') {
|
||||
classes = `${PrimeIcons.CHECK} text-green-500`
|
||||
} else {
|
||||
classes = PrimeIcons.MINUS
|
||||
}
|
||||
|
||||
return `text-3xl pi ${classes}`
|
||||
})
|
||||
|
||||
// Model
|
||||
const props = defineProps<{
|
||||
state: 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' | undefined
|
||||
loading?: MaybeRef<boolean>
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,62 +0,0 @@
|
||||
<template>
|
||||
<Drawer
|
||||
v-model:visible="terminalVisible"
|
||||
:header
|
||||
position="bottom"
|
||||
style="height: max(50vh, 34rem)"
|
||||
>
|
||||
<BaseTerminal @created="terminalCreated" @unmounted="terminalUnmounted" />
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import Drawer from 'primevue/drawer'
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import { useTerminalBuffer } from '@/composables/bottomPanelTabs/useTerminalBuffer'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
// Model
|
||||
const terminalVisible = defineModel<boolean>({ required: true })
|
||||
const props = defineProps<{
|
||||
header: string
|
||||
defaultMessage: string
|
||||
}>()
|
||||
|
||||
const electron = electronAPI()
|
||||
|
||||
/** The actual output of all terminal commands - not rendered */
|
||||
const buffer = useTerminalBuffer()
|
||||
let xterm: Terminal | null = null
|
||||
|
||||
// Created and destroyed with the Drawer - contents copied from hidden buffer
|
||||
const terminalCreated = (
|
||||
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
root: Ref<HTMLElement | undefined>
|
||||
) => {
|
||||
xterm = terminal
|
||||
useAutoSize({ root, autoRows: true, autoCols: true })
|
||||
terminal.write(props.defaultMessage)
|
||||
buffer.copyTo(terminal)
|
||||
|
||||
terminal.options.cursorBlink = false
|
||||
terminal.options.cursorStyle = 'bar'
|
||||
terminal.options.cursorInactiveStyle = 'bar'
|
||||
terminal.options.disableStdin = true
|
||||
}
|
||||
|
||||
const terminalUnmounted = () => {
|
||||
xterm = null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
electron.onLogMessage((message: string) => {
|
||||
buffer.write(message)
|
||||
xterm?.write(message)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user