mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-26 01:27:23 +00:00
Compare commits
2 Commits
DynamicGro
...
feat/deskt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad6adf39e5 | ||
|
|
443b414fd2 |
@@ -42,6 +42,15 @@
|
||||
</Tab>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="popoutAction"
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
:aria-label="t('terminal.openInNewWindow')"
|
||||
@click="openInNewWindow"
|
||||
>
|
||||
<i class="pi pi-external-link" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isShortcutsTabActive"
|
||||
variant="muted-textonly"
|
||||
@@ -87,6 +96,10 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
getLogsPopout,
|
||||
getTerminalBridge
|
||||
} from '@/composables/bottomPanelTabs/useTerminalBridge'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
@@ -103,6 +116,21 @@ const isShortcutsTabActive = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// The host (ComfyUI Desktop 2.0) can open the live Terminal/Logs in a separate
|
||||
// window. Surface the action only on those tabs and only when the host offers it.
|
||||
const popoutAction = computed<(() => Promise<void>) | null>(() => {
|
||||
const activeTabId = bottomPanelStore.activeBottomPanelTabId
|
||||
if (activeTabId === 'command-terminal') {
|
||||
return getTerminalBridge()?.openPopout ?? null
|
||||
}
|
||||
if (activeTabId === 'logs-terminal') return getLogsPopout()
|
||||
return null
|
||||
})
|
||||
|
||||
const openInNewWindow = async () => {
|
||||
await popoutAction.value?.()
|
||||
}
|
||||
|
||||
const shouldCapitalizeTab = (tabId: string): boolean => {
|
||||
return tabId !== 'shortcuts-essentials' && tabId !== 'shortcuts-view-controls'
|
||||
}
|
||||
|
||||
142
src/components/bottomPanel/tabs/terminal/CommandTerminal.test.ts
Normal file
142
src/components/bottomPanel/tabs/terminal/CommandTerminal.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CommandTerminal from './CommandTerminal.vue'
|
||||
|
||||
const { fakeTerminal, autoSize, getTerminalBridge } = vi.hoisted(() => {
|
||||
const term = {
|
||||
onData: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
element: { offsetParent: {} },
|
||||
cols: 80,
|
||||
rows: 30
|
||||
}
|
||||
return {
|
||||
fakeTerminal: term,
|
||||
autoSize: vi.fn(),
|
||||
getTerminalBridge: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/bottomPanelTabs/useTerminalBridge', () => ({
|
||||
getTerminalBridge
|
||||
}))
|
||||
|
||||
// Stub BaseTerminal: synchronously hand the component a fake xterm terminal.
|
||||
vi.mock('./BaseTerminal.vue', () => ({
|
||||
default: defineComponent({
|
||||
emits: ['created'],
|
||||
setup(_props, { emit }) {
|
||||
emit(
|
||||
'created',
|
||||
{ terminal: fakeTerminal, useAutoSize: autoSize },
|
||||
ref(undefined)
|
||||
)
|
||||
return () => h('div', { 'data-testid': 'base-terminal-stub' })
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { terminal: { sessionEnded: 'ended', restart: 'restart' } }
|
||||
}
|
||||
})
|
||||
|
||||
function renderTerminal() {
|
||||
return render(CommandTerminal, { global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
function makeLauncherBridge() {
|
||||
let exitCb: (() => void) | undefined
|
||||
return {
|
||||
subscribe: vi.fn().mockResolvedValue({
|
||||
buffer: [],
|
||||
size: { cols: 80, rows: 30 },
|
||||
exited: false
|
||||
}),
|
||||
write: vi.fn().mockResolvedValue(undefined),
|
||||
resize: vi.fn().mockResolvedValue(undefined),
|
||||
restart: vi.fn().mockResolvedValue({
|
||||
buffer: [],
|
||||
size: { cols: 80, rows: 30 },
|
||||
exited: false
|
||||
}),
|
||||
onOutput: vi.fn(() => () => {}),
|
||||
onExited: vi.fn((cb: () => void) => {
|
||||
exitCb = cb
|
||||
return () => {}
|
||||
}),
|
||||
fireExit: () => exitCb?.()
|
||||
}
|
||||
}
|
||||
|
||||
describe('CommandTerminal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the restart banner when the session exits and clears it on restart', async () => {
|
||||
const bridge = makeLauncherBridge()
|
||||
getTerminalBridge.mockReturnValue(bridge)
|
||||
|
||||
renderTerminal()
|
||||
await waitFor(() => expect(bridge.subscribe).toHaveBeenCalled())
|
||||
|
||||
expect(screen.queryByTestId('terminal-session-ended')).toBeNull()
|
||||
|
||||
bridge.fireExit()
|
||||
expect(await screen.findByTestId('terminal-session-ended')).toBeTruthy()
|
||||
|
||||
await userEvent.click(screen.getByTestId('terminal-restart-button'))
|
||||
expect(bridge.restart).toHaveBeenCalled()
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('terminal-session-ended')).toBeNull()
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-restarts when re-opened after the user killed the session', async () => {
|
||||
const bridge = makeLauncherBridge()
|
||||
bridge.subscribe.mockResolvedValue({
|
||||
buffer: [],
|
||||
size: { cols: 80, rows: 30 },
|
||||
exited: true
|
||||
})
|
||||
getTerminalBridge.mockReturnValue(bridge)
|
||||
|
||||
renderTerminal()
|
||||
|
||||
await waitFor(() => expect(bridge.restart).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('does not offer restart on a legacy host that cannot restart', async () => {
|
||||
const legacy = {
|
||||
subscribe: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ buffer: [], size: { cols: 80, rows: 30 } }),
|
||||
write: vi.fn().mockResolvedValue(undefined),
|
||||
resize: vi.fn().mockResolvedValue(undefined),
|
||||
restart: null,
|
||||
onOutput: vi.fn(() => () => {}),
|
||||
onExited: null
|
||||
}
|
||||
getTerminalBridge.mockReturnValue(legacy)
|
||||
|
||||
renderTerminal()
|
||||
await waitFor(() => expect(legacy.subscribe).toHaveBeenCalled())
|
||||
|
||||
// No exit notifications on legacy, so the banner never shows.
|
||||
expect(screen.queryByTestId('terminal-session-ended')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,25 +1,75 @@
|
||||
<template>
|
||||
<BaseTerminal @created="terminalCreated" />
|
||||
<div class="relative size-full">
|
||||
<BaseTerminal @created="terminalCreated" />
|
||||
<div
|
||||
v-if="exited"
|
||||
data-testid="terminal-session-ended"
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-neutral-800 px-3 py-2 text-sm text-neutral-200"
|
||||
>
|
||||
<span>{{ $t('terminal.sessionEnded') }}</span>
|
||||
<Button
|
||||
v-if="canRestart"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
data-testid="terminal-restart-button"
|
||||
@click="restart"
|
||||
>
|
||||
{{ $t('terminal.restart') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import type { IDisposable, Terminal } from '@xterm/xterm'
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { getTerminalBridge } from '@/composables/bottomPanelTabs/useTerminalBridge'
|
||||
import type {
|
||||
TerminalBridge,
|
||||
TerminalRestore
|
||||
} from '@/composables/bottomPanelTabs/useTerminalBridge'
|
||||
|
||||
import BaseTerminal from './BaseTerminal.vue'
|
||||
|
||||
const exited = ref(false)
|
||||
const canRestart = ref(false)
|
||||
|
||||
let bridge: TerminalBridge | null = null
|
||||
let terminal: Terminal | null = null
|
||||
|
||||
const applyRestore = (restore: TerminalRestore) => {
|
||||
if (!terminal) return
|
||||
if (restore.buffer.length) {
|
||||
terminal.resize(restore.size.cols, restore.size.rows)
|
||||
terminal.write(restore.buffer.join(''))
|
||||
}
|
||||
exited.value = restore.exited ?? false
|
||||
}
|
||||
|
||||
const restart = async () => {
|
||||
if (!bridge?.restart || !terminal) return
|
||||
terminal.reset()
|
||||
exited.value = false
|
||||
applyRestore(await bridge.restart())
|
||||
}
|
||||
|
||||
const terminalCreated = (
|
||||
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
{ terminal: term, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
root: Ref<HTMLElement | undefined>
|
||||
) => {
|
||||
const terminalApi = electronAPI().Terminal
|
||||
bridge = getTerminalBridge()
|
||||
if (!bridge) return
|
||||
const activeBridge = bridge
|
||||
terminal = term
|
||||
canRestart.value = activeBridge.restart !== null
|
||||
|
||||
let offData: IDisposable
|
||||
let offOutput: () => void
|
||||
let offExited: (() => void) | undefined
|
||||
|
||||
useAutoSize({
|
||||
root,
|
||||
@@ -27,33 +77,36 @@ const terminalCreated = (
|
||||
autoCols: true,
|
||||
onResize: async () => {
|
||||
// If we aren't visible, don't resize
|
||||
if (!terminal.element?.offsetParent) return
|
||||
|
||||
await terminalApi.resize(terminal.cols, terminal.rows)
|
||||
if (!term.element?.offsetParent) return
|
||||
await activeBridge.resize(term.cols, term.rows)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
offData = terminal.onData(async (message: string) => {
|
||||
await terminalApi.write(message)
|
||||
offData = term.onData(async (message: string) => {
|
||||
await activeBridge.write(message)
|
||||
})
|
||||
offOutput = activeBridge.onOutput((message) => {
|
||||
term.write(message)
|
||||
})
|
||||
offExited = activeBridge.onExited?.(() => {
|
||||
exited.value = true
|
||||
})
|
||||
|
||||
offOutput = terminalApi.onOutput((message) => {
|
||||
terminal.write(message)
|
||||
})
|
||||
|
||||
const restore = await terminalApi.restore()
|
||||
setTimeout(() => {
|
||||
if (restore.buffer.length) {
|
||||
terminal.resize(restore.size.cols, restore.size.rows)
|
||||
terminal.write(restore.buffer.join(''))
|
||||
}
|
||||
}, 500)
|
||||
const restore = await activeBridge.subscribe()
|
||||
// "Pulling it up again restarts it": if the user had killed the session,
|
||||
// re-opening the tab silently brings it back.
|
||||
if (restore.exited && activeBridge.restart) {
|
||||
await restart()
|
||||
} else {
|
||||
setTimeout(() => applyRestore(restore), 500)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
offData?.dispose()
|
||||
offOutput?.()
|
||||
offExited?.()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
113
src/composables/bottomPanelTabs/useTerminalBridge.test.ts
Normal file
113
src/composables/bottomPanelTabs/useTerminalBridge.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getLogsPopout,
|
||||
getTerminalBridge,
|
||||
isTerminalHostAvailable
|
||||
} from './useTerminalBridge'
|
||||
|
||||
const electronAPIMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/utils/envUtil', () => ({ electronAPI: electronAPIMock }))
|
||||
|
||||
describe('useTerminalBridge', () => {
|
||||
beforeEach(() => {
|
||||
electronAPIMock.mockReturnValue(undefined)
|
||||
delete window.__comfyDesktop2
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__comfyDesktop2
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns null when no terminal host is present (e.g. a browser tab)', () => {
|
||||
expect(getTerminalBridge()).toBeNull()
|
||||
expect(isTerminalHostAvailable()).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers the Desktop 2.0 host and exposes restart + exit + popout', () => {
|
||||
const launcher = {
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
onOutput: vi.fn(),
|
||||
onExited: vi.fn(),
|
||||
openPopout: vi.fn()
|
||||
}
|
||||
window.__comfyDesktop2 = { Terminal: launcher }
|
||||
|
||||
const bridge = getTerminalBridge()
|
||||
expect(bridge).not.toBeNull()
|
||||
expect(bridge?.restart).not.toBeNull()
|
||||
expect(bridge?.onExited).not.toBeNull()
|
||||
expect(bridge?.openPopout).not.toBeNull()
|
||||
|
||||
void bridge?.write('ls\r')
|
||||
expect(launcher.write).toHaveBeenCalledWith('ls\r')
|
||||
void bridge?.openPopout?.()
|
||||
expect(launcher.openPopout).toHaveBeenCalled()
|
||||
expect(isTerminalHostAvailable()).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes the logs popout only under the Desktop 2.0 host', () => {
|
||||
expect(getLogsPopout()).toBeNull()
|
||||
|
||||
const openPopout = vi.fn()
|
||||
window.__comfyDesktop2 = { Logs: { openPopout } }
|
||||
|
||||
const logsPopout = getLogsPopout()
|
||||
expect(logsPopout).not.toBeNull()
|
||||
void logsPopout?.()
|
||||
expect(openPopout).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the legacy desktop host without restart/exit support', () => {
|
||||
const legacy = {
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
restore: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ buffer: [], size: { cols: 80, rows: 30 } }),
|
||||
onOutput: vi.fn()
|
||||
}
|
||||
electronAPIMock.mockReturnValue({ Terminal: legacy })
|
||||
|
||||
const bridge = getTerminalBridge()
|
||||
expect(bridge).not.toBeNull()
|
||||
// Legacy can't restart or report exits.
|
||||
expect(bridge?.restart).toBeNull()
|
||||
expect(bridge?.onExited).toBeNull()
|
||||
|
||||
// subscribe() maps to the legacy restore().
|
||||
void bridge?.subscribe()
|
||||
expect(legacy.restore).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses Desktop 2.0 even when the legacy API is also present', () => {
|
||||
const launcher = {
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
onOutput: vi.fn(),
|
||||
onExited: vi.fn(),
|
||||
openPopout: vi.fn()
|
||||
}
|
||||
const legacy = {
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
onOutput: vi.fn()
|
||||
}
|
||||
window.__comfyDesktop2 = { Terminal: launcher }
|
||||
electronAPIMock.mockReturnValue({ Terminal: legacy })
|
||||
|
||||
const bridge = getTerminalBridge()
|
||||
void bridge?.write('x')
|
||||
expect(launcher.write).toHaveBeenCalledWith('x')
|
||||
expect(legacy.write).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
95
src/composables/bottomPanelTabs/useTerminalBridge.ts
Normal file
95
src/composables/bottomPanelTabs/useTerminalBridge.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
/** Scrollback + size + liveness snapshot used to repaint a terminal. */
|
||||
export interface TerminalRestore {
|
||||
buffer: string[]
|
||||
size: { cols: number; rows: number }
|
||||
exited?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-agnostic interactive terminal transport.
|
||||
*
|
||||
* Two hosts can provide one: the legacy ComfyUI Desktop (`electronAPI().Terminal`)
|
||||
* and ComfyUI Desktop 2.0 (`window.__comfyDesktop2.Terminal`). The launcher host
|
||||
* additionally reports when the user kills the shell and can restart it; the
|
||||
* legacy host can't, so those members are nullable.
|
||||
*/
|
||||
export interface TerminalBridge {
|
||||
subscribe(): Promise<TerminalRestore>
|
||||
write(data: string): Promise<void>
|
||||
resize(cols: number, rows: number): Promise<void>
|
||||
/** Kill and respawn the shell. `null` when the host can't restart. */
|
||||
restart: (() => Promise<TerminalRestore>) | null
|
||||
onOutput(callback: (data: string) => void): () => void
|
||||
/** Notified when the shell exits. `null` when the host doesn't report it. */
|
||||
onExited: ((callback: () => void) => () => void) | null
|
||||
/** Open the shell in a separate host window. `null` when unsupported. */
|
||||
openPopout: (() => Promise<void>) | null
|
||||
}
|
||||
|
||||
interface Desktop2Terminal {
|
||||
subscribe(): Promise<TerminalRestore>
|
||||
unsubscribe(): Promise<void>
|
||||
write(data: string): Promise<void>
|
||||
resize(cols: number, rows: number): Promise<void>
|
||||
restart(): Promise<TerminalRestore>
|
||||
onOutput(callback: (data: string) => void): () => void
|
||||
onExited(callback: () => void): () => void
|
||||
openPopout(): Promise<void>
|
||||
}
|
||||
|
||||
interface Desktop2Logs {
|
||||
openPopout(): Promise<void>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__comfyDesktop2?: { Terminal?: Desktop2Terminal; Logs?: Desktop2Logs }
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve whichever terminal host is present, or `null` (e.g. a browser tab). */
|
||||
export function getTerminalBridge(): TerminalBridge | null {
|
||||
const launcher = window.__comfyDesktop2?.Terminal
|
||||
if (launcher) {
|
||||
return {
|
||||
subscribe: () => launcher.subscribe(),
|
||||
write: (data) => launcher.write(data),
|
||||
resize: (cols, rows) => launcher.resize(cols, rows),
|
||||
restart: () => launcher.restart(),
|
||||
onOutput: (callback) => launcher.onOutput(callback),
|
||||
onExited: (callback) => launcher.onExited(callback),
|
||||
openPopout: () => launcher.openPopout()
|
||||
}
|
||||
}
|
||||
|
||||
const legacy = electronAPI()?.Terminal
|
||||
if (legacy) {
|
||||
return {
|
||||
subscribe: () => legacy.restore(),
|
||||
write: (data) => legacy.write(data),
|
||||
resize: (cols, rows) => legacy.resize(cols, rows),
|
||||
restart: null,
|
||||
onOutput: (callback) => legacy.onOutput(callback),
|
||||
onExited: null,
|
||||
openPopout: null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether any interactive terminal host is reachable from this surface. */
|
||||
export function isTerminalHostAvailable(): boolean {
|
||||
return getTerminalBridge() !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the read-only logs popout, or `null`. Only ComfyUI Desktop 2.0
|
||||
* provides a separate logs window; legacy desktop and browser tabs don't.
|
||||
*/
|
||||
export function getLogsPopout(): (() => Promise<void>) | null {
|
||||
const logs = window.__comfyDesktop2?.Logs
|
||||
return logs ? () => logs.openPopout() : null
|
||||
}
|
||||
@@ -28,7 +28,8 @@ export enum ServerFeatureFlag {
|
||||
WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
|
||||
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button'
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
SUPPORTS_TERMINAL = 'supports_terminal'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,6 +166,10 @@ export function useFeatureFlags() {
|
||||
ServerFeatureFlag.SHOW_SIGNIN_BUTTON,
|
||||
undefined
|
||||
)
|
||||
},
|
||||
/** A terminal host (e.g. ComfyUI Desktop) is available for this server. */
|
||||
get supportsTerminal(): boolean {
|
||||
return api.serverSupportsFeature(ServerFeatureFlag.SUPPORTS_TERMINAL)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -640,6 +640,11 @@
|
||||
"briefcase": "Briefcase",
|
||||
"exclamation-triangle": "Warning"
|
||||
},
|
||||
"terminal": {
|
||||
"sessionEnded": "This terminal session ended. Restart it to keep using the console.",
|
||||
"restart": "Restart",
|
||||
"openInNewWindow": "Open in a new window"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welcome to ComfyUI",
|
||||
"getStarted": "Get Started"
|
||||
|
||||
@@ -56,6 +56,17 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// The store now gates the interactive terminal tab on a terminal host + the
|
||||
// server feature flag. These tests don't exercise the gate, so keep the
|
||||
// dependencies inert.
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: { supportsTerminal: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/bottomPanelTabs/useTerminalBridge', () => ({
|
||||
isTerminalHostAvailable: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
describe('useBottomPanelStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useShortcutsTab } from '@/composables/bottomPanelTabs/useShortcutsTab'
|
||||
import { isTerminalHostAvailable } from '@/composables/bottomPanelTabs/useTerminalBridge'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
@@ -128,6 +130,33 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// The interactive terminal tab needs a host that provides one
|
||||
// (`isTerminalHostAvailable`) AND permission to surface it: legacy desktop
|
||||
// is gated at compile time via `isDesktop`, while ComfyUI Desktop 2.0 gates
|
||||
// on the server-reported `supports_terminal` flag, which arrives over the
|
||||
// websocket handshake — so register reactively once it does.
|
||||
const registerCommandTerminalTab = (tab: BottomPanelExtension) => {
|
||||
if (!isTerminalHostAvailable()) return
|
||||
if (isDesktop) {
|
||||
registerBottomPanelTab(tab)
|
||||
return
|
||||
}
|
||||
const { flags } = useFeatureFlags()
|
||||
if (flags.supportsTerminal) {
|
||||
registerBottomPanelTab(tab)
|
||||
return
|
||||
}
|
||||
const stop = watch(
|
||||
() => flags.supportsTerminal,
|
||||
(supported) => {
|
||||
if (supported) {
|
||||
registerBottomPanelTab(tab)
|
||||
stop()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const registerCoreBottomPanelTabs = async () => {
|
||||
// Register shortcuts tabs first (synchronous, always available)
|
||||
useShortcutsTab().forEach(registerBottomPanelTab)
|
||||
@@ -138,9 +167,7 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
const { useLogsTerminalTab, useCommandTerminalTab } =
|
||||
await import('@/composables/bottomPanelTabs/useTerminalTabs')
|
||||
registerBottomPanelTab(useLogsTerminalTab())
|
||||
if (isDesktop) {
|
||||
registerBottomPanelTab(useCommandTerminalTab())
|
||||
}
|
||||
registerCommandTerminalTab(useCommandTerminalTab())
|
||||
} catch (error) {
|
||||
console.error('Failed to load terminal tabs:', error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user