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
5 changes: 5 additions & 0 deletions .changeset/ninety-shirts-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/theme': patch
---

Create a slight delay when keypressing theme dev shortcut keys to stop accidental copy pasting and opening up a ton of tabs
135 changes: 133 additions & 2 deletions packages/theme/src/cli/services/dev.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {openURLSafely, renderLinks} from './dev.js'
import {describe, expect, test, vi} from 'vitest'
import {openURLSafely, renderLinks, createKeypressHandler} from './dev.js'
import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest'
import {buildTheme} from '@shopify/cli-kit/node/themes/factories'
import {DEVELOPMENT_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils'
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
Expand Down Expand Up @@ -102,3 +102,134 @@ describe('openURLSafely', () => {
})
})
})

describe('createKeypressHandler', () => {
const urls = {
local: 'http://127.0.0.1:9292',
giftCard: 'http://127.0.0.1:9292/gift_cards/[store_id]/preview',
themeEditor: 'https://my-store.myshopify.com/admin/themes/123/editor?hr=9292',
preview: 'https://my-store.myshopify.com/?preview_theme_id=123',
}

const ctx = {lastRequestedPath: '/'}

beforeEach(() => {
vi.mocked(openURL).mockResolvedValue(true)
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
})

test('opens localhost when "t" is pressed', () => {
// Given
const handler = createKeypressHandler(urls, ctx)

// When
handler('t', {name: 't'})

// Then
expect(openURL).toHaveBeenCalledWith(urls.local)
})

test('opens theme preview when "p" is pressed', () => {
// Given
const handler = createKeypressHandler(urls, ctx)

// When
handler('p', {name: 'p'})

// Then
expect(openURL).toHaveBeenCalledWith(urls.preview)
})

test('opens theme editor when "e" is pressed', () => {
// Given
const handler = createKeypressHandler(urls, ctx)

// When
handler('e', {name: 'e'})

// Then
expect(openURL).toHaveBeenCalledWith(urls.themeEditor)
})

test('opens gift card preview when "g" is pressed', () => {
// Given
const handler = createKeypressHandler(urls, ctx)

// When
handler('g', {name: 'g'})

// Then
expect(openURL).toHaveBeenCalledWith(urls.giftCard)
})

test('appends preview path to theme editor URL when lastRequestedPath is not "/"', () => {
// Given
const ctxWithPath = {lastRequestedPath: '/products/test-product'}
const handler = createKeypressHandler(urls, ctxWithPath)

// When
handler('e', {name: 'e'})

// Then
expect(openURL).toHaveBeenCalledWith(
`${urls.themeEditor}&previewPath=${encodeURIComponent('/products/test-product')}`,
)
})

test('debounces rapid keypresses - only opens URL once during debounce window', () => {
// Given
const handler = createKeypressHandler(urls, ctx)

// When
handler('t', {name: 't'})
handler('t', {name: 't'})
handler('t', {name: 't'})
handler('t', {name: 't'})

// Then
expect(openURL).toHaveBeenCalledTimes(1)
expect(openURL).toHaveBeenCalledWith(urls.local)
})

test('allows keypresses after debounce period expires', () => {
// Given
const handler = createKeypressHandler(urls, ctx)

// When
handler('t', {name: 't'})
expect(openURL).toHaveBeenCalledTimes(1)

handler('t', {name: 't'})
handler('t', {name: 't'})
expect(openURL).toHaveBeenCalledTimes(1)

// Advance time to exceed debounce period
vi.advanceTimersByTime(100)

handler('p', {name: 'p'})

// Then
expect(openURL).toHaveBeenCalledTimes(2)
expect(openURL).toHaveBeenNthCalledWith(1, urls.local)
expect(openURL).toHaveBeenNthCalledWith(2, urls.preview)
})

test('debounces different keys during the same debounce window', () => {
// Given
const handler = createKeypressHandler(urls, ctx)

// When
handler('t', {name: 't'})
handler('p', {name: 'p'})
handler('e', {name: 'e'})
handler('g', {name: 'g'})

// Then
expect(openURL).toHaveBeenCalledTimes(1)
expect(openURL).toHaveBeenCalledWith(urls.local)
})
})
49 changes: 31 additions & 18 deletions packages/theme/src/cli/services/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {Theme} from '@shopify/cli-kit/node/themes/types'
import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/tcp'
import {AbortError} from '@shopify/cli-kit/node/error'
import {openURL} from '@shopify/cli-kit/node/system'
import {debounce} from '@shopify/cli-kit/common/function'
import chalk from '@shopify/cli-kit/node/colors'
import readline from 'readline'

Expand Down Expand Up @@ -126,43 +127,55 @@ export async function dev(options: DevOptions) {
process.stdin.setRawMode(true)
}

process.stdin.on('keypress', (_str, key) => {
const keypressHandler = createKeypressHandler(urls, ctx)
process.stdin.on('keypress', keypressHandler)

await Promise.all([
backgroundJobPromise,
renderDevSetupProgress()
.then(serverStart)
.then(() => {
renderLinks(urls)
if (options.open) {
openURLSafely(urls.local, 'development server')
}
}),
])
}

export function createKeypressHandler(
urls: {local: string; giftCard: string; themeEditor: string; preview: string},
ctx: {lastRequestedPath: string},
) {
const debouncedOpenURL = debounce(openURLSafely, 100, {leading: true, trailing: false})

return (_str: string, key: {ctrl?: boolean; name?: string}) => {
if (key.ctrl && key.name === 'c') {
process.exit()
}

switch (key.name) {
case 't':
openURLSafely(urls.local, 'localhost')
debouncedOpenURL(urls.local, 'localhost')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might've debounced the click handler instead of the URL opening itself but this works, too!

break
case 'p':
openURLSafely(urls.preview, 'theme preview')
debouncedOpenURL(urls.preview, 'theme preview')
break
case 'e':
openURLSafely(
debouncedOpenURL(
ctx.lastRequestedPath === '/'
? urls.themeEditor
: `${urls.themeEditor}&previewPath=${encodeURIComponent(ctx.lastRequestedPath)}`,
'theme editor',
)
break
case 'g':
openURLSafely(urls.giftCard, 'gift card preview')
debouncedOpenURL(urls.giftCard, 'gift card preview')
break
default:
break
}
})

await Promise.all([
backgroundJobPromise,
renderDevSetupProgress()
.then(serverStart)
.then(() => {
renderLinks(urls)
if (options.open) {
openURLSafely(urls.local, 'development server')
}
}),
])
}
}

export function openURLSafely(url: string, label: string) {
Expand Down