src/eric7/Cooperation/SharedEditorController.py

branch
eric7
changeset 11260
67773a953b64
child 11269
ce3bcd9df3b3
equal deleted inserted replaced
11259:0802dce3d1c3 11260:67773a953b64
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

eric ide

mercurial