Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/silent-pigs-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli-kit': patch
'@shopify/app': patch
---

Keep showing the footer when running dev with extension only apps
5 changes: 3 additions & 2 deletions packages/app/src/cli/services/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,11 @@ async function dev(options: DevOptions) {
const proxyUrl = usingLocalhost ? `${frontendUrl}:${proxyPort}` : frontendUrl
const hmrServerPort = frontendConfig?.configuration.hmr_server ? await getAvailableTCPPort() : undefined

let previewUrl
// By default, preview goes to the direct URL for the app.
let previewUrl = buildAppURLForWeb(storeFqdn, apiKey)
let shouldUpdateURLs = false

if (frontendConfig || backendConfig) {
previewUrl = buildAppURLForWeb(storeFqdn, apiKey)
if (options.update) {
const newURLs = generatePartnersURLs(
exposedUrl,
Expand Down Expand Up @@ -203,6 +203,7 @@ async function dev(options: DevOptions) {
const draftableExtensions = localApp.allExtensions.filter((ext) => ext.isDraftable(unifiedDeployment))

if (previewableExtensions.length > 0) {
// If any previewable extensions, the preview URL should be the dev console approach
previewUrl = `${proxyUrl}/extensions/dev-console`
const devExt = await devUIExtensionsTarget({
app: localApp,
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/cli/services/dev/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function outputExtensionsMessages(app: AppInterface) {
outputThemeExtensionsMessage(app.allExtensions.filter((ext) => ext.isThemeExtension))
}

export function renderDev(renderConcurrentOptions: RenderConcurrentOptions, previewUrl: string | undefined) {
export function renderDev(renderConcurrentOptions: RenderConcurrentOptions, previewUrl: string) {
let options = renderConcurrentOptions

if (previewUrl) {
Expand Down Expand Up @@ -86,7 +86,7 @@ export function renderDev(renderConcurrentOptions: RenderConcurrentOptions, prev
},
}
}
return renderConcurrent(options)
return renderConcurrent({...options, keepRunningAfterProcessesResolve: true})
}

function outputThemeExtensionsMessage(extensions: ExtensionInstance[]) {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/utilities/app/http-reverse-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface ReverseHTTPProxyTarget {
}

interface Options {
previewUrl: string | undefined
previewUrl: string
portNumber: number
proxyTargets: ReverseHTTPProxyTarget[]
additionalProcesses: OutputProcess[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,6 @@ describe('ConcurrentOutput', () => {
)

await waitForInputsToBeReady()
expect(onInput).toHaveBeenCalledTimes(0)

renderInstance.stdin.write('a')
expect(onInput).toHaveBeenCalledTimes(1)
expect(onInput.mock.calls[0]![0]).toBe('a')
Expand Down Expand Up @@ -338,4 +336,58 @@ describe('ConcurrentOutput', () => {
"
`)
})

test('renders the shortcuts and accepts inputs when the processes resolve and keepRunningAfterProcessesResolve is true', async () => {
const onInput = vi.fn()
// Given
const backendProcess = {
prefix: 'backend',
action: async (stdout: Writable, _stderr: Writable, _signal: AbortSignal) => {
stdout.write('first backend message')
stdout.write('second backend message')
stdout.write('third backend message')
},
}

// When
const renderInstance = render(
<ConcurrentOutput
processes={[backendProcess]}
abortSignal={new AbortController().signal}
footer={{
shortcuts: [
{
key: 'p',
action: 'preview in your browser',
},
{
key: 'q',
action: 'quit',
},
],
subTitle: `Preview URL: https://shopify.com`,
}}
onInput={onInput}
keepRunningAfterProcessesResolve
/>,
)

await new Promise((resolve) => setTimeout(resolve, 1000))

expect(unstyled(getLastFrameAfterUnmount(renderInstance)!).replace(/\d/g, '0')).toMatchInlineSnapshot(`
"0000-00-00 00:00:00 │ backend │ first backend message
0000-00-00 00:00:00 │ backend │ second backend message
0000-00-00 00:00:00 │ backend │ third backend message

› Press p │ preview in your browser
› Press q │ quit

Preview URL: https://shopify.com
"
`)

renderInstance.stdin.write('a')
expect(onInput).toHaveBeenCalledTimes(1)
expect(onInput.mock.calls[0]![0]).toBe('a')
})
})
106 changes: 58 additions & 48 deletions packages/cli-kit/src/private/node/ui/components/ConcurrentOutput.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import {OutputProcess} from '../../../../public/node/output.js'
import useAsyncAndUnmount from '../hooks/use-async-and-unmount.js'
import {AbortSignal} from '../../../../public/node/abort.js'
import {handleCtrlC} from '../../ui.js'
import {addOrUpdateConcurrentUIEventOutput} from '../../demo-recorder.js'
import {treeKill} from '../../tree-kill.js'
import useAbortSignal from '../hooks/use-abort-signal.js'
import React, {FunctionComponent, useState} from 'react'
import {Box, Key, Static, Text, useInput, TextProps, useStdin} from 'ink'
import React, {FunctionComponent, useCallback, useEffect, useMemo, useState} from 'react'
import {Box, Key, Static, Text, useInput, TextProps, useStdin, useApp} from 'ink'
import stripAnsi from 'strip-ansi'
import figures from 'figures'
import {Writable} from 'stream'
Expand All @@ -26,6 +25,8 @@ export interface ConcurrentOutputProps {
shortcuts: Shortcut[]
subTitle?: string
}
// If set, the component is not automatically unmounted once the processes have all finished
keepRunningAfterProcessesResolve?: boolean
}
interface Chunk {
color: TextProps['color']
Expand Down Expand Up @@ -77,51 +78,46 @@ const ConcurrentOutput: FunctionComponent<ConcurrentOutputProps> = ({
showTimestamps = true,
onInput,
footer,
keepRunningAfterProcessesResolve,
}) => {
const [processOutput, setProcessOutput] = useState<Chunk[]>([])
const concurrentColors: TextProps['color'][] = ['yellow', 'cyan', 'magenta', 'green', 'blue']
const {exit: unmountInk} = useApp()
const prefixColumnSize = Math.max(...processes.map((process) => process.prefix.length))
const {isRawModeSupported} = useStdin()
const [state, setState] = useState<ConcurrentOutputState>(ConcurrentOutputState.Running)
const concurrentColors: TextProps['color'][] = useMemo(() => ['yellow', 'cyan', 'magenta', 'green', 'blue'], [])
const lineColor = useCallback(
(index: number) => {
const colorIndex = index < concurrentColors.length ? index : index % concurrentColors.length
return concurrentColors[colorIndex]!
},
[concurrentColors],
)

function lineColor(index: number) {
const colorIndex = index < concurrentColors.length ? index : index % concurrentColors.length
return concurrentColors[colorIndex]!
}

const writableStream = (process: OutputProcess, index: number) => {
return new Writable({
write(chunk, _encoding, next) {
const lines = stripAnsi(chunk.toString('utf8').replace(/(\n)$/, '')).split(/\n/)
addOrUpdateConcurrentUIEventOutput({prefix: process.prefix, index, output: lines.join('\n')}, {footer})

setProcessOutput((previousProcessOutput) => [
...previousProcessOutput,
{
color: lineColor(index),
prefix: process.prefix,
lines,
},
])

next()
},
})
}

const runProcesses = () => {
return Promise.all(
processes.map(async (process, index) => {
const stdout = writableStream(process, index)
const stderr = writableStream(process, index)

await process.action(stdout, stderr, abortSignal)
}),
)
}
const writableStream = useCallback(
(process: OutputProcess, index: number) => {
return new Writable({
write(chunk, _encoding, next) {
const lines = stripAnsi(chunk.toString('utf8').replace(/(\n)$/, '')).split(/\n/)
addOrUpdateConcurrentUIEventOutput({prefix: process.prefix, index, output: lines.join('\n')}, {footer})

setProcessOutput((previousProcessOutput) => [
...previousProcessOutput,
{
color: lineColor(index),
prefix: process.prefix,
lines,
},
])

next()
},
})
},
[footer, lineColor],
)

const {isAborted} = useAbortSignal(abortSignal)

const useShortcuts = isRawModeSupported && state === ConcurrentOutputState.Running && !isAborted

useInput(
Expand All @@ -133,14 +129,28 @@ const ConcurrentOutput: FunctionComponent<ConcurrentOutputProps> = ({
{isActive: typeof onInput !== 'undefined' && useShortcuts},
)

useAsyncAndUnmount(runProcesses, {
onFulfilled: () => {
setState(ConcurrentOutputState.Stopped)
},
onRejected: () => {
setState(ConcurrentOutputState.Stopped)
},
})
useEffect(() => {
;(() => {
return Promise.all(
processes.map(async (process, index) => {
const stdout = writableStream(process, index)
const stderr = writableStream(process, index)

await process.action(stdout, stderr, abortSignal)
}),
)
})()
.then(() => {
if (!keepRunningAfterProcessesResolve) {
setState(ConcurrentOutputState.Stopped)
unmountInk()
}
})
.catch((error) => {
setState(ConcurrentOutputState.Stopped)
unmountInk(error)
})
}, [abortSignal, processes, writableStream, unmountInk, keepRunningAfterProcessesResolve])

const {lineVertical} = figures

Expand Down