src/eric7/Cooperation/SharedEditorController.py

branch
eric7
changeset 11260
67773a953b64
child 11269
ce3bcd9df3b3
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

eric ide

mercurial