11/* eslint-disable no-case-declarations */
22import { AppLinkedInterface } from '../../../models/app/app.js'
33import { configurationFileNames } from '../../../constants.js'
4+ import { ExtensionInstance } from '../../../models/extensions/extension-instance.js'
45import { dirname , joinPath , normalizePath , relativePath } from '@shopify/cli-kit/node/path'
56import { FSWatcher } from 'chokidar'
67import { outputDebug } from '@shopify/cli-kit/node/output'
@@ -9,6 +10,7 @@ import {startHRTime, StartTime} from '@shopify/cli-kit/node/hrtime'
910import { fileExistsSync , matchGlob , mkdir , readFileSync } from '@shopify/cli-kit/node/fs'
1011import { debounce } from '@shopify/cli-kit/common/function'
1112import ignore from 'ignore'
13+ import { getPathValue } from '@shopify/cli-kit/common/object'
1214import { Writable } from 'stream'
1315
1416const DEFAULT_DEBOUNCE_TIME_IN_MS = 200
@@ -36,6 +38,7 @@ export interface WatcherEvent {
3638 | 'file_deleted'
3739 | 'extensions_config_updated'
3840 | 'app_config_deleted'
41+ | 'app_asset_updated'
3942 path : string
4043 extensionPath : string
4144 startTime : StartTime
@@ -58,6 +61,8 @@ export class FileWatcher {
5861 private readonly ignored : { [ key : string ] : ignore . Ignore | undefined } = { }
5962 // Map of file paths to the extensions that watch them
6063 private readonly extensionWatchedFiles = new Map < string , Set < string > > ( )
64+ // Map of asset directory path to the extension directory that owns it
65+ private appAssetToExtensionDir = new Map < string , string > ( )
6166
6267 constructor (
6368 app : AppLinkedInterface ,
@@ -104,7 +109,9 @@ export class FileWatcher {
104109 } ) ,
105110 )
106111
112+ this . appAssetToExtensionDir = this . resolveAppAssetWatchPaths ( this . app . realExtensions )
107113 const watchPaths = [ this . app . configPath , ...fullExtensionDirectories ]
114+ Array . from ( this . appAssetToExtensionDir . keys ( ) ) . forEach ( ( key ) => watchPaths . push ( key ) )
108115
109116 // Get all watched files from extensions
110117 const allWatchedFiles = this . getAllWatchedFiles ( )
@@ -114,15 +121,24 @@ export class FileWatcher {
114121
115122 // Create new watcher
116123 const { default : chokidar } = await import ( 'chokidar' )
124+ const appAssetDirs = [ ...this . appAssetToExtensionDir . keys ( ) ]
117125 this . watcher = chokidar . watch ( watchPaths , {
118126 ignored : [
119127 '**/node_modules/**' ,
120128 '**/.git/**' ,
121129 '**/*.test.*' ,
122- '**/dist/**' ,
123130 '**/*.swp' ,
124131 '**/generated/**' ,
125132 '**/.gitignore' ,
133+ // Ignore files inside dist/ directories, unless the path falls under a watched
134+ // app asset directory (e.g. static_root may point to a dist/ folder).
135+ // Non-dist paths are never ignored here (return false). For dist paths, we only
136+ // allow them through if they are inside one of the app asset directories.
137+ ( filePath : string ) => {
138+ const normalized = normalizePath ( filePath )
139+ if ( ! normalized . includes ( '/dist/' ) && ! normalized . endsWith ( '/dist' ) ) return false
140+ return ! appAssetDirs . some ( ( assetDir ) => normalized . startsWith ( assetDir ) )
141+ } ,
126142 ] ,
127143 persistent : true ,
128144 ignoreInitial : true ,
@@ -177,6 +193,36 @@ export class FileWatcher {
177193 return Array . from ( allFiles )
178194 }
179195
196+ /**
197+ * Resolves app asset directories that should be watched.
198+ * Returns a map of absolute asset directory path → owning extension directory.
199+ */
200+ private resolveAppAssetWatchPaths ( allExtensions : ExtensionInstance [ ] ) : Map < string , string > {
201+ const result = new Map < string , string > ( )
202+ const adminExtension = allExtensions . find ( ( ext ) => ext . specification . identifier === 'admin' )
203+ if ( adminExtension ) {
204+ const staticRootPath = getPathValue < string > ( adminExtension . configuration , 'admin.static_root' )
205+ if ( staticRootPath ) {
206+ const absolutePath = joinPath ( adminExtension . directory , staticRootPath )
207+ result . set ( normalizePath ( absolutePath ) , normalizePath ( adminExtension . directory ) )
208+ }
209+ }
210+ return result
211+ }
212+
213+ /**
214+ * Checks if a file path is inside any app asset directory.
215+ * Returns the owning extension directory if found, undefined otherwise.
216+ */
217+ private findAppAssetExtensionDir ( filePath : string ) : string | undefined {
218+ for ( const [ assetDir , extensionDir ] of this . appAssetToExtensionDir ) {
219+ if ( filePath . startsWith ( assetDir ) ) {
220+ return extensionDir
221+ }
222+ }
223+ return undefined
224+ }
225+
180226 /**
181227 * Emits the accumulated events and resets the current events list.
182228 * It also logs the number of events emitted and their paths for debugging purposes.
@@ -227,7 +273,12 @@ export class FileWatcher {
227273 * Explicit watch paths have priority over custom gitignore files
228274 */
229275 private shouldIgnoreEvent ( event : WatcherEvent ) {
230- if ( event . type === 'extension_folder_deleted' || event . type === 'extension_folder_created' ) return false
276+ if (
277+ event . type === 'extension_folder_deleted' ||
278+ event . type === 'extension_folder_created' ||
279+ event . type === 'app_asset_updated'
280+ )
281+ return false
231282
232283 const extension = this . app . realExtensions . find ( ( ext ) => ext . directory === event . extensionPath )
233284 const watchPaths = extension ?. watchedFiles ( )
@@ -258,6 +309,16 @@ export class FileWatcher {
258309 const affectedExtensions = this . extensionWatchedFiles . get ( normalizedPath )
259310 const isUnknownExtension = affectedExtensions === undefined || affectedExtensions . size === 0
260311
312+ // Check if the file is inside an app asset directory (e.g. static_root)
313+ const appAssetExtensionDir = this . findAppAssetExtensionDir ( normalizedPath )
314+ if ( appAssetExtensionDir ) {
315+ if ( event === 'change' || event === 'add' || event === 'unlink' ) {
316+ this . pushEvent ( { type : 'app_asset_updated' , path, extensionPath : appAssetExtensionDir , startTime} )
317+ }
318+ this . debouncedEmit ( )
319+ return
320+ }
321+
261322 if ( isUnknownExtension && ! isExtensionToml && ! isConfigAppPath ) {
262323 // Ignore an event if it's not part of an existing extension
263324 // Except if it is a toml file (either app config or extension config)
0 commit comments