|
94 | 94 | let isCancelling = $state(false) |
95 | 95 | let isRollingBack = $state(false) |
96 | 96 |
|
| 97 | + // Events that arrived before we know our operationId (from the copyFiles() response). |
| 98 | + // Without buffering, a stale event from a previous copy could claim the ID slot first. |
| 99 | + type BufferedEvent = |
| 100 | + | { type: 'progress'; event: WriteProgressEvent } |
| 101 | + | { type: 'complete'; event: WriteCompleteEvent } |
| 102 | + | { type: 'error'; event: WriteErrorEvent } |
| 103 | + | { type: 'cancelled'; event: WriteCancelledEvent } |
| 104 | + | { type: 'conflict'; event: WriteConflictEvent } |
| 105 | + let pendingEvents: BufferedEvent[] = [] |
| 106 | +
|
| 107 | + /** Returns true if the event belongs to this operation and should be processed. */ |
| 108 | + function filterEvent(entry: BufferedEvent): boolean { |
| 109 | + if (operationId === null) { |
| 110 | + pendingEvents.push(entry) |
| 111 | + return false |
| 112 | + } |
| 113 | + return entry.event.operationId === operationId |
| 114 | + } |
| 115 | +
|
| 116 | + function replayBufferedEvents() { |
| 117 | + const events = pendingEvents |
| 118 | + pendingEvents = [] |
| 119 | + for (const entry of events) { |
| 120 | + if (entry.event.operationId !== operationId) continue |
| 121 | + switch (entry.type) { |
| 122 | + case 'progress': |
| 123 | + handleProgress(entry.event) |
| 124 | + break |
| 125 | + case 'complete': |
| 126 | + handleComplete(entry.event) |
| 127 | + break |
| 128 | + case 'error': |
| 129 | + handleError(entry.event) |
| 130 | + break |
| 131 | + case 'cancelled': |
| 132 | + handleCancelled(entry.event) |
| 133 | + break |
| 134 | + case 'conflict': |
| 135 | + handleConflict(entry.event) |
| 136 | + break |
| 137 | + } |
| 138 | + } |
| 139 | + } |
| 140 | +
|
97 | 141 | // Conflict state |
98 | 142 | let conflictEvent = $state<WriteConflictEvent | null>(null) |
99 | 143 | let isResolvingConflict = $state(false) |
|
134 | 178 | } |
135 | 179 |
|
136 | 180 | function handleProgress(event: WriteProgressEvent) { |
137 | | - // Filter by operationId (events are global) |
138 | | - // If operationId is null, accept the event and capture the ID (handles race condition |
139 | | - // where events arrive before copyFiles() returns the operationId to the frontend) |
140 | | - if (operationId === null) { |
141 | | - operationId = event.operationId |
142 | | - log.debug('Captured operationId from event: {operationId}', { operationId }) |
143 | | - } else if (event.operationId !== operationId) { |
144 | | - return |
145 | | - } |
| 181 | + if (!filterEvent({ type: 'progress', event })) return |
146 | 182 |
|
147 | 183 | log.debug('Progress event: {phase} {filesDone}/{filesTotal} files, {bytesDone}/{bytesTotal} bytes', { |
148 | 184 | phase: event.phase, |
|
161 | 197 | } |
162 | 198 |
|
163 | 199 | function handleComplete(event: WriteCompleteEvent) { |
164 | | - // Filter by operationId (events are global) |
165 | | - // Accept if operationId is null (race condition) or matches |
166 | | - if (operationId === null) { |
167 | | - operationId = event.operationId |
168 | | - } else if (event.operationId !== operationId) { |
169 | | - return |
170 | | - } |
| 200 | + if (!filterEvent({ type: 'complete', event })) return |
171 | 201 |
|
172 | 202 | log.info('Copy complete: {filesProcessed} files, {bytesProcessed} bytes', { |
173 | 203 | filesProcessed: event.filesProcessed, |
|
179 | 209 | } |
180 | 210 |
|
181 | 211 | function handleError(event: WriteErrorEvent) { |
182 | | - // Filter by operationId (events are global) |
183 | | - // Accept if operationId is null (race condition) or matches |
184 | | - if (operationId === null) { |
185 | | - operationId = event.operationId |
186 | | - } else if (event.operationId !== operationId) { |
187 | | - return |
188 | | - } |
| 212 | + if (!filterEvent({ type: 'error', event })) return |
189 | 213 |
|
190 | 214 | log.error('Copy error: {errorType}', { errorType: event.error.type, error: event.error }) |
191 | 215 |
|
|
194 | 218 | } |
195 | 219 |
|
196 | 220 | function handleCancelled(event: WriteCancelledEvent) { |
197 | | - // Filter by operationId (events are global) |
198 | | - // Accept if operationId is null (race condition) or matches |
199 | | - if (operationId === null) { |
200 | | - operationId = event.operationId |
201 | | - } else if (event.operationId !== operationId) { |
202 | | - return |
203 | | - } |
| 221 | + if (!filterEvent({ type: 'cancelled', event })) return |
204 | 222 |
|
205 | 223 | log.info('Copy cancelled after {filesProcessed} files, rolledBack={rolledBack}', { |
206 | 224 | filesProcessed: event.filesProcessed, |
|
212 | 230 | } |
213 | 231 |
|
214 | 232 | function handleConflict(event: WriteConflictEvent) { |
215 | | - // Filter by operationId (events are global) |
216 | | - // Accept if operationId is null (race condition) or matches |
217 | | - if (operationId === null) { |
218 | | - operationId = event.operationId |
219 | | - } else if (event.operationId !== operationId) { |
220 | | - return |
221 | | - } |
| 233 | + if (!filterEvent({ type: 'conflict', event })) return |
222 | 234 |
|
223 | 235 | log.info('Conflict detected: {sourcePath} -> {destinationPath}', { |
224 | 236 | sourcePath: event.sourcePath, |
|
300 | 312 |
|
301 | 313 | operationId = result.operationId |
302 | 314 | log.info('Copy operation started with operationId: {operationId}', { operationId }) |
| 315 | + replayBufferedEvents() |
303 | 316 | } catch (err) { |
304 | 317 | log.error('Failed to start copy operation: {error}', { error: err }) |
305 | 318 | cleanup() |
|
995 | 1008 | border-top: 1px solid var(--color-border-primary); |
996 | 1009 | } |
997 | 1010 |
|
998 | | - /* Size colors (matching file list) */ |
| 1011 | + /* Size colors (matching file list) - these are used dynamically */ |
| 1012 | + /*noinspection CssUnusedSymbol*/ |
999 | 1013 | .size-bytes { |
1000 | 1014 | color: var(--color-text-secondary); |
1001 | 1015 | } |
1002 | 1016 |
|
| 1017 | + /*noinspection CssUnusedSymbol*/ |
1003 | 1018 | .size-kb { |
1004 | 1019 | color: var(--color-size-kb); |
1005 | 1020 | } |
1006 | 1021 |
|
| 1022 | + /*noinspection CssUnusedSymbol*/ |
1007 | 1023 | .size-mb { |
1008 | 1024 | color: var(--color-size-mb); |
1009 | 1025 | } |
1010 | 1026 |
|
| 1027 | + /*noinspection CssUnusedSymbol*/ |
1011 | 1028 | .size-gb { |
1012 | 1029 | color: var(--color-size-gb); |
1013 | 1030 | } |
1014 | 1031 |
|
| 1032 | + /*noinspection CssUnusedSymbol*/ |
1015 | 1033 | .size-tb { |
1016 | 1034 | color: var(--color-size-tb); |
1017 | 1035 | } |
|
0 commit comments