mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +00:00
[Electron] Terminal commands (#1531)
* Add live terminal output * Fix scrolling * Refactor loading * Fallback to polling if endpoint fails * Comment * Move clientId to executionStore Refactor types * Remove polling * wip terminal command input * Refactor to use node-pty * Hide tabs if not electron * Lint fix * ts fix * Refactor tab components
This commit is contained in:
@@ -1,104 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative h-full w-full bg-black">
|
|
||||||
<p v-if="errorMessage" class="p-4 text-center">{{ errorMessage }}</p>
|
|
||||||
<ProgressSpinner
|
|
||||||
v-else-if="loading"
|
|
||||||
class="absolute inset-0 flex justify-center items-center h-full z-10"
|
|
||||||
/>
|
|
||||||
<div v-show="!loading" class="p-terminal rounded-none h-full w-full p-2">
|
|
||||||
<div class="h-full" ref="terminalEl"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import '@xterm/xterm/css/xterm.css'
|
|
||||||
import { Terminal } from '@xterm/xterm'
|
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
|
||||||
import { api } from '@/scripts/api'
|
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
|
||||||
import { debounce } from 'lodash'
|
|
||||||
import ProgressSpinner from 'primevue/progressspinner'
|
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { until } from '@vueuse/core'
|
|
||||||
import { LogEntry, LogsWsMessage, TerminalSize } from '@/types/apiTypes'
|
|
||||||
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const loading = ref(true)
|
|
||||||
const terminalEl = ref<HTMLDivElement>()
|
|
||||||
const fitAddon = new FitAddon()
|
|
||||||
const terminal = new Terminal({
|
|
||||||
convertEol: true
|
|
||||||
})
|
|
||||||
terminal.loadAddon(fitAddon)
|
|
||||||
|
|
||||||
const resizeTerminal = () =>
|
|
||||||
terminal.resize(terminal.cols, fitAddon.proposeDimensions().rows)
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(debounce(resizeTerminal, 50))
|
|
||||||
|
|
||||||
const update = (entries: Array<LogEntry>, size?: TerminalSize) => {
|
|
||||||
if (size) {
|
|
||||||
terminal.resize(size.cols, fitAddon.proposeDimensions().rows)
|
|
||||||
}
|
|
||||||
terminal.write(entries.map((e) => e.m).join(''))
|
|
||||||
}
|
|
||||||
|
|
||||||
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
|
|
||||||
update(e.detail.entries, e.detail.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadLogEntries = async () => {
|
|
||||||
const logs = await api.getRawLogs()
|
|
||||||
update(logs.entries, logs.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
const watchLogs = async () => {
|
|
||||||
const { clientId } = storeToRefs(useExecutionStore())
|
|
||||||
if (!clientId.value) {
|
|
||||||
await until(clientId).not.toBeNull()
|
|
||||||
}
|
|
||||||
api.subscribeLogs(true)
|
|
||||||
api.addEventListener('logs', logReceived)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
terminal.open(terminalEl.value)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadLogEntries()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading logs', err)
|
|
||||||
// On older backends the endpoints wont exist
|
|
||||||
errorMessage.value =
|
|
||||||
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = false
|
|
||||||
resizeObserver.observe(terminalEl.value)
|
|
||||||
|
|
||||||
await watchLogs()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (api.clientId) {
|
|
||||||
api.subscribeLogs(false)
|
|
||||||
}
|
|
||||||
api.removeEventListener('logs', logReceived)
|
|
||||||
|
|
||||||
resizeObserver.disconnect()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.p-terminal) .xterm {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.p-terminal) .xterm-screen {
|
|
||||||
background-color: black;
|
|
||||||
overflow-y: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
30
src/components/bottomPanel/tabs/terminal/BaseTerminal.vue
Normal file
30
src/components/bottomPanel/tabs/terminal/BaseTerminal.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative h-full w-full bg-black" ref="rootEl">
|
||||||
|
<div class="p-terminal rounded-none h-full w-full p-2">
|
||||||
|
<div class="h-full terminal-host" ref="terminalEl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, defineEmits, Ref } from 'vue'
|
||||||
|
import { useTerminal } from '@/hooks/bottomPanelTabs/useTerminal'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
created: [ReturnType<typeof useTerminal>, Ref<HTMLElement>]
|
||||||
|
}>()
|
||||||
|
const terminalEl = ref<HTMLElement>()
|
||||||
|
const rootEl = ref<HTMLElement>()
|
||||||
|
emit('created', useTerminal(terminalEl), rootEl)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.p-terminal) .xterm {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-terminal) .xterm-screen {
|
||||||
|
background-color: black;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
src/components/bottomPanel/tabs/terminal/CommandTerminal.vue
Normal file
73
src/components/bottomPanel/tabs/terminal/CommandTerminal.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<BaseTerminal @created="terminalCreated" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, Ref } from 'vue'
|
||||||
|
import type { useTerminal } from '@/hooks/bottomPanelTabs/useTerminal'
|
||||||
|
import { electronAPI } from '@/utils/envUtil'
|
||||||
|
import { IDisposable } from '@xterm/xterm'
|
||||||
|
import BaseTerminal from './BaseTerminal.vue'
|
||||||
|
|
||||||
|
const terminalCreated = (
|
||||||
|
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||||
|
root: Ref<HTMLElement>
|
||||||
|
) => {
|
||||||
|
// TODO: use types from electron package
|
||||||
|
const terminalApi = electronAPI()['Terminal'] as {
|
||||||
|
onOutput(cb: (message: string) => void): () => void
|
||||||
|
resize(cols: number, rows: number): void
|
||||||
|
restore(): Promise<{
|
||||||
|
buffer: string[]
|
||||||
|
pos: { x: number; y: number }
|
||||||
|
size: { cols: number; rows: number }
|
||||||
|
}>
|
||||||
|
storePos(x: number, y: number): void
|
||||||
|
write(data: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
let offData: IDisposable
|
||||||
|
let offOutput: () => void
|
||||||
|
|
||||||
|
useAutoSize(root, true, true, () => {
|
||||||
|
// If we aren't visible, don't resize
|
||||||
|
if (!terminal.element?.offsetParent) return
|
||||||
|
|
||||||
|
terminalApi.resize(terminal.cols, terminal.rows)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
offData = terminal.onData(async (message: string) => {
|
||||||
|
terminalApi.write(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
offData?.dispose()
|
||||||
|
offOutput?.()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.p-terminal) .xterm {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-terminal) .xterm-screen {
|
||||||
|
background-color: black;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
90
src/components/bottomPanel/tabs/terminal/LogsTerminal.vue
Normal file
90
src/components/bottomPanel/tabs/terminal/LogsTerminal.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-black h-full w-full">
|
||||||
|
<p v-if="errorMessage" class="p-4 text-center">{{ errorMessage }}</p>
|
||||||
|
<ProgressSpinner
|
||||||
|
v-else-if="loading"
|
||||||
|
class="relative inset-0 flex justify-center items-center h-full z-10"
|
||||||
|
/>
|
||||||
|
<BaseTerminal v-show="!loading" @created="terminalCreated" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, Ref, ref } from 'vue'
|
||||||
|
import type { useTerminal } from '@/hooks/bottomPanelTabs/useTerminal'
|
||||||
|
import { LogEntry, LogsWsMessage, TerminalSize } from '@/types/apiTypes'
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
|
import { until } from '@vueuse/core'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import BaseTerminal from './BaseTerminal.vue'
|
||||||
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
|
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const terminalCreated = (
|
||||||
|
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||||
|
root: Ref<HTMLElement>
|
||||||
|
) => {
|
||||||
|
useAutoSize(root, true, false)
|
||||||
|
|
||||||
|
const update = (entries: Array<LogEntry>, size?: TerminalSize) => {
|
||||||
|
if (size) {
|
||||||
|
terminal.resize(size.cols, terminal.rows)
|
||||||
|
}
|
||||||
|
terminal.write(entries.map((e) => e.m).join(''))
|
||||||
|
}
|
||||||
|
|
||||||
|
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
|
||||||
|
update(e.detail.entries, e.detail.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadLogEntries = async () => {
|
||||||
|
const logs = await api.getRawLogs()
|
||||||
|
update(logs.entries, logs.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchLogs = async () => {
|
||||||
|
const { clientId } = storeToRefs(useExecutionStore())
|
||||||
|
if (!clientId.value) {
|
||||||
|
await until(clientId).not.toBeNull()
|
||||||
|
}
|
||||||
|
api.subscribeLogs(true)
|
||||||
|
api.addEventListener('logs', logReceived)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await loadLogEntries()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading logs', err)
|
||||||
|
// On older backends the endpoints wont exist
|
||||||
|
errorMessage.value =
|
||||||
|
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await watchLogs()
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (api.clientId) {
|
||||||
|
api.subscribeLogs(false)
|
||||||
|
}
|
||||||
|
api.removeEventListener('logs', logReceived)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.p-terminal) .xterm {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-terminal) .xterm-screen {
|
||||||
|
background-color: black;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { markRaw } from 'vue'
|
|
||||||
import IntegratedTerminal from '@/components/bottomPanel/tabs/IntegratedTerminal.vue'
|
|
||||||
import { BottomPanelExtension } from '@/types/extensionTypes'
|
|
||||||
|
|
||||||
export const useIntegratedTerminalTab = (): BottomPanelExtension => {
|
|
||||||
const { t } = useI18n()
|
|
||||||
return {
|
|
||||||
id: 'integrated-terminal',
|
|
||||||
title: t('terminal'),
|
|
||||||
component: markRaw(IntegratedTerminal),
|
|
||||||
type: 'vue'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
src/hooks/bottomPanelTabs/terminalTabs.ts
Normal file
25
src/hooks/bottomPanelTabs/terminalTabs.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { markRaw } from 'vue'
|
||||||
|
import { BottomPanelExtension } from '@/types/extensionTypes'
|
||||||
|
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
|
||||||
|
import CommandTerminal from '@/components/bottomPanel/tabs/terminal/CommandTerminal.vue'
|
||||||
|
|
||||||
|
export const useLogsTerminalTab = (): BottomPanelExtension => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
return {
|
||||||
|
id: 'logs-terminal',
|
||||||
|
title: t('logs'),
|
||||||
|
component: markRaw(LogsTerminal),
|
||||||
|
type: 'vue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommandTerminalTab = (): BottomPanelExtension => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
return {
|
||||||
|
id: 'command-terminal',
|
||||||
|
title: t('terminal'),
|
||||||
|
component: markRaw(CommandTerminal),
|
||||||
|
type: 'vue'
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/hooks/bottomPanelTabs/useTerminal.ts
Normal file
69
src/hooks/bottomPanelTabs/useTerminal.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import { debounce } from 'lodash'
|
||||||
|
import { onMounted, onUnmounted, Ref } from 'vue'
|
||||||
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
|
||||||
|
export function useTerminal(element: Ref<HTMLElement>) {
|
||||||
|
const fitAddon = new FitAddon()
|
||||||
|
const terminal = new Terminal({
|
||||||
|
convertEol: true
|
||||||
|
})
|
||||||
|
terminal.loadAddon(fitAddon)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
terminal.open(element.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
terminal.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
terminal,
|
||||||
|
useAutoSize(
|
||||||
|
root: Ref<HTMLElement>,
|
||||||
|
autoRows: boolean = true,
|
||||||
|
autoCols: boolean = true,
|
||||||
|
onResize?: () => void
|
||||||
|
) {
|
||||||
|
const ensureValidRows = (rows: number | undefined) => {
|
||||||
|
if (rows == null || isNaN(rows)) {
|
||||||
|
return root.value?.clientHeight / 20
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureValidCols = (cols: number | undefined): number => {
|
||||||
|
if (cols == null || isNaN(cols)) {
|
||||||
|
// Sometimes this is NaN if so, estimate.
|
||||||
|
return root.value?.clientWidth / 8
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dims = fitAddon.proposeDimensions()
|
||||||
|
// Sometimes propose returns NaN, so we may need to estimate.
|
||||||
|
terminal.resize(
|
||||||
|
autoCols ? ensureValidCols(dims?.cols) : terminal.cols,
|
||||||
|
autoRows ? ensureValidRows(dims?.rows) : terminal.rows
|
||||||
|
)
|
||||||
|
onResize?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(debounce(resize, 25))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
resizeObserver.observe(root.value)
|
||||||
|
resize()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { resize }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ const messages = {
|
|||||||
loadAllFolders: 'Load All Folders',
|
loadAllFolders: 'Load All Folders',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
|
logs: 'Logs',
|
||||||
videoFailedToLoad: 'Video failed to load',
|
videoFailedToLoad: 'Video failed to load',
|
||||||
extensionName: 'Extension Name',
|
extensionName: 'Extension Name',
|
||||||
reloadToApplyChanges: 'Reload to apply changes',
|
reloadToApplyChanges: 'Reload to apply changes',
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import type { BottomPanelExtension } from '@/types/extensionTypes'
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useIntegratedTerminalTab } from '@/hooks/bottomPanelTabs/integratedTerminalTab'
|
import {
|
||||||
|
useLogsTerminalTab,
|
||||||
|
useCommandTerminalTab
|
||||||
|
} from '@/hooks/bottomPanelTabs/terminalTabs'
|
||||||
import { ComfyExtension } from '@/types/comfy'
|
import { ComfyExtension } from '@/types/comfy'
|
||||||
|
import { isElectron } from '@/utils/envUtil'
|
||||||
|
|
||||||
export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||||
const bottomPanelVisible = ref(false)
|
const bottomPanelVisible = ref(false)
|
||||||
@@ -49,7 +53,10 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const registerCoreBottomPanelTabs = () => {
|
const registerCoreBottomPanelTabs = () => {
|
||||||
registerBottomPanelTab(useIntegratedTerminalTab())
|
registerBottomPanelTab(useLogsTerminalTab())
|
||||||
|
if (isElectron()) {
|
||||||
|
registerBottomPanelTab(useCommandTerminalTab())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const registerExtensionBottomPanelTabs = (extension: ComfyExtension) => {
|
const registerExtensionBottomPanelTabs = (extension: ComfyExtension) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user