mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +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',
|
||||
refresh: 'Refresh',
|
||||
terminal: 'Terminal',
|
||||
logs: 'Logs',
|
||||
videoFailedToLoad: 'Video failed to load',
|
||||
extensionName: 'Extension Name',
|
||||
reloadToApplyChanges: 'Reload to apply changes',
|
||||
|
||||
@@ -2,8 +2,12 @@ import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useIntegratedTerminalTab } from '@/hooks/bottomPanelTabs/integratedTerminalTab'
|
||||
import {
|
||||
useLogsTerminalTab,
|
||||
useCommandTerminalTab
|
||||
} from '@/hooks/bottomPanelTabs/terminalTabs'
|
||||
import { ComfyExtension } from '@/types/comfy'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
const bottomPanelVisible = ref(false)
|
||||
@@ -49,7 +53,10 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
|
||||
}
|
||||
|
||||
const registerCoreBottomPanelTabs = () => {
|
||||
registerBottomPanelTab(useIntegratedTerminalTab())
|
||||
registerBottomPanelTab(useLogsTerminalTab())
|
||||
if (isElectron()) {
|
||||
registerBottomPanelTab(useCommandTerminalTab())
|
||||
}
|
||||
}
|
||||
|
||||
const registerExtensionBottomPanelTabs = (extension: ComfyExtension) => {
|
||||
|
||||
Reference in New Issue
Block a user