diff -r 0802dce3d1c3 -r 67773a953b64 src/eric7/Cooperation/SharedEditorController.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/Cooperation/SharedEditorController.py Sun May 04 16:38:04 2025 +0200 @@ -0,0 +1,453 @@ +# -*- 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) + # TODO: change this to use local method + 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