|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2025 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a controller for shared editing. |
|
8 """ |
|
9 |
|
10 import difflib |
|
11 |
|
12 from PyQt6.QtCore import QCryptographicHash, QObject |
|
13 |
|
14 from eric7 import Utilities |
|
15 from eric7.EricWidgets.EricApplication import ericApp |
|
16 |
|
17 from .SharedEditStatus import SharedEditStatus |
|
18 |
|
19 |
|
20 class SharedEditorController(QObject): |
|
21 """ |
|
22 Class implementing a controller for shared editing. |
|
23 """ |
|
24 |
|
25 Separator = "@@@" |
|
26 |
|
27 StartEditToken = "START_EDIT" |
|
28 EndEditToken = "END_EDIT" |
|
29 CancelEditToken = "CANCEL_EDIT" |
|
30 RequestSyncToken = "REQUEST_SYNC" |
|
31 SyncToken = "SYNC" |
|
32 |
|
33 def __init__(self, cooperationClient, chatWidget): |
|
34 """ |
|
35 Constructor |
|
36 |
|
37 @param cooperationClient reference to the cooperation client object |
|
38 @type CooperationClient |
|
39 @param chatWidget reference to the main cooperation widget |
|
40 @type ChatWidget |
|
41 """ |
|
42 super().__init__(chatWidget) |
|
43 |
|
44 self.__cooperationWidget = chatWidget |
|
45 self.__cooperationClient = cooperationClient |
|
46 |
|
47 def __send(self, editor, editToken, args=None): |
|
48 """ |
|
49 Private method to send an editor command to remote editors. |
|
50 |
|
51 @param editor reference to the editor object |
|
52 @type Editor |
|
53 @param editToken edit command token |
|
54 @type str |
|
55 @param args arguments for the command |
|
56 @type str |
|
57 """ |
|
58 if self.__cooperationClient.hasConnections(): |
|
59 project = ericApp().getObject("Project") |
|
60 fileName = editor.getFileName() |
|
61 if fileName and project.isProjectFile(fileName): |
|
62 msg = "" |
|
63 if editToken in ( |
|
64 SharedEditorController.StartEditToken, |
|
65 SharedEditorController.EndEditToken, |
|
66 SharedEditorController.RequestSyncToken, |
|
67 SharedEditorController.SyncToken, |
|
68 ): |
|
69 msg = f"{editToken}{SharedEditorController.Separator}{args}" |
|
70 elif editToken == SharedEditorController.CancelEditToken: |
|
71 msg = f"{editToken}{SharedEditorController.Separator}c" |
|
72 |
|
73 self.__cooperationClient.sendEditorCommand( |
|
74 project.getHash(), project.getRelativeUniversalPath(fileName), msg |
|
75 ) |
|
76 |
|
77 def receiveEditorCommand(self, projectHash, fileName, command): |
|
78 """ |
|
79 Public method to handle received editor commands. |
|
80 |
|
81 @param projectHash hash of the project |
|
82 @type str |
|
83 @param fileName project relative file name of the editor |
|
84 @type str |
|
85 @param command command string |
|
86 @type str |
|
87 """ |
|
88 project = ericApp().getObject("Project") |
|
89 if projectHash == project.getHash(): |
|
90 fn = project.getAbsoluteUniversalPath(fileName) |
|
91 # TODO: change this to use local method |
|
92 editor = ericApp().getObject("ViewManager").getOpenEditor(fn) |
|
93 if editor: |
|
94 status = self.__getSharedEditStatus(editor) |
|
95 if status.isShared: |
|
96 if status.isSyncing and not command.startswith( |
|
97 SharedEditorController.SyncToken |
|
98 + SharedEditorController.Separator |
|
99 ): |
|
100 status.receivedWhileSyncing.append(command) |
|
101 else: |
|
102 self.__dispatchCommand(editor, command) |
|
103 |
|
104 def shareConnected(self, connected): |
|
105 """ |
|
106 Public slot to handle a change of the connected state. |
|
107 |
|
108 @param connected flag indicating the connected state |
|
109 @type bool |
|
110 """ |
|
111 for editor in ericApp().getObject("ViewManager").getOpenEditors(): |
|
112 self.__shareConnected(editor, connected) |
|
113 |
|
114 def shareEditor(self, share): |
|
115 """ |
|
116 Public slot to set the shared status of the current editor. |
|
117 |
|
118 @param share flag indicating the share status |
|
119 @type bool |
|
120 """ |
|
121 aw = ericApp().getObject("ViewManager").activeWindow() |
|
122 if aw is not None: |
|
123 fn = aw.getFileName() |
|
124 if fn and ericApp().getObject("Project").isProjectFile(fn): |
|
125 self.__shareEditor(aw, share) |
|
126 |
|
127 def startSharedEdit(self): |
|
128 """ |
|
129 Public slot to start a shared edit session for the current editor. |
|
130 """ |
|
131 aw = ericApp().getObject("ViewManager").activeWindow() |
|
132 if aw is not None: |
|
133 fn = aw.getFileName() |
|
134 if fn and ericApp().getObject("Project").isProjectFile(fn): |
|
135 self.__startSharedEdit(aw) |
|
136 |
|
137 def sendSharedEdit(self): |
|
138 """ |
|
139 Public slot to end a shared edit session for the current editor and |
|
140 send the changes. |
|
141 """ |
|
142 aw = ericApp().getObject("ViewManager").activeWindow() |
|
143 if aw is not None: |
|
144 fn = aw.getFileName() |
|
145 if fn and ericApp().getObject("Project").isProjectFile(fn): |
|
146 self.__sendSharedEdit(aw) |
|
147 |
|
148 def cancelSharedEdit(self): |
|
149 """ |
|
150 Public slot to cancel a shared edit session for the current editor. |
|
151 """ |
|
152 aw = ericApp().getObject("ViewManager").activeWindow() |
|
153 if aw is not None: |
|
154 fn = aw.getFileName() |
|
155 if fn and ericApp().getObject("Project").isProjectFile(fn): |
|
156 self.__cancelSharedEdit(aw) |
|
157 |
|
158 ############################################################################ |
|
159 ## Shared editor related methods |
|
160 ############################################################################ |
|
161 |
|
162 def __getSharedEditStatus(self, editor): |
|
163 """ |
|
164 Private method to get the shared edit status object of a given editor. |
|
165 |
|
166 If the editor does not have such an object, a default one is created and |
|
167 set for the editor. |
|
168 |
|
169 @param editor reference to the editor object |
|
170 @type Editor |
|
171 @return reference to the shared edit status |
|
172 @rtype SharedEditStatus |
|
173 """ |
|
174 status = editor.getSharedEditStatus() |
|
175 if status is None: |
|
176 status = SharedEditStatus() |
|
177 editor.setSharedEditStatus(status) |
|
178 return status |
|
179 |
|
180 def getSharingStatus(self, editor): |
|
181 """ |
|
182 Public method to get some share status info. |
|
183 |
|
184 @param editor reference to the editor object |
|
185 @type Editor |
|
186 @return tuple indicating, if the editor is sharable, the sharing |
|
187 status, if it is inside a locally initiated shared edit session |
|
188 and if it is inside a remotely initiated shared edit session |
|
189 @rtype tuple of (bool, bool, bool, bool) |
|
190 """ |
|
191 project = ericApp().getObject("Project") |
|
192 fn = editor.getFileName() |
|
193 status = self.__getSharedEditStatus(editor) |
|
194 |
|
195 return ( |
|
196 bool(fn) and project.isOpen() and project.isProjectFile(fn), |
|
197 False if status is None else status.isShared, |
|
198 False if status is None else status.inSharedEdit, |
|
199 False if status is None else status.inRemoteSharedEdit, |
|
200 ) |
|
201 |
|
202 def __shareConnected(self, editor, connected): |
|
203 """ |
|
204 Private method to handle a change of the connected state. |
|
205 |
|
206 @param editor reference to the editor object |
|
207 @type Editor |
|
208 @param connected flag indicating the connected state |
|
209 @type bool |
|
210 """ |
|
211 if not connected: |
|
212 status = self.__getSharedEditStatus(editor) |
|
213 status.inRemoteSharedEdit = False |
|
214 status.isSyncing = False |
|
215 status.receivedWhileSyncing = [] |
|
216 |
|
217 editor.setReadOnly(False) |
|
218 editor.updateReadOnly() |
|
219 self.__cancelSharedEdit(editor, send=False) |
|
220 |
|
221 def __shareEditor(self, editor, share): |
|
222 """ |
|
223 Private method to set the shared status of the editor. |
|
224 |
|
225 @param editor reference to the editor object |
|
226 @type Editor |
|
227 @param share flag indicating the share status |
|
228 @type bool |
|
229 """ |
|
230 status = self.__getSharedEditStatus(editor) |
|
231 status.isShared = share |
|
232 if not share: |
|
233 self.__shareConnected(editor, False) |
|
234 |
|
235 def __startSharedEdit(self, editor): |
|
236 """ |
|
237 Private method to start a shared edit session for the editor. |
|
238 |
|
239 @param editor reference to the editor object |
|
240 @type Editor |
|
241 """ |
|
242 status = self.__getSharedEditStatus(editor) |
|
243 status.inSharedEdit = True |
|
244 status.savedText = editor.text() |
|
245 hashStr = str( |
|
246 QCryptographicHash.hash( |
|
247 Utilities.encode(status.savedText, editor.getEncoding())[0], |
|
248 QCryptographicHash.Algorithm.Sha1, |
|
249 ).toHex(), |
|
250 encoding="utf-8", |
|
251 ) |
|
252 self.__send(editor, SharedEditorController.StartEditToken, hashStr) |
|
253 |
|
254 def __sendSharedEdit(self, editor): |
|
255 """ |
|
256 Private method to end a shared edit session for the editor and |
|
257 send the changes. |
|
258 |
|
259 @param editor reference to the editor object |
|
260 @type Editor |
|
261 """ |
|
262 status = self.__getSharedEditStatus(editor) |
|
263 commands = self.__calculateChanges(status.savedText, editor.text()) |
|
264 self.__send(editor, SharedEditorController.EndEditToken, commands) |
|
265 status.inSharedEdit = False |
|
266 status.savedText = "" |
|
267 |
|
268 def __cancelSharedEdit(self, editor, send=True): |
|
269 """ |
|
270 Private method to cancel a shared edit session for the editor. |
|
271 |
|
272 @param editor reference to the editor object |
|
273 @type Editor |
|
274 @param send flag indicating to send the CancelEdit command |
|
275 @type bool |
|
276 """ |
|
277 status = self.__getSharedEditStatus(editor) |
|
278 status.inSharedEdit = False |
|
279 status.savedText = "" |
|
280 if send: |
|
281 self.__send(editor, SharedEditorController.CancelEditToken) |
|
282 |
|
283 def __dispatchCommand(self, editor, command): |
|
284 """ |
|
285 Private method to dispatch received commands. |
|
286 |
|
287 @param editor reference to the edior object |
|
288 @type Editor |
|
289 @param command command to be processed |
|
290 @type str |
|
291 """ |
|
292 editToken, argsString = command.split(SharedEditorController.Separator, 1) |
|
293 if editToken == SharedEditorController.StartEditToken: |
|
294 self.__processStartEditCommand(editor, argsString) |
|
295 elif editToken == SharedEditorController.CancelEditToken: |
|
296 self.__shareConnected(editor, False) |
|
297 elif editToken == SharedEditorController.EndEditToken: |
|
298 self.__processEndEditCommand(editor, argsString) |
|
299 elif editToken == SharedEditorController.RequestSyncToken: |
|
300 self.__processRequestSyncCommand(editor, argsString) |
|
301 elif editToken == SharedEditorController.SyncToken: |
|
302 self.__processSyncCommand(editor, argsString) |
|
303 |
|
304 def __processStartEditCommand(self, editor, argsString): |
|
305 """ |
|
306 Private method to process a remote StartEdit command. |
|
307 |
|
308 @param editor reference to the editor object |
|
309 @type Editor |
|
310 @param argsString string containing the command parameters |
|
311 @type str |
|
312 """ |
|
313 status = self.__getSharedEditStatus(editor) |
|
314 if not status.inSharedEdit and not status.inRemoteSharedEdit: |
|
315 status.inRemoteSharedEdit = True |
|
316 editor.setReadOnly(True) |
|
317 editor.updateReadOnly() |
|
318 hashStr = str( |
|
319 QCryptographicHash.hash( |
|
320 Utilities.encode(editor.text(), editor.getEncoding())[0], |
|
321 QCryptographicHash.Algorithm.Sha1, |
|
322 ).toHex(), |
|
323 encoding="utf-8", |
|
324 ) |
|
325 if hashStr != argsString: |
|
326 # text is different to the remote site, request to sync it |
|
327 status.isSyncing = True |
|
328 self.__send(editor, SharedEditorController.RequestSyncToken, argsString) |
|
329 |
|
330 def __calculateChanges(self, old, new): |
|
331 """ |
|
332 Private method to determine change commands to convert old text into |
|
333 new text. |
|
334 |
|
335 @param old old text |
|
336 @type str |
|
337 @param new new text |
|
338 @type str |
|
339 @return commands to change old into new |
|
340 @rtype str |
|
341 """ |
|
342 oldL = old.splitlines() |
|
343 newL = new.splitlines() |
|
344 matcher = difflib.SequenceMatcher(None, oldL, newL) |
|
345 |
|
346 formatStr = "@@{0} {1} {2} {3}" |
|
347 commands = [] |
|
348 for diffToken, i1, i2, j1, j2 in matcher.get_opcodes(): |
|
349 if diffToken == "insert": |
|
350 commands.append(formatStr.format("i", j1, j2 - j1, -1)) |
|
351 commands.extend(newL[j1:j2]) |
|
352 elif diffToken == "delete": |
|
353 commands.append(formatStr.format("d", j1, i2 - i1, -1)) |
|
354 elif diffToken == "replace": |
|
355 commands.append(formatStr.format("r", j1, i2 - i1, j2 - j1)) |
|
356 commands.extend(newL[j1:j2]) |
|
357 |
|
358 return "\n".join(commands) + "\n" |
|
359 |
|
360 def __processEndEditCommand(self, editor, argsString): |
|
361 """ |
|
362 Private method to process a remote EndEdit command. |
|
363 |
|
364 @param editor reference to the editor object |
|
365 @type Editor |
|
366 @param argsString string containing the command parameters |
|
367 @type str |
|
368 """ |
|
369 status = self.__getSharedEditStatus(editor) |
|
370 |
|
371 commands = argsString.splitlines() |
|
372 sep = editor.getLineSeparator() |
|
373 cur = editor.getCursorPosition() |
|
374 |
|
375 editor.setReadOnly(False) |
|
376 editor.beginUndoAction() |
|
377 while commands: |
|
378 commandLine = commands.pop(0) |
|
379 if not commandLine.startswith("@@"): |
|
380 continue |
|
381 |
|
382 args = commandLine.split() |
|
383 command = args.pop(0) |
|
384 pos, l1, l2 = [int(arg) for arg in args] |
|
385 if command == "@@i": |
|
386 txt = sep.join(commands[0:l1]) + sep |
|
387 editor.insertAt(txt, pos, 0) |
|
388 del commands[0:l1] |
|
389 elif command == "@@d": |
|
390 editor.setSelection(pos, 0, pos + l1, 0) |
|
391 editor.removeSelectedText() |
|
392 elif command == "@@r": |
|
393 editor.setSelection(pos, 0, pos + l1, 0) |
|
394 editor.removeSelectedText() |
|
395 txt = sep.join(commands[0:l2]) + sep |
|
396 editor.insertAt(txt, pos, 0) |
|
397 del commands[0:l2] |
|
398 editor.endUndoAction() |
|
399 editor.updateReadOnly() |
|
400 status.inRemoteSharedEdit = False |
|
401 |
|
402 editor.setCursorPosition(*cur) |
|
403 |
|
404 def __processRequestSyncCommand(self, editor, argsString): |
|
405 """ |
|
406 Private method to process a remote RequestSync command. |
|
407 |
|
408 @param editor reference to the editor object |
|
409 @type Editor |
|
410 @param argsString string containing the command parameters |
|
411 @type str |
|
412 """ |
|
413 status = self.__getSharedEditStatus(editor) |
|
414 if status.inSharedEdit: |
|
415 hashStr = str( |
|
416 QCryptographicHash.hash( |
|
417 Utilities.encode(status.savedText, editor.getEncoding())[0], |
|
418 QCryptographicHash.Algorithm.Sha1, |
|
419 ).toHex(), |
|
420 encoding="utf-8", |
|
421 ) |
|
422 |
|
423 if hashStr == argsString: |
|
424 self.__send(editor, SharedEditorController.SyncToken, status.savedText) |
|
425 |
|
426 def __processSyncCommand(self, editor, argsString): |
|
427 """ |
|
428 Private method to process a remote Sync command. |
|
429 |
|
430 @param editor reference to the editor object |
|
431 @type Editor |
|
432 @param argsString string containing the command parameters |
|
433 @type str |
|
434 """ |
|
435 status = self.__getSharedEditStatus(editor) |
|
436 if status.isSyncing: |
|
437 cur = editor.getCursorPosition() |
|
438 |
|
439 editor.setReadOnly(False) |
|
440 editor.beginUndoAction() |
|
441 editor.selectAll() |
|
442 editor.removeSelectedText() |
|
443 editor.insertAt(argsString, 0, 0) |
|
444 editor.endUndoAction() |
|
445 editor.setReadOnly(True) |
|
446 |
|
447 editor.setCursorPosition(*cur) |
|
448 |
|
449 while status.receivedWhileSyncing: |
|
450 command = status.receivedWhileSyncing.pop(0) |
|
451 self.__dispatchCommand(editor, command) |
|
452 |
|
453 status.isSyncing = False |