@wendongfly/zihi 1.0.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/bin/daemon.js +23 -0
- package/bin/zihi.js +603 -0
- package/dist/admin.html +297 -0
- package/dist/attach.js +2 -0
- package/dist/chat.html +2254 -0
- package/dist/client-dist/socket.io.esm.min.js +7 -0
- package/dist/client-dist/socket.io.js +4955 -0
- package/dist/client-dist/socket.io.min.js +7 -0
- package/dist/client-dist/socket.io.msgpack.min.js +7 -0
- package/dist/files.html +722 -0
- package/dist/icon.png +0 -0
- package/dist/icon.svg +4 -0
- package/dist/index.html +976 -0
- package/dist/index.js +485 -0
- package/dist/lib/ansi_up.js +431 -0
- package/dist/lib/xterm/LICENSE +21 -0
- package/dist/lib/xterm/README.md +230 -0
- package/dist/lib/xterm/css/xterm.css +209 -0
- package/dist/lib/xterm/lib/xterm.js +2 -0
- package/dist/lib/xterm/lib/xterm.js.map +1 -0
- package/dist/lib/xterm/package.json +100 -0
- package/dist/lib/xterm/src/browser/AccessibilityManager.ts +300 -0
- package/dist/lib/xterm/src/browser/Clipboard.ts +93 -0
- package/dist/lib/xterm/src/browser/ColorContrastCache.ts +34 -0
- package/dist/lib/xterm/src/browser/Lifecycle.ts +33 -0
- package/dist/lib/xterm/src/browser/Linkifier2.ts +416 -0
- package/dist/lib/xterm/src/browser/LocalizableStrings.ts +12 -0
- package/dist/lib/xterm/src/browser/OscLinkProvider.ts +128 -0
- package/dist/lib/xterm/src/browser/RenderDebouncer.ts +83 -0
- package/dist/lib/xterm/src/browser/ScreenDprMonitor.ts +72 -0
- package/dist/lib/xterm/src/browser/Terminal.ts +1305 -0
- package/dist/lib/xterm/src/browser/TimeBasedDebouncer.ts +86 -0
- package/dist/lib/xterm/src/browser/Types.d.ts +181 -0
- package/dist/lib/xterm/src/browser/Viewport.ts +401 -0
- package/dist/lib/xterm/src/browser/decorations/BufferDecorationRenderer.ts +134 -0
- package/dist/lib/xterm/src/browser/decorations/ColorZoneStore.ts +117 -0
- package/dist/lib/xterm/src/browser/decorations/OverviewRulerRenderer.ts +219 -0
- package/dist/lib/xterm/src/browser/input/CompositionHelper.ts +246 -0
- package/dist/lib/xterm/src/browser/input/Mouse.ts +54 -0
- package/dist/lib/xterm/src/browser/input/MoveToCell.ts +249 -0
- package/dist/lib/xterm/src/browser/public/Terminal.ts +260 -0
- package/dist/lib/xterm/src/browser/renderer/dom/DomRenderer.ts +506 -0
- package/dist/lib/xterm/src/browser/renderer/dom/DomRendererRowFactory.ts +522 -0
- package/dist/lib/xterm/src/browser/renderer/dom/WidthCache.ts +157 -0
- package/dist/lib/xterm/src/browser/renderer/shared/CellColorResolver.ts +137 -0
- package/dist/lib/xterm/src/browser/renderer/shared/CharAtlasCache.ts +96 -0
- package/dist/lib/xterm/src/browser/renderer/shared/CharAtlasUtils.ts +75 -0
- package/dist/lib/xterm/src/browser/renderer/shared/Constants.ts +14 -0
- package/dist/lib/xterm/src/browser/renderer/shared/CursorBlinkStateManager.ts +146 -0
- package/dist/lib/xterm/src/browser/renderer/shared/CustomGlyphs.ts +687 -0
- package/dist/lib/xterm/src/browser/renderer/shared/DevicePixelObserver.ts +41 -0
- package/dist/lib/xterm/src/browser/renderer/shared/README.md +1 -0
- package/dist/lib/xterm/src/browser/renderer/shared/RendererUtils.ts +58 -0
- package/dist/lib/xterm/src/browser/renderer/shared/SelectionRenderModel.ts +91 -0
- package/dist/lib/xterm/src/browser/renderer/shared/TextureAtlas.ts +1082 -0
- package/dist/lib/xterm/src/browser/renderer/shared/Types.d.ts +173 -0
- package/dist/lib/xterm/src/browser/selection/SelectionModel.ts +144 -0
- package/dist/lib/xterm/src/browser/selection/Types.d.ts +15 -0
- package/dist/lib/xterm/src/browser/services/CharSizeService.ts +102 -0
- package/dist/lib/xterm/src/browser/services/CharacterJoinerService.ts +339 -0
- package/dist/lib/xterm/src/browser/services/CoreBrowserService.ts +33 -0
- package/dist/lib/xterm/src/browser/services/MouseService.ts +46 -0
- package/dist/lib/xterm/src/browser/services/RenderService.ts +284 -0
- package/dist/lib/xterm/src/browser/services/SelectionService.ts +1029 -0
- package/dist/lib/xterm/src/browser/services/Services.ts +138 -0
- package/dist/lib/xterm/src/browser/services/ThemeService.ts +237 -0
- package/dist/lib/xterm/src/common/CircularList.ts +241 -0
- package/dist/lib/xterm/src/common/Clone.ts +23 -0
- package/dist/lib/xterm/src/common/Color.ts +356 -0
- package/dist/lib/xterm/src/common/CoreTerminal.ts +284 -0
- package/dist/lib/xterm/src/common/EventEmitter.ts +73 -0
- package/dist/lib/xterm/src/common/InputHandler.ts +3443 -0
- package/dist/lib/xterm/src/common/Lifecycle.ts +108 -0
- package/dist/lib/xterm/src/common/MultiKeyMap.ts +42 -0
- package/dist/lib/xterm/src/common/Platform.ts +43 -0
- package/dist/lib/xterm/src/common/SortedList.ts +118 -0
- package/dist/lib/xterm/src/common/TaskQueue.ts +166 -0
- package/dist/lib/xterm/src/common/TypedArrayUtils.ts +17 -0
- package/dist/lib/xterm/src/common/Types.d.ts +553 -0
- package/dist/lib/xterm/src/common/WindowsMode.ts +27 -0
- package/dist/lib/xterm/src/common/buffer/AttributeData.ts +196 -0
- package/dist/lib/xterm/src/common/buffer/Buffer.ts +654 -0
- package/dist/lib/xterm/src/common/buffer/BufferLine.ts +520 -0
- package/dist/lib/xterm/src/common/buffer/BufferRange.ts +13 -0
- package/dist/lib/xterm/src/common/buffer/BufferReflow.ts +223 -0
- package/dist/lib/xterm/src/common/buffer/BufferSet.ts +134 -0
- package/dist/lib/xterm/src/common/buffer/CellData.ts +94 -0
- package/dist/lib/xterm/src/common/buffer/Constants.ts +149 -0
- package/dist/lib/xterm/src/common/buffer/Marker.ts +43 -0
- package/dist/lib/xterm/src/common/buffer/Types.d.ts +52 -0
- package/dist/lib/xterm/src/common/data/Charsets.ts +256 -0
- package/dist/lib/xterm/src/common/data/EscapeSequences.ts +153 -0
- package/dist/lib/xterm/src/common/input/Keyboard.ts +398 -0
- package/dist/lib/xterm/src/common/input/TextDecoder.ts +346 -0
- package/dist/lib/xterm/src/common/input/UnicodeV6.ts +132 -0
- package/dist/lib/xterm/src/common/input/WriteBuffer.ts +246 -0
- package/dist/lib/xterm/src/common/input/XParseColor.ts +80 -0
- package/dist/lib/xterm/src/common/parser/Constants.ts +58 -0
- package/dist/lib/xterm/src/common/parser/DcsParser.ts +192 -0
- package/dist/lib/xterm/src/common/parser/EscapeSequenceParser.ts +792 -0
- package/dist/lib/xterm/src/common/parser/OscParser.ts +238 -0
- package/dist/lib/xterm/src/common/parser/Params.ts +229 -0
- package/dist/lib/xterm/src/common/parser/Types.d.ts +274 -0
- package/dist/lib/xterm/src/common/public/AddonManager.ts +53 -0
- package/dist/lib/xterm/src/common/public/BufferApiView.ts +35 -0
- package/dist/lib/xterm/src/common/public/BufferLineApiView.ts +29 -0
- package/dist/lib/xterm/src/common/public/BufferNamespaceApi.ts +36 -0
- package/dist/lib/xterm/src/common/public/ParserApi.ts +37 -0
- package/dist/lib/xterm/src/common/public/UnicodeApi.ts +27 -0
- package/dist/lib/xterm/src/common/services/BufferService.ts +151 -0
- package/dist/lib/xterm/src/common/services/CharsetService.ts +34 -0
- package/dist/lib/xterm/src/common/services/CoreMouseService.ts +318 -0
- package/dist/lib/xterm/src/common/services/CoreService.ts +87 -0
- package/dist/lib/xterm/src/common/services/DecorationService.ts +140 -0
- package/dist/lib/xterm/src/common/services/InstantiationService.ts +85 -0
- package/dist/lib/xterm/src/common/services/LogService.ts +124 -0
- package/dist/lib/xterm/src/common/services/OptionsService.ts +201 -0
- package/dist/lib/xterm/src/common/services/OscLinkService.ts +115 -0
- package/dist/lib/xterm/src/common/services/ServiceRegistry.ts +49 -0
- package/dist/lib/xterm/src/common/services/Services.ts +342 -0
- package/dist/lib/xterm/src/common/services/UnicodeService.ts +86 -0
- package/dist/lib/xterm/src/headless/Terminal.ts +136 -0
- package/dist/lib/xterm/src/headless/public/Terminal.ts +195 -0
- package/dist/lib/xterm/typings/xterm.d.ts +1844 -0
- package/dist/lib/xterm-fit/LICENSE +19 -0
- package/dist/lib/xterm-fit/README.md +24 -0
- package/dist/lib/xterm-fit/lib/xterm-addon-fit.js +2 -0
- package/dist/lib/xterm-fit/lib/xterm-addon-fit.js.map +1 -0
- package/dist/lib/xterm-fit/package.json +26 -0
- package/dist/lib/xterm-fit/src/FitAddon.ts +89 -0
- package/dist/lib/xterm-fit/typings/xterm-addon-fit.d.ts +55 -0
- package/dist/lib/xterm-links/LICENSE +19 -0
- package/dist/lib/xterm-links/README.md +21 -0
- package/dist/lib/xterm-links/lib/xterm-addon-web-links.js +2 -0
- package/dist/lib/xterm-links/lib/xterm-addon-web-links.js.map +1 -0
- package/dist/lib/xterm-links/package.json +26 -0
- package/dist/lib/xterm-links/src/WebLinkProvider.ts +198 -0
- package/dist/lib/xterm-links/src/WebLinksAddon.ts +57 -0
- package/dist/lib/xterm-links/typings/xterm-addon-web-links.d.ts +53 -0
- package/dist/login.html +163 -0
- package/dist/manifest.json +12 -0
- package/dist/package.json +1 -0
- package/dist/sw.js +127 -0
- package/dist/sync.html +816 -0
- package/package.json +47 -0
package/dist/sync.html
ADDED
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
|
|
6
|
+
<meta name="theme-color" content="#0d1117">
|
|
7
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
8
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
9
|
+
<title>weHi - Sync</title>
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #0d1117;
|
|
13
|
+
--surface: #161b22;
|
|
14
|
+
--border: #21262d;
|
|
15
|
+
--accent: #f97316;
|
|
16
|
+
--accent2: #fb923c;
|
|
17
|
+
--text: #e6edf3;
|
|
18
|
+
--muted: #8b949e;
|
|
19
|
+
--green: #3fb950;
|
|
20
|
+
--red: #f85149;
|
|
21
|
+
--yellow: #d29922;
|
|
22
|
+
--blue: #58a6ff;
|
|
23
|
+
}
|
|
24
|
+
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
|
25
|
+
html, body {
|
|
26
|
+
height: 100%;
|
|
27
|
+
background: var(--bg);
|
|
28
|
+
color: var(--text);
|
|
29
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
30
|
+
overscroll-behavior: none;
|
|
31
|
+
}
|
|
32
|
+
#app {
|
|
33
|
+
display: flex; flex-direction: column; height: 100%;
|
|
34
|
+
max-width: 640px; margin: 0 auto;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* ── Header ── */
|
|
38
|
+
#header {
|
|
39
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
40
|
+
padding: 0 1rem; padding-top: max(1rem, env(safe-area-inset-top));
|
|
41
|
+
padding-bottom: 0.75rem; background: var(--bg);
|
|
42
|
+
position: sticky; top: 0; z-index: 10;
|
|
43
|
+
border-bottom: 1px solid var(--border);
|
|
44
|
+
}
|
|
45
|
+
.header-left { display: flex; align-items: center; gap: 0.6rem; }
|
|
46
|
+
.back-btn {
|
|
47
|
+
background: none; border: 1px solid var(--border); color: var(--muted);
|
|
48
|
+
font-size: 0.85rem; padding: 0.3rem 0.6rem; border-radius: 6px; cursor: pointer;
|
|
49
|
+
text-decoration: none;
|
|
50
|
+
}
|
|
51
|
+
.back-btn:active { border-color: var(--accent); color: var(--accent); }
|
|
52
|
+
.logo { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.5px; }
|
|
53
|
+
.logo span { color: var(--accent); }
|
|
54
|
+
.hdr-btn {
|
|
55
|
+
background: none; border: 1px solid var(--border); color: var(--muted);
|
|
56
|
+
font-size: 0.75rem; padding: 0.3rem 0.6rem; border-radius: 6px; cursor: pointer;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ── Content ── */
|
|
60
|
+
#content {
|
|
61
|
+
flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch;
|
|
62
|
+
padding: 1rem 1rem 4rem;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ── Section ── */
|
|
66
|
+
.section {
|
|
67
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
68
|
+
border-radius: 12px; padding: 1rem; margin-bottom: 1rem;
|
|
69
|
+
}
|
|
70
|
+
.section-title {
|
|
71
|
+
font-size: 0.85rem; font-weight: 600; color: var(--muted);
|
|
72
|
+
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 0.75rem;
|
|
73
|
+
}
|
|
74
|
+
.field { margin-bottom: 0.75rem; }
|
|
75
|
+
.field label { font-size: 0.8rem; color: var(--muted); display: block; margin-bottom: 0.3rem; }
|
|
76
|
+
.field input, .field select {
|
|
77
|
+
width: 100%; padding: 0.55rem 0.75rem; background: var(--bg);
|
|
78
|
+
border: 1px solid var(--border); border-radius: 8px; color: var(--text);
|
|
79
|
+
font-size: 0.85rem; outline: none;
|
|
80
|
+
}
|
|
81
|
+
.field input:focus { border-color: var(--accent); }
|
|
82
|
+
.field-row { display: flex; gap: 0.5rem; align-items: flex-end; }
|
|
83
|
+
.field-row .field { flex: 1; margin-bottom: 0; }
|
|
84
|
+
|
|
85
|
+
/* ── Buttons ── */
|
|
86
|
+
.btn {
|
|
87
|
+
padding: 0.55rem 1rem; border-radius: 8px; font-size: 0.85rem;
|
|
88
|
+
cursor: pointer; border: none; font-weight: 600; white-space: nowrap;
|
|
89
|
+
}
|
|
90
|
+
.btn-primary { background: var(--accent); color: #fff; }
|
|
91
|
+
.btn-primary:active { opacity: 0.8; }
|
|
92
|
+
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
93
|
+
.btn-outline {
|
|
94
|
+
background: none; border: 1px solid var(--border); color: var(--text);
|
|
95
|
+
}
|
|
96
|
+
.btn-outline:active { border-color: var(--accent); color: var(--accent); }
|
|
97
|
+
.btn-sm { padding: 0.35rem 0.6rem; font-size: 0.75rem; }
|
|
98
|
+
.btn-green { background: var(--green); color: #fff; }
|
|
99
|
+
.btn-blue { background: var(--blue); color: #fff; }
|
|
100
|
+
.btn-red { background: var(--red); color: #fff; }
|
|
101
|
+
|
|
102
|
+
.btn-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
103
|
+
|
|
104
|
+
/* ── Project card ── */
|
|
105
|
+
.project-card {
|
|
106
|
+
background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
|
|
107
|
+
padding: 0.75rem; margin-bottom: 0.5rem;
|
|
108
|
+
}
|
|
109
|
+
.project-card .name { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.35rem; }
|
|
110
|
+
.project-card .paths { font-size: 0.75rem; color: var(--muted); }
|
|
111
|
+
.project-card .paths div { margin-bottom: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
112
|
+
.project-card .actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
|
|
113
|
+
|
|
114
|
+
/* ── Diff list ── */
|
|
115
|
+
#diff-section { display: none; }
|
|
116
|
+
#diff-section.show { display: block; }
|
|
117
|
+
.diff-stats {
|
|
118
|
+
display: flex; gap: 1rem; margin-bottom: 0.75rem; font-size: 0.8rem;
|
|
119
|
+
}
|
|
120
|
+
.stat { display: flex; align-items: center; gap: 0.3rem; }
|
|
121
|
+
.stat .dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
122
|
+
.dot-new { background: var(--green); }
|
|
123
|
+
.dot-mod { background: var(--yellow); }
|
|
124
|
+
.dot-del { background: var(--red); }
|
|
125
|
+
.dot-same { background: var(--muted); }
|
|
126
|
+
|
|
127
|
+
.diff-list { max-height: 50vh; overflow-y: auto; }
|
|
128
|
+
.diff-item {
|
|
129
|
+
display: flex; align-items: center; gap: 0.5rem;
|
|
130
|
+
padding: 0.4rem 0.5rem; font-size: 0.8rem;
|
|
131
|
+
border-bottom: 1px solid var(--border);
|
|
132
|
+
font-family: 'SF Mono', Consolas, monospace;
|
|
133
|
+
}
|
|
134
|
+
.diff-item:last-child { border-bottom: none; }
|
|
135
|
+
.diff-badge {
|
|
136
|
+
font-size: 0.65rem; font-weight: 700; padding: 0.1rem 0.4rem;
|
|
137
|
+
border-radius: 4px; flex-shrink: 0; text-transform: uppercase;
|
|
138
|
+
}
|
|
139
|
+
.badge-new { background: rgba(63,185,80,0.2); color: var(--green); }
|
|
140
|
+
.badge-mod { background: rgba(210,153,34,0.2); color: var(--yellow); }
|
|
141
|
+
.badge-del { background: rgba(248,81,73,0.2); color: var(--red); }
|
|
142
|
+
.diff-path {
|
|
143
|
+
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
144
|
+
}
|
|
145
|
+
.diff-size { color: var(--muted); font-size: 0.7rem; flex-shrink: 0; }
|
|
146
|
+
.diff-check { flex-shrink: 0; accent-color: var(--accent); }
|
|
147
|
+
|
|
148
|
+
/* ── Progress ── */
|
|
149
|
+
#sync-progress { display: none; }
|
|
150
|
+
#sync-progress.show { display: block; }
|
|
151
|
+
.progress-track {
|
|
152
|
+
width: 100%; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden;
|
|
153
|
+
}
|
|
154
|
+
.progress-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.15s; width: 0%; }
|
|
155
|
+
#progress-text { font-size: 0.75rem; color: var(--muted); margin-top: 0.35rem; }
|
|
156
|
+
#sync-log {
|
|
157
|
+
margin-top: 0.5rem; max-height: 200px; overflow-y: auto;
|
|
158
|
+
font-size: 0.7rem; font-family: 'SF Mono', Consolas, monospace;
|
|
159
|
+
color: var(--muted); background: var(--bg); border-radius: 6px;
|
|
160
|
+
padding: 0.5rem; display: none;
|
|
161
|
+
}
|
|
162
|
+
#sync-log.show { display: block; }
|
|
163
|
+
|
|
164
|
+
/* ── Ignore editor ── */
|
|
165
|
+
.ignore-area {
|
|
166
|
+
width: 100%; min-height: 80px; padding: 0.5rem 0.75rem; background: var(--bg);
|
|
167
|
+
border: 1px solid var(--border); border-radius: 8px; color: var(--text);
|
|
168
|
+
font-size: 0.8rem; font-family: 'SF Mono', Consolas, monospace;
|
|
169
|
+
outline: none; resize: vertical; line-height: 1.6;
|
|
170
|
+
}
|
|
171
|
+
.ignore-area:focus { border-color: var(--accent); }
|
|
172
|
+
|
|
173
|
+
/* ── Unsupported ── */
|
|
174
|
+
.unsupported {
|
|
175
|
+
text-align: center; padding: 3rem 1rem; color: var(--muted);
|
|
176
|
+
}
|
|
177
|
+
.unsupported h2 { font-size: 1.1rem; margin-bottom: 0.75rem; color: var(--text); }
|
|
178
|
+
.unsupported p { font-size: 0.85rem; line-height: 1.6; }
|
|
179
|
+
.unsupported a { color: var(--accent); }
|
|
180
|
+
|
|
181
|
+
/* ── Notification toast ── */
|
|
182
|
+
.toast {
|
|
183
|
+
position: fixed; top: 1rem; left: 50%; transform: translateX(-50%);
|
|
184
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
185
|
+
color: var(--text); padding: 0.5rem 1rem; border-radius: 8px;
|
|
186
|
+
font-size: 0.8rem; z-index: 100; opacity: 0; transition: opacity 0.3s;
|
|
187
|
+
pointer-events: none;
|
|
188
|
+
}
|
|
189
|
+
.toast.show { opacity: 1; }
|
|
190
|
+
</style>
|
|
191
|
+
</head>
|
|
192
|
+
<body>
|
|
193
|
+
<div id="app">
|
|
194
|
+
<div id="header">
|
|
195
|
+
<div class="header-left">
|
|
196
|
+
<a id="back-link" href="/" class="back-btn">←</a>
|
|
197
|
+
<div class="logo">we<span>Hi</span> Sync</div>
|
|
198
|
+
</div>
|
|
199
|
+
<div style="display:flex;gap:0.6rem">
|
|
200
|
+
<a id="files-link" href="/files" class="hdr-btn">文件</a>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div id="content">
|
|
205
|
+
<!-- Unsupported browser message (hidden if supported) -->
|
|
206
|
+
<div id="unsupported" class="unsupported" style="display:none">
|
|
207
|
+
<h2>浏览器不支持文件系统 API</h2>
|
|
208
|
+
<p>同步功能需要 <strong>File System Access API</strong>,请使用:</p>
|
|
209
|
+
<p>Chrome 86+ / Edge 86+ / Opera 72+</p>
|
|
210
|
+
<p style="margin-top:0.5rem">或者使用 <a href="/files">文件管理器</a> 手动上传下载。</p>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<!-- Main sync UI -->
|
|
214
|
+
<div id="main-ui">
|
|
215
|
+
<!-- Project config section -->
|
|
216
|
+
<div class="section">
|
|
217
|
+
<div class="section-title">同步项目</div>
|
|
218
|
+
<div id="project-list"></div>
|
|
219
|
+
<button class="btn btn-outline" onclick="addProject()" style="width:100%;margin-top:0.5rem">+ 添加同步项目</button>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<!-- New/Edit project form -->
|
|
223
|
+
<div class="section" id="project-form" style="display:none">
|
|
224
|
+
<div class="section-title" id="form-title">新建同步项目</div>
|
|
225
|
+
<div class="field">
|
|
226
|
+
<label>项目名称</label>
|
|
227
|
+
<input id="inp-name" placeholder="如:my-project" autocomplete="off">
|
|
228
|
+
</div>
|
|
229
|
+
<div class="field">
|
|
230
|
+
<label>远程目录 (服务器上的项目路径)</label>
|
|
231
|
+
<div class="field-row">
|
|
232
|
+
<div class="field">
|
|
233
|
+
<input id="inp-remote" placeholder="如:projects/my-app" autocomplete="off">
|
|
234
|
+
</div>
|
|
235
|
+
<button class="btn btn-outline btn-sm" onclick="browseRemote()">浏览</button>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
<div class="field">
|
|
239
|
+
<label>本地目录 (当前电脑上的文件夹)</label>
|
|
240
|
+
<div class="field-row">
|
|
241
|
+
<div class="field">
|
|
242
|
+
<input id="inp-local" placeholder="点击右侧选择..." readonly>
|
|
243
|
+
</div>
|
|
244
|
+
<button class="btn btn-outline btn-sm" onclick="pickLocalDir()">选择</button>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
<div class="field">
|
|
248
|
+
<label>忽略文件/文件夹 (每行一个)</label>
|
|
249
|
+
<textarea class="ignore-area" id="inp-ignore"></textarea>
|
|
250
|
+
</div>
|
|
251
|
+
<div class="btn-row" style="margin-top:0.75rem">
|
|
252
|
+
<button class="btn btn-primary" onclick="saveProject()">保存</button>
|
|
253
|
+
<button class="btn btn-outline" onclick="cancelForm()">取消</button>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<!-- Active sync panel -->
|
|
258
|
+
<div class="section" id="sync-panel" style="display:none">
|
|
259
|
+
<div class="section-title">
|
|
260
|
+
<span id="sync-project-name"></span>
|
|
261
|
+
<button class="btn btn-outline btn-sm" style="float:right" onclick="closeSyncPanel()">关闭</button>
|
|
262
|
+
</div>
|
|
263
|
+
<div class="btn-row" style="margin-bottom:0.75rem">
|
|
264
|
+
<button class="btn btn-blue btn-sm" id="btn-compare" onclick="doCompare()">对比差异</button>
|
|
265
|
+
<button class="btn btn-green btn-sm" id="btn-pull" onclick="doSync('pull')" disabled>拉取到本地</button>
|
|
266
|
+
<button class="btn btn-primary btn-sm" id="btn-push" onclick="doSync('push')" disabled>推送到远程</button>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<div id="diff-section">
|
|
270
|
+
<div class="diff-stats">
|
|
271
|
+
<div class="stat"><div class="dot dot-new"></div><span id="stat-new">0</span> 新增</div>
|
|
272
|
+
<div class="stat"><div class="dot dot-mod"></div><span id="stat-mod">0</span> 修改</div>
|
|
273
|
+
<div class="stat"><div class="dot dot-del"></div><span id="stat-del">0</span> 删除</div>
|
|
274
|
+
<div class="stat"><div class="dot dot-same"></div><span id="stat-same">0</span> 相同</div>
|
|
275
|
+
</div>
|
|
276
|
+
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem">
|
|
277
|
+
<button class="btn btn-outline btn-sm" onclick="selectAll(true)">全选</button>
|
|
278
|
+
<button class="btn btn-outline btn-sm" onclick="selectAll(false)">全不选</button>
|
|
279
|
+
<span style="font-size:0.75rem;color:var(--muted);margin-left:auto" id="selected-count"></span>
|
|
280
|
+
</div>
|
|
281
|
+
<div class="diff-list" id="diff-list"></div>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<div id="sync-progress">
|
|
285
|
+
<div class="progress-track"><div class="progress-fill" id="progress-fill"></div></div>
|
|
286
|
+
<div id="progress-text"></div>
|
|
287
|
+
<div id="sync-log"></div>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
<div class="toast" id="toast"></div>
|
|
294
|
+
|
|
295
|
+
<script>
|
|
296
|
+
// ── State ──
|
|
297
|
+
let config = { projects: [] };
|
|
298
|
+
let editIndex = -1; // -1 = new, >= 0 = editing
|
|
299
|
+
let localDirHandle = null; // File System Access API handle
|
|
300
|
+
let activeProjectIndex = -1;
|
|
301
|
+
let diffItems = []; // current diff result
|
|
302
|
+
|
|
303
|
+
// 从 URL 获取 sessionId
|
|
304
|
+
const SESSION_ID = new URLSearchParams(location.search).get('session') || '';
|
|
305
|
+
function sessionParam(sep) { return SESSION_ID ? sep + 'sessionId=' + SESSION_ID : ''; }
|
|
306
|
+
|
|
307
|
+
// ── Check browser support ──
|
|
308
|
+
const hasFileAccess = 'showDirectoryPicker' in window;
|
|
309
|
+
if (!hasFileAccess) {
|
|
310
|
+
document.getElementById('unsupported').style.display = 'block';
|
|
311
|
+
document.getElementById('main-ui').style.display = 'none';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Init ──
|
|
315
|
+
(async function init() {
|
|
316
|
+
if (SESSION_ID) {
|
|
317
|
+
document.getElementById('back-link').href = '/terminal/' + SESSION_ID;
|
|
318
|
+
document.getElementById('files-link').href = '/files?session=' + SESSION_ID;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const res = await fetch('/api/sync/config');
|
|
322
|
+
if (res.status === 401) { location.href = '/login'; return; }
|
|
323
|
+
config = await res.json();
|
|
324
|
+
if (!config.projects) config.projects = [];
|
|
325
|
+
renderProjects();
|
|
326
|
+
} catch (e) {
|
|
327
|
+
toast('加载配置失败: ' + e.message);
|
|
328
|
+
}
|
|
329
|
+
})();
|
|
330
|
+
|
|
331
|
+
// ── Render project list ──
|
|
332
|
+
function renderProjects() {
|
|
333
|
+
const el = document.getElementById('project-list');
|
|
334
|
+
if (!config.projects.length) {
|
|
335
|
+
el.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;text-align:center;padding:1rem">还没有同步项目,点击下方添加</div>';
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
el.innerHTML = config.projects.map((p, i) => `
|
|
339
|
+
<div class="project-card">
|
|
340
|
+
<div class="name">${esc(p.name)}</div>
|
|
341
|
+
<div class="paths">
|
|
342
|
+
<div>远程: ${esc(p.remotePath)}</div>
|
|
343
|
+
<div>本地: ${esc(p.localName || '(需要重新选择)')}</div>
|
|
344
|
+
</div>
|
|
345
|
+
<div class="actions">
|
|
346
|
+
<button class="btn btn-blue btn-sm" onclick="openSync(${i})">同步</button>
|
|
347
|
+
<button class="btn btn-outline btn-sm" onclick="editProject(${i})">编辑</button>
|
|
348
|
+
<button class="btn btn-outline btn-sm" style="color:var(--red);border-color:var(--red)" onclick="deleteProject(${i})">删除</button>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
`).join('');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Add / Edit project ──
|
|
355
|
+
function addProject() {
|
|
356
|
+
editIndex = -1;
|
|
357
|
+
document.getElementById('form-title').textContent = '新建同步项目';
|
|
358
|
+
document.getElementById('inp-name').value = '';
|
|
359
|
+
document.getElementById('inp-remote').value = '';
|
|
360
|
+
document.getElementById('inp-local').value = '';
|
|
361
|
+
document.getElementById('inp-ignore').value = [
|
|
362
|
+
'node_modules', '.git', '.DS_Store', 'Thumbs.db',
|
|
363
|
+
'__pycache__', '.env', '.env.local', '*.pyc',
|
|
364
|
+
'.cache', 'dist', 'build',
|
|
365
|
+
].join('\n');
|
|
366
|
+
localDirHandle = null;
|
|
367
|
+
document.getElementById('project-form').style.display = 'block';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function editProject(i) {
|
|
371
|
+
editIndex = i;
|
|
372
|
+
const p = config.projects[i];
|
|
373
|
+
document.getElementById('form-title').textContent = '编辑同步项目';
|
|
374
|
+
document.getElementById('inp-name').value = p.name;
|
|
375
|
+
document.getElementById('inp-remote').value = p.remotePath;
|
|
376
|
+
document.getElementById('inp-local').value = p.localName || '';
|
|
377
|
+
document.getElementById('inp-ignore').value = (p.ignore || []).join('\n');
|
|
378
|
+
localDirHandle = null; // will need to re-pick
|
|
379
|
+
document.getElementById('project-form').style.display = 'block';
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function cancelForm() {
|
|
383
|
+
document.getElementById('project-form').style.display = 'none';
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function pickLocalDir() {
|
|
387
|
+
try {
|
|
388
|
+
localDirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
|
389
|
+
document.getElementById('inp-local').value = localDirHandle.name;
|
|
390
|
+
} catch (e) {
|
|
391
|
+
if (e.name !== 'AbortError') toast('选择失败: ' + e.message);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function browseRemote() {
|
|
396
|
+
window.open('/files', '_blank');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function saveProject() {
|
|
400
|
+
const name = document.getElementById('inp-name').value.trim();
|
|
401
|
+
const remotePath = document.getElementById('inp-remote').value.trim();
|
|
402
|
+
const localName = document.getElementById('inp-local').value.trim();
|
|
403
|
+
const ignoreText = document.getElementById('inp-ignore').value;
|
|
404
|
+
const ignore = ignoreText.split('\n').map(s => s.trim()).filter(Boolean);
|
|
405
|
+
|
|
406
|
+
if (!name) { toast('请输入项目名称'); return; }
|
|
407
|
+
if (!remotePath) { toast('请输入远程目录'); return; }
|
|
408
|
+
|
|
409
|
+
const project = { name, remotePath, localName, ignore };
|
|
410
|
+
|
|
411
|
+
if (editIndex >= 0) {
|
|
412
|
+
config.projects[editIndex] = project;
|
|
413
|
+
} else {
|
|
414
|
+
config.projects.push(project);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
await fetch('/api/sync/config', {
|
|
419
|
+
method: 'POST',
|
|
420
|
+
headers: { 'Content-Type': 'application/json' },
|
|
421
|
+
body: JSON.stringify(config),
|
|
422
|
+
});
|
|
423
|
+
renderProjects();
|
|
424
|
+
cancelForm();
|
|
425
|
+
toast('已保存');
|
|
426
|
+
} catch (e) { toast('保存失败: ' + e.message); }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function deleteProject(i) {
|
|
430
|
+
if (!confirm('确定删除同步项目 "' + config.projects[i].name + '"?')) return;
|
|
431
|
+
config.projects.splice(i, 1);
|
|
432
|
+
fetch('/api/sync/config', {
|
|
433
|
+
method: 'POST',
|
|
434
|
+
headers: { 'Content-Type': 'application/json' },
|
|
435
|
+
body: JSON.stringify(config),
|
|
436
|
+
});
|
|
437
|
+
renderProjects();
|
|
438
|
+
closeSyncPanel();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── Open sync panel ──
|
|
442
|
+
async function openSync(i) {
|
|
443
|
+
activeProjectIndex = i;
|
|
444
|
+
const p = config.projects[i];
|
|
445
|
+
diffItems = [];
|
|
446
|
+
|
|
447
|
+
// Request local dir handle
|
|
448
|
+
try {
|
|
449
|
+
localDirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
|
450
|
+
} catch (e) {
|
|
451
|
+
if (e.name !== 'AbortError') toast('需要选择本地文件夹');
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
document.getElementById('sync-project-name').textContent = p.name + ' 同步';
|
|
456
|
+
document.getElementById('sync-panel').style.display = 'block';
|
|
457
|
+
document.getElementById('diff-section').classList.remove('show');
|
|
458
|
+
document.getElementById('sync-progress').classList.remove('show');
|
|
459
|
+
document.getElementById('btn-pull').disabled = true;
|
|
460
|
+
document.getElementById('btn-push').disabled = true;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function closeSyncPanel() {
|
|
464
|
+
document.getElementById('sync-panel').style.display = 'none';
|
|
465
|
+
activeProjectIndex = -1;
|
|
466
|
+
diffItems = [];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Compare ──
|
|
470
|
+
async function doCompare() {
|
|
471
|
+
if (activeProjectIndex < 0 || !localDirHandle) return;
|
|
472
|
+
const p = config.projects[activeProjectIndex];
|
|
473
|
+
const btn = document.getElementById('btn-compare');
|
|
474
|
+
btn.disabled = true;
|
|
475
|
+
btn.textContent = '对比中...';
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
// 1. Get remote manifest (SHA-256 hashes)
|
|
479
|
+
const params = new URLSearchParams({ path: p.remotePath });
|
|
480
|
+
if (SESSION_ID) params.set('sessionId', SESSION_ID);
|
|
481
|
+
const remoteRes = await fetch('/api/files/manifest?' + params);
|
|
482
|
+
const remoteData = await remoteRes.json();
|
|
483
|
+
if (remoteData.error) { toast('远程错误: ' + remoteData.error); return; }
|
|
484
|
+
// 转换 manifest 数组为 tree 对象
|
|
485
|
+
const remoteTree = {};
|
|
486
|
+
for (const f of remoteData.files) {
|
|
487
|
+
remoteTree[f.name] = { size: f.size, mtime: f.mtime, hash: f.hash };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// 2. Scan local tree (SHA-256)
|
|
491
|
+
const localTree = await scanLocalDir(localDirHandle, p.ignore || []);
|
|
492
|
+
|
|
493
|
+
// 3. Compute diff
|
|
494
|
+
diffItems = computeDiff(remoteTree, localTree);
|
|
495
|
+
|
|
496
|
+
// 4. Render
|
|
497
|
+
renderDiff();
|
|
498
|
+
document.getElementById('diff-section').classList.add('show');
|
|
499
|
+
document.getElementById('btn-pull').disabled = false;
|
|
500
|
+
document.getElementById('btn-push').disabled = false;
|
|
501
|
+
} catch (e) {
|
|
502
|
+
toast('对比失败: ' + e.message);
|
|
503
|
+
} finally {
|
|
504
|
+
btn.disabled = false;
|
|
505
|
+
btn.textContent = '对比差异';
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Scan local directory recursively ──
|
|
510
|
+
async function scanLocalDir(dirHandle, ignoreList, prefix = '') {
|
|
511
|
+
const tree = {};
|
|
512
|
+
for await (const [name, handle] of dirHandle.entries()) {
|
|
513
|
+
if (name.startsWith('.') || shouldIgnoreLocal(name, ignoreList)) continue;
|
|
514
|
+
const relPath = prefix ? prefix + '/' + name : name;
|
|
515
|
+
if (handle.kind === 'directory') {
|
|
516
|
+
const sub = await scanLocalDir(handle, ignoreList, relPath);
|
|
517
|
+
Object.assign(tree, sub);
|
|
518
|
+
} else {
|
|
519
|
+
try {
|
|
520
|
+
const file = await handle.getFile();
|
|
521
|
+
// Compute hash for small files
|
|
522
|
+
let hash = null;
|
|
523
|
+
if (file.size < 50 * 1024 * 1024) {
|
|
524
|
+
const buf = await file.arrayBuffer();
|
|
525
|
+
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
|
|
526
|
+
// Use first 16 bytes as hex (not MD5, but consistent comparison)
|
|
527
|
+
hash = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
528
|
+
}
|
|
529
|
+
tree[relPath] = { size: file.size, mtime: file.lastModified, hash };
|
|
530
|
+
} catch { /* skip unreadable */ }
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return tree;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function shouldIgnoreLocal(name, ignoreList) {
|
|
537
|
+
for (const pat of ignoreList) {
|
|
538
|
+
if (pat.startsWith('*.')) {
|
|
539
|
+
if (name.endsWith(pat.slice(1))) return true;
|
|
540
|
+
} else if (name === pat) return true;
|
|
541
|
+
}
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── Compute diff ──
|
|
546
|
+
// Server uses MD5, browser uses SHA-256 → can't compare hashes directly
|
|
547
|
+
// Instead compare by size + mtime proximity, or if both have files just by size
|
|
548
|
+
function computeDiff(remoteTree, localTree) {
|
|
549
|
+
const items = [];
|
|
550
|
+
const allPaths = new Set([...Object.keys(remoteTree), ...Object.keys(localTree)]);
|
|
551
|
+
|
|
552
|
+
for (const path of allPaths) {
|
|
553
|
+
const remote = remoteTree[path];
|
|
554
|
+
const local = localTree[path];
|
|
555
|
+
|
|
556
|
+
if (remote && !local) {
|
|
557
|
+
// Exists on remote only → pull-able (new remote file)
|
|
558
|
+
items.push({ path, status: 'remote-only', remote, local: null, checked: true, size: remote.size });
|
|
559
|
+
} else if (!remote && local) {
|
|
560
|
+
// Exists locally only → push-able (new local file)
|
|
561
|
+
items.push({ path, status: 'local-only', remote: null, local, checked: true, size: local.size });
|
|
562
|
+
} else {
|
|
563
|
+
// Both exist → compare by size first, then hash (both SHA-256)
|
|
564
|
+
let modified = false;
|
|
565
|
+
if (remote.size !== local.size) {
|
|
566
|
+
modified = true;
|
|
567
|
+
} else if (remote.hash && local.hash && remote.hash !== local.hash) {
|
|
568
|
+
modified = true;
|
|
569
|
+
}
|
|
570
|
+
if (modified) {
|
|
571
|
+
items.push({ path, status: 'modified', remote, local, checked: true, size: Math.max(remote.size, local.size) });
|
|
572
|
+
} else {
|
|
573
|
+
items.push({ path, status: 'same', remote, local, checked: false, size: remote.size });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Sort: different files first, then by path
|
|
579
|
+
items.sort((a, b) => {
|
|
580
|
+
const order = { 'modified': 0, 'local-only': 1, 'remote-only': 2, 'same': 3 };
|
|
581
|
+
return (order[a.status] - order[b.status]) || a.path.localeCompare(b.path);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
return items;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── Render diff ──
|
|
588
|
+
function renderDiff() {
|
|
589
|
+
const newCount = diffItems.filter(d => d.status === 'remote-only').length;
|
|
590
|
+
const modCount = diffItems.filter(d => d.status === 'modified').length;
|
|
591
|
+
const delCount = diffItems.filter(d => d.status === 'local-only').length;
|
|
592
|
+
const sameCount = diffItems.filter(d => d.status === 'same').length;
|
|
593
|
+
|
|
594
|
+
document.getElementById('stat-new').textContent = newCount;
|
|
595
|
+
document.getElementById('stat-mod').textContent = modCount;
|
|
596
|
+
document.getElementById('stat-del').textContent = delCount;
|
|
597
|
+
document.getElementById('stat-same').textContent = sameCount;
|
|
598
|
+
|
|
599
|
+
const list = document.getElementById('diff-list');
|
|
600
|
+
// Only show non-same by default, or all if all are same
|
|
601
|
+
const showItems = diffItems.filter(d => d.status !== 'same');
|
|
602
|
+
const displayItems = showItems.length ? showItems : diffItems;
|
|
603
|
+
|
|
604
|
+
list.innerHTML = displayItems.map((d, i) => {
|
|
605
|
+
const realIndex = diffItems.indexOf(d);
|
|
606
|
+
const badge = {
|
|
607
|
+
'remote-only': '<span class="diff-badge badge-new">Remote+</span>',
|
|
608
|
+
'local-only': '<span class="diff-badge badge-del">Local+</span>',
|
|
609
|
+
'modified': '<span class="diff-badge badge-mod">修改</span>',
|
|
610
|
+
'same': '<span class="diff-badge" style="background:rgba(139,148,158,0.2);color:var(--muted)">相同</span>',
|
|
611
|
+
}[d.status];
|
|
612
|
+
|
|
613
|
+
return `<div class="diff-item">
|
|
614
|
+
<input type="checkbox" class="diff-check" data-index="${realIndex}" ${d.checked ? 'checked' : ''} onchange="toggleCheck(${realIndex})">
|
|
615
|
+
${badge}
|
|
616
|
+
<span class="diff-path" title="${esc(d.path)}">${esc(d.path)}</span>
|
|
617
|
+
<span class="diff-size">${formatSize(d.size)}</span>
|
|
618
|
+
</div>`;
|
|
619
|
+
}).join('');
|
|
620
|
+
|
|
621
|
+
updateSelectedCount();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function toggleCheck(i) { diffItems[i].checked = !diffItems[i].checked; updateSelectedCount(); }
|
|
625
|
+
function selectAll(v) {
|
|
626
|
+
diffItems.forEach(d => { if (d.status !== 'same') d.checked = v; });
|
|
627
|
+
renderDiff();
|
|
628
|
+
}
|
|
629
|
+
function updateSelectedCount() {
|
|
630
|
+
const n = diffItems.filter(d => d.checked && d.status !== 'same').length;
|
|
631
|
+
document.getElementById('selected-count').textContent = n + ' 项已选';
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ── Sync (push / pull) ──
|
|
635
|
+
async function doSync(direction) {
|
|
636
|
+
if (activeProjectIndex < 0 || !localDirHandle) return;
|
|
637
|
+
const p = config.projects[activeProjectIndex];
|
|
638
|
+
const selected = diffItems.filter(d => d.checked && d.status !== 'same');
|
|
639
|
+
if (!selected.length) { toast('没有选中需要同步的文件'); return; }
|
|
640
|
+
|
|
641
|
+
const isPull = direction === 'pull';
|
|
642
|
+
const desc = isPull ? '拉取' : '推送';
|
|
643
|
+
|
|
644
|
+
// Determine which files to transfer
|
|
645
|
+
let toTransfer = [];
|
|
646
|
+
if (isPull) {
|
|
647
|
+
// Pull: download remote-only & modified from remote; remove local-only from local
|
|
648
|
+
toTransfer = selected.filter(d => d.status === 'remote-only' || d.status === 'modified');
|
|
649
|
+
} else {
|
|
650
|
+
// Push: upload local-only & modified to remote; remove remote-only from remote
|
|
651
|
+
toTransfer = selected.filter(d => d.status === 'local-only' || d.status === 'modified');
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const progress = document.getElementById('sync-progress');
|
|
655
|
+
const fill = document.getElementById('progress-fill');
|
|
656
|
+
const text = document.getElementById('progress-text');
|
|
657
|
+
const log = document.getElementById('sync-log');
|
|
658
|
+
progress.classList.add('show');
|
|
659
|
+
log.classList.add('show');
|
|
660
|
+
log.innerHTML = '';
|
|
661
|
+
fill.style.width = '0%';
|
|
662
|
+
|
|
663
|
+
document.getElementById('btn-pull').disabled = true;
|
|
664
|
+
document.getElementById('btn-push').disabled = true;
|
|
665
|
+
document.getElementById('btn-compare').disabled = true;
|
|
666
|
+
|
|
667
|
+
let done = 0;
|
|
668
|
+
const total = toTransfer.length;
|
|
669
|
+
let errors = 0;
|
|
670
|
+
|
|
671
|
+
for (const item of toTransfer) {
|
|
672
|
+
done++;
|
|
673
|
+
const pct = Math.round(done / total * 100);
|
|
674
|
+
fill.style.width = pct + '%';
|
|
675
|
+
text.textContent = `${desc}中 ${done}/${total} — ${item.path}`;
|
|
676
|
+
|
|
677
|
+
try {
|
|
678
|
+
if (isPull) {
|
|
679
|
+
await pullFile(p.remotePath, item.path, localDirHandle);
|
|
680
|
+
addLog(log, `[OK] ${item.path}`, 'var(--green)');
|
|
681
|
+
} else {
|
|
682
|
+
await pushFile(p.remotePath, item.path, localDirHandle);
|
|
683
|
+
addLog(log, `[OK] ${item.path}`, 'var(--green)');
|
|
684
|
+
}
|
|
685
|
+
} catch (e) {
|
|
686
|
+
errors++;
|
|
687
|
+
addLog(log, `[ERR] ${item.path}: ${e.message}`, 'var(--red)');
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Handle deletions if selected
|
|
692
|
+
if (isPull) {
|
|
693
|
+
const toDelete = selected.filter(d => d.status === 'local-only');
|
|
694
|
+
for (const item of toDelete) {
|
|
695
|
+
try {
|
|
696
|
+
await deleteLocalFile(localDirHandle, item.path);
|
|
697
|
+
addLog(log, `[DEL] ${item.path}`, 'var(--yellow)');
|
|
698
|
+
} catch (e) {
|
|
699
|
+
addLog(log, `[DEL ERR] ${item.path}: ${e.message}`, 'var(--red)');
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
} else {
|
|
703
|
+
const toDelete = selected.filter(d => d.status === 'remote-only');
|
|
704
|
+
for (const item of toDelete) {
|
|
705
|
+
try {
|
|
706
|
+
await fetch('/api/sync/delete-file', {
|
|
707
|
+
method: 'POST',
|
|
708
|
+
headers: { 'Content-Type': 'application/json' },
|
|
709
|
+
body: JSON.stringify({ path: p.remotePath + '/' + item.path, sessionId: SESSION_ID }),
|
|
710
|
+
});
|
|
711
|
+
addLog(log, `[DEL] ${item.path}`, 'var(--yellow)');
|
|
712
|
+
} catch (e) {
|
|
713
|
+
addLog(log, `[DEL ERR] ${item.path}: ${e.message}`, 'var(--red)');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
text.textContent = `${desc}完成 — ${total - errors} 成功, ${errors} 失败`;
|
|
719
|
+
document.getElementById('btn-pull').disabled = false;
|
|
720
|
+
document.getElementById('btn-push').disabled = false;
|
|
721
|
+
document.getElementById('btn-compare').disabled = false;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ── Pull a file from remote to local ──
|
|
725
|
+
async function pullFile(remotePath, filePath, rootHandle) {
|
|
726
|
+
const url = '/api/files/download?path=' + encodeURIComponent(remotePath + '/' + filePath) + sessionParam('&');
|
|
727
|
+
const res = await fetch(url);
|
|
728
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
729
|
+
const blob = await res.blob();
|
|
730
|
+
|
|
731
|
+
// Navigate to the correct subdirectory and create file
|
|
732
|
+
const parts = filePath.split('/');
|
|
733
|
+
const fileName = parts.pop();
|
|
734
|
+
let dirHandle = rootHandle;
|
|
735
|
+
for (const dir of parts) {
|
|
736
|
+
dirHandle = await dirHandle.getDirectoryHandle(dir, { create: true });
|
|
737
|
+
}
|
|
738
|
+
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
|
|
739
|
+
const writable = await fileHandle.createWritable();
|
|
740
|
+
await writable.write(blob);
|
|
741
|
+
await writable.close();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ── Push files from local to remote (batch) ──
|
|
745
|
+
async function pushFile(remotePath, filePath, rootHandle) {
|
|
746
|
+
await pushFilesBatch(remotePath, [filePath], rootHandle);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function pushFilesBatch(remotePath, filePaths, rootHandle) {
|
|
750
|
+
const BATCH = 20;
|
|
751
|
+
for (let i = 0; i < filePaths.length; i += BATCH) {
|
|
752
|
+
const batch = filePaths.slice(i, i + BATCH);
|
|
753
|
+
const formData = new FormData();
|
|
754
|
+
for (const fp of batch) {
|
|
755
|
+
const parts = fp.split('/');
|
|
756
|
+
const fileName = parts.pop();
|
|
757
|
+
let dirHandle = rootHandle;
|
|
758
|
+
for (const dir of parts) {
|
|
759
|
+
dirHandle = await dirHandle.getDirectoryHandle(dir);
|
|
760
|
+
}
|
|
761
|
+
const fileHandle = await dirHandle.getFileHandle(fileName);
|
|
762
|
+
const file = await fileHandle.getFile();
|
|
763
|
+
// 用完整相对路径作为 originalname 保留目录结构
|
|
764
|
+
formData.append('files', new File([file], fp, { type: file.type }));
|
|
765
|
+
}
|
|
766
|
+
const uploadParams = new URLSearchParams({ path: remotePath });
|
|
767
|
+
if (SESSION_ID) uploadParams.set('sessionId', SESSION_ID);
|
|
768
|
+
const res = await fetch('/api/files/upload?' + uploadParams, {
|
|
769
|
+
method: 'POST',
|
|
770
|
+
body: formData,
|
|
771
|
+
});
|
|
772
|
+
if (!res.ok) {
|
|
773
|
+
const data = await res.json().catch(() => ({}));
|
|
774
|
+
throw new Error(data.error || 'HTTP ' + res.status);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ── Delete local file ──
|
|
780
|
+
async function deleteLocalFile(rootHandle, filePath) {
|
|
781
|
+
const parts = filePath.split('/');
|
|
782
|
+
const fileName = parts.pop();
|
|
783
|
+
let dirHandle = rootHandle;
|
|
784
|
+
for (const dir of parts) {
|
|
785
|
+
dirHandle = await dirHandle.getDirectoryHandle(dir);
|
|
786
|
+
}
|
|
787
|
+
await dirHandle.removeEntry(fileName);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ── Utilities ──
|
|
791
|
+
function addLog(el, msg, color) {
|
|
792
|
+
const line = document.createElement('div');
|
|
793
|
+
line.style.color = color || 'var(--muted)';
|
|
794
|
+
line.textContent = msg;
|
|
795
|
+
el.appendChild(line);
|
|
796
|
+
el.scrollTop = el.scrollHeight;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function formatSize(bytes) {
|
|
800
|
+
if (!bytes) return '0 B';
|
|
801
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
802
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
803
|
+
return (bytes / Math.pow(1024, i)).toFixed(i ? 1 : 0) + ' ' + units[i];
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
807
|
+
|
|
808
|
+
function toast(msg) {
|
|
809
|
+
const el = document.getElementById('toast');
|
|
810
|
+
el.textContent = msg;
|
|
811
|
+
el.classList.add('show');
|
|
812
|
+
setTimeout(() => el.classList.remove('show'), 2500);
|
|
813
|
+
}
|
|
814
|
+
</script>
|
|
815
|
+
</body>
|
|
816
|
+
</html>
|