Skip to content

Commit be8170c

Browse files
Merge pull request #7259 from Shopify/nwesselman/dev-footer-hyperlinks
Hyperlink shortcut labels in DevSessionUI footer
2 parents d8171c6 + 1fe29dd commit be8170c

4 files changed

Lines changed: 106 additions & 13 deletions

File tree

.changeset/afraid-hairs-talk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/app': minor
3+
---
4+
5+
Render footer links in `app dev` as hyperlinks, if supported by the terminal.

packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ import {unstyled} from '@shopify/cli-kit/node/output'
1414
import {openURL} from '@shopify/cli-kit/node/system'
1515
import {Writable} from 'stream'
1616

17-
vi.mock('@shopify/cli-kit/node/system')
17+
vi.mock('@shopify/cli-kit/node/system', async () => {
18+
const actual: any = await vi.importActual('@shopify/cli-kit/node/system')
19+
return {
20+
...actual,
21+
openURL: vi.fn(),
22+
terminalSupportsHyperlinks: mocks.terminalSupportsHyperlinks,
23+
}
24+
})
1825
vi.mock('@shopify/cli-kit/node/context/local')
1926
vi.mock('@shopify/cli-kit/node/tree-kill')
2027

@@ -23,6 +30,7 @@ const mocks = vi.hoisted(() => {
2330
useStdin: vi.fn(() => {
2431
return {isRawModeSupported: true}
2532
}),
33+
terminalSupportsHyperlinks: vi.fn(() => false),
2634
}
2735
})
2836

@@ -48,6 +56,8 @@ const onAbort = vi.fn()
4856

4957
describe('DevSessionUI', () => {
5058
beforeEach(() => {
59+
mocks.terminalSupportsHyperlinks.mockReturnValue(false)
60+
mocks.useStdin.mockReturnValue({isRawModeSupported: true})
5161
devSessionStatusManager = new DevSessionStatusManager()
5262
devSessionStatusManager.reset()
5363
devSessionStatusManager.updateStatus(initialStatus)
@@ -544,6 +554,62 @@ describe('DevSessionUI', () => {
544554
renderInstance.unmount()
545555
})
546556

557+
test('hides URL list when terminal supports hyperlinks', async () => {
558+
// Given
559+
mocks.terminalSupportsHyperlinks.mockReturnValue(true)
560+
561+
const renderInstance = render(
562+
<DevSessionUI
563+
processes={[]}
564+
abortController={new AbortController()}
565+
devSessionStatusManager={devSessionStatusManager}
566+
shopFqdn="mystore.myshopify.com"
567+
onAbort={onAbort}
568+
/>,
569+
)
570+
571+
await waitForInputsToBeReady()
572+
573+
// Then - shortcuts with label text should be present but URL list should be hidden
574+
const output = unstyled(renderInstance.lastFrame()!)
575+
expect(output).toContain('(p) Open app preview')
576+
expect(output).toContain('(c) Open Dev Console for extension previews')
577+
expect(output).toContain('(g) Open GraphiQL (Admin API)')
578+
expect(output).not.toContain('Preview URL:')
579+
expect(output).not.toContain('Dev Console URL:')
580+
expect(output).not.toContain('GraphiQL URL:')
581+
582+
renderInstance.unmount()
583+
})
584+
585+
test('shows URL list when terminal does not support hyperlinks', async () => {
586+
// Given
587+
mocks.terminalSupportsHyperlinks.mockReturnValue(false)
588+
589+
const renderInstance = render(
590+
<DevSessionUI
591+
processes={[]}
592+
abortController={new AbortController()}
593+
devSessionStatusManager={devSessionStatusManager}
594+
shopFqdn="mystore.myshopify.com"
595+
onAbort={onAbort}
596+
/>,
597+
)
598+
599+
await waitForInputsToBeReady()
600+
601+
// Then - both shortcuts with label text and URL list should be present
602+
const output = unstyled(renderInstance.lastFrame()!)
603+
expect(output).toContain('(p) Open app preview')
604+
expect(output).toContain('(c) Open Dev Console for extension previews')
605+
expect(output).toContain('(g) Open GraphiQL (Admin API)')
606+
expect(output).toContain('Preview URL: https://shopify.com')
607+
expect(output).toContain('Dev Console URL: https://mystore.myshopify.com/admin?dev-console=show')
608+
expect(output).toContain('GraphiQL URL: https://graphiql.shopify.com')
609+
610+
renderInstance.unmount()
611+
})
612+
547613
test('shows non-interactive fallback when raw mode is not supported', async () => {
548614
// Given - mock useStdin to return false for isRawModeSupported
549615
mocks.useStdin.mockReturnValue({isRawModeSupported: false})
@@ -570,8 +636,5 @@ describe('DevSessionUI', () => {
570636
expect(output).toContain('GraphiQL URL: https://graphiql.shopify.com')
571637

572638
renderInstance.unmount()
573-
574-
// Restore original mock for other tests
575-
mocks.useStdin.mockReturnValue({isRawModeSupported: true})
576639
})
577640
})

packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import React, {FunctionComponent, useEffect, useMemo, useState} from 'react'
1515
import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort'
1616
import {Box, Text, useInput, useStdin} from '@shopify/cli-kit/node/ink'
1717
import {handleCtrlC} from '@shopify/cli-kit/node/ui'
18-
import {openURL} from '@shopify/cli-kit/node/system'
18+
import {openURL, terminalSupportsHyperlinks} from '@shopify/cli-kit/node/system'
1919
import figures from '@shopify/cli-kit/node/figures'
2020
import {isUnitTest} from '@shopify/cli-kit/node/context/local'
2121
import {treeKill} from '@shopify/cli-kit/node/tree-kill'
@@ -126,7 +126,7 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
126126
shortcuts: [
127127
{
128128
key: 'p',
129-
condition: () => Boolean(status.previewURL && status.isReady),
129+
condition: () => Boolean(status.isReady && status.previewURL),
130130
action: async () => {
131131
await metadata.addPublicMetadata(() => ({
132132
cmd_dev_preview_url_opened: true,
@@ -138,7 +138,7 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
138138
},
139139
{
140140
key: 'g',
141-
condition: () => Boolean(status.graphiqlURL && status.isReady),
141+
condition: () => Boolean(status.isReady && status.graphiqlURL),
142142
action: async () => {
143143
await metadata.addPublicMetadata(() => ({
144144
cmd_dev_graphiql_opened: true,
@@ -168,19 +168,34 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
168168
)}
169169
{canUseShortcuts && (
170170
<Box marginTop={1} flexDirection="column">
171-
{status.isReady ? (
171+
{status.isReady && status.previewURL ? (
172172
<Text>
173-
{figures.pointerSmall} <Text bold>(p)</Text> Open app preview
173+
{figures.pointerSmall} <Text bold>(p)</Text>{' '}
174+
{terminalSupportsHyperlinks() ? (
175+
<Link url={status.previewURL} label="Open app preview" />
176+
) : (
177+
'Open app preview'
178+
)}
174179
</Text>
175180
) : null}
176181
{status.isReady && !status.appEmbedded && status.hasExtensions ? (
177182
<Text>
178-
{figures.pointerSmall} <Text bold>(c)</Text> Open Dev Console for extension previews
183+
{figures.pointerSmall} <Text bold>(c)</Text>{' '}
184+
{terminalSupportsHyperlinks() ? (
185+
<Link url={buildDevConsoleURL(shopFqdn)} label="Open Dev Console for extension previews" />
186+
) : (
187+
'Open Dev Console for extension previews'
188+
)}
179189
</Text>
180190
) : null}
181-
{status.graphiqlURL && status.isReady ? (
191+
{status.isReady && status.graphiqlURL ? (
182192
<Text>
183-
{figures.pointerSmall} <Text bold>(g)</Text> Open GraphiQL (Admin API)
193+
{figures.pointerSmall} <Text bold>(g)</Text>{' '}
194+
{terminalSupportsHyperlinks() ? (
195+
<Link url={status.graphiqlURL} label="Open GraphiQL (Admin API)" />
196+
) : (
197+
'Open GraphiQL (Admin API)'
198+
)}
184199
</Text>
185200
) : null}
186201
</Box>
@@ -190,7 +205,7 @@ const DevSessionUI: FunctionComponent<DevSesionUIProps> = ({
190205
<Text>{isShuttingDownMessage}</Text>
191206
) : (
192207
<>
193-
{status.isReady && (
208+
{status.isReady && !(canUseShortcuts && terminalSupportsHyperlinks()) && (
194209
<>
195210
{status.previewURL ? (
196211
<Text>

packages/cli-kit/src/public/node/system.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {renderWarning} from './ui.js'
77
import {platformAndArch} from './os.js'
88
import {shouldDisplayColors, outputDebug} from './output.js'
99
import {execa, execaCommand, ExecaChildProcess} from 'execa'
10+
import supportsHyperlinks from 'supports-hyperlinks'
1011
import which from 'which'
1112
import {delimiter} from 'pathe'
1213

@@ -346,6 +347,15 @@ export async function sleep(seconds: number): Promise<void> {
346347
})
347348
}
348349

350+
/**
351+
* Check if the terminal supports OSC 8 hyperlinks.
352+
*
353+
* @returns True if the terminal supports hyperlinks.
354+
*/
355+
export function terminalSupportsHyperlinks(): boolean {
356+
return supportsHyperlinks.stdout
357+
}
358+
349359
/**
350360
* Check if the standard input and output streams support prompting.
351361
*

0 commit comments

Comments
 (0)