@tuskydp/cli 0.2.0 → 0.3.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/src/commands/decrypt.d.ts.map +1 -1
  3. package/dist/src/commands/decrypt.js +53 -21
  4. package/dist/src/commands/decrypt.js.map +1 -1
  5. package/dist/src/commands/download.d.ts +1 -0
  6. package/dist/src/commands/download.d.ts.map +1 -1
  7. package/dist/src/commands/download.js +81 -11
  8. package/dist/src/commands/download.js.map +1 -1
  9. package/dist/src/commands/export.d.ts +6 -0
  10. package/dist/src/commands/export.d.ts.map +1 -1
  11. package/dist/src/commands/export.js +29 -17
  12. package/dist/src/commands/export.js.map +1 -1
  13. package/dist/src/commands/files.d.ts.map +1 -1
  14. package/dist/src/commands/files.js +89 -10
  15. package/dist/src/commands/files.js.map +1 -1
  16. package/dist/src/commands/folder.d.ts +3 -0
  17. package/dist/src/commands/folder.d.ts.map +1 -0
  18. package/dist/src/commands/folder.js +151 -0
  19. package/dist/src/commands/folder.js.map +1 -0
  20. package/dist/src/commands/rehydrate.d.ts +1 -0
  21. package/dist/src/commands/rehydrate.d.ts.map +1 -1
  22. package/dist/src/commands/rehydrate.js +15 -7
  23. package/dist/src/commands/rehydrate.js.map +1 -1
  24. package/dist/src/commands/trash.d.ts +3 -0
  25. package/dist/src/commands/trash.d.ts.map +1 -0
  26. package/dist/src/commands/trash.js +109 -0
  27. package/dist/src/commands/trash.js.map +1 -0
  28. package/dist/src/commands/upload.d.ts +4 -0
  29. package/dist/src/commands/upload.d.ts.map +1 -1
  30. package/dist/src/commands/upload.js +104 -3
  31. package/dist/src/commands/upload.js.map +1 -1
  32. package/dist/src/commands/wallet.d.ts +3 -0
  33. package/dist/src/commands/wallet.d.ts.map +1 -0
  34. package/dist/src/commands/wallet.js +126 -0
  35. package/dist/src/commands/wallet.js.map +1 -0
  36. package/dist/src/commands/webhook.d.ts +3 -0
  37. package/dist/src/commands/webhook.d.ts.map +1 -0
  38. package/dist/src/commands/webhook.js +172 -0
  39. package/dist/src/commands/webhook.js.map +1 -0
  40. package/dist/src/crypto.d.ts +16 -0
  41. package/dist/src/crypto.d.ts.map +1 -1
  42. package/dist/src/crypto.js +26 -0
  43. package/dist/src/crypto.js.map +1 -1
  44. package/dist/src/index.js +17 -3
  45. package/dist/src/index.js.map +1 -1
  46. package/dist/src/mcp/server.d.ts.map +1 -1
  47. package/dist/src/mcp/server.js +2 -1
  48. package/dist/src/mcp/server.js.map +1 -1
  49. package/dist/src/mcp/tools/files.d.ts.map +1 -1
  50. package/dist/src/mcp/tools/files.js +40 -5
  51. package/dist/src/mcp/tools/files.js.map +1 -1
  52. package/dist/src/seal.d.ts +16 -0
  53. package/dist/src/seal.d.ts.map +1 -1
  54. package/dist/src/seal.js +23 -0
  55. package/dist/src/seal.js.map +1 -1
  56. package/dist/src/tui/files-panel.d.ts +31 -1
  57. package/dist/src/tui/files-panel.d.ts.map +1 -1
  58. package/dist/src/tui/files-panel.js +118 -11
  59. package/dist/src/tui/files-panel.js.map +1 -1
  60. package/dist/src/tui/index.d.ts.map +1 -1
  61. package/dist/src/tui/index.js +272 -33
  62. package/dist/src/tui/index.js.map +1 -1
  63. package/dist/src/tui/overview.d.ts.map +1 -1
  64. package/dist/src/tui/overview.js +24 -8
  65. package/dist/src/tui/overview.js.map +1 -1
  66. package/dist/src/tui/trash-screen.d.ts +4 -0
  67. package/dist/src/tui/trash-screen.d.ts.map +1 -0
  68. package/dist/src/tui/trash-screen.js +190 -0
  69. package/dist/src/tui/trash-screen.js.map +1 -0
  70. package/dist/src/tui/vaults-panel.d.ts +8 -0
  71. package/dist/src/tui/vaults-panel.d.ts.map +1 -1
  72. package/dist/src/tui/vaults-panel.js +45 -6
  73. package/dist/src/tui/vaults-panel.js.map +1 -1
  74. package/dist/src/version.d.ts +2 -0
  75. package/dist/src/version.d.ts.map +1 -0
  76. package/dist/src/version.js +21 -0
  77. package/dist/src/version.js.map +1 -0
  78. package/package.json +3 -3
  79. package/src/commands/decrypt.ts +56 -23
  80. package/src/commands/download.ts +82 -11
  81. package/src/commands/export.ts +35 -19
  82. package/src/commands/files.ts +93 -9
  83. package/src/commands/folder.ts +169 -0
  84. package/src/commands/rehydrate.ts +15 -8
  85. package/src/commands/trash.ts +121 -0
  86. package/src/commands/upload.ts +126 -3
  87. package/src/commands/wallet.ts +183 -0
  88. package/src/commands/webhook.ts +193 -0
  89. package/src/crypto.ts +35 -0
  90. package/src/index.ts +17 -4
  91. package/src/mcp/server.ts +2 -1
  92. package/src/mcp/tools/files.ts +43 -5
  93. package/src/seal.ts +34 -1
  94. package/src/tui/files-panel.ts +139 -11
  95. package/src/tui/index.ts +289 -33
  96. package/src/tui/overview.ts +22 -8
  97. package/src/tui/trash-screen.ts +203 -0
  98. package/src/tui/vaults-panel.ts +55 -6
  99. package/src/version.ts +21 -0
  100. package/vitest.config.ts +1 -0
  101. package/dist/src/client.d.ts +0 -120
  102. package/dist/src/client.d.ts.map +0 -1
  103. package/dist/src/client.js +0 -152
  104. package/dist/src/client.js.map +0 -1
package/src/tui/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import blessed from 'blessed';
2
- import { readFileSync, statSync, readdirSync } from 'fs';
2
+ import { readFileSync, writeFileSync, statSync, readdirSync } from 'fs';
3
+ import { randomUUID } from 'crypto';
3
4
  import { basename, join, resolve } from 'path';
4
5
 
5
6
  function collectFiles(dir: string): string[] {
@@ -15,19 +16,21 @@ function collectFiles(dir: string): string[] {
15
16
  import { lookup } from 'mime-types';
16
17
  import { createSDKClient } from '../sdk.js';
17
18
  import { cliConfig, getApiUrl } from '../config.js';
18
- import { encryptBuffer } from '../crypto.js';
19
+ import { encryptBuffer, decryptBuffer } from '../crypto.js';
19
20
  import { loadMasterKey } from '../lib/keyring.js';
21
+ import { isSealConfigured, sealEncrypt, sealDecrypt, getSuiKeypair } from '../seal.js';
20
22
  import { showAuthScreen } from './auth-screen.js';
21
23
  import { VaultsPanel, VaultItem } from './vaults-panel.js';
22
24
  import { FilesPanel } from './files-panel.js';
23
25
  import { StatusBar } from './status-bar.js';
24
26
  import { showOverview } from './overview.js';
27
+ import { showTrashScreen } from './trash-screen.js';
25
28
  import { confirmDialog, textInputDialog, selectDialog, detailPopup, fileBrowserDialog } from './dialogs.js';
26
29
  import { formatBytes, formatDate, formatRow, showError, showMessage, statusColor, statusColorClose, getCurrentTheme, cycleTheme, Theme } from './helpers.js';
27
30
 
28
31
  function formatDetailTable(rows: [string, string][]): string {
29
32
  const labelW = Math.max(...rows.map(([l]) => l.length));
30
- return rows.map(([label, value]) => ` {bold}${label.padEnd(labelW)}{/bold} ${value}`).join('\n');
33
+ return rows.map(([label, value]) => ` {bold}${label.padEnd(labelW)}{/bold} | ${value}`).join('\n');
31
34
  }
32
35
 
33
36
  type FocusTarget = 'vaults' | 'files';
@@ -74,6 +77,7 @@ export async function launchTui() {
74
77
 
75
78
  const statusBar = new StatusBar(screen);
76
79
  const vaultsPanel = new VaultsPanel(screen, sdk, leftBox, (vault) => {
80
+ filesPanel.resetFolderNav();
77
81
  filesPanel.loadForVault(vault.id, vault.name);
78
82
  });
79
83
  const filesPanel = new FilesPanel(screen, sdk, rightBox);
@@ -85,11 +89,11 @@ export async function launchTui() {
85
89
  if (target === 'vaults') {
86
90
  vaultsPanel.focus();
87
91
  filesPanel.blur();
88
- statusBar.setHints('n:new ←→:switch u:upload o:overview ?:help');
92
+ statusBar.setHints('n:new <->:switch u:upload o:overview T:trash ?:help');
89
93
  } else {
90
94
  filesPanel.focus();
91
95
  vaultsPanel.blur();
92
- statusBar.setHints('u:upload d:delete r:retry ←→:switch Enter:detail ?:help');
96
+ statusBar.setHints('d:download u:upload f:folder R:remove r:retry T:trash ?:help');
93
97
  }
94
98
  }
95
99
 
@@ -124,28 +128,74 @@ export async function launchTui() {
124
128
  }
125
129
  });
126
130
 
131
+ // ── Create vault (n) — now with shared option ──────────────────────
127
132
  screen.key(['n'], async () => {
128
133
  const name = await textInputDialog(screen, 'Vault name');
129
134
  if (!name) {
130
135
  setFocus(currentFocus);
131
136
  return;
132
137
  }
133
- const visIdx = await selectDialog(screen, 'Visibility', ['private', 'public']);
138
+ const visIdx = await selectDialog(screen, 'Visibility', ['private', 'public', 'shared']);
134
139
  if (visIdx < 0) {
135
140
  setFocus(currentFocus);
136
141
  return;
137
142
  }
138
- const visibility = visIdx === 0 ? 'private' as const : 'public' as const;
143
+ const visMap = ['private', 'public', 'shared'] as const;
144
+ const visibility = visMap[visIdx];
145
+
139
146
  try {
140
- await sdk.vaults.create({ name, visibility });
147
+ if (visibility === 'shared') {
148
+ // Validate Sui address is linked
149
+ const acct = await sdk.account.get();
150
+ if (!(acct as any).suiAddress) {
151
+ showError(screen, 'Link a Sui address first: tusky account link-sui <addr>');
152
+ setFocus(currentFocus);
153
+ return;
154
+ }
155
+ }
156
+ const created = await sdk.vaults.create({ name, visibility });
141
157
  await vaultsPanel.load();
142
- showMessage(screen, `Vault "${name}" created`);
158
+ let msg = `Vault "${name}" created [${visibility}]`;
159
+ if (visibility === 'shared' && created.sealAllowlistObjectId) {
160
+ msg += `\nSEAL allowlist: ${created.sealAllowlistObjectId}`;
161
+ }
162
+ showMessage(screen, msg);
163
+ } catch (err: any) {
164
+ showError(screen, err.message);
165
+ }
166
+ setFocus(currentFocus);
167
+ });
168
+
169
+ // ── Create folder (f) ──────────────────────────────────────────────
170
+ screen.key(['f'], async () => {
171
+ if (currentFocus !== 'files') return;
172
+ const vaultId = filesPanel.getCurrentVaultId();
173
+ if (!vaultId) {
174
+ showError(screen, 'Select a vault first');
175
+ return;
176
+ }
177
+
178
+ const name = await textInputDialog(screen, 'Folder name');
179
+ if (!name) {
180
+ setFocus(currentFocus);
181
+ return;
182
+ }
183
+
184
+ try {
185
+ await sdk.folders.create({
186
+ vaultId,
187
+ parentId: filesPanel.getCurrentFolderId() || undefined,
188
+ name,
189
+ });
190
+ await filesPanel.loadForVault(vaultId, filesPanel.getCurrentVaultName());
191
+ showMessage(screen, `Folder "${name}" created`);
143
192
  } catch (err: any) {
144
193
  showError(screen, err.message);
145
194
  }
146
195
  setFocus(currentFocus);
147
196
  });
148
197
 
198
+ // ── Upload (u) — with SEAL support and folder awareness ────────────
149
199
  screen.key(['u'], async () => {
150
200
  const vaultId = filesPanel.getCurrentVaultId();
151
201
  if (!vaultId) {
@@ -176,6 +226,7 @@ export async function launchTui() {
176
226
  try {
177
227
  const vault = await sdk.vaults.get(vaultId);
178
228
  const isPrivate = vault.visibility === 'private';
229
+ const isShared = vault.visibility === 'shared' && isSealConfigured(vault);
179
230
  let masterKey: Buffer | null = null;
180
231
 
181
232
  if (isPrivate) {
@@ -187,8 +238,18 @@ export async function launchTui() {
187
238
  }
188
239
  }
189
240
 
241
+ if (isShared) {
242
+ const keypair = getSuiKeypair();
243
+ if (!keypair) {
244
+ showError(screen, 'TUSKYDP_SUI_PRIVATE_KEY env var required for shared vault uploads');
245
+ setFocus(currentFocus);
246
+ return;
247
+ }
248
+ }
249
+
190
250
  let successCount = 0;
191
251
  const total = filePaths.length;
252
+ const currentFolderId = filesPanel.getCurrentFolderId();
192
253
 
193
254
  for (const filePath of filePaths) {
194
255
  const fileName = basename(filePath);
@@ -205,6 +266,8 @@ export async function launchTui() {
205
266
  encryptionIv?: string;
206
267
  plaintextSizeBytes?: number;
207
268
  plaintextChecksumSha256?: string;
269
+ sealIdentity?: string;
270
+ sealEncryptedObject?: string;
208
271
  } = {};
209
272
 
210
273
  if (isPrivate && masterKey) {
@@ -216,6 +279,17 @@ export async function launchTui() {
216
279
  plaintextSizeBytes: stat.size,
217
280
  plaintextChecksumSha256: plaintextChecksum,
218
281
  };
282
+ } else if (isShared) {
283
+ statusBar.setHints(`SEAL encrypting ${successCount + 1}/${total}: ${fileName}...`);
284
+ screen.render();
285
+ const fileNonce = randomUUID();
286
+ const sealResult = await sealEncrypt(new Uint8Array(fileBuffer), vault, fileNonce);
287
+ uploadBody = Buffer.from(sealResult.encryptedData);
288
+ encryptionMeta = {
289
+ sealIdentity: sealResult.sealIdentity,
290
+ sealEncryptedObject: sealResult.sealEncryptedObject,
291
+ plaintextSizeBytes: stat.size,
292
+ };
219
293
  } else {
220
294
  uploadBody = fileBuffer;
221
295
  }
@@ -225,6 +299,7 @@ export async function launchTui() {
225
299
  mimeType,
226
300
  sizeBytes: uploadBody.length,
227
301
  vaultId,
302
+ ...(currentFolderId ? { folderId: currentFolderId } : {}),
228
303
  ...encryptionMeta,
229
304
  });
230
305
 
@@ -240,7 +315,8 @@ export async function launchTui() {
240
315
  successCount++;
241
316
  }
242
317
 
243
- showMessage(screen, `Uploaded ${successCount} file(s)`);
318
+ const label = isPrivate ? 'private' : isShared ? 'shared/SEAL' : 'public';
319
+ showMessage(screen, `Uploaded ${successCount} file(s) [${label}]`);
244
320
  await filesPanel.loadForVault(vaultId, filesPanel.getCurrentVaultName());
245
321
  await vaultsPanel.load();
246
322
  } catch (err: any) {
@@ -249,10 +325,115 @@ export async function launchTui() {
249
325
  setFocus(currentFocus);
250
326
  });
251
327
 
252
- screen.key(['d', 'delete'], async () => {
328
+ // ── Download (d) download selected file to local disk ─────────────
329
+ screen.key(['d'], async () => {
330
+ if (currentFocus !== 'files') return;
331
+ const file = filesPanel.getSelected();
332
+ if (!file) {
333
+ showError(screen, 'Select a file to download');
334
+ setFocus(currentFocus);
335
+ return;
336
+ }
337
+
338
+ statusBar.setHints(`Downloading "${file.name}"...`);
339
+ screen.render();
340
+
341
+ try {
342
+ // Get download URL + encryption info
343
+ let dlResponse = await sdk.files.getDownloadUrl(file.id);
344
+ let { downloadUrl, status: dlStatus, encryption } = dlResponse;
345
+
346
+ // Poll if rehydrating from cold storage
347
+ if (dlStatus === 'rehydrating') {
348
+ statusBar.setHints(`Rehydrating "${file.name}" from Walrus...`);
349
+ screen.render();
350
+ for (let i = 0; i < 30; i++) {
351
+ await new Promise(r => setTimeout(r, 2000));
352
+ const check = await sdk.files.getDownloadUrl(file.id);
353
+ if (check.status === 'ready') {
354
+ downloadUrl = check.downloadUrl;
355
+ encryption = check.encryption;
356
+ dlStatus = 'ready';
357
+ break;
358
+ }
359
+ }
360
+ if (dlStatus !== 'ready') {
361
+ showError(screen, 'File rehydration timed out. Try again later.');
362
+ setFocus(currentFocus);
363
+ return;
364
+ }
365
+ }
366
+
367
+ if (!downloadUrl) {
368
+ showError(screen, 'No download URL available.');
369
+ setFocus(currentFocus);
370
+ return;
371
+ }
372
+
373
+ // Download the bytes
374
+ statusBar.setHints(`Downloading "${file.name}"...`);
375
+ screen.render();
376
+ const response = await fetch(downloadUrl);
377
+ if (!response.ok) throw new Error(`Download failed: ${response.status}`);
378
+ const arrayBuf = await response.arrayBuffer();
379
+ let fileBuffer: Buffer = Buffer.from(new Uint8Array(arrayBuf));
380
+
381
+ // Decrypt if needed
382
+ if (encryption?.encrypted) {
383
+ if ('type' in encryption && encryption.type === 'seal') {
384
+ statusBar.setHints(`SEAL decrypting "${file.name}"...`);
385
+ screen.render();
386
+ const keypair = getSuiKeypair();
387
+ if (!keypair) {
388
+ showError(screen, 'TUSKYDP_SUI_PRIVATE_KEY env var required for shared vault files');
389
+ setFocus(currentFocus);
390
+ return;
391
+ }
392
+ const fileInfo = await sdk.files.get(file.id);
393
+ const vault = await sdk.vaults.get(fileInfo.vaultId);
394
+ const decrypted = await sealDecrypt(new Uint8Array(fileBuffer), vault, keypair);
395
+ fileBuffer = Buffer.from(decrypted);
396
+ } else {
397
+ statusBar.setHints(`Decrypting "${file.name}"...`);
398
+ screen.render();
399
+ const masterKey = loadMasterKey();
400
+ if (!masterKey) {
401
+ showError(screen, 'Encryption not unlocked. Run: tusky encryption unlock');
402
+ setFocus(currentFocus);
403
+ return;
404
+ }
405
+ const enc = encryption as { type: 'passphrase'; encrypted: true; wrappedKey: string; iv: string; plaintextChecksumSha256: string | null };
406
+ fileBuffer = decryptBuffer(
407
+ fileBuffer,
408
+ enc.wrappedKey,
409
+ enc.iv,
410
+ masterKey,
411
+ enc.plaintextChecksumSha256 ?? undefined,
412
+ );
413
+ }
414
+ }
415
+
416
+ // Write to disk
417
+ const outputPath = join(process.cwd(), file.name);
418
+ writeFileSync(outputPath, fileBuffer);
419
+
420
+ showMessage(screen, `Downloaded "${file.name}" (${formatBytes(fileBuffer.length)})\n-> ${outputPath}`);
421
+ } catch (err: any) {
422
+ showError(screen, err.message);
423
+ }
424
+ setFocus(currentFocus);
425
+ });
426
+
427
+ // ── Remove (R = Shift+r) — delete selected item ───────────────────
428
+ screen.key(['S-r'], async () => {
253
429
  if (currentFocus === 'vaults') {
254
430
  const vault = vaultsPanel.getSelected();
255
431
  if (!vault) return;
432
+ if (vault.isSharedMembership) {
433
+ showError(screen, 'Cannot delete a vault you are a member of (not owner)');
434
+ setFocus(currentFocus);
435
+ return;
436
+ }
256
437
  const confirmed = await confirmDialog(screen, `Delete vault "${vault.name}" and ALL its files?`);
257
438
  if (confirmed) {
258
439
  try {
@@ -266,27 +447,45 @@ export async function launchTui() {
266
447
  }
267
448
  setFocus(currentFocus);
268
449
  } else {
269
- const file = filesPanel.getSelected();
270
- if (!file) return;
271
- const confirmed = await confirmDialog(screen, `Delete file "${file.name}"?`);
272
- if (confirmed) {
273
- try {
274
- await sdk.files.delete(file.id);
275
- const vaultId = filesPanel.getCurrentVaultId();
276
- if (vaultId) {
277
- await filesPanel.loadForVault(vaultId, filesPanel.getCurrentVaultName());
278
- await vaultsPanel.load();
450
+ const item = filesPanel.getSelectedItem();
451
+ if (!item) return;
452
+
453
+ if (item.type === 'folder') {
454
+ const confirmed = await confirmDialog(screen, `Delete folder "${item.folder.name}"?`);
455
+ if (confirmed) {
456
+ try {
457
+ await sdk.folders.delete(item.folder.id);
458
+ const vaultId = filesPanel.getCurrentVaultId();
459
+ if (vaultId) {
460
+ await filesPanel.loadForVault(vaultId, filesPanel.getCurrentVaultName());
461
+ }
462
+ showMessage(screen, `Folder "${item.folder.name}" deleted`);
463
+ } catch (err: any) {
464
+ showError(screen, err.message);
465
+ }
466
+ }
467
+ } else if (item.type === 'file') {
468
+ const confirmed = await confirmDialog(screen, `Delete file "${item.file.name}"?`);
469
+ if (confirmed) {
470
+ try {
471
+ await sdk.files.delete(item.file.id);
472
+ const vaultId = filesPanel.getCurrentVaultId();
473
+ if (vaultId) {
474
+ await filesPanel.loadForVault(vaultId, filesPanel.getCurrentVaultName());
475
+ await vaultsPanel.load();
476
+ }
477
+ showMessage(screen, `File "${item.file.name}" moved to trash`);
478
+ } catch (err: any) {
479
+ showError(screen, err.message);
279
480
  }
280
- showMessage(screen, `File "${file.name}" deleted`);
281
- } catch (err: any) {
282
- showError(screen, err.message);
283
481
  }
284
482
  }
285
483
  setFocus(currentFocus);
286
484
  }
287
485
  });
288
486
 
289
- screen.key(['enter'], () => {
487
+ // ── Enter / Space — detail popup or folder navigation ──────────────
488
+ screen.key(['enter', 'space'], async () => {
290
489
  if (currentFocus === 'vaults') {
291
490
  const vault = vaultsPanel.getSelected();
292
491
  if (vault) {
@@ -298,11 +497,45 @@ export async function launchTui() {
298
497
  ['Files', String(vault.fileCount)],
299
498
  ['Size', formatBytes(vault.totalSizeBytes)],
300
499
  ];
500
+ if (vault.description) {
501
+ rows.push(['Description', vault.description]);
502
+ }
503
+ if (vault.createdAt) {
504
+ rows.push(['Created', formatDate(vault.createdAt)]);
505
+ }
506
+ if (vault.isSharedMembership) {
507
+ rows.push(['Your Role', vault.memberRole || 'member']);
508
+ }
509
+ if (vault.visibility === 'shared') {
510
+ rows.push(['', '']);
511
+ rows.push(['{cyan-fg}SEAL Config{/cyan-fg}', '']);
512
+ rows.push(['Allowlist', vault.sealAllowlistObjectId || 'N/A']);
513
+ rows.push(['Package', vault.sealPackageId || 'N/A']);
514
+ rows.push(['Threshold', String(vault.sealThreshold ?? 'N/A')]);
515
+ rows.push(['Key Servers', String(vault.sealKeyServerIds?.length ?? 0)]);
516
+ }
301
517
  detailPopup(screen, vault.name, formatDetailTable(rows));
302
518
  }
303
519
  } else {
304
- const file = filesPanel.getSelected();
305
- if (file) {
520
+ const item = filesPanel.getSelectedItem();
521
+ if (!item) return;
522
+
523
+ if (item.type === 'parent') {
524
+ // Navigate up
525
+ await filesPanel.navigateUp();
526
+ setFocus(currentFocus);
527
+ return;
528
+ }
529
+
530
+ if (item.type === 'folder') {
531
+ // Navigate into folder
532
+ await filesPanel.navigateToFolder(item.folder.id, item.folder.name);
533
+ setFocus(currentFocus);
534
+ return;
535
+ }
536
+
537
+ if (item.type === 'file') {
538
+ const file = item.file;
306
539
  const open = statusColor(file.status);
307
540
  const close = statusColorClose(file.status);
308
541
  const rows: [string, string][] = [
@@ -316,11 +549,21 @@ export async function launchTui() {
316
549
  ['Blob ID', file.walrusBlobId || 'N/A'],
317
550
  ['Blob Object', file.walrusBlobObjectId || 'N/A'],
318
551
  ];
552
+ if (file.folderId) {
553
+ rows.push(['Folder ID', file.folderId]);
554
+ }
555
+ if (file.autoRenew) {
556
+ rows.push(['Auto-Renew', '{green-fg}On{/green-fg}']);
557
+ }
558
+ if (file.ppuEpochEnd) {
559
+ rows.push(['PPU Epoch', formatDate(file.ppuEpochEnd)]);
560
+ }
319
561
  detailPopup(screen, file.name, formatDetailTable(rows));
320
562
  }
321
563
  }
322
564
  });
323
565
 
566
+ // ── Retry (r) ──────────────────────────────────────────────────────
324
567
  screen.key(['r'], async () => {
325
568
  if (currentFocus === 'files') {
326
569
  const file = filesPanel.getSelected();
@@ -343,11 +586,21 @@ export async function launchTui() {
343
586
  }
344
587
  });
345
588
 
589
+ // ── Overview (o) ───────────────────────────────────────────────────
346
590
  screen.key(['o'], async () => {
347
591
  await showOverview(screen, sdk);
348
592
  setFocus(currentFocus);
349
593
  });
350
594
 
595
+ // ── Trash (T = Shift+t) ───────────────────────────────────────────
596
+ screen.key(['S-t'], async () => {
597
+ await showTrashScreen(screen, sdk);
598
+ // Reload vaults panel in case restores happened
599
+ await vaultsPanel.load();
600
+ setFocus(currentFocus);
601
+ });
602
+
603
+ // ── Logout (L = Shift+l) ──────────────────────────────────────────
351
604
  screen.key(['S-l'], async () => {
352
605
  const confirmed = await confirmDialog(screen, 'Log out?');
353
606
  if (confirmed) {
@@ -388,19 +641,22 @@ export async function launchTui() {
388
641
  '{bold}{cyan-fg}Tusky TUI — Keyboard Shortcuts{/cyan-fg}{/bold}',
389
642
  '',
390
643
  '{bold}Navigation{/bold}',
391
- ' ←/→ Switch between vaults/files',
644
+ ' <-/-> Switch between vaults/files',
392
645
  ' Tab Switch between vaults/files',
393
646
  ' Up/Down Navigate list',
647
+ ' Enter Open folder / view details',
394
648
  '',
395
649
  '{bold}Actions{/bold}',
396
- ' n Create new vault',
397
- ' u Upload file to current vault',
398
- ' d/Delete Delete selected item',
399
- ' r Retry failed file',
400
- ' Space/Enter View details',
650
+ ' n Create new vault (private/public/shared)',
651
+ ' f Create folder in current vault',
652
+ ' u Upload file to current vault/folder',
653
+ ' d Download selected file',
654
+ ' R Remove selected item (trash for files)',
655
+ ' r Retry failed file sync',
401
656
  '',
402
657
  '{bold}Screens{/bold}',
403
658
  ' o Overview (account, storage, files)',
659
+ ' T Trash management (list, restore, delete)',
404
660
  ' t Cycle theme',
405
661
  ' L Logout',
406
662
  ' ? This help',
@@ -74,13 +74,27 @@ export async function showOverview(
74
74
 
75
75
  if (vaults.length > 0) {
76
76
  // Calculate column widths for vaults table
77
- const vaultRows = vaults.map((v) => ({
78
- name: v.name || '',
79
- vis: v.visibility === 'private' ? 'private' : 'public',
80
- visTag: v.visibility === 'private' ? '{yellow-fg}private{/yellow-fg}' : '{green-fg}public{/green-fg}',
81
- files: String(v.fileCount || 0),
82
- size: formatBytes(v.totalSizeBytes || 0),
83
- }));
77
+ const vaultRows = vaults.map((v) => {
78
+ let vis: string;
79
+ let visTag: string;
80
+ if (v.visibility === 'private') {
81
+ vis = 'private';
82
+ visTag = '{yellow-fg}private{/yellow-fg}';
83
+ } else if (v.visibility === 'shared') {
84
+ vis = 'shared';
85
+ visTag = '{magenta-fg}shared{/magenta-fg}';
86
+ } else {
87
+ vis = 'public';
88
+ visTag = '{green-fg}public{/green-fg}';
89
+ }
90
+ return {
91
+ name: v.name || '',
92
+ vis,
93
+ visTag,
94
+ files: String(v.fileCount || 0),
95
+ size: formatBytes(v.totalSizeBytes || 0),
96
+ };
97
+ });
84
98
  const vNameW = Math.max(4, ...vaultRows.map((r) => r.name.length));
85
99
  const vVisW = 7; // "private" is longest
86
100
  const vFilesW = Math.max(5, ...vaultRows.map((r) => r.files.length));
@@ -122,7 +136,7 @@ export async function showOverview(
122
136
  content += ` ${'Name'.padEnd(maxNameW)} ${'Size'.padStart(fSizeW)} ${'Status'.padEnd(fStatW)} ${'Date'.padEnd(fDateW)}\n`;
123
137
  content += ` ${'─'.repeat(maxNameW)} ${'─'.repeat(fSizeW)} ${'─'.repeat(fStatW)} ${'─'.repeat(fDateW)}\n`;
124
138
  for (const r of fileRows) {
125
- const truncName = r.name.length > maxNameW ? r.name.slice(0, maxNameW - 1) + '' : r.name;
139
+ const truncName = r.name.length > maxNameW ? r.name.slice(0, maxNameW - 1) + '...' : r.name;
126
140
  const statusPad = ' '.repeat(Math.max(0, fStatW - r.status.length));
127
141
  content += ` ${truncName.padEnd(maxNameW)} ${r.size.padStart(fSizeW)} ${r.statusTag}${statusPad} ${r.date}\n`;
128
142
  }