Tue, 06 May 2025 11:09:21 +0200
Removed a forgotten TODO marker.
# -*- coding: utf-8 -*- # Copyright (c) 2025 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing a controller for shared editing. """ import difflib from PyQt6.QtCore import QCryptographicHash, QObject from eric7 import Utilities from eric7.EricWidgets.EricApplication import ericApp from .SharedEditStatus import SharedEditStatus class SharedEditorController(QObject): """ Class implementing a controller for shared editing. """ Separator = "@@@" StartEditToken = "START_EDIT" EndEditToken = "END_EDIT" CancelEditToken = "CANCEL_EDIT" RequestSyncToken = "REQUEST_SYNC" SyncToken = "SYNC" def __init__(self, cooperationClient, chatWidget): """ Constructor @param cooperationClient reference to the cooperation client object @type CooperationClient @param chatWidget reference to the main cooperation widget @type ChatWidget """ super().__init__(chatWidget) self.__cooperationWidget = chatWidget self.__cooperationClient = cooperationClient def __send(self, editor, editToken, args=None): """ Private method to send an editor command to remote editors. @param editor reference to the editor object @type Editor @param editToken edit command token @type str @param args arguments for the command @type str """ if self.__cooperationClient.hasConnections(): project = ericApp().getObject("Project") fileName = editor.getFileName() if fileName and project.isProjectFile(fileName): msg = "" if editToken in ( SharedEditorController.StartEditToken, SharedEditorController.EndEditToken, SharedEditorController.RequestSyncToken, SharedEditorController.SyncToken, ): msg = f"{editToken}{SharedEditorController.Separator}{args}" elif editToken == SharedEditorController.CancelEditToken: msg = f"{editToken}{SharedEditorController.Separator}c" self.__cooperationClient.sendEditorCommand( project.getHash(), project.getRelativeUniversalPath(fileName), msg ) def receiveEditorCommand(self, projectHash, fileName, command): """ Public method to handle received editor commands. @param projectHash hash of the project @type str @param fileName project relative file name of the editor @type str @param command command string @type str """ project = ericApp().getObject("Project") if projectHash == project.getHash(): fn = project.getAbsoluteUniversalPath(fileName) editor = ericApp().getObject("ViewManager").getOpenEditor(fn) if editor: status = self.__getSharedEditStatus(editor) if status.isShared: if status.isSyncing and not command.startswith( SharedEditorController.SyncToken + SharedEditorController.Separator ): status.receivedWhileSyncing.append(command) else: self.__dispatchCommand(editor, command) def shareConnected(self, connected): """ Public slot to handle a change of the connected state. @param connected flag indicating the connected state @type bool """ for editor in ericApp().getObject("ViewManager").getOpenEditors(): self.__shareConnected(editor, connected) def shareEditor(self, share): """ Public slot to set the shared status of the current editor. @param share flag indicating the share status @type bool """ aw = ericApp().getObject("ViewManager").activeWindow() if aw is not None: fn = aw.getFileName() if fn and ericApp().getObject("Project").isProjectFile(fn): self.__shareEditor(aw, share) def startSharedEdit(self): """ Public slot to start a shared edit session for the current editor. """ aw = ericApp().getObject("ViewManager").activeWindow() if aw is not None: fn = aw.getFileName() if fn and ericApp().getObject("Project").isProjectFile(fn): self.__startSharedEdit(aw) def sendSharedEdit(self): """ Public slot to end a shared edit session for the current editor and send the changes. """ aw = ericApp().getObject("ViewManager").activeWindow() if aw is not None: fn = aw.getFileName() if fn and ericApp().getObject("Project").isProjectFile(fn): self.__sendSharedEdit(aw) def cancelSharedEdit(self): """ Public slot to cancel a shared edit session for the current editor. """ aw = ericApp().getObject("ViewManager").activeWindow() if aw is not None: fn = aw.getFileName() if fn and ericApp().getObject("Project").isProjectFile(fn): self.__cancelSharedEdit(aw) ############################################################################ ## Shared editor related methods ############################################################################ def __getSharedEditStatus(self, editor): """ Private method to get the shared edit status object of a given editor. If the editor does not have such an object, a default one is created and set for the editor. @param editor reference to the editor object @type Editor @return reference to the shared edit status @rtype SharedEditStatus """ status = editor.getSharedEditStatus() if status is None: status = SharedEditStatus() editor.setSharedEditStatus(status) return status def getSharingStatus(self, editor): """ Public method to get some share status info. @param editor reference to the editor object @type Editor @return tuple indicating, if the editor is sharable, the sharing status, if it is inside a locally initiated shared edit session and if it is inside a remotely initiated shared edit session @rtype tuple of (bool, bool, bool, bool) """ project = ericApp().getObject("Project") fn = editor.getFileName() status = self.__getSharedEditStatus(editor) return ( bool(fn) and project.isOpen() and project.isProjectFile(fn), False if status is None else status.isShared, False if status is None else status.inSharedEdit, False if status is None else status.inRemoteSharedEdit, ) def __shareConnected(self, editor, connected): """ Private method to handle a change of the connected state. @param editor reference to the editor object @type Editor @param connected flag indicating the connected state @type bool """ if not connected: status = self.__getSharedEditStatus(editor) status.inRemoteSharedEdit = False status.isSyncing = False status.receivedWhileSyncing = [] editor.setReadOnly(False) editor.updateReadOnly() self.__cancelSharedEdit(editor, send=False) def __shareEditor(self, editor, share): """ Private method to set the shared status of the editor. @param editor reference to the editor object @type Editor @param share flag indicating the share status @type bool """ status = self.__getSharedEditStatus(editor) status.isShared = share if not share: self.__shareConnected(editor, False) def __startSharedEdit(self, editor): """ Private method to start a shared edit session for the editor. @param editor reference to the editor object @type Editor """ status = self.__getSharedEditStatus(editor) status.inSharedEdit = True status.savedText = editor.text() hashStr = str( QCryptographicHash.hash( Utilities.encode(status.savedText, editor.getEncoding())[0], QCryptographicHash.Algorithm.Sha1, ).toHex(), encoding="utf-8", ) self.__send(editor, SharedEditorController.StartEditToken, hashStr) def __sendSharedEdit(self, editor): """ Private method to end a shared edit session for the editor and send the changes. @param editor reference to the editor object @type Editor """ status = self.__getSharedEditStatus(editor) commands = self.__calculateChanges(status.savedText, editor.text()) self.__send(editor, SharedEditorController.EndEditToken, commands) status.inSharedEdit = False status.savedText = "" def __cancelSharedEdit(self, editor, send=True): """ Private method to cancel a shared edit session for the editor. @param editor reference to the editor object @type Editor @param send flag indicating to send the CancelEdit command @type bool """ status = self.__getSharedEditStatus(editor) status.inSharedEdit = False status.savedText = "" if send: self.__send(editor, SharedEditorController.CancelEditToken) def __dispatchCommand(self, editor, command): """ Private method to dispatch received commands. @param editor reference to the edior object @type Editor @param command command to be processed @type str """ editToken, argsString = command.split(SharedEditorController.Separator, 1) if editToken == SharedEditorController.StartEditToken: self.__processStartEditCommand(editor, argsString) elif editToken == SharedEditorController.CancelEditToken: self.__shareConnected(editor, False) elif editToken == SharedEditorController.EndEditToken: self.__processEndEditCommand(editor, argsString) elif editToken == SharedEditorController.RequestSyncToken: self.__processRequestSyncCommand(editor, argsString) elif editToken == SharedEditorController.SyncToken: self.__processSyncCommand(editor, argsString) def __processStartEditCommand(self, editor, argsString): """ Private method to process a remote StartEdit command. @param editor reference to the editor object @type Editor @param argsString string containing the command parameters @type str """ status = self.__getSharedEditStatus(editor) if not status.inSharedEdit and not status.inRemoteSharedEdit: status.inRemoteSharedEdit = True editor.setReadOnly(True) editor.updateReadOnly() hashStr = str( QCryptographicHash.hash( Utilities.encode(editor.text(), editor.getEncoding())[0], QCryptographicHash.Algorithm.Sha1, ).toHex(), encoding="utf-8", ) if hashStr != argsString: # text is different to the remote site, request to sync it status.isSyncing = True self.__send(editor, SharedEditorController.RequestSyncToken, argsString) def __calculateChanges(self, old, new): """ Private method to determine change commands to convert old text into new text. @param old old text @type str @param new new text @type str @return commands to change old into new @rtype str """ oldL = old.splitlines() newL = new.splitlines() matcher = difflib.SequenceMatcher(None, oldL, newL) formatStr = "@@{0} {1} {2} {3}" commands = [] for diffToken, i1, i2, j1, j2 in matcher.get_opcodes(): if diffToken == "insert": commands.append(formatStr.format("i", j1, j2 - j1, -1)) commands.extend(newL[j1:j2]) elif diffToken == "delete": commands.append(formatStr.format("d", j1, i2 - i1, -1)) elif diffToken == "replace": commands.append(formatStr.format("r", j1, i2 - i1, j2 - j1)) commands.extend(newL[j1:j2]) return "\n".join(commands) + "\n" def __processEndEditCommand(self, editor, argsString): """ Private method to process a remote EndEdit command. @param editor reference to the editor object @type Editor @param argsString string containing the command parameters @type str """ status = self.__getSharedEditStatus(editor) commands = argsString.splitlines() sep = editor.getLineSeparator() cur = editor.getCursorPosition() editor.setReadOnly(False) editor.beginUndoAction() while commands: commandLine = commands.pop(0) if not commandLine.startswith("@@"): continue args = commandLine.split() command = args.pop(0) pos, l1, l2 = [int(arg) for arg in args] if command == "@@i": txt = sep.join(commands[0:l1]) + sep editor.insertAt(txt, pos, 0) del commands[0:l1] elif command == "@@d": editor.setSelection(pos, 0, pos + l1, 0) editor.removeSelectedText() elif command == "@@r": editor.setSelection(pos, 0, pos + l1, 0) editor.removeSelectedText() txt = sep.join(commands[0:l2]) + sep editor.insertAt(txt, pos, 0) del commands[0:l2] editor.endUndoAction() editor.updateReadOnly() status.inRemoteSharedEdit = False editor.setCursorPosition(*cur) def __processRequestSyncCommand(self, editor, argsString): """ Private method to process a remote RequestSync command. @param editor reference to the editor object @type Editor @param argsString string containing the command parameters @type str """ status = self.__getSharedEditStatus(editor) if status.inSharedEdit: hashStr = str( QCryptographicHash.hash( Utilities.encode(status.savedText, editor.getEncoding())[0], QCryptographicHash.Algorithm.Sha1, ).toHex(), encoding="utf-8", ) if hashStr == argsString: self.__send(editor, SharedEditorController.SyncToken, status.savedText) def __processSyncCommand(self, editor, argsString): """ Private method to process a remote Sync command. @param editor reference to the editor object @type Editor @param argsString string containing the command parameters @type str """ status = self.__getSharedEditStatus(editor) if status.isSyncing: cur = editor.getCursorPosition() editor.setReadOnly(False) editor.beginUndoAction() editor.selectAll() editor.removeSelectedText() editor.insertAt(argsString, 0, 0) editor.endUndoAction() editor.setReadOnly(True) editor.setCursorPosition(*cur) while status.receivedWhileSyncing: command = status.receivedWhileSyncing.pop(0) self.__dispatchCommand(editor, command) status.isSyncing = False