aethel 0.1.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/src/tui/app.js ADDED
@@ -0,0 +1,1025 @@
1
+ import path from "node:path";
2
+ import React, { useEffect, useMemo, useState } from "react";
3
+ import { Box, Text, useApp, useInput } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import TextInput from "ink-text-input";
6
+ import {
7
+ batchOperateFiles,
8
+ getAccountInfo,
9
+ humanSize,
10
+ iconForMime,
11
+ listAccessibleFiles,
12
+ sourceBadgeForItem,
13
+ syncLocalDirectoryToParent,
14
+ uploadLocalEntry,
15
+ } from "../core/drive-api.js";
16
+ import {
17
+ defaultLocalRoot,
18
+ deleteLocalEntry,
19
+ ensureLocalDirectory,
20
+ listLocalEntries,
21
+ renameLocalEntry,
22
+ } from "../core/local-fs.js";
23
+
24
+ const h = React.createElement;
25
+
26
+ function truncate(value, width) {
27
+ if (value.length <= width) {
28
+ return value;
29
+ }
30
+
31
+ if (width <= 1) {
32
+ return value.slice(0, width);
33
+ }
34
+
35
+ return `${value.slice(0, width - 1)}…`;
36
+ }
37
+
38
+ function scrollWindow(length, cursor, height) {
39
+ return Math.min(
40
+ Math.max(cursor - Math.floor(height / 2), 0),
41
+ Math.max(length - height, 0)
42
+ );
43
+ }
44
+
45
+ function renderRemoteRow(file, isCursor, isSelected, width) {
46
+ const mark = isSelected ? "[x]" : "[ ]";
47
+ const sourceBadge = sourceBadgeForItem(file);
48
+ const icon = iconForMime(file.mimeType || "");
49
+ const size = humanSize(file.size);
50
+ const line = truncate(
51
+ `${mark} ${sourceBadge} ${icon} ${size} ${file.name}`,
52
+ width
53
+ );
54
+
55
+ return h(
56
+ Text,
57
+ {
58
+ key: file.id,
59
+ inverse: isCursor,
60
+ color: isSelected ? "cyan" : undefined,
61
+ bold: isSelected,
62
+ wrap: "truncate-end",
63
+ },
64
+ line
65
+ );
66
+ }
67
+
68
+ function renderLocalRow(entry, isCursor, width) {
69
+ const icon = entry.isDirectory ? "[DIR]" : "[FIL]";
70
+ const line = truncate(`[LOC] ${icon} ${entry.sizeLabel} ${entry.name}`, width);
71
+
72
+ return h(
73
+ Text,
74
+ {
75
+ key: entry.id,
76
+ inverse: isCursor,
77
+ color: entry.isDirectory ? "green" : undefined,
78
+ bold: entry.isDirectory,
79
+ wrap: "truncate-end",
80
+ },
81
+ line
82
+ );
83
+ }
84
+
85
+ function renderPane({
86
+ title,
87
+ breadcrumb,
88
+ focused,
89
+ entries,
90
+ cursor,
91
+ renderer,
92
+ width,
93
+ height,
94
+ emptyMessage,
95
+ }) {
96
+ const bodyHeight = Math.max(height - 3, 1);
97
+ const start = scrollWindow(entries.length, cursor, bodyHeight);
98
+ const visibleEntries = entries.slice(start, start + bodyHeight);
99
+
100
+ return h(
101
+ Box,
102
+ {
103
+ width,
104
+ flexDirection: "column",
105
+ paddingRight: 1,
106
+ },
107
+ h(
108
+ Text,
109
+ {
110
+ bold: true,
111
+ color: focused ? "cyan" : "white",
112
+ },
113
+ truncate(`${focused ? ">" : " "} ${title}`, width)
114
+ ),
115
+ h(Text, { dimColor: true }, truncate(breadcrumb, width)),
116
+ ...(visibleEntries.length
117
+ ? visibleEntries.map((entry, index) =>
118
+ renderer(entry, start + index === cursor, width)
119
+ )
120
+ : [
121
+ h(
122
+ Text,
123
+ { key: `${title}-empty`, dimColor: true },
124
+ truncate(emptyMessage, width)
125
+ ),
126
+ ])
127
+ );
128
+ }
129
+
130
+ function renderHelp() {
131
+ return h(
132
+ Box,
133
+ {
134
+ borderStyle: "round",
135
+ borderColor: "cyan",
136
+ paddingX: 1,
137
+ marginTop: 1,
138
+ flexDirection: "column",
139
+ },
140
+ h(Text, { bold: true }, "Aethel TUI - Keyboard Shortcuts"),
141
+ h(Text, null, "Tab Switch focus between Drive and Local panes"),
142
+ h(Text, null, "Up/Down, j/k Navigate the focused pane"),
143
+ h(Text, null, "Left/Right Move to parent or enter directory in focused pane"),
144
+ h(Text, null, "u Upload selected local entry to current Drive directory"),
145
+ h(Text, null, "s Sync selected local directory contents to current Drive directory"),
146
+ h(Text, null, "n Rename selected local file or directory"),
147
+ h(Text, null, "x Delete selected local file or directory"),
148
+ h(Text, null, "Space Toggle Drive selection in Drive pane"),
149
+ h(Text, null, "t / d Trash or permanently delete selected Drive items"),
150
+ h(Text, null, "/ Filter the focused pane"),
151
+ h(Text, null, "U Manually enter a local path and upload"),
152
+ h(Text, null, "r Reload Drive and Local panes"),
153
+ h(Text, null, "q Quit"),
154
+ h(Text, { dimColor: true }, "Press any key to close this help.")
155
+ );
156
+ }
157
+
158
+ export function AethelTui({ drive, includeSharedDrives = false }) {
159
+ const { exit } = useApp();
160
+ const [mode, setMode] = useState("loading");
161
+ const [remoteLoading, setRemoteLoading] = useState(true);
162
+ const [focusPane, setFocusPane] = useState("local");
163
+ const [account, setAccount] = useState(null);
164
+ const [remoteFiles, setRemoteFiles] = useState([]);
165
+ const [remoteFolderStack, setRemoteFolderStack] = useState([]);
166
+ const [localRoot] = useState(defaultLocalRoot);
167
+ const [localDirectory, setLocalDirectory] = useState(defaultLocalRoot);
168
+ const [localEntries, setLocalEntries] = useState([]);
169
+ const [remoteFilter, setRemoteFilter] = useState("");
170
+ const [localFilter, setLocalFilter] = useState("");
171
+ const [remoteCursor, setRemoteCursor] = useState(0);
172
+ const [localCursor, setLocalCursor] = useState(0);
173
+ const [selectedRemoteIds, setSelectedRemoteIds] = useState(() => new Set());
174
+ const [status, setStatus] = useState("Loading account info...");
175
+ const [errorMessage, setErrorMessage] = useState("");
176
+ const [pendingAction, setPendingAction] = useState(null);
177
+ const [inputValue, setInputValue] = useState("");
178
+ const width = process.stdout.columns || 80;
179
+ const height = process.stdout.rows || 24;
180
+ const currentRemoteFolderId = remoteFolderStack.length
181
+ ? remoteFolderStack[remoteFolderStack.length - 1].id
182
+ : null;
183
+ const currentUploadParentId = currentRemoteFolderId || "root";
184
+
185
+ const remoteFolderIds = useMemo(
186
+ () =>
187
+ new Set(
188
+ remoteFiles.filter((file) => file.isFolder).map((file) => file.id)
189
+ ),
190
+ [remoteFiles]
191
+ );
192
+
193
+ const currentRemoteDirectoryEntries = useMemo(() => {
194
+ const entries = remoteFiles.filter((file) => {
195
+ if (!currentRemoteFolderId) {
196
+ return (
197
+ file.isRootLevel ||
198
+ !file.parentId ||
199
+ !remoteFolderIds.has(file.parentId)
200
+ );
201
+ }
202
+
203
+ return file.parentId === currentRemoteFolderId;
204
+ });
205
+
206
+ return entries.sort((left, right) => {
207
+ if (left.isFolder !== right.isFolder) {
208
+ return left.isFolder ? -1 : 1;
209
+ }
210
+
211
+ return left.name.localeCompare(right.name);
212
+ });
213
+ }, [currentRemoteFolderId, remoteFiles, remoteFolderIds]);
214
+
215
+ const filteredRemoteEntries = useMemo(() => {
216
+ const query = remoteFilter.trim().toLowerCase();
217
+ if (!query) {
218
+ return currentRemoteDirectoryEntries;
219
+ }
220
+
221
+ return currentRemoteDirectoryEntries.filter((file) =>
222
+ file.name.toLowerCase().includes(query)
223
+ );
224
+ }, [currentRemoteDirectoryEntries, remoteFilter]);
225
+
226
+ const filteredLocalEntries = useMemo(() => {
227
+ const query = localFilter.trim().toLowerCase();
228
+ if (!query) {
229
+ return localEntries;
230
+ }
231
+
232
+ return localEntries.filter((entry) =>
233
+ entry.name.toLowerCase().includes(query)
234
+ );
235
+ }, [localEntries, localFilter]);
236
+
237
+ const currentLocalEntry = filteredLocalEntries[localCursor] || null;
238
+ const currentRemoteEntry = filteredRemoteEntries[remoteCursor] || null;
239
+ const currentRemoteFolderMeta = currentRemoteFolderId
240
+ ? remoteFiles.find((file) => file.id === currentRemoteFolderId) || null
241
+ : null;
242
+ const currentRemoteFolderWritable =
243
+ !currentRemoteFolderMeta ||
244
+ currentRemoteFolderMeta.capabilities?.canAddChildren !== false;
245
+
246
+ useEffect(() => {
247
+ setRemoteCursor((current) =>
248
+ Math.min(current, Math.max(filteredRemoteEntries.length - 1, 0))
249
+ );
250
+ }, [filteredRemoteEntries.length]);
251
+
252
+ useEffect(() => {
253
+ setLocalCursor((current) =>
254
+ Math.min(current, Math.max(filteredLocalEntries.length - 1, 0))
255
+ );
256
+ }, [filteredLocalEntries.length]);
257
+
258
+ async function loadLocalPane(nextDirectory = localDirectory) {
259
+ const resolvedDirectory = await ensureLocalDirectory(nextDirectory);
260
+ const items = await listLocalEntries(resolvedDirectory);
261
+ setLocalDirectory(resolvedDirectory);
262
+ setLocalEntries(items);
263
+ setLocalCursor(0);
264
+ }
265
+
266
+ async function loadAllData(nextStatus = "", preserveRemoteFolder = false) {
267
+ const nextRemoteFolderStack = preserveRemoteFolder ? [...remoteFolderStack] : [];
268
+ setRemoteLoading(true);
269
+
270
+ // Load local pane immediately — it's instant (disk I/O only)
271
+ try {
272
+ await loadLocalPane(localDirectory);
273
+ // Switch to normal mode so the user can browse local files while remote loads
274
+ setMode("normal");
275
+ setStatus("Loading Drive files...");
276
+ } catch (error) {
277
+ setErrorMessage(error.message);
278
+ setMode("error");
279
+ return;
280
+ }
281
+
282
+ // Fetch remote in background
283
+ try {
284
+ const [nextAccount, nextRemoteFiles] = await Promise.all([
285
+ getAccountInfo(drive),
286
+ listAccessibleFiles(drive, includeSharedDrives),
287
+ ]);
288
+
289
+ setAccount(nextAccount);
290
+ setRemoteFiles(nextRemoteFiles);
291
+ setRemoteFolderStack(nextRemoteFolderStack);
292
+ setSelectedRemoteIds(new Set());
293
+ setRemoteLoading(false);
294
+ setStatus(
295
+ nextStatus || `Loaded ${nextRemoteFiles.length} Drive item(s). Press ? for help.`
296
+ );
297
+ } catch (error) {
298
+ setRemoteLoading(false);
299
+ setStatus(`Drive load failed: ${error.message}. Local pane is still usable.`);
300
+ }
301
+ }
302
+
303
+ useEffect(() => {
304
+ void loadAllData();
305
+ }, []);
306
+
307
+ async function executeRemoteDelete(permanent) {
308
+ const targets = remoteFiles.filter((file) => selectedRemoteIds.has(file.id));
309
+ if (targets.length === 0) {
310
+ setStatus("No Drive items selected.");
311
+ return;
312
+ }
313
+
314
+ setMode("busy");
315
+ try {
316
+ const result = await batchOperateFiles(drive, targets, {
317
+ permanent,
318
+ includeSharedDrives,
319
+ onProgress: (done, total, verb, name) => {
320
+ setStatus(`[${done}/${total}] ${verb}: ${name}`);
321
+ },
322
+ });
323
+
324
+ await loadAllData(
325
+ `Done: ${result.success} succeeded, ${result.errors} failed.`,
326
+ true
327
+ );
328
+ } catch (error) {
329
+ setErrorMessage(error.message);
330
+ setMode("error");
331
+ }
332
+ }
333
+
334
+ async function executeUpload(targetPath) {
335
+ const trimmedPath = targetPath.trim();
336
+ if (!trimmedPath) {
337
+ setMode("normal");
338
+ setStatus("Upload cancelled.");
339
+ return;
340
+ }
341
+
342
+ setMode("busy");
343
+ try {
344
+ const result = await uploadLocalEntry(
345
+ drive,
346
+ trimmedPath,
347
+ currentUploadParentId,
348
+ (verb, localPath, name) => {
349
+ if (verb === "mkdir") {
350
+ setStatus(`Creating remote directory: ${name}`);
351
+ return;
352
+ }
353
+ setStatus(`Uploading: ${name} (${localPath})`);
354
+ }
355
+ );
356
+
357
+ setInputValue("");
358
+ await loadAllData(
359
+ `Uploaded ${result.uploadedFiles} file(s) and ${result.uploadedDirectories} director${result.uploadedDirectories === 1 ? "y" : "ies"}.`,
360
+ true
361
+ );
362
+ } catch (error) {
363
+ setMode("normal");
364
+ setStatus(`Upload failed: ${error.message}`);
365
+ }
366
+ }
367
+
368
+ async function executeSyncDirectory(targetPath) {
369
+ setMode("busy");
370
+ try {
371
+ const result = await syncLocalDirectoryToParent(
372
+ drive,
373
+ targetPath,
374
+ currentUploadParentId,
375
+ (verb, localPath, name) => {
376
+ if (verb === "mkdir") {
377
+ setStatus(`Sync mkdir: ${name}`);
378
+ return;
379
+ }
380
+ setStatus(`Sync upload: ${name} (${localPath})`);
381
+ }
382
+ );
383
+
384
+ await loadAllData(
385
+ `Synced ${result.uploadedFiles} file(s) and ${result.uploadedDirectories} director${result.uploadedDirectories === 1 ? "y" : "ies"} into current Drive directory.`,
386
+ true
387
+ );
388
+ } catch (error) {
389
+ setMode("normal");
390
+ setStatus(`Sync failed: ${error.message}`);
391
+ }
392
+ }
393
+
394
+ async function executeLocalDelete(targetPath) {
395
+ setMode("busy");
396
+ try {
397
+ await deleteLocalEntry(targetPath);
398
+ await loadLocalPane(path.dirname(targetPath) === targetPath ? localDirectory : localDirectory);
399
+ setMode("normal");
400
+ setStatus(`Deleted local entry: ${path.basename(targetPath)}`);
401
+ } catch (error) {
402
+ setMode("normal");
403
+ setStatus(`Local delete failed: ${error.message}`);
404
+ }
405
+ }
406
+
407
+ async function executeLocalRename(targetPath, nextName) {
408
+ setMode("busy");
409
+ try {
410
+ const renamedPath = await renameLocalEntry(targetPath, nextName);
411
+ await loadLocalPane(path.dirname(renamedPath));
412
+ setMode("normal");
413
+ setStatus(`Renamed local entry to: ${path.basename(renamedPath)}`);
414
+ } catch (error) {
415
+ setMode("normal");
416
+ setStatus(`Local rename failed: ${error.message}`);
417
+ }
418
+ }
419
+
420
+ async function openLocalDirectory(nextPath) {
421
+ try {
422
+ await loadLocalPane(nextPath);
423
+ setLocalFilter("");
424
+ setStatus(`Opened local directory: ${path.basename(nextPath) || nextPath}`);
425
+ } catch (error) {
426
+ setStatus(`Local directory error: ${error.message}`);
427
+ }
428
+ }
429
+
430
+ function switchFocus() {
431
+ setFocusPane((current) => (current === "local" ? "remote" : "local"));
432
+ setStatus(
433
+ focusPane === "local"
434
+ ? "Switched focus to Drive pane."
435
+ : "Switched focus to Local pane."
436
+ );
437
+ }
438
+
439
+ useInput((input, key) => {
440
+ if ((key.ctrl && input === "c") || (mode === "error" && input === "q")) {
441
+ exit();
442
+ return;
443
+ }
444
+
445
+ if (mode === "loading" || mode === "busy") {
446
+ return;
447
+ }
448
+
449
+ if (mode === "error") {
450
+ if (key.escape || input === "q" || input === "Q") {
451
+ exit();
452
+ }
453
+ return;
454
+ }
455
+
456
+ if (mode === "help") {
457
+ setMode("normal");
458
+ return;
459
+ }
460
+
461
+ if (mode === "confirm") {
462
+ if (input === "y" || input === "Y") {
463
+ const action = pendingAction;
464
+ setPendingAction(null);
465
+ if (!action) {
466
+ setMode("normal");
467
+ return;
468
+ }
469
+
470
+ if (action.type === "remote-trash") {
471
+ void executeRemoteDelete(false);
472
+ return;
473
+ }
474
+
475
+ if (action.type === "remote-delete") {
476
+ void executeRemoteDelete(true);
477
+ return;
478
+ }
479
+
480
+ if (action.type === "local-delete") {
481
+ void executeLocalDelete(action.targetPath);
482
+ return;
483
+ }
484
+
485
+ if (action.type === "local-sync") {
486
+ void executeSyncDirectory(action.targetPath);
487
+ return;
488
+ }
489
+ }
490
+
491
+ if (input === "n" || input === "N" || key.escape) {
492
+ setPendingAction(null);
493
+ setMode("normal");
494
+ setStatus("Cancelled.");
495
+ }
496
+ return;
497
+ }
498
+
499
+ if (mode === "filter") {
500
+ if (key.escape) {
501
+ if (focusPane === "local") {
502
+ setLocalFilter("");
503
+ } else {
504
+ setRemoteFilter("");
505
+ }
506
+ setMode("normal");
507
+ setStatus("Filter cleared.");
508
+ return;
509
+ }
510
+
511
+ if (key.return) {
512
+ setMode("normal");
513
+ setStatus("Filter applied.");
514
+ return;
515
+ }
516
+
517
+ if (key.backspace || input === "\u007f") {
518
+ if (focusPane === "local") {
519
+ setLocalFilter((current) => current.slice(0, -1));
520
+ } else {
521
+ setRemoteFilter((current) => current.slice(0, -1));
522
+ }
523
+ return;
524
+ }
525
+
526
+ if (input && !key.ctrl && !key.meta) {
527
+ if (focusPane === "local") {
528
+ setLocalFilter((current) => current + input);
529
+ } else {
530
+ setRemoteFilter((current) => current + input);
531
+ }
532
+ }
533
+ return;
534
+ }
535
+
536
+ if (mode === "upload") {
537
+ if (key.escape) {
538
+ setInputValue("");
539
+ setMode("normal");
540
+ setStatus("Manual upload cancelled.");
541
+ }
542
+ return;
543
+ }
544
+
545
+ if (mode === "rename") {
546
+ if (key.escape) {
547
+ setInputValue("");
548
+ setPendingAction(null);
549
+ setMode("normal");
550
+ setStatus("Rename cancelled.");
551
+ }
552
+ return;
553
+ }
554
+
555
+ if (input === "q" || input === "Q") {
556
+ exit();
557
+ return;
558
+ }
559
+
560
+ if (key.tab || input === "\t") {
561
+ switchFocus();
562
+ return;
563
+ }
564
+
565
+ if (input === "?") {
566
+ setMode("help");
567
+ return;
568
+ }
569
+
570
+ if (input === "/") {
571
+ setMode("filter");
572
+ setStatus(`Type to filter the ${focusPane} pane.`);
573
+ return;
574
+ }
575
+
576
+ if (input === "r" || input === "R") {
577
+ void loadAllData("Reloaded Drive and Local panes.", true);
578
+ return;
579
+ }
580
+
581
+ if (key.upArrow || input === "k") {
582
+ if (focusPane === "local") {
583
+ setLocalCursor((current) => Math.max(current - 1, 0));
584
+ } else {
585
+ setRemoteCursor((current) => Math.max(current - 1, 0));
586
+ }
587
+ return;
588
+ }
589
+
590
+ if (key.downArrow || input === "j") {
591
+ if (focusPane === "local") {
592
+ setLocalCursor((current) =>
593
+ Math.min(current + 1, Math.max(filteredLocalEntries.length - 1, 0))
594
+ );
595
+ } else {
596
+ setRemoteCursor((current) =>
597
+ Math.min(current + 1, Math.max(filteredRemoteEntries.length - 1, 0))
598
+ );
599
+ }
600
+ return;
601
+ }
602
+
603
+ if (key.pageUp) {
604
+ const delta = Math.max(height - 13, 1);
605
+ if (focusPane === "local") {
606
+ setLocalCursor((current) => Math.max(current - delta, 0));
607
+ } else {
608
+ setRemoteCursor((current) => Math.max(current - delta, 0));
609
+ }
610
+ return;
611
+ }
612
+
613
+ if (key.pageDown) {
614
+ const delta = Math.max(height - 13, 1);
615
+ if (focusPane === "local") {
616
+ setLocalCursor((current) =>
617
+ Math.min(current + delta, Math.max(filteredLocalEntries.length - 1, 0))
618
+ );
619
+ } else {
620
+ setRemoteCursor((current) =>
621
+ Math.min(current + delta, Math.max(filteredRemoteEntries.length - 1, 0))
622
+ );
623
+ }
624
+ return;
625
+ }
626
+
627
+ if (key.home) {
628
+ if (focusPane === "local") {
629
+ setLocalCursor(0);
630
+ } else {
631
+ setRemoteCursor(0);
632
+ }
633
+ return;
634
+ }
635
+
636
+ if (key.end) {
637
+ if (focusPane === "local") {
638
+ setLocalCursor(Math.max(filteredLocalEntries.length - 1, 0));
639
+ } else {
640
+ setRemoteCursor(Math.max(filteredRemoteEntries.length - 1, 0));
641
+ }
642
+ return;
643
+ }
644
+
645
+ if (key.leftArrow) {
646
+ if (focusPane === "local") {
647
+ if (
648
+ localDirectory === localRoot ||
649
+ localDirectory === path.dirname(localDirectory)
650
+ ) {
651
+ setStatus("Already at the local root directory.");
652
+ return;
653
+ }
654
+ void openLocalDirectory(path.dirname(localDirectory));
655
+ return;
656
+ }
657
+
658
+ if (remoteFolderStack.length === 0) {
659
+ setStatus("Already at the Drive root directory.");
660
+ return;
661
+ }
662
+
663
+ setRemoteFolderStack((current) => current.slice(0, -1));
664
+ setRemoteCursor(0);
665
+ setRemoteFilter("");
666
+ setStatus("Moved to the parent Drive directory.");
667
+ return;
668
+ }
669
+
670
+ if (key.rightArrow) {
671
+ if (focusPane === "local") {
672
+ if (!currentLocalEntry) {
673
+ setStatus("No local item is selected.");
674
+ return;
675
+ }
676
+
677
+ if (!currentLocalEntry.isDirectory) {
678
+ setStatus("The selected local item is not a directory.");
679
+ return;
680
+ }
681
+
682
+ void openLocalDirectory(currentLocalEntry.absolutePath);
683
+ return;
684
+ }
685
+
686
+ if (!currentRemoteEntry) {
687
+ setStatus("No Drive item is selected.");
688
+ return;
689
+ }
690
+
691
+ if (!currentRemoteEntry.isFolder) {
692
+ setStatus("The selected Drive item is not a directory.");
693
+ return;
694
+ }
695
+
696
+ setRemoteFolderStack((current) => [
697
+ ...current,
698
+ { id: currentRemoteEntry.id, name: currentRemoteEntry.name },
699
+ ]);
700
+ setRemoteCursor(0);
701
+ setRemoteFilter("");
702
+ setStatus(`Entered Drive directory: ${currentRemoteEntry.name}`);
703
+ return;
704
+ }
705
+
706
+ if (focusPane === "local") {
707
+ if (input === "u" || input === "U") {
708
+ if (!currentLocalEntry) {
709
+ setStatus("No local item is selected.");
710
+ return;
711
+ }
712
+ if (!currentRemoteFolderWritable) {
713
+ setStatus(
714
+ `Cannot upload into current Drive directory${
715
+ currentRemoteFolderMeta?.name ? `: ${currentRemoteFolderMeta.name}` : ""
716
+ }. This folder does not allow adding children.`
717
+ );
718
+ return;
719
+ }
720
+ void executeUpload(currentLocalEntry.absolutePath);
721
+ return;
722
+ }
723
+
724
+ if (input === "s" || input === "S") {
725
+ if (!currentLocalEntry) {
726
+ setStatus("No local directory is selected.");
727
+ return;
728
+ }
729
+ if (!currentLocalEntry.isDirectory) {
730
+ setStatus("Batch sync requires a local directory.");
731
+ return;
732
+ }
733
+ if (!currentRemoteFolderWritable) {
734
+ setStatus(
735
+ `Cannot sync into current Drive directory${
736
+ currentRemoteFolderMeta?.name ? `: ${currentRemoteFolderMeta.name}` : ""
737
+ }. This folder does not allow adding children.`
738
+ );
739
+ return;
740
+ }
741
+
742
+ setPendingAction({
743
+ type: "local-sync",
744
+ targetPath: currentLocalEntry.absolutePath,
745
+ });
746
+ setMode("confirm");
747
+ setStatus(
748
+ `Press y to sync contents of ${currentLocalEntry.name} into current Drive directory.`
749
+ );
750
+ return;
751
+ }
752
+
753
+ if (input === "n" || input === "N") {
754
+ if (!currentLocalEntry) {
755
+ setStatus("No local item is selected.");
756
+ return;
757
+ }
758
+
759
+ setPendingAction({
760
+ type: "local-rename",
761
+ targetPath: currentLocalEntry.absolutePath,
762
+ });
763
+ setInputValue(currentLocalEntry.name);
764
+ setMode("rename");
765
+ setStatus(`Enter a new name for ${currentLocalEntry.name}.`);
766
+ return;
767
+ }
768
+
769
+ if (input === "x" || input === "X") {
770
+ if (!currentLocalEntry) {
771
+ setStatus("No local item is selected.");
772
+ return;
773
+ }
774
+
775
+ setPendingAction({
776
+ type: "local-delete",
777
+ targetPath: currentLocalEntry.absolutePath,
778
+ });
779
+ setMode("confirm");
780
+ setStatus(
781
+ `Press y to delete local ${currentLocalEntry.isDirectory ? "directory" : "file"} ${currentLocalEntry.name}.`
782
+ );
783
+ return;
784
+ }
785
+
786
+ return;
787
+ }
788
+
789
+ if (input === " ") {
790
+ if (!currentRemoteEntry) {
791
+ return;
792
+ }
793
+
794
+ setSelectedRemoteIds((current) => {
795
+ const next = new Set(current);
796
+ if (next.has(currentRemoteEntry.id)) {
797
+ next.delete(currentRemoteEntry.id);
798
+ } else {
799
+ next.add(currentRemoteEntry.id);
800
+ }
801
+ return next;
802
+ });
803
+ return;
804
+ }
805
+
806
+ if (input === "a" || input === "A") {
807
+ const visibleIds = filteredRemoteEntries.map((file) => file.id);
808
+ const allVisibleSelected =
809
+ visibleIds.length > 0 && visibleIds.every((id) => selectedRemoteIds.has(id));
810
+
811
+ setSelectedRemoteIds((current) => {
812
+ const next = new Set(current);
813
+ if (allVisibleSelected) {
814
+ for (const id of visibleIds) {
815
+ next.delete(id);
816
+ }
817
+ } else {
818
+ for (const id of visibleIds) {
819
+ next.add(id);
820
+ }
821
+ }
822
+ return next;
823
+ });
824
+ setStatus(
825
+ allVisibleSelected
826
+ ? "Deselected all visible Drive items."
827
+ : `Selected ${visibleIds.length} visible Drive item(s).`
828
+ );
829
+ return;
830
+ }
831
+
832
+ if (input === "t" || input === "T") {
833
+ if (selectedRemoteIds.size === 0) {
834
+ setStatus("No Drive items selected.");
835
+ return;
836
+ }
837
+
838
+ setPendingAction({ type: "remote-trash" });
839
+ setMode("confirm");
840
+ setStatus(`Press y to trash ${selectedRemoteIds.size} Drive item(s).`);
841
+ return;
842
+ }
843
+
844
+ if (input === "d" || input === "D") {
845
+ if (selectedRemoteIds.size === 0) {
846
+ setStatus("No Drive items selected.");
847
+ return;
848
+ }
849
+
850
+ setPendingAction({ type: "remote-delete" });
851
+ setMode("confirm");
852
+ setStatus(`Press y to permanently delete ${selectedRemoteIds.size} Drive item(s).`);
853
+ return;
854
+ }
855
+
856
+ if (input === "U") {
857
+ if (!currentRemoteFolderWritable) {
858
+ setStatus(
859
+ `Cannot upload into current Drive directory${
860
+ currentRemoteFolderMeta?.name ? `: ${currentRemoteFolderMeta.name}` : ""
861
+ }. This folder does not allow adding children.`
862
+ );
863
+ return;
864
+ }
865
+ setInputValue("");
866
+ setMode("upload");
867
+ setStatus("Enter a local file or directory path to upload.");
868
+ }
869
+ });
870
+
871
+ const driveBreadcrumb = remoteFolderStack.length
872
+ ? `Drive: /${remoteFolderStack.map((folder) => folder.name).join("/")}`
873
+ : "Drive: /";
874
+ const localBreadcrumb = `Local: ${localDirectory}`;
875
+ const selectedCount = selectedRemoteIds.size;
876
+ const contentHeight = Math.max(height - 10, 6);
877
+ const paneWidth = Math.max(Math.floor((width - 3) / 2), 24);
878
+
879
+ if (mode === "error") {
880
+ return h(
881
+ Box,
882
+ { flexDirection: "column" },
883
+ h(Text, { color: "red", bold: true }, "Aethel failed to load."),
884
+ h(Text, null, errorMessage || "Unknown error."),
885
+ h(Text, { dimColor: true }, "Press q or Esc to quit.")
886
+ );
887
+ }
888
+
889
+ if (mode === "loading") {
890
+ return h(
891
+ Box,
892
+ { flexDirection: "column" },
893
+ h(
894
+ Text,
895
+ { color: "cyan" },
896
+ h(Spinner, { type: "dots" }),
897
+ ` ${status}`
898
+ )
899
+ );
900
+ }
901
+
902
+ let hints =
903
+ "Tab:focus Left/Right:navigate u:upload local s:sync dir n:rename local x:delete local Space:select drive t/d:delete drive /:filter U:manual upload r:reload q:quit ?:help";
904
+ if (mode === "filter") {
905
+ hints = "Enter: apply filter Esc: cancel";
906
+ } else if (mode === "confirm") {
907
+ hints = "y: confirm n: cancel";
908
+ } else if (mode === "upload") {
909
+ hints = "Enter: upload path Esc: cancel";
910
+ } else if (mode === "rename") {
911
+ hints = "Enter: rename Esc: cancel";
912
+ }
913
+
914
+ return h(
915
+ Box,
916
+ { flexDirection: "column" },
917
+ h(Text, { color: "cyan", bold: true }, truncate("Aethel", width)),
918
+ account
919
+ ? h(
920
+ Text,
921
+ null,
922
+ truncate(
923
+ `${account.name} <${account.email}> | ${account.usage} / ${account.limit}`,
924
+ width
925
+ )
926
+ )
927
+ : null,
928
+ h(
929
+ Text,
930
+ { dimColor: true },
931
+ truncate(
932
+ `${driveBreadcrumb} | ${localBreadcrumb}`,
933
+ width
934
+ )
935
+ ),
936
+ h(
937
+ Text,
938
+ { dimColor: true },
939
+ truncate(
940
+ `Legend: [MY ] owned by me [SHR] shared with me [DRV] shared drive [LOC] local item | ${selectedCount} Drive item(s) selected | Upload ${currentRemoteFolderWritable ? "enabled" : "blocked"}`,
941
+ width
942
+ )
943
+ ),
944
+ h(Text, { dimColor: true }, "─".repeat(Math.max(Math.min(width, 80), 10))),
945
+ h(
946
+ Box,
947
+ { flexDirection: "row" },
948
+ renderPane({
949
+ title: "Local",
950
+ breadcrumb: truncate(localDirectory, paneWidth),
951
+ focused: focusPane === "local",
952
+ entries: filteredLocalEntries,
953
+ cursor: localCursor,
954
+ renderer: renderLocalRow,
955
+ width: paneWidth,
956
+ height: contentHeight,
957
+ emptyMessage: localFilter
958
+ ? "No local items match the filter."
959
+ : "No local items found.",
960
+ }),
961
+ h(Text, { dimColor: true }, " | "),
962
+ renderPane({
963
+ title: remoteLoading ? "Drive (loading...)" : "Drive",
964
+ breadcrumb: truncate(
965
+ remoteFolderStack.length
966
+ ? `/${remoteFolderStack.map((folder) => folder.name).join("/")}`
967
+ : "/",
968
+ paneWidth
969
+ ),
970
+ focused: focusPane === "remote",
971
+ entries: filteredRemoteEntries,
972
+ cursor: remoteCursor,
973
+ renderer: (entry, isCursor, itemWidth) =>
974
+ renderRemoteRow(
975
+ entry,
976
+ isCursor,
977
+ selectedRemoteIds.has(entry.id),
978
+ itemWidth
979
+ ),
980
+ width: paneWidth,
981
+ height: contentHeight,
982
+ emptyMessage: remoteFilter
983
+ ? "No Drive items match the filter."
984
+ : "No Drive items found.",
985
+ })
986
+ ),
987
+ h(Text, { color: mode === "busy" ? "yellow" : "green" }, truncate(status, width)),
988
+ mode === "upload"
989
+ ? h(
990
+ Box,
991
+ { flexDirection: "column" },
992
+ h(Text, { color: "cyan" }, "Local path to upload into current Drive directory:"),
993
+ h(TextInput, {
994
+ value: inputValue,
995
+ onChange: setInputValue,
996
+ onSubmit: (value) => {
997
+ void executeUpload(value);
998
+ },
999
+ })
1000
+ )
1001
+ : null,
1002
+ mode === "rename"
1003
+ ? h(
1004
+ Box,
1005
+ { flexDirection: "column" },
1006
+ h(Text, { color: "cyan" }, "New local name:"),
1007
+ h(TextInput, {
1008
+ value: inputValue,
1009
+ onChange: setInputValue,
1010
+ onSubmit: (value) => {
1011
+ const action = pendingAction;
1012
+ setPendingAction(null);
1013
+ if (!action || action.type !== "local-rename") {
1014
+ setMode("normal");
1015
+ return;
1016
+ }
1017
+ void executeLocalRename(action.targetPath, value);
1018
+ },
1019
+ })
1020
+ )
1021
+ : null,
1022
+ h(Text, { dimColor: true }, truncate(hints, width)),
1023
+ mode === "help" ? renderHelp() : null
1024
+ );
1025
+ }