src/eric7/Cooperation/SharedEditorController.py

Tue, 06 May 2025 11:09:21 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Tue, 06 May 2025 11:09:21 +0200
branch
eric7
changeset 11269
ce3bcd9df3b3
parent 11260
67773a953b64
permissions
-rw-r--r--

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

eric ide

mercurial