aethel 0.3.3 → 0.3.5
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/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/src/cli.js +55 -28
- package/src/core/diff.js +52 -0
- package/src/core/drive-api.js +35 -0
- package/src/core/progress.js +90 -0
- package/src/core/snapshot.js +62 -0
- package/src/core/staging.js +5 -0
- package/src/core/sync.js +39 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.5 (2026-04-05)
|
|
4
|
+
|
|
5
|
+
- Add progress bars and spinners for all time-consuming CLI operations
|
|
6
|
+
|
|
7
|
+
## 0.3.4 (2026-04-05)
|
|
8
|
+
|
|
9
|
+
- Add empty folder sync support between local and Google Drive
|
|
10
|
+
|
|
3
11
|
## 0.3.3 (2026-04-05)
|
|
4
12
|
|
|
5
13
|
- Persist credentials to ~/.config/aethel/ after auth for seamless init
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
DuplicateFoldersError,
|
|
32
32
|
} from "./core/drive-api.js";
|
|
33
33
|
import { createDefaultIgnoreFile, loadIgnoreRules } from "./core/ignore.js";
|
|
34
|
+
import { createProgressBar, createSpinner } from "./core/progress.js";
|
|
34
35
|
import { Repository } from "./core/repository.js";
|
|
35
36
|
import { runTui } from "./tui/index.js";
|
|
36
37
|
|
|
@@ -42,16 +43,36 @@ function addAuthOptions(command) {
|
|
|
42
43
|
.option("--token <path>", "Path to cached OAuth token JSON");
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
async function openRepo(options, { requireWorkspace = true } = {}) {
|
|
46
|
+
async function openRepo(options, { requireWorkspace = true, silent = false } = {}) {
|
|
46
47
|
const root = requireWorkspace ? requireRoot() : null;
|
|
47
48
|
const repo = new Repository(root, {
|
|
48
49
|
credentials: options.credentials,
|
|
49
50
|
token: options.token,
|
|
50
51
|
});
|
|
51
|
-
|
|
52
|
+
const spinner = silent ? null : createSpinner("Connecting to Google Drive...");
|
|
53
|
+
try {
|
|
54
|
+
await repo.connect();
|
|
55
|
+
spinner?.succeed("Connected to Google Drive");
|
|
56
|
+
} catch (err) {
|
|
57
|
+
spinner?.fail("Connection failed");
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
52
60
|
return repo;
|
|
53
61
|
}
|
|
54
62
|
|
|
63
|
+
async function loadStateWithProgress(repo, opts) {
|
|
64
|
+
const spinner = createSpinner("Loading workspace state...");
|
|
65
|
+
try {
|
|
66
|
+
const state = await repo.loadState(opts);
|
|
67
|
+
const n = state.diff.changes.length;
|
|
68
|
+
spinner.succeed(n ? `Loaded state — ${n} change(s) detected` : "Loaded state — everything up to date");
|
|
69
|
+
return state;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
spinner.fail("Failed to load workspace state");
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
55
76
|
function matchesPattern(targetPath, pattern) {
|
|
56
77
|
if (targetPath === pattern) {
|
|
57
78
|
return true;
|
|
@@ -117,7 +138,9 @@ function requireConfirmation(options) {
|
|
|
117
138
|
|
|
118
139
|
async function handleAuth(options) {
|
|
119
140
|
const repo = await openRepo(options, { requireWorkspace: false });
|
|
141
|
+
const spinner = createSpinner("Fetching account info...");
|
|
120
142
|
const account = await repo.getAccountInfo();
|
|
143
|
+
spinner.succeed(`Authenticated as ${account.email}`);
|
|
121
144
|
|
|
122
145
|
const credentialsPath = resolveCredentialsPath(options.credentials);
|
|
123
146
|
await persistCredentials(credentialsPath);
|
|
@@ -134,7 +157,9 @@ async function handleAuth(options) {
|
|
|
134
157
|
async function handleClean(options) {
|
|
135
158
|
requireConfirmation(options);
|
|
136
159
|
const repo = await openRepo(options, { requireWorkspace: false });
|
|
160
|
+
const spinner = createSpinner("Listing remote files...");
|
|
137
161
|
const files = await repo.listRemoteFiles({ includeSharedDrives: Boolean(options.sharedDrives) });
|
|
162
|
+
spinner.succeed(`Found ${files.length} file(s) on Drive`);
|
|
138
163
|
|
|
139
164
|
printCleanerPlan(files, options);
|
|
140
165
|
|
|
@@ -148,13 +173,15 @@ async function handleClean(options) {
|
|
|
148
173
|
return;
|
|
149
174
|
}
|
|
150
175
|
|
|
176
|
+
const bar = createProgressBar(`Cleaning ${files.length} file(s)`, files.length);
|
|
151
177
|
const result = await repo.batchOperateFiles(files, {
|
|
152
178
|
permanent: Boolean(options.permanent),
|
|
153
179
|
includeSharedDrives: Boolean(options.sharedDrives),
|
|
154
|
-
onProgress: (done
|
|
155
|
-
|
|
180
|
+
onProgress: (done) => {
|
|
181
|
+
bar.update(done);
|
|
156
182
|
},
|
|
157
183
|
});
|
|
184
|
+
bar.done(`Cleaned ${files.length} file(s)`);
|
|
158
185
|
|
|
159
186
|
if (result.errors) {
|
|
160
187
|
console.log(`Completed with ${result.errors} error(s) out of ${files.length} file(s).`);
|
|
@@ -171,8 +198,9 @@ async function handleInit(options) {
|
|
|
171
198
|
// Interactive folder selection when no --drive-folder is provided
|
|
172
199
|
if (!driveFolderId) {
|
|
173
200
|
const repo = await openRepo(options, { requireWorkspace: false });
|
|
174
|
-
|
|
201
|
+
const spinner = createSpinner("Fetching root-level Drive folders...");
|
|
175
202
|
const folders = await repo.listRootFolders();
|
|
203
|
+
spinner.succeed(`Found ${folders.length} folder(s) in Drive root`);
|
|
176
204
|
|
|
177
205
|
if (folders.length === 0) {
|
|
178
206
|
console.log("No folders found in Drive root. Syncing entire My Drive.");
|
|
@@ -225,7 +253,7 @@ async function handleInit(options) {
|
|
|
225
253
|
|
|
226
254
|
async function handleStatus(options) {
|
|
227
255
|
const repo = await openRepo(options);
|
|
228
|
-
const { diff } = await repo
|
|
256
|
+
const { diff } = await loadStateWithProgress(repo);
|
|
229
257
|
const staged = repo.getStagedEntries();
|
|
230
258
|
|
|
231
259
|
if (diff.isClean && staged.length === 0) {
|
|
@@ -264,7 +292,7 @@ async function handleStatus(options) {
|
|
|
264
292
|
|
|
265
293
|
async function handleDiff(options) {
|
|
266
294
|
const repo = await openRepo(options);
|
|
267
|
-
const { diff } = await repo
|
|
295
|
+
const { diff } = await loadStateWithProgress(repo);
|
|
268
296
|
|
|
269
297
|
if (diff.isClean) {
|
|
270
298
|
console.log("No changes detected.");
|
|
@@ -298,7 +326,7 @@ async function handleDiff(options) {
|
|
|
298
326
|
|
|
299
327
|
async function handleAdd(paths, options) {
|
|
300
328
|
const repo = await openRepo(options);
|
|
301
|
-
const { diff } = await repo
|
|
329
|
+
const { diff } = await loadStateWithProgress(repo);
|
|
302
330
|
|
|
303
331
|
if (options.all) {
|
|
304
332
|
const toStage = diff.changes.filter(
|
|
@@ -367,25 +395,22 @@ async function handleCommit(options, { repo: existingRepo } = {}) {
|
|
|
367
395
|
}
|
|
368
396
|
|
|
369
397
|
const message = options.message || "sync";
|
|
398
|
+
const bar = createProgressBar(`Syncing ${staged.length} change(s)`, staged.length);
|
|
370
399
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const result = await repo.executeStaged((done, total, verb, name) => {
|
|
374
|
-
if (done < total) {
|
|
375
|
-
console.log(` [${done + 1}/${total}] ${verb}: ${name}`);
|
|
376
|
-
}
|
|
400
|
+
const result = await repo.executeStaged((done) => {
|
|
401
|
+
bar.update(done + 1);
|
|
377
402
|
});
|
|
378
403
|
|
|
379
|
-
|
|
404
|
+
bar.done(`Commit complete: ${result.summary}`);
|
|
380
405
|
if (result.errors.length) {
|
|
381
406
|
for (const error of result.errors) {
|
|
382
407
|
console.log(` ERROR: ${error}`);
|
|
383
408
|
}
|
|
384
409
|
}
|
|
385
410
|
|
|
386
|
-
|
|
411
|
+
const spinner = createSpinner("Saving snapshot...");
|
|
387
412
|
await repo.saveSnapshot(message);
|
|
388
|
-
|
|
413
|
+
spinner.succeed(`Snapshot saved: "${message}"`);
|
|
389
414
|
}
|
|
390
415
|
|
|
391
416
|
function handleLog(options) {
|
|
@@ -409,10 +434,10 @@ async function handleFetch(options) {
|
|
|
409
434
|
const repo = await openRepo(options);
|
|
410
435
|
|
|
411
436
|
repo.invalidateRemoteCache();
|
|
412
|
-
|
|
437
|
+
const spinner = createSpinner("Fetching remote file list...");
|
|
413
438
|
const remoteState = await repo.getRemoteState({ useCache: false });
|
|
414
439
|
const remote = remoteState.files;
|
|
415
|
-
|
|
440
|
+
spinner.succeed(`Found ${remote.length} file(s) on Drive`);
|
|
416
441
|
|
|
417
442
|
const snapshot = repo.getSnapshot();
|
|
418
443
|
if (snapshot) {
|
|
@@ -445,7 +470,7 @@ async function handleFetch(options) {
|
|
|
445
470
|
|
|
446
471
|
async function handlePull(paths, options) {
|
|
447
472
|
const repo = await openRepo(options);
|
|
448
|
-
const { diff } = await repo
|
|
473
|
+
const { diff } = await loadStateWithProgress(repo, { useCache: false });
|
|
449
474
|
|
|
450
475
|
let remoteChanges = diff.changes.filter((change) =>
|
|
451
476
|
[
|
|
@@ -494,7 +519,7 @@ async function handlePull(paths, options) {
|
|
|
494
519
|
|
|
495
520
|
async function handlePush(paths, options) {
|
|
496
521
|
const repo = await openRepo(options);
|
|
497
|
-
const { diff } = await repo
|
|
522
|
+
const { diff } = await loadStateWithProgress(repo, { useCache: false });
|
|
498
523
|
|
|
499
524
|
let localChanges = diff.changes.filter((change) =>
|
|
500
525
|
[
|
|
@@ -543,7 +568,7 @@ async function handlePush(paths, options) {
|
|
|
543
568
|
|
|
544
569
|
async function handleResolve(paths, options) {
|
|
545
570
|
const repo = await openRepo(options);
|
|
546
|
-
const { diff } = await repo
|
|
571
|
+
const { diff } = await loadStateWithProgress(repo);
|
|
547
572
|
const conflicts = diff.conflicts;
|
|
548
573
|
|
|
549
574
|
if (conflicts.length === 0) {
|
|
@@ -719,7 +744,7 @@ async function handleRestore(paths, options) {
|
|
|
719
744
|
}
|
|
720
745
|
|
|
721
746
|
const localDest = path.join(root, entry.localPath || entry.path);
|
|
722
|
-
|
|
747
|
+
const spinner = createSpinner(`Restoring ${targetPath}...`);
|
|
723
748
|
|
|
724
749
|
try {
|
|
725
750
|
const meta = await repo.drive.files.get({
|
|
@@ -729,16 +754,16 @@ async function handleRestore(paths, options) {
|
|
|
729
754
|
|
|
730
755
|
const { downloadFile } = await import("./core/drive-api.js");
|
|
731
756
|
await downloadFile(repo.drive, { ...meta.data, id: entry.id }, localDest);
|
|
732
|
-
|
|
757
|
+
spinner.succeed(`Restored: ${targetPath}`);
|
|
733
758
|
} catch (err) {
|
|
734
|
-
|
|
759
|
+
spinner.fail(`Failed to restore ${targetPath}: ${err.message}`);
|
|
735
760
|
}
|
|
736
761
|
}
|
|
737
762
|
}
|
|
738
763
|
|
|
739
764
|
async function handleRm(paths, options) {
|
|
740
765
|
const repo = await openRepo(options);
|
|
741
|
-
const { diff } = await repo
|
|
766
|
+
const { diff } = await loadStateWithProgress(repo);
|
|
742
767
|
const root = repo.root;
|
|
743
768
|
|
|
744
769
|
for (const targetPath of paths) {
|
|
@@ -784,7 +809,7 @@ async function handleMv(source, dest, options) {
|
|
|
784
809
|
}
|
|
785
810
|
|
|
786
811
|
async function handleTui(options) {
|
|
787
|
-
const repo = await openRepo(options, { requireWorkspace: false });
|
|
812
|
+
const repo = await openRepo(options, { requireWorkspace: false, silent: true });
|
|
788
813
|
const cliArgs = [];
|
|
789
814
|
if (options.credentials) {
|
|
790
815
|
cliArgs.push("--credentials", options.credentials);
|
|
@@ -819,6 +844,7 @@ async function handleDedupeFolders(options) {
|
|
|
819
844
|
const config = repo.getConfig();
|
|
820
845
|
const rootFolderId = config.drive_folder_id || null;
|
|
821
846
|
const ignoreRules = loadIgnoreRules(repo.root);
|
|
847
|
+
const dedupeSpinner = createSpinner("Scanning for duplicate folders...");
|
|
822
848
|
const result = await dedupeDuplicateFolders(repo.drive, rootFolderId, {
|
|
823
849
|
execute: Boolean(options.execute),
|
|
824
850
|
ignoreRules,
|
|
@@ -848,8 +874,9 @@ async function handleDedupeFolders(options) {
|
|
|
848
874
|
},
|
|
849
875
|
});
|
|
850
876
|
|
|
877
|
+
dedupeSpinner.succeed(`Scan complete — ${result.duplicateFolders.length} duplicate group(s) found`);
|
|
878
|
+
|
|
851
879
|
if (result.duplicateFolders.length === 0) {
|
|
852
|
-
console.log("No duplicate folders detected.");
|
|
853
880
|
return;
|
|
854
881
|
}
|
|
855
882
|
|
package/src/core/diff.js
CHANGED
|
@@ -87,6 +87,11 @@ function buildDiffResult(changes) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function remoteChanged(snapshotEntry, remoteEntry) {
|
|
90
|
+
// Folders don't change — only their existence matters
|
|
91
|
+
if (remoteEntry.isFolder || remoteEntry.mimeType === "application/vnd.google-apps.folder") {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
90
95
|
if (isWorkspaceType(remoteEntry.mimeType || "")) {
|
|
91
96
|
return snapshotEntry.modifiedTime !== remoteEntry.modifiedTime;
|
|
92
97
|
}
|
|
@@ -95,6 +100,8 @@ function remoteChanged(snapshotEntry, remoteEntry) {
|
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
function localChanged(snapshotEntry, localEntry) {
|
|
103
|
+
// Folders don't change — only their existence matters
|
|
104
|
+
if (localEntry.isFolder) return false;
|
|
98
105
|
return snapshotEntry.md5 !== localEntry.md5;
|
|
99
106
|
}
|
|
100
107
|
|
|
@@ -153,6 +160,21 @@ function promoteConflicts(changes) {
|
|
|
153
160
|
* @param {object} localFiles
|
|
154
161
|
* @param {{ root?: string, respectIgnore?: boolean }} options
|
|
155
162
|
*/
|
|
163
|
+
/**
|
|
164
|
+
* Collect all implicit folder paths from a set of file paths.
|
|
165
|
+
* e.g. "a/b/c.txt" → {"a", "a/b"}
|
|
166
|
+
*/
|
|
167
|
+
function collectFolderPaths(filePaths) {
|
|
168
|
+
const folders = new Set();
|
|
169
|
+
for (const p of filePaths) {
|
|
170
|
+
const parts = p.split("/");
|
|
171
|
+
for (let i = 1; i < parts.length; i++) {
|
|
172
|
+
folders.add(parts.slice(0, i).join("/"));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return folders;
|
|
176
|
+
}
|
|
177
|
+
|
|
156
178
|
export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIgnore = true } = {}) {
|
|
157
179
|
const ignoreRules = root && respectIgnore ? loadIgnoreRules(root) : null;
|
|
158
180
|
|
|
@@ -164,6 +186,19 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
|
|
|
164
186
|
const snapshotFiles = snapshot?.files || {};
|
|
165
187
|
const snapshotLocalFiles = snapshot?.localFiles || {};
|
|
166
188
|
|
|
189
|
+
// Build sets of all folder paths that implicitly exist on each side
|
|
190
|
+
// (from parent directories of files), so we can skip redundant folder additions.
|
|
191
|
+
const remoteFolderPaths = collectFolderPaths(remoteFiles.map((f) => f.path));
|
|
192
|
+
const localFolderPaths = collectFolderPaths(Object.keys(localFiles));
|
|
193
|
+
|
|
194
|
+
// Also include explicit folder entries
|
|
195
|
+
for (const f of remoteFiles) {
|
|
196
|
+
if (f.isFolder) remoteFolderPaths.add(f.path);
|
|
197
|
+
}
|
|
198
|
+
for (const [p, meta] of Object.entries(localFiles)) {
|
|
199
|
+
if (meta.isFolder) localFolderPaths.add(p);
|
|
200
|
+
}
|
|
201
|
+
|
|
167
202
|
// Build remote lookup and detect additions/modifications in one pass
|
|
168
203
|
const remoteById = new Map();
|
|
169
204
|
for (const remoteFile of remoteFiles) {
|
|
@@ -171,6 +206,10 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
|
|
|
171
206
|
const snapshotEntry = snapshotFiles[remoteFile.id];
|
|
172
207
|
|
|
173
208
|
if (!snapshotEntry) {
|
|
209
|
+
// Skip remote folder if it already exists locally (as parent or explicit dir)
|
|
210
|
+
if (remoteFile.isFolder && localFolderPaths.has(remoteFile.path)) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
174
213
|
changes.push(
|
|
175
214
|
createChange({
|
|
176
215
|
changeType: ChangeType.REMOTE_ADDED,
|
|
@@ -199,6 +238,11 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
|
|
|
199
238
|
for (const fileId of Object.keys(snapshotFiles)) {
|
|
200
239
|
if (!remoteById.has(fileId)) {
|
|
201
240
|
const snapshotEntry = snapshotFiles[fileId];
|
|
241
|
+
// Skip folder deletion if the folder still implicitly exists on Drive
|
|
242
|
+
// (e.g. it became non-empty, or was recreated with a different ID)
|
|
243
|
+
if (snapshotEntry.isFolder && remoteFolderPaths.has(snapshotEntry.path || snapshotEntry.localPath || "")) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
202
246
|
changes.push(
|
|
203
247
|
createChange({
|
|
204
248
|
changeType: ChangeType.REMOTE_DELETED,
|
|
@@ -214,6 +258,10 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
|
|
|
214
258
|
const snapshotEntry = snapshotLocalFiles[relativePath];
|
|
215
259
|
|
|
216
260
|
if (!snapshotEntry) {
|
|
261
|
+
// Skip local folder if it already exists on Drive (as parent or explicit dir)
|
|
262
|
+
if (localMeta.isFolder && remoteFolderPaths.has(relativePath)) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
217
265
|
changes.push(
|
|
218
266
|
createChange({
|
|
219
267
|
changeType: ChangeType.LOCAL_ADDED,
|
|
@@ -238,6 +286,10 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
|
|
|
238
286
|
|
|
239
287
|
for (const [relativePath, snapshotEntry] of Object.entries(snapshotLocalFiles)) {
|
|
240
288
|
if (!(relativePath in localFiles)) {
|
|
289
|
+
// Skip folder deletion if the folder still implicitly exists locally
|
|
290
|
+
if (snapshotEntry.isFolder && localFolderPaths.has(relativePath)) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
241
293
|
changes.push(
|
|
242
294
|
createChange({
|
|
243
295
|
changeType: ChangeType.LOCAL_DELETED,
|
package/src/core/drive-api.js
CHANGED
|
@@ -302,6 +302,10 @@ function buildRemoteFiles(folders, rawFiles, rootFolderId = null) {
|
|
|
302
302
|
const resolve = createFolderResolver(folders, rootFolderId);
|
|
303
303
|
const isOrphaned = createOrphanChecker(folders);
|
|
304
304
|
const files = [];
|
|
305
|
+
|
|
306
|
+
// Track which folders have children (files or subfolders)
|
|
307
|
+
const foldersWithChildren = new Set();
|
|
308
|
+
|
|
305
309
|
for (const file of rawFiles) {
|
|
306
310
|
const parentId = file.parents?.[0] || "";
|
|
307
311
|
|
|
@@ -312,6 +316,9 @@ function buildRemoteFiles(folders, rawFiles, rootFolderId = null) {
|
|
|
312
316
|
|
|
313
317
|
if (rootFolderId && parentPath === null) continue;
|
|
314
318
|
|
|
319
|
+
// Mark parent as having children
|
|
320
|
+
if (parentId) foldersWithChildren.add(parentId);
|
|
321
|
+
|
|
315
322
|
files.push({
|
|
316
323
|
id: file.id,
|
|
317
324
|
name: file.name,
|
|
@@ -322,6 +329,34 @@ function buildRemoteFiles(folders, rawFiles, rootFolderId = null) {
|
|
|
322
329
|
md5Checksum: file.md5Checksum || null,
|
|
323
330
|
});
|
|
324
331
|
}
|
|
332
|
+
|
|
333
|
+
// Mark folders that have subfolders as children
|
|
334
|
+
for (const folder of folders.values()) {
|
|
335
|
+
const parentId = folder.parents?.[0] || "";
|
|
336
|
+
if (parentId) foldersWithChildren.add(parentId);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Include empty folders (no file children AND no subfolder children)
|
|
340
|
+
for (const folder of folders.values()) {
|
|
341
|
+
if (foldersWithChildren.has(folder.id)) continue;
|
|
342
|
+
if (isOrphaned(folder.id)) continue;
|
|
343
|
+
|
|
344
|
+
const folderPath = resolve(folder.id);
|
|
345
|
+
if (rootFolderId && folderPath === null) continue;
|
|
346
|
+
if (!folderPath) continue; // skip root-level marker
|
|
347
|
+
|
|
348
|
+
files.push({
|
|
349
|
+
id: folder.id,
|
|
350
|
+
name: folder.name,
|
|
351
|
+
path: folderPath,
|
|
352
|
+
mimeType: FOLDER_MIME,
|
|
353
|
+
size: null,
|
|
354
|
+
modifiedTime: folder.modifiedTime || null,
|
|
355
|
+
md5Checksum: null,
|
|
356
|
+
isFolder: true,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
325
360
|
return files;
|
|
326
361
|
}
|
|
327
362
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight terminal progress indicators (spinner + bar).
|
|
3
|
+
* Writes to stderr so stdout stays clean for piped output.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
7
|
+
const SPINNER_INTERVAL = 80;
|
|
8
|
+
const BAR_WIDTH = 25;
|
|
9
|
+
|
|
10
|
+
const isTTY = process.stderr.isTTY;
|
|
11
|
+
|
|
12
|
+
function clearLine() {
|
|
13
|
+
if (isTTY) process.stderr.write("\r\x1b[K");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createSpinner(message) {
|
|
17
|
+
if (!isTTY) {
|
|
18
|
+
process.stderr.write(`${message}\n`);
|
|
19
|
+
return {
|
|
20
|
+
update() {},
|
|
21
|
+
succeed(msg) { if (msg) process.stderr.write(`${msg}\n`); },
|
|
22
|
+
fail(msg) { if (msg) process.stderr.write(`${msg}\n`); },
|
|
23
|
+
stop() {},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let frame = 0;
|
|
28
|
+
let currentMessage = message;
|
|
29
|
+
|
|
30
|
+
const timer = setInterval(() => {
|
|
31
|
+
clearLine();
|
|
32
|
+
process.stderr.write(`${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${currentMessage}`);
|
|
33
|
+
frame++;
|
|
34
|
+
}, SPINNER_INTERVAL);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
update(msg) { currentMessage = msg; },
|
|
38
|
+
succeed(msg) {
|
|
39
|
+
clearInterval(timer);
|
|
40
|
+
clearLine();
|
|
41
|
+
process.stderr.write(`✔ ${msg || currentMessage}\n`);
|
|
42
|
+
},
|
|
43
|
+
fail(msg) {
|
|
44
|
+
clearInterval(timer);
|
|
45
|
+
clearLine();
|
|
46
|
+
process.stderr.write(`✖ ${msg || currentMessage}\n`);
|
|
47
|
+
},
|
|
48
|
+
stop() {
|
|
49
|
+
clearInterval(timer);
|
|
50
|
+
clearLine();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createProgressBar(label, total) {
|
|
56
|
+
let lastRendered = -1;
|
|
57
|
+
|
|
58
|
+
function render(current) {
|
|
59
|
+
if (current === lastRendered) return;
|
|
60
|
+
lastRendered = current;
|
|
61
|
+
|
|
62
|
+
const ratio = total > 0 ? Math.min(current / total, 1) : 0;
|
|
63
|
+
const filled = Math.round(BAR_WIDTH * ratio);
|
|
64
|
+
const empty = BAR_WIDTH - filled;
|
|
65
|
+
const pct = Math.round(ratio * 100);
|
|
66
|
+
const bar = "█".repeat(filled) + "░".repeat(empty);
|
|
67
|
+
const line = `${label} [${bar}] ${current}/${total} (${pct}%)`;
|
|
68
|
+
|
|
69
|
+
if (isTTY) {
|
|
70
|
+
clearLine();
|
|
71
|
+
process.stderr.write(line);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Initial render
|
|
76
|
+
render(0);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
update(current) { render(current); },
|
|
80
|
+
done(msg) {
|
|
81
|
+
render(total);
|
|
82
|
+
if (isTTY) {
|
|
83
|
+
clearLine();
|
|
84
|
+
process.stderr.write(`✔ ${msg || label}\n`);
|
|
85
|
+
} else {
|
|
86
|
+
process.stderr.write(`${msg || label}\n`);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/core/snapshot.js
CHANGED
|
@@ -52,6 +52,9 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
52
52
|
|
|
53
53
|
// Phase 1: collect all file stats (fast — no hashing yet)
|
|
54
54
|
const filesToHash = [];
|
|
55
|
+
// Track directories and their child counts to detect empty folders
|
|
56
|
+
const dirChildCount = new Map();
|
|
57
|
+
const dirStats = new Map();
|
|
55
58
|
|
|
56
59
|
async function walk(currentPath) {
|
|
57
60
|
let entries;
|
|
@@ -61,8 +64,20 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
61
64
|
return;
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
const relativeDirPath = currentPath === resolvedRoot
|
|
68
|
+
? null
|
|
69
|
+
: path.relative(resolvedRoot, currentPath).split(path.sep).join("/");
|
|
70
|
+
|
|
71
|
+
// Register this directory (skip root itself)
|
|
72
|
+
if (relativeDirPath !== null) {
|
|
73
|
+
if (!dirChildCount.has(relativeDirPath)) {
|
|
74
|
+
dirChildCount.set(relativeDirPath, 0);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
const subdirs = [];
|
|
65
79
|
const statPromises = [];
|
|
80
|
+
let trackedChildren = 0;
|
|
66
81
|
|
|
67
82
|
for (const entry of entries) {
|
|
68
83
|
const fullPath = path.join(currentPath, entry.name);
|
|
@@ -76,6 +91,7 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
if (entry.isDirectory()) {
|
|
94
|
+
trackedChildren++;
|
|
79
95
|
subdirs.push(fullPath);
|
|
80
96
|
continue;
|
|
81
97
|
}
|
|
@@ -84,6 +100,7 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
84
100
|
continue;
|
|
85
101
|
}
|
|
86
102
|
|
|
103
|
+
trackedChildren++;
|
|
87
104
|
statPromises.push(
|
|
88
105
|
fs.promises.stat(fullPath).then((stat) => {
|
|
89
106
|
filesToHash.push({ fullPath, relativePath, stat });
|
|
@@ -91,6 +108,16 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
91
108
|
);
|
|
92
109
|
}
|
|
93
110
|
|
|
111
|
+
if (relativeDirPath !== null) {
|
|
112
|
+
dirChildCount.set(relativeDirPath, trackedChildren);
|
|
113
|
+
try {
|
|
114
|
+
const stat = await fs.promises.stat(currentPath);
|
|
115
|
+
dirStats.set(relativeDirPath, stat);
|
|
116
|
+
} catch {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
94
121
|
await Promise.all([
|
|
95
122
|
...statPromises,
|
|
96
123
|
...subdirs.map((dir) => walk(dir)),
|
|
@@ -121,6 +148,40 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
|
|
|
121
148
|
}
|
|
122
149
|
}
|
|
123
150
|
|
|
151
|
+
// Phase 3: detect empty folders (directories with zero tracked children)
|
|
152
|
+
// Walk bottom-up: a dir is "empty" if it has no tracked children AND
|
|
153
|
+
// all its subdirectories are also empty.
|
|
154
|
+
const emptyDirs = new Set();
|
|
155
|
+
// Sort by depth (deepest first) for bottom-up processing
|
|
156
|
+
const sortedDirs = [...dirChildCount.keys()].sort(
|
|
157
|
+
(a, b) => b.split("/").length - a.split("/").length
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
for (const dirPath of sortedDirs) {
|
|
161
|
+
const childCount = dirChildCount.get(dirPath);
|
|
162
|
+
if (childCount === 0) {
|
|
163
|
+
emptyDirs.add(dirPath);
|
|
164
|
+
// Propagate: decrement parent's tracked child count since this child is empty
|
|
165
|
+
const parentDir = dirPath.includes("/")
|
|
166
|
+
? dirPath.slice(0, dirPath.lastIndexOf("/"))
|
|
167
|
+
: null;
|
|
168
|
+
if (parentDir && dirChildCount.has(parentDir)) {
|
|
169
|
+
dirChildCount.set(parentDir, dirChildCount.get(parentDir) - 1);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const dirPath of emptyDirs) {
|
|
175
|
+
const stat = dirStats.get(dirPath);
|
|
176
|
+
result[dirPath] = {
|
|
177
|
+
localPath: dirPath,
|
|
178
|
+
isFolder: true,
|
|
179
|
+
size: 0,
|
|
180
|
+
md5: null,
|
|
181
|
+
modifiedTime: stat ? new Date(stat.mtimeMs).toISOString() : new Date().toISOString(),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
124
185
|
// Persist updated cache
|
|
125
186
|
saveHashCache(resolvedRoot, nextCache);
|
|
126
187
|
return result;
|
|
@@ -158,6 +219,7 @@ export function buildSnapshot(remoteFiles, localFiles, message = "") {
|
|
|
158
219
|
mimeType: file.mimeType || "",
|
|
159
220
|
modifiedTime: file.modifiedTime ?? null,
|
|
160
221
|
localPath: file.path,
|
|
222
|
+
...(file.isFolder ? { isFolder: true } : {}),
|
|
161
223
|
};
|
|
162
224
|
}
|
|
163
225
|
|
package/src/core/staging.js
CHANGED
|
@@ -19,6 +19,11 @@ function changeToEntry(change) {
|
|
|
19
19
|
entry.remotePath = change.remoteMeta.path;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Propagate folder flag so sync knows to create folder instead of uploading file
|
|
23
|
+
if (change.localMeta?.isFolder || change.remoteMeta?.isFolder) {
|
|
24
|
+
entry.isFolder = true;
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
return entry;
|
|
23
28
|
}
|
|
24
29
|
|
package/src/core/sync.js
CHANGED
|
@@ -20,6 +20,7 @@ export class CommitResult {
|
|
|
20
20
|
this.uploaded = 0;
|
|
21
21
|
this.deletedLocal = 0;
|
|
22
22
|
this.deletedRemote = 0;
|
|
23
|
+
this.foldersCreated = 0;
|
|
23
24
|
this.errors = [];
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -28,7 +29,8 @@ export class CommitResult {
|
|
|
28
29
|
this.downloaded +
|
|
29
30
|
this.uploaded +
|
|
30
31
|
this.deletedLocal +
|
|
31
|
-
this.deletedRemote
|
|
32
|
+
this.deletedRemote +
|
|
33
|
+
this.foldersCreated
|
|
32
34
|
);
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -41,6 +43,9 @@ export class CommitResult {
|
|
|
41
43
|
if (this.uploaded) {
|
|
42
44
|
parts.push(`${this.uploaded} uploaded`);
|
|
43
45
|
}
|
|
46
|
+
if (this.foldersCreated) {
|
|
47
|
+
parts.push(`${this.foldersCreated} folders created`);
|
|
48
|
+
}
|
|
44
49
|
if (this.deletedLocal) {
|
|
45
50
|
parts.push(`${this.deletedLocal} deleted locally`);
|
|
46
51
|
}
|
|
@@ -56,9 +61,16 @@ export class CommitResult {
|
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
async function downloadStagedFile(drive, entry, root) {
|
|
59
|
-
const fileId = entry.fileId;
|
|
60
64
|
const localRelativePath = entry.localPath || entry.path;
|
|
61
65
|
const localAbsolutePath = toLocalAbsolutePath(root, localRelativePath);
|
|
66
|
+
|
|
67
|
+
// Empty folder: just create the directory locally
|
|
68
|
+
if (entry.isFolder) {
|
|
69
|
+
fs.mkdirSync(localAbsolutePath, { recursive: true });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const fileId = entry.fileId;
|
|
62
74
|
const response = await drive.files.get({
|
|
63
75
|
fileId,
|
|
64
76
|
fields: "id,name,mimeType",
|
|
@@ -69,9 +81,16 @@ async function downloadStagedFile(drive, entry, root) {
|
|
|
69
81
|
|
|
70
82
|
async function uploadStagedFile(drive, entry, root, driveFolderId) {
|
|
71
83
|
const localRelativePath = entry.localPath || entry.path;
|
|
72
|
-
const localAbsolutePath = toLocalAbsolutePath(root, localRelativePath);
|
|
73
84
|
const remotePath = entry.remotePath || entry.path;
|
|
74
85
|
|
|
86
|
+
// Empty folder: just ensure it exists on Drive
|
|
87
|
+
if (entry.isFolder) {
|
|
88
|
+
await ensureFolder(drive, remotePath, driveFolderId);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const localAbsolutePath = toLocalAbsolutePath(root, localRelativePath);
|
|
93
|
+
|
|
75
94
|
if (!fs.existsSync(localAbsolutePath)) {
|
|
76
95
|
throw new Error(`Local file not found: ${localAbsolutePath}`);
|
|
77
96
|
}
|
|
@@ -97,18 +116,25 @@ async function deleteLocalFile(entry, root) {
|
|
|
97
116
|
return;
|
|
98
117
|
}
|
|
99
118
|
|
|
100
|
-
|
|
119
|
+
// Empty folder: remove the directory itself
|
|
120
|
+
if (entry.isFolder) {
|
|
121
|
+
await fs.promises.rmdir(localAbsolutePath).catch(() => {});
|
|
122
|
+
} else {
|
|
123
|
+
await fs.promises.unlink(localAbsolutePath);
|
|
124
|
+
}
|
|
101
125
|
|
|
102
|
-
|
|
126
|
+
// Clean up empty parent directories up to workspace root
|
|
127
|
+
let currentPath = entry.isFolder ? localAbsolutePath : path.dirname(localAbsolutePath);
|
|
103
128
|
const resolvedRoot = path.resolve(root);
|
|
104
129
|
|
|
105
130
|
while (currentPath !== resolvedRoot) {
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
try {
|
|
132
|
+
const contents = await fs.promises.readdir(currentPath);
|
|
133
|
+
if (contents.length > 0) break;
|
|
134
|
+
await fs.promises.rmdir(currentPath);
|
|
135
|
+
} catch {
|
|
108
136
|
break;
|
|
109
137
|
}
|
|
110
|
-
|
|
111
|
-
await fs.promises.rmdir(currentPath);
|
|
112
138
|
currentPath = path.dirname(currentPath);
|
|
113
139
|
}
|
|
114
140
|
}
|
|
@@ -195,10 +221,12 @@ export async function executeStaged(drive, root, progress) {
|
|
|
195
221
|
const action = entry.action;
|
|
196
222
|
if (action === "download") {
|
|
197
223
|
await downloadStagedFile(drive, entry, root);
|
|
198
|
-
result.
|
|
224
|
+
if (entry.isFolder) result.foldersCreated++;
|
|
225
|
+
else result.downloaded++;
|
|
199
226
|
} else if (action === "upload") {
|
|
200
227
|
await uploadStagedFile(drive, entry, root, driveFolderId);
|
|
201
|
-
result.
|
|
228
|
+
if (entry.isFolder) result.foldersCreated++;
|
|
229
|
+
else result.uploaded++;
|
|
202
230
|
} else if (action === "delete_remote") {
|
|
203
231
|
await deleteRemoteFile(drive, entry);
|
|
204
232
|
result.deletedRemote++;
|