@worca/ui 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/main.bundle.js +681 -679
- package/app/main.bundle.js.map +3 -3
- package/app/styles.css +40 -0
- package/package.json +1 -1
- package/server/worktrees-routes.js +192 -106
package/app/styles.css
CHANGED
|
@@ -221,6 +221,19 @@ h1, h2, h3, h4, h5, h6 {
|
|
|
221
221
|
opacity: 1;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
/* Loading spinner shown in sidebar nav items while underlying data is still
|
|
225
|
+
being fetched. Delayed fade-in avoids a noisy flash on fast loads. */
|
|
226
|
+
.sidebar-loading-spinner {
|
|
227
|
+
font-size: 14px;
|
|
228
|
+
--indicator-color: var(--text-secondary, currentColor);
|
|
229
|
+
opacity: 0;
|
|
230
|
+
animation: sidebar-spinner-fade 200ms ease-out 150ms forwards;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@keyframes sidebar-spinner-fade {
|
|
234
|
+
to { opacity: 0.7; }
|
|
235
|
+
}
|
|
236
|
+
|
|
224
237
|
.sidebar-item.active sl-badge {
|
|
225
238
|
--sl-color-primary-600: #ffffff;
|
|
226
239
|
--sl-color-neutral-600: #ffffff;
|
|
@@ -4635,6 +4648,33 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
4635
4648
|
color: var(--muted);
|
|
4636
4649
|
word-break: break-all;
|
|
4637
4650
|
}
|
|
4651
|
+
/* Worktree card mid-cleanup state — surfaces server-side cleanup_state
|
|
4652
|
+
so a reload during a long bulk cleanup shows the same progress. */
|
|
4653
|
+
.worktree-card-cleaning {
|
|
4654
|
+
opacity: 0.72;
|
|
4655
|
+
pointer-events: none;
|
|
4656
|
+
}
|
|
4657
|
+
.worktree-card-cleaning .btn-cleanup {
|
|
4658
|
+
pointer-events: none;
|
|
4659
|
+
}
|
|
4660
|
+
.status-badge-cleaning {
|
|
4661
|
+
display: inline-flex;
|
|
4662
|
+
align-items: center;
|
|
4663
|
+
gap: 4px;
|
|
4664
|
+
}
|
|
4665
|
+
.badge-spinner,
|
|
4666
|
+
.btn-cleanup-spinner {
|
|
4667
|
+
font-size: 12px;
|
|
4668
|
+
}
|
|
4669
|
+
.worktree-card-cleanup-error {
|
|
4670
|
+
margin-top: 6px;
|
|
4671
|
+
padding: 6px 10px;
|
|
4672
|
+
border-radius: 6px;
|
|
4673
|
+
background: var(--sl-color-danger-50, #fef2f2);
|
|
4674
|
+
color: var(--sl-color-danger-700, #b91c1c);
|
|
4675
|
+
font-size: 12px;
|
|
4676
|
+
}
|
|
4677
|
+
|
|
4638
4678
|
.worktrees-bulk-groups {
|
|
4639
4679
|
margin: 8px 0 0;
|
|
4640
4680
|
padding-left: 18px;
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* the discrepancy with `du -sh`.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import * as fsp from 'node:fs/promises';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { Router } from 'express';
|
|
@@ -192,11 +192,84 @@ function _readWorktreeStatus(worktreePath) {
|
|
|
192
192
|
return null;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Run a pre-validated cleanup batch in the background. Each task stamps
|
|
197
|
+
* `cleanup_state: 'cleaning'`, calls `removeWorktree` (which deletes the
|
|
198
|
+
* registry entry on success), and on failure stamps `cleanup_error` while
|
|
199
|
+
* clearing `cleanup_state` so the UI can render the error and let the user
|
|
200
|
+
* retry. Concurrency is bounded by `CLEANUP_CONCURRENCY`.
|
|
201
|
+
*/
|
|
202
|
+
async function _runCleanupBatch(worcaDir, accepted) {
|
|
203
|
+
const tasks = accepted.map(({ run_id, reg }) => ({
|
|
204
|
+
run_id,
|
|
205
|
+
fn: async () => {
|
|
206
|
+
_patchRegistry(worcaDir, run_id, { cleanup_state: 'cleaning' });
|
|
207
|
+
try {
|
|
208
|
+
await removeWorktree(worcaDir, run_id, { skipPrune: true });
|
|
209
|
+
if (reg.worktree_path) _diskCache.delete(reg.worktree_path);
|
|
210
|
+
return { run_id, ok: true };
|
|
211
|
+
} catch (err) {
|
|
212
|
+
_patchRegistry(worcaDir, run_id, {
|
|
213
|
+
cleanup_state: undefined,
|
|
214
|
+
cleanup_error: err?.message || String(err),
|
|
215
|
+
});
|
|
216
|
+
return { run_id, ok: false, error: err?.message || String(err) };
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
await runWithConcurrencyLimit(tasks, CLEANUP_CONCURRENCY);
|
|
223
|
+
} catch {
|
|
224
|
+
/* per-task failures already persisted into the registry */
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
await pruneWorktrees(worcaDir);
|
|
229
|
+
} catch {
|
|
230
|
+
/* non-fatal */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Atomically patch fields on a pipelines.d/<run>.json entry.
|
|
236
|
+
* Set a field to `undefined` to delete it. Returns `false` if the file is
|
|
237
|
+
* gone (the worktree was already cleaned up) or unreadable.
|
|
238
|
+
*
|
|
239
|
+
* Note: write is not strictly atomic — for a single-writer-per-id model
|
|
240
|
+
* (the cleanup background task owns its registry entry for the lifetime
|
|
241
|
+
* of the cleanup), read-modify-write is fine. A multi-writer scenario
|
|
242
|
+
* would need rename-into-place; we don't have that here.
|
|
243
|
+
*/
|
|
244
|
+
function _patchRegistry(worcaDir, runId, patch) {
|
|
245
|
+
const regFile = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
|
|
246
|
+
if (!existsSync(regFile)) return false;
|
|
247
|
+
let reg;
|
|
248
|
+
try {
|
|
249
|
+
reg = JSON.parse(readFileSync(regFile, 'utf8'));
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
254
|
+
if (v === undefined) delete reg[k];
|
|
255
|
+
else reg[k] = v;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
writeFileSync(regFile, JSON.stringify(reg, null, 2), 'utf8');
|
|
259
|
+
return true;
|
|
260
|
+
} catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
195
265
|
async function _listWorktrees(worcaDir) {
|
|
196
266
|
const pipelinesDir = join(worcaDir, 'multi', 'pipelines.d');
|
|
197
267
|
if (!existsSync(pipelinesDir)) return [];
|
|
198
268
|
|
|
199
|
-
|
|
269
|
+
// Phase 1: cheap synchronous metadata (registry parse, status read).
|
|
270
|
+
// Phase 2: disk walks in parallel — without this, 13 worktrees serialize
|
|
271
|
+
// ~3s of awaits even when most results would have been disk-cache hits.
|
|
272
|
+
const metas = [];
|
|
200
273
|
for (const file of readdirSync(pipelinesDir)) {
|
|
201
274
|
if (!file.endsWith('.json')) continue;
|
|
202
275
|
|
|
@@ -211,7 +284,6 @@ async function _listWorktrees(worcaDir) {
|
|
|
211
284
|
const worktreePath = reg.worktree_path;
|
|
212
285
|
const worktreeExists = existsSync(worktreePath);
|
|
213
286
|
|
|
214
|
-
// Prefer actual status.json; fall back to registry field
|
|
215
287
|
let status = reg.status || 'unknown';
|
|
216
288
|
if (worktreeExists) {
|
|
217
289
|
const actual = _readWorktreeStatus(worktreePath);
|
|
@@ -226,32 +298,44 @@ async function _listWorktrees(worcaDir) {
|
|
|
226
298
|
}
|
|
227
299
|
}
|
|
228
300
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
entries.push({
|
|
235
|
-
run_id: reg.run_id || '',
|
|
236
|
-
title: reg.title || '',
|
|
237
|
-
branch: reg.branch || '',
|
|
238
|
-
worktree_path: worktreePath,
|
|
239
|
-
disk_bytes: diskInfo.bytes,
|
|
240
|
-
truncated: diskInfo.truncated,
|
|
241
|
-
age_seconds: ageSeconds,
|
|
242
|
-
// started_at lets the client sort with the same sortByStartDesc helper
|
|
243
|
-
// used by run-list, keeping ordering consistent across views.
|
|
244
|
-
started_at: reg.started_at || null,
|
|
301
|
+
metas.push({
|
|
302
|
+
reg,
|
|
303
|
+
worktreePath,
|
|
304
|
+
worktreeExists,
|
|
245
305
|
status,
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
group_type: reg.group_type || null,
|
|
250
|
-
group_status: null, // populated by W-040 / W-047
|
|
251
|
-
resumable: RESUMABLE_STATUSES.has(status),
|
|
306
|
+
ageSeconds,
|
|
307
|
+
cleanup_state: reg.cleanup_state || null,
|
|
308
|
+
cleanup_error: reg.cleanup_error || null,
|
|
252
309
|
});
|
|
253
310
|
}
|
|
254
|
-
|
|
311
|
+
|
|
312
|
+
const disks = await Promise.all(
|
|
313
|
+
metas.map((m) =>
|
|
314
|
+
m.worktreeExists
|
|
315
|
+
? _getDiskBytes(m.worktreePath)
|
|
316
|
+
: Promise.resolve({ bytes: 0, truncated: false }),
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
return metas.map((m, i) => ({
|
|
321
|
+
run_id: m.reg.run_id || '',
|
|
322
|
+
title: m.reg.title || '',
|
|
323
|
+
branch: m.reg.branch || '',
|
|
324
|
+
worktree_path: m.worktreePath,
|
|
325
|
+
disk_bytes: disks[i].bytes,
|
|
326
|
+
truncated: disks[i].truncated,
|
|
327
|
+
age_seconds: m.ageSeconds,
|
|
328
|
+
started_at: m.reg.started_at || null,
|
|
329
|
+
status: m.status,
|
|
330
|
+
removable: m.status !== 'running',
|
|
331
|
+
fleet_id: m.reg.fleet_id || null,
|
|
332
|
+
workspace_id: m.reg.workspace_id || null,
|
|
333
|
+
group_type: m.reg.group_type || null,
|
|
334
|
+
group_status: null,
|
|
335
|
+
resumable: RESUMABLE_STATUSES.has(m.status),
|
|
336
|
+
cleanup_state: m.cleanup_state,
|
|
337
|
+
cleanup_error: m.cleanup_error,
|
|
338
|
+
}));
|
|
255
339
|
}
|
|
256
340
|
|
|
257
341
|
const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
@@ -367,13 +451,19 @@ export function createWorktreesRouter() {
|
|
|
367
451
|
|
|
368
452
|
// POST /worktrees/cleanup
|
|
369
453
|
//
|
|
370
|
-
// Batch worktree removal.
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
454
|
+
// Batch worktree removal — async. Synchronously validates each id and
|
|
455
|
+
// stamps `cleanup_state: 'pending'` on the registry entries that pass
|
|
456
|
+
// pre-flight checks, then returns 202. The actual removal happens in
|
|
457
|
+
// the background with bounded concurrency. Clients poll GET /worktrees
|
|
458
|
+
// and observe `cleanup_state` per entry; on success the entry vanishes,
|
|
459
|
+
// on failure `cleanup_error` is set and `cleanup_state` is cleared.
|
|
460
|
+
//
|
|
461
|
+
// Response shape `{ ok, accepted, rejected }` where `rejected[]` carries
|
|
462
|
+
// entries that failed pre-flight (running, resumable without force, etc).
|
|
463
|
+
// A single bad id never blocks the rest of the batch; this stays
|
|
464
|
+
// compatible with the legacy synchronous shape's promise that partial
|
|
465
|
+
// failures are not signalled via HTTP status.
|
|
466
|
+
router.post('/cleanup', (req, res) => {
|
|
377
467
|
const worcaDir = req.project?.worcaDir;
|
|
378
468
|
if (!worcaDir) {
|
|
379
469
|
return res
|
|
@@ -395,86 +485,82 @@ export function createWorktreesRouter() {
|
|
|
395
485
|
}
|
|
396
486
|
}
|
|
397
487
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
let status = reg.status || 'unknown';
|
|
427
|
-
if (reg.worktree_path && existsSync(reg.worktree_path)) {
|
|
428
|
-
const actual = _readWorktreeStatus(reg.worktree_path);
|
|
429
|
-
if (actual) status = actual;
|
|
430
|
-
}
|
|
488
|
+
// Pre-flight: read each registry entry, decide pending vs reject. We do
|
|
489
|
+
// this synchronously so the HTTP response can carry the rejection list
|
|
490
|
+
// — clients shouldn't have to poll to learn that a 'running' worktree
|
|
491
|
+
// was refused.
|
|
492
|
+
const accepted = [];
|
|
493
|
+
const rejected = [];
|
|
494
|
+
for (const run_id of run_ids) {
|
|
495
|
+
const regFile = join(worcaDir, 'multi', 'pipelines.d', `${run_id}.json`);
|
|
496
|
+
if (!existsSync(regFile)) {
|
|
497
|
+
rejected.push({
|
|
498
|
+
run_id,
|
|
499
|
+
ok: false,
|
|
500
|
+
error: `Worktree "${run_id}" not found`,
|
|
501
|
+
});
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
let reg;
|
|
505
|
+
try {
|
|
506
|
+
reg = JSON.parse(readFileSync(regFile, 'utf8'));
|
|
507
|
+
} catch {
|
|
508
|
+
rejected.push({
|
|
509
|
+
run_id,
|
|
510
|
+
ok: false,
|
|
511
|
+
error: 'Failed to read registry entry',
|
|
512
|
+
});
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
431
515
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
code: 'running',
|
|
438
|
-
};
|
|
439
|
-
}
|
|
516
|
+
let status = reg.status || 'unknown';
|
|
517
|
+
if (reg.worktree_path && existsSync(reg.worktree_path)) {
|
|
518
|
+
const actual = _readWorktreeStatus(reg.worktree_path);
|
|
519
|
+
if (actual) status = actual;
|
|
520
|
+
}
|
|
440
521
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
};
|
|
451
|
-
}
|
|
522
|
+
if (status === 'running') {
|
|
523
|
+
rejected.push({
|
|
524
|
+
run_id,
|
|
525
|
+
ok: false,
|
|
526
|
+
error: 'Cannot remove a running worktree',
|
|
527
|
+
code: 'running',
|
|
528
|
+
});
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
452
531
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
532
|
+
const isResumable = RESUMABLE_STATUSES.has(status);
|
|
533
|
+
const isGrouped = !!(reg.fleet_id || reg.workspace_id);
|
|
534
|
+
if (!force && (isResumable || isGrouped)) {
|
|
535
|
+
rejected.push({
|
|
536
|
+
run_id,
|
|
537
|
+
ok: false,
|
|
538
|
+
error:
|
|
539
|
+
'Removing this worktree prevents resuming the run. Pass force=true to confirm.',
|
|
540
|
+
code: 'resumable_or_grouped',
|
|
541
|
+
});
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
462
544
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
545
|
+
// Stamp pending so a reload mid-cleanup shows the same state.
|
|
546
|
+
_patchRegistry(worcaDir, run_id, {
|
|
547
|
+
cleanup_state: 'pending',
|
|
548
|
+
cleanup_error: undefined,
|
|
549
|
+
});
|
|
550
|
+
accepted.push({ run_id, reg });
|
|
468
551
|
}
|
|
469
552
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
553
|
+
// Respond immediately — the client polls GET /worktrees to observe progress.
|
|
554
|
+
res.status(202).json({
|
|
555
|
+
ok: rejected.length === 0,
|
|
556
|
+
accepted: accepted.map((a) => a.run_id),
|
|
557
|
+
rejected,
|
|
558
|
+
});
|
|
475
559
|
|
|
476
|
-
|
|
477
|
-
|
|
560
|
+
// Fire-and-forget background removal. Errors are persisted into the
|
|
561
|
+
// registry so the client can render them; nothing here is awaited by
|
|
562
|
+
// the HTTP request.
|
|
563
|
+
void _runCleanupBatch(worcaDir, accepted);
|
|
478
564
|
});
|
|
479
565
|
|
|
480
566
|
return router;
|