diff --git a/.changeset/ninety-shirts-guess.md b/.changeset/ninety-shirts-guess.md new file mode 100644 index 00000000000..490c4544d04 --- /dev/null +++ b/.changeset/ninety-shirts-guess.md @@ -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 diff --git a/packages/theme/src/cli/services/dev.test.ts b/packages/theme/src/cli/services/dev.test.ts index 946b40de6c5..f0ebf6f3e90 100644 --- a/packages/theme/src/cli/services/dev.test.ts +++ b/packages/theme/src/cli/services/dev.test.ts @@ -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' @@ -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) + }) +}) diff --git a/packages/theme/src/cli/services/dev.ts b/packages/theme/src/cli/services/dev.ts index e223e8159ec..a54ef69fcf8 100644 --- a/packages/theme/src/cli/services/dev.ts +++ b/packages/theme/src/cli/services/dev.ts @@ -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' @@ -126,20 +127,42 @@ 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') 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)}`, @@ -147,22 +170,12 @@ export async function dev(options: DevOptions) { ) 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) {