Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/afraid-hairs-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Render footer links in `app dev` as hyperlinks, if supported by the terminal.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ import {unstyled} from '@shopify/cli-kit/node/output'
import {openURL} from '@shopify/cli-kit/node/system'
import {Writable} from 'stream'

vi.mock('@shopify/cli-kit/node/system')
vi.mock('@shopify/cli-kit/node/system', async () => {
const actual: any = await vi.importActual('@shopify/cli-kit/node/system')
return {
...actual,
openURL: vi.fn(),
terminalSupportsHyperlinks: mocks.terminalSupportsHyperlinks,
}
})
vi.mock('@shopify/cli-kit/node/context/local')
vi.mock('@shopify/cli-kit/node/tree-kill')

Expand All @@ -23,6 +30,7 @@ const mocks = vi.hoisted(() => {
useStdin: vi.fn(() => {
return {isRawModeSupported: true}
}),
terminalSupportsHyperlinks: vi.fn(() => false),
}
})

Expand Down Expand Up @@ -544,6 +552,59 @@ describe('DevSessionUI', () => {
renderInstance.unmount()
})

test('hides URL list when terminal supports hyperlinks', async () => {
// Given
mocks.terminalSupportsHyperlinks.mockReturnValue(true)

const renderInstance = render(
<DevSessionUI
processes={[]}
abortController={new AbortController()}
devSessionStatusManager={devSessionStatusManager}
shopFqdn="mystore.myshopify.com"
onAbort={onAbort}
/>,
)

await waitForInputsToBeReady()

// Then - shortcuts should be present but URL list should be hidden
const output = unstyled(renderInstance.lastFrame()!)
expect(output).toContain('(p)')
expect(output).toContain('(g)')
expect(output).not.toContain('Preview URL:')
expect(output).not.toContain('GraphiQL URL:')

renderInstance.unmount()
mocks.terminalSupportsHyperlinks.mockReturnValue(false)
})

test('shows URL list when terminal does not support hyperlinks', async () => {
// Given
mocks.terminalSupportsHyperlinks.mockReturnValue(false)

const renderInstance = render(
<DevSessionUI
processes={[]}
abortController={new AbortController()}
devSessionStatusManager={devSessionStatusManager}
shopFqdn="mystore.myshopify.com"
onAbort={onAbort}
/>,
)

await waitForInputsToBeReady()

// Then - both shortcuts and URL list should be present
const output = unstyled(renderInstance.lastFrame()!)
expect(output).toContain('(p)')
expect(output).toContain('(g)')
expect(output).toContain('Preview URL: https://shopify.com')
expect(output).toContain('GraphiQL URL: https://graphiql.shopify.com')

renderInstance.unmount()
})

test('shows non-interactive fallback when raw mode is not supported', async () => {
// Given - mock useStdin to return false for isRawModeSupported
mocks.useStdin.mockReturnValue({isRawModeSupported: false})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import React, {FunctionComponent, useEffect, useMemo, useState} from 'react'
import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort'
import {Box, Text, useInput, useStdin} from '@shopify/cli-kit/node/ink'
import {handleCtrlC} from '@shopify/cli-kit/node/ui'
import {openURL} from '@shopify/cli-kit/node/system'
import {openURL, terminalSupportsHyperlinks} from '@shopify/cli-kit/node/system'
import figures from '@shopify/cli-kit/node/figures'
import {isUnitTest} from '@shopify/cli-kit/node/context/local'
import {treeKill} from '@shopify/cli-kit/node/tree-kill'
Expand Down Expand Up @@ -126,7 +126,7 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
shortcuts: [
{
key: 'p',
condition: () => Boolean(status.previewURL && status.isReady),
condition: () => Boolean(status.isReady && status.previewURL),
action: async () => {
await metadata.addPublicMetadata(() => ({
cmd_dev_preview_url_opened: true,
Expand All @@ -138,7 +138,7 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
},
{
key: 'g',
condition: () => Boolean(status.graphiqlURL && status.isReady),
condition: () => Boolean(status.isReady && status.graphiqlURL),
action: async () => {
await metadata.addPublicMetadata(() => ({
cmd_dev_graphiql_opened: true,
Expand Down Expand Up @@ -168,19 +168,34 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
)}
{canUseShortcuts && (
<Box marginTop={1} flexDirection="column">
{status.isReady ? (
{status.isReady && status.previewURL ? (
<Text>
{figures.pointerSmall} <Text bold>(p)</Text> Open app preview
{figures.pointerSmall} <Text bold>(p)</Text>{' '}
{terminalSupportsHyperlinks() ? (
<Link url={status.previewURL} label="Open app preview" />
) : (
'Open app preview'
)}
</Text>
) : null}
{status.isReady && !status.appEmbedded && status.hasExtensions ? (
<Text>
{figures.pointerSmall} <Text bold>(c)</Text> Open Dev Console for extension previews
{figures.pointerSmall} <Text bold>(c)</Text>{' '}
{terminalSupportsHyperlinks() ? (
<Link url={buildDevConsoleURL(shopFqdn)} label="Open Dev Console for extension previews" />
) : (
'Open Dev Console for extension previews'
)}
</Text>
) : null}
{status.graphiqlURL && status.isReady ? (
{status.isReady && status.graphiqlURL ? (
<Text>
{figures.pointerSmall} <Text bold>(g)</Text> Open GraphiQL (Admin API)
{figures.pointerSmall} <Text bold>(g)</Text>{' '}
{terminalSupportsHyperlinks() ? (
<Link url={status.graphiqlURL} label="Open GraphiQL (Admin API)" />
) : (
'Open GraphiQL (Admin API)'
)}
</Text>
) : null}
</Box>
Expand All @@ -190,7 +205,7 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
<Text>{isShuttingDownMessage}</Text>
) : (
<>
{status.isReady && (
{status.isReady && !(canUseShortcuts && terminalSupportsHyperlinks()) && (
<>
{status.previewURL ? (
<Text>
Expand Down
10 changes: 10 additions & 0 deletions packages/cli-kit/src/public/node/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {renderWarning} from './ui.js'
import {platformAndArch} from './os.js'
import {shouldDisplayColors, outputDebug} from './output.js'
import {execa, execaCommand, ExecaChildProcess} from 'execa'
import supportsHyperlinks from 'supports-hyperlinks'
import which from 'which'
import {delimiter} from 'pathe'

Expand Down Expand Up @@ -346,6 +347,15 @@ export async function sleep(seconds: number): Promise<void> {
})
}

/**
* Check if the terminal supports OSC 8 hyperlinks.
*
* @returns True if the terminal supports hyperlinks.
*/
export function terminalSupportsHyperlinks(): boolean {
return supportsHyperlinks.stdout
}

/**
* Check if the standard input and output streams support prompting.
*
Expand Down
Loading