Repository navigation
Expand file tree
/
Copy pathmain.js
More file actions
1076 lines (1001 loc) · 40 KB
/
Copy pathmain.js
File metadata and controls
1076 lines (1001 loc) · 40 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const { app, BrowserWindow, ipcMain, shell, Tray, Menu, nativeImage, screen, clipboard: sysClipboard, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
const fsp = require('fs/promises');
const isDev = !app.isPackaged;
// Persisted path for the associations database and recent dirs
const dataDir = path.join(app.getPath('userData'), 'filelink');
const dbPath = path.join(dataDir, 'associations.json');
const recentPath = path.join(dataDir, 'recent.json');
const quickPath = path.join(dataDir, 'quick.json');
const settingsPath = path.join(dataDir, 'settings.json');
// 全局设置(白名单持久化;新增项只需在此补默认值)
const DEFAULT_SETTINGS = {
startup: 'last', // 启动时打开:last=上次目录 | home=用户主目录 | custom=指定文件夹
startupDir: '', // startup=custom 时的文件夹
lastDir: '', // 上次浏览的目录(startup=last 使用)
recentEnabled: true, // 是否记录并在侧栏显示“最近访问”
recentLimit: 8, // 最近访问保留条数(用户可设置 0–10 的整数)
closeToTray: true, // 点关闭按钮时隐藏到托盘;false=直接退出
showHidden: true, // 是否显示以 . 开头的隐藏项目
autoStart: false, // 开机(登录 Windows)后自动启动并驻留托盘
rememberWindow: true, // 记住主窗口大小与位置
showAssocBadge: true // 文件/文件夹名称旁显示关联数量标记
};
let settingsCache = null;
async function loadSettings() {
if (settingsCache) return settingsCache;
try {
const j = JSON.parse(await fsp.readFile(settingsPath, 'utf8'));
settingsCache = { ...DEFAULT_SETTINGS, ...(j && typeof j === 'object' ? j : {}) };
} catch {
settingsCache = { ...DEFAULT_SETTINGS };
}
// 旧版允许 15/30,新版限定 0–10:超界值加载时收敛到上限 10
if (!Number.isInteger(settingsCache.recentLimit) || settingsCache.recentLimit < 0 || settingsCache.recentLimit > 10) {
settingsCache.recentLimit = 10;
}
return settingsCache;
}
async function saveSettings(patch) {
const cur = await loadSettings();
settingsCache = { ...cur, ...patch };
await fsp.writeFile(settingsPath, JSON.stringify(settingsCache, null, 2), 'utf8');
return settingsCache;
}
// Clipboard for cross-process copy/cut operations (kept in memory only)
let clipboard = { items: [], mode: 'copy' };
// 单实例:再次启动时直接唤起已驻留后台的主窗口
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on('second-instance', () => showMainWindow());
}
async function ensureDataDir() {
await fsp.mkdir(dataDir, { recursive: true });
if (!fs.existsSync(dbPath)) await fsp.writeFile(dbPath, JSON.stringify({}, null, 2), 'utf8');
if (!fs.existsSync(recentPath)) await fsp.writeFile(recentPath, JSON.stringify([], null, 2), 'utf8');
if (!fs.existsSync(quickPath)) await fsp.writeFile(quickPath, JSON.stringify({ custom: [], hidden: [] }, null, 2), 'utf8');
if (!fs.existsSync(settingsPath)) await fsp.writeFile(settingsPath, JSON.stringify(DEFAULT_SETTINGS, null, 2), 'utf8');
}
async function loadDb() {
try {
return JSON.parse(await fsp.readFile(dbPath, 'utf8'));
} catch {
return {};
}
}
async function saveDb(db) {
await fsp.writeFile(dbPath, JSON.stringify(db, null, 2), 'utf8');
}
// 把关联库里的旧路径批量改写成新路径(软件内重命名/移动时调用,链接不断)。
// moves: [{ from, to }]。等值替换;from 是文件夹时,其内部项目(from\xxx)也做前缀替换。
// key(关联源)被改名时迁移到新 key,与已有记录合并去重;空数组的 key 删除。
async function rewritePathsInDb(movesRaw) {
const allMoves = (Array.isArray(movesRaw) ? movesRaw : [])
.filter(m => m && typeof m.from === 'string' && typeof m.to === 'string' && m.from !== m.to);
if (!allMoves.length) return;
// 长路径优先:同时移动父、子文件夹时,先替换更深的路径,避免被父级规则误伤
const moves = [...allMoves].sort((a, b) => b.from.length - a.from.length);
const db = await loadDb();
let changed = false;
const rewrite = (p) => {
if (typeof p !== 'string' || !p) return p;
const low = p.toLowerCase();
for (const m of moves) {
const fl = m.from.toLowerCase();
if (low === fl) { changed = true; return m.to; }
if (low.startsWith(fl + path.sep)) { changed = true; return m.to + p.slice(m.from.length); }
}
return p;
};
const mergeInto = (dest, arr) => {
if (!Array.isArray(db[dest])) db[dest] = [];
const seen = new Set(db[dest].map(x => String(x).toLowerCase()));
for (const p of arr) {
if (!seen.has(String(p).toLowerCase())) { seen.add(String(p).toLowerCase()); db[dest].push(p); }
}
};
// 1) 数组内的目标路径替换(顺带去重)
for (const k of Object.keys(db)) {
if (!Array.isArray(db[k])) { delete db[k]; changed = true; continue; }
const seen = new Set();
const out = [];
for (const p0 of db[k]) {
const p = rewrite(p0);
const pl = String(p).toLowerCase();
if (!seen.has(pl)) { seen.add(pl); out.push(p); }
}
if (out.length !== db[k].length || out.some((p, i) => p !== db[k][i])) changed = true;
db[k] = out;
}
// 2) key(关联源)路径替换:等值或文件夹前缀
for (const oldK of Object.keys(db)) {
const newK = rewrite(oldK);
if (newK === oldK) continue;
const arr = db[oldK];
delete db[oldK];
mergeInto(newK, arr);
changed = true;
}
// 3) 清理空记录
for (const k of Object.keys(db)) {
if (Array.isArray(db[k]) && db[k].length === 0) { delete db[k]; changed = true; }
}
if (changed) await saveDb(db);
}
async function loadRecent() {
try {
return JSON.parse(await fsp.readFile(recentPath, 'utf8'));
} catch {
return [];
}
}
async function saveRecent(arr, limit) {
const max = Number.isFinite(limit) ? limit : ((await loadSettings()).recentLimit || 8);
await fsp.writeFile(recentPath, JSON.stringify(arr.slice(0, max), null, 2), 'utf8');
}
// Quick-access state: { custom: [{ name, path }], hidden: [path, ...] }
// hidden 记录用户删除的内置项(桌面/文档等),可恢复;兼容旧版的纯数组格式。
async function loadQuick() {
const empty = { custom: [], hidden: [] };
try {
const j = JSON.parse(await fsp.readFile(quickPath, 'utf8'));
if (Array.isArray(j)) return { custom: j.filter(x => x && x.path), hidden: [] };
return {
custom: Array.isArray(j.custom) ? j.custom.filter(x => x && x.path) : [],
hidden: Array.isArray(j.hidden) ? j.hidden.filter(x => typeof x === 'string') : []
};
} catch {
return empty;
}
}
async function saveQuick(q) {
const data = { custom: (q.custom || []).slice(0, 30), hidden: q.hidden || [] };
await fsp.writeFile(quickPath, JSON.stringify(data, null, 2), 'utf8');
return data;
}
let tray = null;
let mainWindow = null;
let isQuiting = false;
let trayHintShown = false;
function showMainWindow() {
if (!mainWindow || mainWindow.isDestroyed()) { createWindow(); return; }
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
function createTray() {
// 优先高分图标,Windows 托盘会自动缩到 16px
const iconFile = fs.existsSync(path.join(__dirname, 'assets', 'tray-icon-16.png'))
? 'tray-icon-16.png' : null;
let img = nativeImage.createEmpty();
if (iconFile) {
img = nativeImage.createFromPath(path.join(__dirname, 'assets', iconFile));
if (img.isEmpty()) img = nativeImage.createFromPath(path.join(__dirname, 'assets', 'tray-icon-32.png'));
}
tray = new Tray(img.isEmpty() ? nativeImage.createEmpty() : img);
tray.setToolTip('FileLinkManager —— 后台运行中');
const menu = Menu.buildFromTemplate([
{ label: '显示主窗口', click: showMainWindow },
{ type: 'separator' },
{ label: '退出 FileLinkManager', click: () => { isQuiting = true; app.quit(); } }
]);
tray.setContextMenu(menu);
tray.on('click', showMainWindow);
tray.on('double-click', showMainWindow);
}
async function createWindow() {
const s = await loadSettings();
const winOpts = {
width: 1280,
height: 820,
minWidth: 900,
minHeight: 560,
title: 'FileLinkManager',
backgroundColor: '#0a0a0a',
show: false, // shown on ready-to-show to avoid white flash
// 'hidden' removes the OS title bar & caption buttons on Windows,
// letting our custom HTML titlebar (in renderer/index.html) be the sole control.
titleBarStyle: process.platform === 'win32' ? 'hidden' : 'default',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
};
// 恢复上次窗口大小与位置(必须落在某个可用显示器工作区内,防止移出屏幕)
if (s.rememberWindow && s.winBounds && typeof s.winBounds === 'object') {
const b = s.winBounds;
const valid = ['x', 'y', 'width', 'height'].every(k => Number.isFinite(b[k]));
if (valid && b.width >= winOpts.minWidth && b.height >= winOpts.minHeight) {
const onScreen = screen.getAllDisplays().some(d => {
const wa = d.workArea;
return b.x + b.width > wa.x && b.x < wa.x + wa.width &&
b.y + b.height > wa.y && b.y < wa.y + wa.height;
});
if (onScreen) Object.assign(winOpts, { x: b.x, y: b.y, width: b.width, height: b.height });
}
}
const win = new BrowserWindow(winOpts);
mainWindow = win;
// 拖动/缩放结束后防抖持久化窗口 bounds;最大化、最小化期间不记录
let boundsTimer = null;
const scheduleBoundsSave = () => {
const cur = settingsCache;
if (!cur || !cur.rememberWindow || win.isMaximized() || win.isMinimized()) return;
clearTimeout(boundsTimer);
boundsTimer = setTimeout(() => {
if (!win.isDestroyed() && !win.isMaximized() && !win.isMinimized()) {
saveSettings({ winBounds: win.getBounds() }).catch(() => {});
}
}, 500);
};
win.on('resize', scheduleBoundsSave);
win.on('move', scheduleBoundsSave);
win.once('ready-to-show', () => win.show());
// 关闭按钮 = 隐藏到托盘;只有托盘菜单“退出”才真正结束
win.on('close', (e) => {
if (!isQuiting) {
e.preventDefault();
win.hide();
if (tray && !trayHintShown) {
trayHintShown = true;
tray.displayBalloon({
iconType: 'info',
title: 'FileLinkManager 仍在后台运行',
content: '点击托盘图标可重新打开窗口;右键托盘图标选择“退出”才会完全关闭。'
});
}
}
});
win.on('closed', () => { if (mainWindow === win) mainWindow = null; });
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
if (isDev) win.webContents.openDevTools({ mode: 'detach' });
}
app.whenReady().then(async () => {
await ensureDataDir();
// 开机自启以系统注册表为准,与设置项同步一次
try {
const s = await loadSettings();
app.setLoginItemSettings({ openAtLogin: !!s.autoStart, name: 'FileLinkManager' });
} catch { /* 非 Windows 或无权限时忽略 */ }
createTray();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
// Window controls (custom titlebar)
ipcMain.on('win:minimize', (e) => {
const w = BrowserWindow.fromWebContents(e.sender);
if (w) w.minimize();
});
ipcMain.on('win:maximize', (e) => {
const w = BrowserWindow.fromWebContents(e.sender);
if (!w) return;
if (w.isMaximized()) w.unmaximize();
else w.maximize();
});
ipcMain.on('win:close', async (e) => {
const w = BrowserWindow.fromWebContents(e.sender);
if (!w) return;
// 设置为“直接退出”时彻底结束;否则走 win.on('close') 的隐藏到托盘逻辑
const s = await loadSettings();
if (s.closeToTray === false) {
isQuiting = true;
app.quit();
} else {
w.close();
}
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
// ============================================================
// IPC: Directory listing
// ============================================================
ipcMain.handle('dir:list', async (event, dirPath) => {
try {
const target = dirPath || app.getPath('home');
const settings = await loadSettings();
const entries = await fsp.readdir(target, { withFileTypes: true });
const items = [];
for (const e of entries) {
// Skip protected/hidden system items for clarity but keep .something
if (e.name === 'System Volume Information' || e.name === '$RECYCLE.BIN') continue;
// 用户关闭“显示隐藏项目”时,过滤以 . 开头的文件/文件夹
if (!settings.showHidden && e.name.startsWith('.')) continue;
try {
const full = path.join(target, e.name);
const stat = await fsp.stat(full);
items.push({
name: e.name,
path: full,
isDir: e.isDirectory(),
size: stat.size,
mtime: stat.mtimeMs,
ext: e.isDirectory() ? '' : path.extname(e.name).toLowerCase()
});
} catch {
// ignore stat errors (permission denied, locked files)
}
}
// Directories first, then files; both alphabetical
items.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name, 'zh') : a.isDir ? -1 : 1));
// Aggregate association counts in ONE db read (avoids per-file IPC storm).
// Both files and folders can be association sources.
// assoc = outgoing links per item path (item -> others)
// assocIn = incoming references per item path (others -> item)
const assoc = {};
const assocIn = {};
try {
const db = await loadDb();
const itemPaths = new Set(items.map(it => it.path));
for (const [itemPath, list] of Object.entries(db)) {
if (!Array.isArray(list) || !list.length) continue;
if (itemPaths.has(itemPath)) assoc[itemPath] = list.length;
for (const related of list) {
if (itemPaths.has(related)) assocIn[related] = (assocIn[related] || 0) + 1;
}
}
} catch { /* assoc info is optional */ }
return { ok: true, path: target, items, assoc, assocIn };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('dir:home', () => app.getPath('home'));
ipcMain.handle('dir:drives', async () => {
// Windows: list drives by checking letters
const drives = [];
if (process.platform === 'win32') {
for (let c = 65; c <= 90; c++) {
const letter = String.fromCharCode(c) + ':\\';
try {
await fsp.access(letter, fs.constants.R_OK);
drives.push(letter);
} catch {
/* drive doesn't exist */
}
}
}
return drives;
});
ipcMain.handle('dir:pick', async (e) => {
// 统一使用自定义选择窗口(文件夹单选模式),初始目录取上次浏览位置
const s = await loadSettings();
let startDir = s.lastDir || app.getPath('home');
try { await fsp.access(startDir, fs.constants.R_OK); } catch { startDir = app.getPath('home'); }
const parentWin = BrowserWindow.fromWebContents(e.sender);
const paths = await openPicker(parentWin, { startDir, mode: 'dir', title: '选择文件夹' });
return paths[0] || null;
});
ipcMain.handle('dir:parent', (e, p) => path.dirname(p));
ipcMain.handle('recent:list', async () => loadRecent());
ipcMain.handle('recent:add', async (e, p) => {
const s = await loadSettings();
let arr = await loadRecent();
// 用户关闭“最近访问”后只保留历史数据,不再追加(重新开启后旧记录仍在)
if (s.recentEnabled) {
arr = arr.filter(x => x !== p);
arr.unshift(p);
arr = arr.slice(0, s.recentLimit);
await saveRecent(arr, s.recentLimit);
}
return arr;
});
ipcMain.handle('recent:clear', async () => { await saveRecent([], 0); return []; });
// ============================================================
// IPC: 全局设置
// ============================================================
const SETTING_KEYS = ['startup', 'startupDir', 'lastDir', 'recentEnabled', 'recentLimit', 'closeToTray', 'showHidden', 'autoStart', 'rememberWindow', 'showAssocBadge'];
ipcMain.handle('settings:get', async () => ({ ...(await loadSettings()), version: app.getVersion() }));
ipcMain.handle('settings:set', async (e, patch) => {
const clean = {};
if (patch && typeof patch === 'object') {
for (const k of SETTING_KEYS) if (k in patch) clean[k] = patch[k];
}
if ('startup' in clean && !['last', 'home', 'custom'].includes(clean.startup)) delete clean.startup;
if ('startupDir' in clean && typeof clean.startupDir !== 'string') delete clean.startupDir;
if ('lastDir' in clean && typeof clean.lastDir !== 'string') delete clean.lastDir;
if ('recentLimit' in clean) {
const n = Number(clean.recentLimit);
// 用户可自填 0–10 的整数(0 = 不保留);非法值回退默认 8
clean.recentLimit = Number.isInteger(n) && n >= 0 && n <= 10 ? n : 8;
}
for (const k of ['recentEnabled', 'closeToTray', 'showHidden', 'autoStart', 'rememberWindow', 'showAssocBadge']) {
if (k in clean && typeof clean[k] !== 'boolean') delete clean[k];
}
const saved = await saveSettings(clean);
// 开机自启:同步写入 Windows 登录项
if ('autoStart' in clean) {
try { app.setLoginItemSettings({ openAtLogin: clean.autoStart, name: 'FileLinkManager' }); } catch { /* 忽略平台不支持 */ }
}
return saved;
});
// 复制文本到系统剪贴板(关于页反馈邮箱用)
ipcMain.handle('sys:copy', (e, text) => {
try {
sysClipboard.writeText(String(text == null ? '' : text));
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
});
// ============================================================
// IPC: Custom quick-access entries
// ============================================================
ipcMain.handle('quick:list', async () => loadQuick());
ipcMain.handle('quick:add', async (e, p) => {
try {
if (!p) return { ok: false, error: '缺少路径', ...(await loadQuick()) };
const s = await fsp.stat(p);
if (!s.isDirectory()) return { ok: false, error: '只能添加文件夹', ...(await loadQuick()) };
const q = await loadQuick();
const exists = q.custom.some(it => it.path.toLowerCase() === p.toLowerCase());
if (!exists) {
q.custom.push({ name: path.basename(p) || p, path: p });
await saveQuick(q);
}
return { ok: true, ...q };
} catch (err) {
return { ok: false, error: err.message, ...(await loadQuick()) };
}
});
// 自定义项:彻底移除;内置项:加入 hidden 列表(可恢复)
ipcMain.handle('quick:remove', async (e, p) => {
const q = await loadQuick();
q.custom = q.custom.filter(it => it.path !== p);
await saveQuick(q);
return { ok: true, ...q };
});
ipcMain.handle('quick:hide', async (e, p) => {
const q = await loadQuick();
if (!q.hidden.includes(p)) q.hidden.push(p);
await saveQuick(q);
return { ok: true, ...q };
});
ipcMain.handle('quick:restore', async () => {
const q = await loadQuick();
q.hidden = [];
await saveQuick(q);
return { ok: true, ...q };
});
// ============================================================
// IPC: File operations
// ============================================================
async function copyRecursive(src, dest) {
const stat = await fsp.stat(src);
if (stat.isDirectory()) {
await fsp.mkdir(dest, { recursive: true });
const entries = await fsp.readdir(src, { withFileTypes: true });
for (const e of entries) {
await copyRecursive(path.join(src, e.name), path.join(dest, e.name));
}
} else {
await fsp.copyFile(src, dest);
await fsp.utimes(dest, stat.atime, stat.mtime);
}
}
async function safeJoinName(folder, name) {
let candidate = path.join(folder, name);
if (!fs.existsSync(candidate)) return candidate;
const ext = path.extname(name);
const base = path.basename(name, ext);
for (let i = 2; i < 1000; i++) {
const test = path.join(folder, `${base} (${i})${ext}`);
if (!fs.existsSync(test)) return test;
}
return path.join(folder, `${Date.now()}_${name}`);
}
ipcMain.handle('clip:set', (e, items, mode) => {
clipboard = { items: items || [], mode: mode === 'cut' ? 'cut' : 'copy' };
return clipboard;
});
ipcMain.handle('clip:get', () => clipboard);
ipcMain.handle('op:paste', async (e, destDir) => {
if (!clipboard.items.length) return { ok: false, error: 'Clipboard empty' };
const results = [];
const destLower = String(destDir).toLowerCase();
for (const src of clipboard.items) {
try {
// 剪切时禁止把项目移入其自身或其子目录(资源管理器同样阻止,
// 否则 copy 进自己内部再删除源会毁掉整棵目录树)
if (clipboard.mode === 'cut') {
const srcLower = String(src).toLowerCase();
if (destLower === srcLower || destLower.startsWith(srcLower + path.sep)) {
results.push({ ok: false, from: src, error: '目标文件夹位于待移动项目内部,已跳过' });
continue;
}
}
const name = path.basename(src);
const target = await safeJoinName(destDir, name);
await copyRecursive(src, target);
if (clipboard.mode === 'cut') {
await fsp.rm(src, { recursive: true, force: true });
}
results.push({ ok: true, from: src, to: target, moved: clipboard.mode === 'cut' });
} catch (err) {
results.push({ ok: false, from: src, error: err.message });
}
}
// 剪切:全部成功才清空剪贴板;有失败项则保留,便于换位置重新粘贴
if (clipboard.mode === 'cut') {
const failed = results.filter(x => !x.ok).map(x => x.from);
clipboard = failed.length ? { items: failed, mode: 'cut' } : { items: [], mode: 'copy' };
}
// 剪切成功的项目:同步改写关联库路径,链接不断(复制不动库,副本是新文件)
const moved = results.filter(x => x.ok && x.moved).map(x => ({ from: x.from, to: x.to }));
if (moved.length) await rewritePathsInDb(moved).catch(() => {});
return { ok: true, results };
});
// 拖拽移动/复制:把一批项目放入 destDir
// mode: 'move' 移动 | 'copy' 复制 | 'auto' 同盘移动、异盘复制(资源管理器默认规则)
ipcMain.handle('op:transfer', async (e, items, destDir, mode) => {
const results = [];
let destStat;
try {
destStat = await fsp.stat(destDir);
} catch {
return { ok: false, error: '目标文件夹不存在或无法访问', results: [] };
}
if (!destStat.isDirectory()) return { ok: false, error: '目标不是文件夹', results: [] };
const sameVolume = (p) => path.parse(p).root.toLowerCase() === path.parse(destDir).root.toLowerCase();
// 兼容单个路径字符串或路径数组
const itemList = Array.isArray(items) ? items : (items ? [items] : []);
for (const srcRaw of itemList) {
const src = String(srcRaw || '');
if (!src) continue;
const srcLower = src.toLowerCase();
const destLower = String(destDir).toLowerCase();
// 统一的动作判定(auto:同盘移动、异盘复制)
let action = mode === 'copy' ? 'copy' : mode === 'auto' ? (sameVolume(src) ? 'move' : 'copy') : 'move';
try {
const s = await fsp.stat(src);
// 禁止:丢到自己身上、丢进自己内部(移动时会毁掉整棵子树)
if (destLower === srcLower || (action === 'move' && destLower.startsWith(srcLower + path.sep))) {
results.push({ ok: false, from: src, error: '目标位于待处理项目内部,已跳过' });
continue;
}
// 已在目标文件夹内:移动无意义,复制则允许(safeJoinName 会生成“原名 (2)”)
if (action === 'move' && path.dirname(src).toLowerCase() === destLower) {
results.push({ ok: false, from: src, error: '项目已在该文件夹中' });
continue;
}
const target = await safeJoinName(destDir, path.basename(src));
if (action === 'move') {
try {
await fsp.rename(src, target); // 同盘原子操作,最快
} catch (err) {
if (err && (err.code === 'EXDEV' || err.code === 'EPERM' || err.code === 'EACCES')) {
// 跨卷/权限不允许 rename:复制后删除源(同剪切粘贴的兜底路径)
action = 'copy';
await copyRecursive(src, target);
await fsp.rm(src, { recursive: true, force: true });
action = 'move';
} else {
throw err;
}
}
} else {
await copyRecursive(src, target);
}
results.push({ ok: true, from: src, to: target, action });
} catch (err) {
results.push({ ok: false, from: src, error: err.message });
}
}
// 移动成功的项目同步改写关联库;复制不动库
const moved = results.filter(x => x.ok && x.action === 'move').map(x => ({ from: x.from, to: x.to }));
if (moved.length) await rewritePathsInDb(moved).catch(() => {});
return { ok: true, results };
});
ipcMain.handle('op:mkdir', async (e, parent) => {
try {
const name = await uniqueFolderName(parent, 'New folder');
const full = path.join(parent, name);
await fsp.mkdir(full, { recursive: true });
return { ok: true, path: full };
} catch (err) {
return { ok: false, error: err.message };
}
});
async function uniqueFolderName(parent, base) {
let candidate = base;
if (!fs.existsSync(path.join(parent, candidate))) return candidate;
for (let i = 2; i < 1000; i++) {
const t = `${base} (${i})`;
if (!fs.existsSync(path.join(parent, t))) return t;
}
return `${base}_${Date.now()}`;
}
ipcMain.handle('op:rename', async (e, oldPath, newName) => {
try {
const dir = path.dirname(oldPath);
const target = await safeJoinName(dir, newName);
await fsp.rename(oldPath, target);
// 同步改写关联库(含文件夹内部项目的路径前缀),链接不断
await rewritePathsInDb([{ from: oldPath, to: target }]).catch(() => {});
return { ok: true, path: target };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('op:delete', async (e, paths) => {
const results = [];
for (const p of paths) {
try {
await fsp.rm(p, { recursive: true, force: true });
results.push({ ok: true, path: p });
} catch (err) {
results.push({ ok: false, path: p, error: err.message });
}
}
return results;
});
ipcMain.handle('shell:reveal', (e, p) => shell.showItemInFolder(p));
ipcMain.handle('shell:open', (e, p) => shell.openPath(p));
// “打开方式”:调用 Windows 系统对话框(OpenAs_RunDLL),让用户挑选程序
ipcMain.handle('shell:openas', (e, p) => {
return new Promise((resolve) => {
const child = require('child_process').spawn(
'rundll32.exe',
['shell32.dll,OpenAs_RunDLL', p],
{ detached: true, stdio: 'ignore', windowsVerbatimArguments: false }
);
child.once('spawn', () => resolve({ ok: true }));
child.on('error', (err) => resolve({ ok: false, error: err.message }));
// rundll32 拉起系统对话框后即返回;解除引用避免其退出句柄影响主进程
child.unref();
});
});
ipcMain.handle('shell:trash', async (e, paths) => {
// Use shell.trashItem for safe recycle-bin deletion.
// Accept a single path or array of paths.
const arr = Array.isArray(paths) ? paths : [paths];
const errors = [];
for (const p of arr) {
try {
await shell.trashItem(p);
} catch (err) {
errors.push({ path: p, error: err.message });
}
}
return { ok: errors.length === 0, errors };
});
// ============================================================
// IPC: Associations (link related files together)
// ============================================================
ipcMain.handle('assoc:list', async (e, filePath) => {
const db = await loadDb();
return db[filePath] || [];
});
ipcMain.handle('assoc:add', async (e, filePath, relatedPath) => {
if (!filePath || !relatedPath) return { ok: false, error: 'Missing paths' };
const db = await loadDb();
if (!db[filePath]) db[filePath] = [];
if (!db[filePath].includes(relatedPath)) {
db[filePath].push(relatedPath);
await saveDb(db);
}
return { ok: true, related: db[filePath] };
});
ipcMain.handle('assoc:remove', async (e, filePath, relatedPath) => {
const db = await loadDb();
if (db[filePath]) {
db[filePath] = db[filePath].filter(p => p !== relatedPath);
if (db[filePath].length === 0) delete db[filePath];
await saveDb(db);
}
return { ok: true, related: db[filePath] || [] };
});
// 批量取消关联:一次移除同一源文件下的多个关联目标
ipcMain.handle('assoc:removeMany', async (e, filePath, relatedPaths) => {
const remove = new Set(Array.isArray(relatedPaths) ? relatedPaths.filter(p => typeof p === 'string') : []);
if (!filePath || !remove.size) {
const db0 = await loadDb();
return { ok: true, related: db0[filePath] || [] };
}
const db = await loadDb();
if (db[filePath]) {
db[filePath] = db[filePath].filter(p => !remove.has(p));
if (db[filePath].length === 0) delete db[filePath];
await saveDb(db);
}
return { ok: true, related: db[filePath] || [] };
});
// 一键清理:删除所有指向“已不存在路径”的关联记录。
// - 源路径本身已不存在(文件被移动/删除)的整组记录删除
// - 其余源记录里,逐条剔除已失效的目标路径(不影响仍有效的关联)
ipcMain.handle('assoc:pruneMissing', async () => {
const db = await loadDb();
let sourcesRemoved = 0;
let edgesRemoved = 0;
for (const k of Object.keys(db)) {
let srcExists = true;
try { await fsp.access(k); } catch { srcExists = false; }
if (!srcExists) {
edgesRemoved += Array.isArray(db[k]) ? db[k].length : 0;
delete db[k];
sourcesRemoved++;
continue;
}
if (!Array.isArray(db[k])) { delete db[k]; continue; }
const kept = [];
for (const p of db[k]) {
let ok = true;
try { await fsp.access(p); } catch { ok = false; }
if (ok) kept.push(p);
else edgesRemoved++;
}
if (kept.length === 0) delete db[k];
else if (kept.length !== db[k].length) db[k] = kept;
}
await saveDb(db);
return { ok: true, edgesRemoved, sourcesRemoved };
});
// 重新指定失效关联的新位置:弹框选择文件的新位置,再把库里所有 oldPath 改指 newPath
ipcMain.handle('assoc:relink', async (e, oldPath) => {
try {
if (typeof oldPath !== 'string' || !oldPath) return { ok: false, error: '路径无效' };
const win = BrowserWindow.fromWebContents(e.sender) || undefined;
// 无扩展名按文件夹处理(旧路径已不存在,无法 stat 判断类型)
const isLikelyDir = !path.extname(oldPath);
const r = await dialog.showOpenDialog(win, {
title: `选择「${path.basename(oldPath)}」的新位置`,
defaultPath: path.dirname(oldPath),
properties: [isLikelyDir ? 'openDirectory' : 'openFile']
});
if (r.canceled || !r.filePaths || !r.filePaths.length) return { ok: false, canceled: true };
const newPath = r.filePaths[0];
if (oldPath.toLowerCase() === newPath.toLowerCase()) return { ok: false, error: '新旧位置相同' };
const before = JSON.stringify(await loadDb());
await rewritePathsInDb([{ from: oldPath, to: newPath }]);
const after = JSON.stringify(await loadDb());
if (before === after) return { ok: false, error: '没有指向该旧路径的记录' };
return { ok: true, path: newPath };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('assoc:pick', async (e, sourcePath) => {
// 选择器初始目录:传入的是目录则直接使用(批量关联传当前目录);
// 传入的是文件则取其所在目录(单个源文件添加关联)
let startDir;
try {
const st = await fsp.stat(sourcePath || '');
startDir = st.isDirectory() ? sourcePath : path.dirname(sourcePath || '');
await fsp.access(startDir, fs.constants.R_OK);
} catch {
startDir = app.getPath('home');
}
const parentWin = BrowserWindow.fromWebContents(e.sender);
return await openPicker(parentWin, { startDir, mode: 'both', title: '选择要关联的文件或文件夹', target: sourcePath || '' });
});
// ============================================================
// 自定义选择器窗口(替代 Windows 原生对话框)
// - mode='both':文件和文件夹同框多选、跨目录累积(添加关联用)
// - mode='dir' :仅文件夹,单选“当前所在文件夹”(快速访问/设置用)
// 返回绝对路径数组(取消返回 [])。
// ============================================================
let pickerResolve = null;
function openPicker(parentWin, opts = {}) {
const startDir = opts.startDir || '';
const mode = opts.mode === 'dir' ? 'dir' : 'both';
const target = opts.target || '';
const title = opts.title || '选择文件或文件夹';
return new Promise((resolve) => {
const win = new BrowserWindow({
width: 1080,
height: 680,
minWidth: 880,
minHeight: 480,
title,
backgroundColor: '#0a0a0a',
parent: parentWin || undefined,
modal: true,
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
});
pickerResolve = resolve;
// 关闭窗口(×、Esc)一律视为取消
win.on('closed', () => {
if (pickerResolve) { pickerResolve([]); pickerResolve = null; }
});
const q = '?dir=' + encodeURIComponent(startDir) + '&mode=' + encodeURIComponent(mode)
+ '&target=' + encodeURIComponent(target);
win.loadFile(path.join(__dirname, 'renderer', 'picker.html'), { search: q });
});
}
ipcMain.on('picker:confirm', (e, paths) => {
const win = BrowserWindow.fromWebContents(e.sender);
const arr = Array.isArray(paths) ? paths.filter(p => typeof p === 'string' && p) : [];
if (pickerResolve) { pickerResolve(arr); pickerResolve = null; }
if (win && !win.isDestroyed()) win.close();
});
ipcMain.on('picker:cancel', (e) => {
const win = BrowserWindow.fromWebContents(e.sender);
if (pickerResolve) { pickerResolve([]); pickerResolve = null; }
if (win && !win.isDestroyed()) win.close();
});
ipcMain.handle('assoc:search', async (e, filePath) => {
// Reverse lookup: find files that link to filePath
const db = await loadDb();
const refs = [];
for (const [k, list] of Object.entries(db)) {
if (list.includes(filePath)) refs.push(k);
}
return refs;
});
// Relationship graph: bidirectional BFS from a center item.
// Returns directed edges + nodes (up to 2 hops, capped) for the graph panel.
ipcMain.handle('assoc:graph', async (e, centerPath) => {
try {
if (!centerPath) return { ok: false, error: '缺少路径' };
const db = await loadDb();
// Build reverse index once (path -> items that link to it)
const rev = new Map();
for (const [k, list] of Object.entries(db)) {
if (!Array.isArray(list)) continue;
for (const q of list) {
if (!rev.has(q)) rev.set(q, []);
rev.get(q).push(k);
}
}
const MAX_DEPTH = 2;
const MAX_NODES = 60;
const SEP = String.fromCharCode(0); // NUL can never occur in a file path
const depth = new Map([[centerPath, 0]]);
const parent = new Map();
const edgeSet = new Set();
const queue = [centerPath];
while (queue.length && depth.size <= MAX_NODES) {
const p = queue.shift();
const d = depth.get(p);
if (d >= MAX_DEPTH) continue;
const discover = (q) => {
if (!depth.has(q)) {
depth.set(q, d + 1);
parent.set(q, p);
queue.push(q);
}
};
for (const q of (db[p] || [])) {
edgeSet.add(p + SEP + q);
discover(q);
if (depth.size > MAX_NODES) break;
}
for (const k of (rev.get(p) || [])) {
edgeSet.add(k + SEP + p);
discover(k);
if (depth.size > MAX_NODES) break;
}
}
const nodes = [];
for (const [p, d] of depth) {
let isDir = false, missing = false;
try {
const s = await fsp.stat(p);
isDir = s.isDirectory();
} catch {
missing = true;
}
nodes.push({
path: p,
name: path.basename(p) || p,
depth: d,
isDir,
missing,
parent: parent.get(p) || null
});
}
const edges = [...edgeSet]
.map(s => { const i = s.indexOf(SEP); return { from: s.slice(0, i), to: s.slice(i + 1) }; })
.filter(e => depth.has(e.from) && depth.has(e.to));
return { ok: true, center: centerPath, nodes, edges, truncated: depth.size > MAX_NODES };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('stat:file', async (e, p) => {
try {
const s = await fsp.stat(p);
return {
ok: true,
size: s.size,
mtime: s.mtimeMs,
isDir: s.isDirectory(),
ext: s.isDirectory() ? '' : path.extname(p).toLowerCase()
};
} catch (err) {
return { ok: false, error: err.message };
}
});
// ============================================================
// IPC: 关联数据备份 / 恢复(升级软件前保存链接)
// 备份文件为带版本标记的 JSON;恢复支持 合并(默认) / 覆盖
// ============================================================
function countLinks(db) {
let n = 0;
for (const list of Object.values(db || {})) if (Array.isArray(list)) n += list.length;
return n;