agentuity-vscode 0.1.5 → 0.1.7

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.
@@ -0,0 +1,803 @@
1
+ import * as vscode from 'vscode';
2
+ import {
3
+ getCliClient,
4
+ type SandboxInfo,
5
+ type SandboxFileInfo,
6
+ type SnapshotInfo,
7
+ type ExecutionInfo,
8
+ type SandboxStatus,
9
+ } from '../../core/cliClient';
10
+ import { getAuthStatus } from '../../core/auth';
11
+ import { getSandboxManager, type LinkedSandbox } from '../../core/sandboxManager';
12
+
13
+ /**
14
+ * Types of items in the sandbox tree.
15
+ */
16
+ export type SandboxItemType =
17
+ | 'sandbox'
18
+ | 'category'
19
+ | 'file'
20
+ | 'directory'
21
+ | 'snapshot'
22
+ | 'snapshotFile'
23
+ | 'execution'
24
+ | 'message'
25
+ | 'createSandbox';
26
+
27
+ /**
28
+ * A tree item in the sandbox explorer.
29
+ */
30
+ export class SandboxTreeItem extends vscode.TreeItem {
31
+ constructor(
32
+ public readonly label: string,
33
+ public readonly collapsibleState: vscode.TreeItemCollapsibleState,
34
+ public readonly itemType: SandboxItemType,
35
+ public readonly sandboxData?: SandboxInfo,
36
+ public readonly fileData?: SandboxFileInfo,
37
+ public readonly snapshotData?: SnapshotInfo,
38
+ public readonly executionData?: ExecutionInfo,
39
+ public readonly parentSandboxId?: string,
40
+ public readonly categoryType?: 'files' | 'snapshots' | 'executions',
41
+ public readonly linkedData?: LinkedSandbox,
42
+ public readonly filePath?: string
43
+ ) {
44
+ super(label, collapsibleState);
45
+ this.setupItem();
46
+ }
47
+
48
+ private setupItem(): void {
49
+ switch (this.itemType) {
50
+ case 'sandbox':
51
+ this.setupSandboxItem();
52
+ break;
53
+ case 'category':
54
+ this.setupCategoryItem();
55
+ break;
56
+ case 'file':
57
+ case 'directory':
58
+ this.setupFileItem();
59
+ break;
60
+ case 'snapshot':
61
+ this.setupSnapshotItem();
62
+ break;
63
+ case 'snapshotFile':
64
+ this.setupSnapshotFileItem();
65
+ break;
66
+ case 'execution':
67
+ this.setupExecutionItem();
68
+ break;
69
+ case 'createSandbox':
70
+ this.setupCreateItem();
71
+ break;
72
+ case 'message':
73
+ this.iconPath = new vscode.ThemeIcon('info');
74
+ this.contextValue = 'message';
75
+ break;
76
+ }
77
+ }
78
+
79
+ private setupSandboxItem(): void {
80
+ if (!this.sandboxData) return;
81
+
82
+ const status = this.sandboxData.status;
83
+ const isLinked = this.linkedData !== undefined;
84
+
85
+ // Set icon based on status
86
+ this.iconPath = new vscode.ThemeIcon(
87
+ this.getStatusIcon(status),
88
+ this.getStatusColor(status)
89
+ );
90
+
91
+ // Set context value for menu targeting
92
+ let contextValue = `sandbox.${status}`;
93
+ if (isLinked) {
94
+ contextValue += '.linked';
95
+ }
96
+ this.contextValue = contextValue;
97
+
98
+ // Set description
99
+ const statusLabel = status.charAt(0).toUpperCase() + status.slice(1);
100
+ this.description = isLinked ? `${statusLabel} [linked]` : statusLabel;
101
+
102
+ // Set tooltip
103
+ this.tooltip = this.formatSandboxTooltip();
104
+ }
105
+
106
+ private getStatusIcon(status: SandboxStatus): string {
107
+ switch (status) {
108
+ case 'idle':
109
+ return 'vm';
110
+ case 'running':
111
+ return 'vm-running';
112
+ case 'creating':
113
+ return 'loading~spin';
114
+ case 'terminated':
115
+ return 'vm-outline';
116
+ case 'failed':
117
+ return 'error';
118
+ default:
119
+ return 'vm';
120
+ }
121
+ }
122
+
123
+ private getStatusColor(status: SandboxStatus): vscode.ThemeColor | undefined {
124
+ switch (status) {
125
+ case 'idle':
126
+ return new vscode.ThemeColor('charts.blue');
127
+ case 'running':
128
+ return new vscode.ThemeColor('charts.green');
129
+ case 'failed':
130
+ return new vscode.ThemeColor('charts.red');
131
+ case 'terminated':
132
+ return new vscode.ThemeColor('disabledForeground');
133
+ default:
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ private formatSandboxTooltip(): string {
139
+ if (!this.sandboxData) return '';
140
+
141
+ const lines = [
142
+ `ID: ${this.sandboxData.sandboxId}`,
143
+ `Status: ${this.sandboxData.status}`,
144
+ `Region: ${this.sandboxData.region}`,
145
+ `Created: ${new Date(this.sandboxData.createdAt).toLocaleString()}`,
146
+ ];
147
+
148
+ if (this.sandboxData.resources) {
149
+ const r = this.sandboxData.resources;
150
+ if (r.memory) lines.push(`Memory: ${r.memory}`);
151
+ if (r.cpu) lines.push(`CPU: ${r.cpu}`);
152
+ if (r.disk) lines.push(`Disk: ${r.disk}`);
153
+ }
154
+
155
+ if (this.linkedData) {
156
+ lines.push('', '--- Linked ---');
157
+ lines.push(`Remote Path: ${this.linkedData.remotePath}`);
158
+ if (this.linkedData.lastSyncedAt) {
159
+ lines.push(`Last Synced: ${new Date(this.linkedData.lastSyncedAt).toLocaleString()}`);
160
+ }
161
+ }
162
+
163
+ return lines.join('\n');
164
+ }
165
+
166
+ private setupCategoryItem(): void {
167
+ switch (this.categoryType) {
168
+ case 'files':
169
+ this.iconPath = new vscode.ThemeIcon('folder');
170
+ this.contextValue = 'sandboxCategory.files';
171
+ break;
172
+ case 'snapshots':
173
+ this.iconPath = new vscode.ThemeIcon('device-camera');
174
+ this.contextValue = 'sandboxCategory.snapshots';
175
+ break;
176
+ case 'executions':
177
+ this.iconPath = new vscode.ThemeIcon('terminal');
178
+ this.contextValue = 'sandboxCategory.executions';
179
+ break;
180
+ }
181
+ }
182
+
183
+ private setupFileItem(): void {
184
+ if (this.itemType === 'directory') {
185
+ this.iconPath = new vscode.ThemeIcon('folder');
186
+ this.contextValue = 'sandboxFile.directory';
187
+ } else {
188
+ this.iconPath = new vscode.ThemeIcon('file');
189
+ this.contextValue = 'sandboxFile';
190
+
191
+ // Add click command to open file directly
192
+ this.command = {
193
+ command: 'agentuity.sandbox.viewFile',
194
+ title: 'Open File',
195
+ arguments: [this],
196
+ };
197
+ }
198
+
199
+ if (this.fileData) {
200
+ this.description = this.formatFileSize(this.fileData.size);
201
+ this.tooltip = `${this.filePath}\nSize: ${this.formatFileSize(this.fileData.size)}\nModified: ${this.fileData.modTime}`;
202
+ }
203
+ }
204
+
205
+ private formatFileSize(bytes: number): string {
206
+ if (bytes < 1024) return `${bytes} B`;
207
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
208
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
209
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
210
+ }
211
+
212
+ private setupSnapshotItem(): void {
213
+ if (!this.snapshotData) return;
214
+
215
+ this.iconPath = new vscode.ThemeIcon('device-camera');
216
+
217
+ if (this.snapshotData.tag) {
218
+ this.contextValue = 'snapshot.tagged';
219
+ this.description = `[${this.snapshotData.tag}] ${this.snapshotData.fileCount} files`;
220
+ } else {
221
+ this.contextValue = 'snapshot';
222
+ this.description = `${this.snapshotData.fileCount} files`;
223
+ }
224
+
225
+ this.tooltip = [
226
+ `ID: ${this.snapshotData.snapshotId}`,
227
+ `Size: ${this.formatFileSize(this.snapshotData.sizeBytes)}`,
228
+ `Files: ${this.snapshotData.fileCount}`,
229
+ `Created: ${new Date(this.snapshotData.createdAt).toLocaleString()}`,
230
+ this.snapshotData.tag ? `Tag: ${this.snapshotData.tag}` : '',
231
+ '',
232
+ 'Click to view snapshot details',
233
+ ]
234
+ .filter(Boolean)
235
+ .join('\n');
236
+
237
+ // Add click command to view snapshot details JSON
238
+ this.command = {
239
+ command: 'agentuity.sandbox.snapshot.viewDetails',
240
+ title: 'View Snapshot Details',
241
+ arguments: [this],
242
+ };
243
+ }
244
+
245
+ private setupSnapshotFileItem(): void {
246
+ this.iconPath = new vscode.ThemeIcon('file');
247
+ this.contextValue = 'snapshotFile';
248
+
249
+ if (this.fileData) {
250
+ this.description = this.formatFileSize(this.fileData.size);
251
+ this.tooltip = `${this.filePath}\nSize: ${this.formatFileSize(this.fileData.size)}\n\nClick to view file (readonly)`;
252
+ }
253
+
254
+ // Add click command to view snapshot file
255
+ this.command = {
256
+ command: 'agentuity.sandbox.snapshot.viewFile',
257
+ title: 'View Snapshot File',
258
+ arguments: [this],
259
+ };
260
+ }
261
+
262
+ private setupExecutionItem(): void {
263
+ if (!this.executionData) return;
264
+
265
+ const status = this.executionData.status;
266
+ const icon = this.getExecutionIcon(status);
267
+ const color = this.getExecutionColor(status);
268
+
269
+ this.iconPath = new vscode.ThemeIcon(icon, color);
270
+ this.contextValue = status === 'running' ? 'execution.running' : 'execution';
271
+
272
+ // Build description
273
+ const parts: string[] = [];
274
+ if (this.executionData.exitCode !== undefined) {
275
+ parts.push(`exit ${this.executionData.exitCode}`);
276
+ }
277
+ if (this.executionData.durationMs !== undefined) {
278
+ parts.push(`${(this.executionData.durationMs / 1000).toFixed(1)}s`);
279
+ }
280
+ this.description = parts.join(', ');
281
+
282
+ this.tooltip = [
283
+ `ID: ${this.executionData.executionId}`,
284
+ `Status: ${status}`,
285
+ this.executionData.exitCode !== undefined ? `Exit Code: ${this.executionData.exitCode}` : '',
286
+ this.executionData.durationMs !== undefined
287
+ ? `Duration: ${(this.executionData.durationMs / 1000).toFixed(2)}s`
288
+ : '',
289
+ '',
290
+ 'Click to view execution details',
291
+ ]
292
+ .filter(Boolean)
293
+ .join('\n');
294
+
295
+ // Add click command to view execution details
296
+ this.command = {
297
+ command: 'agentuity.sandbox.viewExecution',
298
+ title: 'View Execution',
299
+ arguments: [this],
300
+ };
301
+ }
302
+
303
+ private getExecutionIcon(status: string): string {
304
+ switch (status) {
305
+ case 'completed':
306
+ return 'check';
307
+ case 'failed':
308
+ return 'x';
309
+ case 'running':
310
+ return 'loading~spin';
311
+ case 'queued':
312
+ return 'clock';
313
+ case 'timeout':
314
+ return 'watch';
315
+ case 'cancelled':
316
+ return 'circle-slash';
317
+ default:
318
+ return 'terminal';
319
+ }
320
+ }
321
+
322
+ private getExecutionColor(status: string): vscode.ThemeColor | undefined {
323
+ switch (status) {
324
+ case 'completed':
325
+ return new vscode.ThemeColor('charts.green');
326
+ case 'failed':
327
+ case 'timeout':
328
+ return new vscode.ThemeColor('charts.red');
329
+ case 'cancelled':
330
+ return new vscode.ThemeColor('charts.orange');
331
+ default:
332
+ return undefined;
333
+ }
334
+ }
335
+
336
+ private setupCreateItem(): void {
337
+ this.iconPath = new vscode.ThemeIcon('add');
338
+ this.contextValue = 'createSandbox';
339
+ this.command = {
340
+ command: 'agentuity.sandbox.create',
341
+ title: 'Create Sandbox',
342
+ };
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Tree data provider for the sandbox explorer.
348
+ */
349
+ export class SandboxTreeDataProvider implements vscode.TreeDataProvider<SandboxTreeItem> {
350
+ private _onDidChangeTreeData = new vscode.EventEmitter<SandboxTreeItem | undefined | null | void>();
351
+ readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
352
+
353
+ private sandboxes: SandboxInfo[] = [];
354
+ private loading = false;
355
+ private sandboxesLoaded = false;
356
+ private error: string | undefined;
357
+
358
+ // Cache for lazy-loaded data
359
+ private snapshotsCache: Map<string, SnapshotInfo[]> = new Map();
360
+ private executionsCache: Map<string, ExecutionInfo[]> = new Map();
361
+ private filesCache: Map<string, SandboxFileInfo[]> = new Map();
362
+
363
+ refresh(): void {
364
+ this._onDidChangeTreeData.fire();
365
+ }
366
+
367
+ getTreeItem(element: SandboxTreeItem): vscode.TreeItem {
368
+ return element;
369
+ }
370
+
371
+ async getChildren(element?: SandboxTreeItem): Promise<SandboxTreeItem[]> {
372
+ // Root level
373
+ if (!element) {
374
+ return this.getRootChildren();
375
+ }
376
+
377
+ // Sandbox children (categories)
378
+ if (element.itemType === 'sandbox' && element.sandboxData) {
379
+ return this.getSandboxCategories(element.sandboxData, element.linkedData);
380
+ }
381
+
382
+ // Category children
383
+ if (element.itemType === 'category' && element.parentSandboxId) {
384
+ switch (element.categoryType) {
385
+ case 'files':
386
+ // Pass undefined for root listing (CLI defaults to sandbox home)
387
+ return this.getFilesChildren(element.parentSandboxId, undefined);
388
+ case 'snapshots':
389
+ return this.getSnapshotsChildren(element.parentSandboxId);
390
+ case 'executions':
391
+ return this.getExecutionsChildren(element.parentSandboxId);
392
+ }
393
+ }
394
+
395
+ // Directory children
396
+ if (element.itemType === 'directory' && element.parentSandboxId && element.filePath) {
397
+ return this.getFilesChildren(element.parentSandboxId, element.filePath);
398
+ }
399
+
400
+ // Snapshot children (files from snapshot get)
401
+ if (element.itemType === 'snapshot' && element.snapshotData) {
402
+ return this.getSnapshotFilesChildren(element.snapshotData.snapshotId);
403
+ }
404
+
405
+ return [];
406
+ }
407
+
408
+ private async getRootChildren(): Promise<SandboxTreeItem[]> {
409
+ const authStatus = getAuthStatus();
410
+
411
+ if (authStatus.state === 'unknown') {
412
+ return [
413
+ new SandboxTreeItem('Checking auth...', vscode.TreeItemCollapsibleState.None, 'message'),
414
+ ];
415
+ }
416
+
417
+ if (authStatus.state === 'cli-missing') {
418
+ return [
419
+ new SandboxTreeItem('CLI not installed', vscode.TreeItemCollapsibleState.None, 'message'),
420
+ ];
421
+ }
422
+
423
+ if (authStatus.state === 'unauthenticated') {
424
+ return [
425
+ new SandboxTreeItem('Not logged in', vscode.TreeItemCollapsibleState.None, 'message'),
426
+ ];
427
+ }
428
+
429
+ if (this.loading) {
430
+ return [new SandboxTreeItem('Loading...', vscode.TreeItemCollapsibleState.None, 'message')];
431
+ }
432
+
433
+ if (this.error) {
434
+ return [
435
+ new SandboxTreeItem(
436
+ `Error: ${this.error}`,
437
+ vscode.TreeItemCollapsibleState.None,
438
+ 'message'
439
+ ),
440
+ ];
441
+ }
442
+
443
+ // Load sandboxes if not loaded
444
+ if (!this.sandboxesLoaded && !this.loading) {
445
+ await this.loadSandboxes();
446
+ }
447
+
448
+ const items: SandboxTreeItem[] = [];
449
+
450
+ // Get linked sandboxes for this workspace
451
+ let linkedSandboxes: LinkedSandbox[] = [];
452
+ try {
453
+ linkedSandboxes = getSandboxManager().getLinkedSandboxes();
454
+ } catch {
455
+ // SandboxManager not initialized yet
456
+ }
457
+
458
+ // Add sandbox items
459
+ for (const sandbox of this.sandboxes) {
460
+ const linked = linkedSandboxes.find((l) => l.sandboxId === sandbox.sandboxId);
461
+ const displayName = linked?.name || sandbox.sandboxId;
462
+
463
+ items.push(
464
+ new SandboxTreeItem(
465
+ displayName,
466
+ vscode.TreeItemCollapsibleState.Collapsed,
467
+ 'sandbox',
468
+ sandbox,
469
+ undefined,
470
+ undefined,
471
+ undefined,
472
+ undefined,
473
+ undefined,
474
+ linked
475
+ )
476
+ );
477
+ }
478
+
479
+ // Add "Create Sandbox" action at the end
480
+ items.push(
481
+ new SandboxTreeItem(
482
+ 'Create Sandbox',
483
+ vscode.TreeItemCollapsibleState.None,
484
+ 'createSandbox'
485
+ )
486
+ );
487
+
488
+ if (items.length === 1) {
489
+ // Only the "Create Sandbox" item
490
+ return [
491
+ new SandboxTreeItem(
492
+ 'No sandboxes found',
493
+ vscode.TreeItemCollapsibleState.None,
494
+ 'message'
495
+ ),
496
+ items[0],
497
+ ];
498
+ }
499
+
500
+ return items;
501
+ }
502
+
503
+ private getSandboxCategories(
504
+ sandbox: SandboxInfo,
505
+ linked?: LinkedSandbox
506
+ ): SandboxTreeItem[] {
507
+ return [
508
+ new SandboxTreeItem(
509
+ 'Files',
510
+ vscode.TreeItemCollapsibleState.Collapsed,
511
+ 'category',
512
+ sandbox,
513
+ undefined,
514
+ undefined,
515
+ undefined,
516
+ sandbox.sandboxId,
517
+ 'files',
518
+ linked
519
+ ),
520
+ new SandboxTreeItem(
521
+ 'Snapshots',
522
+ vscode.TreeItemCollapsibleState.Collapsed,
523
+ 'category',
524
+ sandbox,
525
+ undefined,
526
+ undefined,
527
+ undefined,
528
+ sandbox.sandboxId,
529
+ 'snapshots',
530
+ linked
531
+ ),
532
+ new SandboxTreeItem(
533
+ 'Executions',
534
+ vscode.TreeItemCollapsibleState.Collapsed,
535
+ 'category',
536
+ sandbox,
537
+ undefined,
538
+ undefined,
539
+ undefined,
540
+ sandbox.sandboxId,
541
+ 'executions',
542
+ linked
543
+ ),
544
+ ];
545
+ }
546
+
547
+ private async getFilesChildren(sandboxId: string, dirPath?: string): Promise<SandboxTreeItem[]> {
548
+ // Always fetch from root to get full file list, then filter
549
+ const cacheKey = `${sandboxId}:root`;
550
+
551
+ // Check cache first - always cache from root
552
+ if (!this.filesCache.has(cacheKey)) {
553
+ const cli = getCliClient();
554
+ // Always fetch from root (no path) to get complete file list
555
+ const result = await cli.sandboxLs(sandboxId);
556
+
557
+ if (result.success && result.data) {
558
+ this.filesCache.set(cacheKey, result.data);
559
+ } else {
560
+ return [
561
+ new SandboxTreeItem(
562
+ `Error: ${result.error || 'Failed to list files'}`,
563
+ vscode.TreeItemCollapsibleState.None,
564
+ 'message'
565
+ ),
566
+ ];
567
+ }
568
+ }
569
+
570
+ const allFiles = this.filesCache.get(cacheKey) || [];
571
+
572
+ // Filter to only show direct children of the current directory
573
+ const directChildren = allFiles.filter((file) => {
574
+ const filePath = file.path;
575
+
576
+ if (!dirPath) {
577
+ // Root level: only show items without '/' in path
578
+ return !filePath.includes('/');
579
+ } else {
580
+ // Subdirectory: only show direct children
581
+ // File must start with dirPath/
582
+ if (!filePath.startsWith(dirPath + '/')) {
583
+ return false;
584
+ }
585
+ // The remaining part after dirPath/ should not contain another '/'
586
+ const remaining = filePath.slice(dirPath.length + 1);
587
+ return !remaining.includes('/');
588
+ }
589
+ });
590
+
591
+ if (directChildren.length === 0) {
592
+ return [
593
+ new SandboxTreeItem('(empty)', vscode.TreeItemCollapsibleState.None, 'message'),
594
+ ];
595
+ }
596
+
597
+ // Sort: directories first, then files, alphabetically
598
+ const sorted = [...directChildren].sort((a, b) => {
599
+ if (a.isDir && !b.isDir) return -1;
600
+ if (!a.isDir && b.isDir) return 1;
601
+ return a.name.localeCompare(b.name);
602
+ });
603
+
604
+ return sorted.map((file) => {
605
+ return new SandboxTreeItem(
606
+ file.name,
607
+ file.isDir
608
+ ? vscode.TreeItemCollapsibleState.Collapsed
609
+ : vscode.TreeItemCollapsibleState.None,
610
+ file.isDir ? 'directory' : 'file',
611
+ undefined,
612
+ file,
613
+ undefined,
614
+ undefined,
615
+ sandboxId,
616
+ undefined,
617
+ undefined,
618
+ file.path // Use the full path from CLI
619
+ );
620
+ });
621
+ }
622
+
623
+ private async getSnapshotsChildren(sandboxId: string): Promise<SandboxTreeItem[]> {
624
+ if (!this.snapshotsCache.has(sandboxId)) {
625
+ const cli = getCliClient();
626
+ const result = await cli.snapshotList(sandboxId);
627
+
628
+ if (result.success && result.data) {
629
+ this.snapshotsCache.set(sandboxId, result.data);
630
+ } else {
631
+ return [
632
+ new SandboxTreeItem(
633
+ `Error: ${result.error || 'Failed to list snapshots'}`,
634
+ vscode.TreeItemCollapsibleState.None,
635
+ 'message'
636
+ ),
637
+ ];
638
+ }
639
+ }
640
+
641
+ const snapshots = this.snapshotsCache.get(sandboxId) || [];
642
+
643
+ if (snapshots.length === 0) {
644
+ return [
645
+ new SandboxTreeItem('No snapshots', vscode.TreeItemCollapsibleState.None, 'message'),
646
+ ];
647
+ }
648
+
649
+ return snapshots.map(
650
+ (snap) =>
651
+ new SandboxTreeItem(
652
+ snap.tag || snap.snapshotId.slice(0, 12),
653
+ // Make expandable to show files
654
+ vscode.TreeItemCollapsibleState.Collapsed,
655
+ 'snapshot',
656
+ undefined,
657
+ undefined,
658
+ snap,
659
+ undefined,
660
+ sandboxId
661
+ )
662
+ );
663
+ }
664
+
665
+ private async getSnapshotFilesChildren(snapshotId: string): Promise<SandboxTreeItem[]> {
666
+ // Fetch snapshot details to get file list
667
+ const cli = getCliClient();
668
+ const result = await cli.snapshotGet(snapshotId);
669
+
670
+ if (!result.success || !result.data) {
671
+ return [
672
+ new SandboxTreeItem(
673
+ `Error: ${result.error || 'Failed to get snapshot'}`,
674
+ vscode.TreeItemCollapsibleState.None,
675
+ 'message'
676
+ ),
677
+ ];
678
+ }
679
+
680
+ const snapshotData = result.data;
681
+ const files = snapshotData.files || [];
682
+
683
+ if (files.length === 0) {
684
+ return [
685
+ new SandboxTreeItem('(no files)', vscode.TreeItemCollapsibleState.None, 'message'),
686
+ ];
687
+ }
688
+
689
+ // Sort files alphabetically
690
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
691
+
692
+ return sorted.map((file) => {
693
+ const fileName = file.path.split('/').pop() || file.path;
694
+ return new SandboxTreeItem(
695
+ fileName,
696
+ vscode.TreeItemCollapsibleState.None,
697
+ 'snapshotFile',
698
+ undefined,
699
+ { path: file.path, name: fileName, size: file.size, isDir: false, mode: '', modTime: '' },
700
+ snapshotData, // Pass snapshot data so we have access to downloadUrl
701
+ undefined,
702
+ undefined,
703
+ undefined,
704
+ undefined,
705
+ file.path
706
+ );
707
+ });
708
+ }
709
+
710
+ private async getExecutionsChildren(sandboxId: string): Promise<SandboxTreeItem[]> {
711
+ if (!this.executionsCache.has(sandboxId)) {
712
+ const cli = getCliClient();
713
+ const result = await cli.executionList(sandboxId);
714
+
715
+ if (result.success && result.data) {
716
+ this.executionsCache.set(sandboxId, result.data);
717
+ } else {
718
+ return [
719
+ new SandboxTreeItem(
720
+ `Error: ${result.error || 'Failed to list executions'}`,
721
+ vscode.TreeItemCollapsibleState.None,
722
+ 'message'
723
+ ),
724
+ ];
725
+ }
726
+ }
727
+
728
+ const executions = this.executionsCache.get(sandboxId) || [];
729
+
730
+ if (executions.length === 0) {
731
+ return [
732
+ new SandboxTreeItem('No executions', vscode.TreeItemCollapsibleState.None, 'message'),
733
+ ];
734
+ }
735
+
736
+ return executions.map(
737
+ (exec) =>
738
+ new SandboxTreeItem(
739
+ exec.executionId.slice(0, 12),
740
+ vscode.TreeItemCollapsibleState.None,
741
+ 'execution',
742
+ undefined,
743
+ undefined,
744
+ undefined,
745
+ exec,
746
+ sandboxId
747
+ )
748
+ );
749
+ }
750
+
751
+ private async loadSandboxes(): Promise<void> {
752
+ this.loading = true;
753
+ this.error = undefined;
754
+
755
+ try {
756
+ const cli = getCliClient();
757
+ const result = await cli.sandboxList();
758
+
759
+ if (result.success && result.data) {
760
+ this.sandboxes = Array.isArray(result.data) ? result.data : [];
761
+ } else {
762
+ this.error = result.error || 'Failed to load sandboxes';
763
+ this.sandboxes = [];
764
+ }
765
+ } catch (err) {
766
+ this.error = err instanceof Error ? err.message : 'Unknown error';
767
+ this.sandboxes = [];
768
+ } finally {
769
+ this.loading = false;
770
+ this.sandboxesLoaded = true;
771
+ }
772
+ }
773
+
774
+ async forceRefresh(): Promise<void> {
775
+ this.sandboxes = [];
776
+ this.sandboxesLoaded = false;
777
+ this.error = undefined;
778
+ this.clearCaches();
779
+ await this.loadSandboxes();
780
+ this.refresh();
781
+ }
782
+
783
+ clearCaches(): void {
784
+ this.snapshotsCache.clear();
785
+ this.executionsCache.clear();
786
+ this.filesCache.clear();
787
+ }
788
+
789
+ /**
790
+ * Clear cache for a specific sandbox.
791
+ */
792
+ clearSandboxCache(sandboxId: string): void {
793
+ this.snapshotsCache.delete(sandboxId);
794
+ this.executionsCache.delete(sandboxId);
795
+
796
+ // Clear files cache for this sandbox
797
+ this.filesCache.delete(`${sandboxId}:root`);
798
+ }
799
+
800
+ dispose(): void {
801
+ this._onDidChangeTreeData.dispose();
802
+ }
803
+ }