@@ -98,7 +98,13 @@ const app = new Hono()
9898
9999// GET /api/worktrees - Stream worktrees via SSE for progressive loading
100100app . get ( '/' , ( c ) => {
101+ // Disable proxy buffering for SSE (required for Cloudflare tunnels)
102+ c . header ( 'X-Accel-Buffering' , 'no' )
103+
101104 return streamSSE ( c , async ( stream ) => {
105+ // Send immediate comment to establish connection (helps with proxies/tunnels)
106+ await stream . write ( ': ping\n\n' )
107+
102108 const worktreeBasePath = getWorktreeBasePath ( )
103109
104110 // Handle missing directory
@@ -223,6 +229,90 @@ app.get('/', (c) => {
223229 } )
224230} )
225231
232+ // GET /api/worktrees/json - JSON fallback for environments where SSE doesn't work (e.g., Cloudflare tunnels)
233+ app . get ( '/json' , async ( c ) => {
234+ const worktreeBasePath = getWorktreeBasePath ( )
235+
236+ // Handle missing directory
237+ if ( ! fs . existsSync ( worktreeBasePath ) ) {
238+ return c . json ( {
239+ worktrees : [ ] ,
240+ summary : {
241+ total : 0 ,
242+ orphaned : 0 ,
243+ totalSize : 0 ,
244+ totalSizeFormatted : '0 B' ,
245+ } ,
246+ } )
247+ }
248+
249+ // Get all tasks to build a map of worktreePath -> task
250+ const allTasks = db . select ( ) . from ( tasks ) . all ( )
251+ const worktreeToTask = new Map < string , ( typeof allTasks ) [ 0 ] > ( )
252+ for ( const task of allTasks ) {
253+ if ( task . worktreePath ) {
254+ worktreeToTask . set ( task . worktreePath , task )
255+ }
256+ }
257+
258+ // Read all directories in worktreeBasePath
259+ const entries = fs . readdirSync ( worktreeBasePath , { withFileTypes : true } )
260+ const worktrees : ( WorktreeBasic & Partial < WorktreeDetails > ) [ ] = [ ]
261+
262+ for ( const entry of entries ) {
263+ if ( ! entry . isDirectory ( ) ) continue
264+
265+ const fullPath = path . join ( worktreeBasePath , entry . name )
266+
267+ // Check if it's a git worktree (has .git file or directory)
268+ const gitPath = path . join ( fullPath , '.git' )
269+ if ( ! fs . existsSync ( gitPath ) ) continue
270+
271+ const stats = fs . statSync ( fullPath )
272+ const linkedTask = worktreeToTask . get ( fullPath )
273+
274+ // Get size and branch in parallel
275+ const [ size , branch ] = await Promise . all ( [
276+ getDirectorySizeAsync ( fullPath ) ,
277+ getGitBranchAsync ( fullPath ) ,
278+ ] )
279+
280+ worktrees . push ( {
281+ path : fullPath ,
282+ name : entry . name ,
283+ lastModified : stats . mtime . toISOString ( ) ,
284+ isOrphaned : ! linkedTask ,
285+ taskId : linkedTask ?. id ,
286+ taskTitle : linkedTask ?. title ,
287+ taskStatus : linkedTask ?. status ,
288+ repoPath : linkedTask ?. repoPath ,
289+ size,
290+ sizeFormatted : formatBytes ( size ) ,
291+ branch,
292+ } )
293+ }
294+
295+ // Sort: orphaned first, then by last modified (newest first)
296+ worktrees . sort ( ( a , b ) => {
297+ if ( a . isOrphaned !== b . isOrphaned ) {
298+ return a . isOrphaned ? - 1 : 1
299+ }
300+ return new Date ( b . lastModified ) . getTime ( ) - new Date ( a . lastModified ) . getTime ( )
301+ } )
302+
303+ const totalSize = worktrees . reduce ( ( sum , w ) => sum + ( w . size || 0 ) , 0 )
304+
305+ return c . json ( {
306+ worktrees,
307+ summary : {
308+ total : worktrees . length ,
309+ orphaned : worktrees . filter ( ( w ) => w . isOrphaned ) . length ,
310+ totalSize,
311+ totalSizeFormatted : formatBytes ( totalSize ) ,
312+ } ,
313+ } )
314+ } )
315+
226316// DELETE /api/worktrees - Delete a worktree (optionally delete linked task)
227317app . delete ( '/' , async ( c ) => {
228318 try {
0 commit comments