feat: major refactor.

the client doesn't hold the task manager anymore, instead it is now a
singleton that all objects dump their tasks into to be managed.
This allows to better separate the responsibilities of the various
virtual-objects. The client now is also a module-singleton which allows
us to remove that ugly global variable.

fixup: uses relative imports instead that absolute ones with Codemp in front.
rename of codemp_client.py into just client.py

Former-commit-id: 4a0cc20e82f9631931ba2f61379a61c461b1f291
This commit is contained in:
Camillo Schenone 2024-08-04 19:57:59 +02:00
parent b66b368907
commit 705c5e014e
5 changed files with 260 additions and 198 deletions

111
plugin.py
View file

@ -5,71 +5,80 @@ import sublime_plugin
# import sys # import sys
# import importlib.util # import importlib.util
from .src.codemp_client import VirtualClient from .src.TaskManager import tm
from .src.codemp_client import CodempLogger from .src.client import client, VirtualClient
from .src.TaskManager import rt from .src.client import CodempLogger
from .src.utils import status_log from .src.utils import status_log
from .src.utils import safe_listener_detach from .src.utils import safe_listener_detach
from .src.utils import safe_listener_attach
from .src import globals as g from .src import globals as g
from .bindings.codemp import init_logger from .bindings.codemp import init_logger
CLIENT = None
TEXT_LISTENER = None TEXT_LISTENER = None
# Initialisation and Deinitialisation # Initialisation and Deinitialisation
############################################################################## ##############################################################################
def plugin_loaded(): def plugin_loaded():
global CLIENT
global TEXT_LISTENER global TEXT_LISTENER
# instantiate and start a global asyncio event loop. # instantiate and start a global asyncio event loop.
# pass in the exit_handler coroutine that will be called upon relasing the event loop. # pass in the exit_handler coroutine that will be called upon relasing the event loop.
CLIENT = VirtualClient(disconnect_client) tm.acquire(disconnect_client)
TEXT_LISTENER = CodempClientTextChangeListener()
logger = CodempLogger(init_logger(False)) logger = CodempLogger(init_logger(False))
CLIENT.tm.dispatch(logger.spawn_logger(), "codemp-logger") tm.dispatch(logger.log(), "codemp-logger")
TEXT_LISTENER = CodempClientTextChangeListener()
status_log("plugin loaded") status_log("plugin loaded")
async def disconnect_client(): async def disconnect_client():
global CLIENT
global TEXT_LISTENER global TEXT_LISTENER
safe_listener_detach(TEXT_LISTENER) tm.stop_all()
CLIENT.tm.stop_all()
for vws in CLIENT.workspaces.values(): if TEXT_LISTENER is not None:
safe_listener_detach(TEXT_LISTENER)
for vws in client.workspaces.values():
vws.cleanup() vws.cleanup()
def plugin_unloaded(): def plugin_unloaded():
global CLIENT
# releasing the runtime, runs the disconnect callback defined when acquiring the event loop. # releasing the runtime, runs the disconnect callback defined when acquiring the event loop.
CLIENT.tm.release(False) status_log("unloading")
tm.release(False)
# Listeners # Listeners
############################################################################## ##############################################################################
class EventListener(sublime_plugin.EventListener): class EventListener(sublime_plugin.EventListener):
def on_exit(self): def on_exit(self):
global CLIENT tm.release(True)
CLIENT.tm.release(True)
def on_pre_close_window(self, window): def on_pre_close_window(self, window):
global CLIENT if client.active_workspace is None:
return # nothing to do
client.make_active(None)
s = window.settings() s = window.settings()
if s.get(g.CODEMP_WINDOW_TAG, False): if not s.get(g.CODEMP_WINDOW_TAG, False):
return
for wsid in s[g.CODEMP_WINDOW_WORKSPACES]: for wsid in s[g.CODEMP_WINDOW_WORKSPACES]:
ws = CLIENT[wsid] ws = client[wsid]
if ws is not None: if ws is None:
if ws.id == CLIENT.active_workspace.id: status_log(
CLIENT.active_workspace = None "[WARN] a tag on the window was found but not a matching workspace."
CLIENT.tm.stop(f"{g.CURCTL_TASK_PREFIX}-{ws.id}") )
continue
ws.cleanup() ws.cleanup()
del CLIENT.workspaces[wsid] del client.workspaces[wsid]
class CodempClientViewEventListener(sublime_plugin.ViewEventListener): class CodempClientViewEventListener(sublime_plugin.ViewEventListener):
@ -82,12 +91,13 @@ class CodempClientViewEventListener(sublime_plugin.ViewEventListener):
return False return False
def on_selection_modified_async(self): def on_selection_modified_async(self):
s = self.view.settings() ws = client.get_workspace(self.view)
if ws is None:
return
global CLIENT vbuff = ws.get_by_local(self.view.buffer_id())
vbuff = CLIENT[s[g.CODEMP_WORKSPACE_ID]].get_by_local(self.view.buffer_id())
if vbuff is not None: if vbuff is not None:
CLIENT.send_cursor(vbuff) vbuff.send_cursor(ws)
def on_activated(self): def on_activated(self):
# sublime has no proper way to check if a view gained or lost input focus outside of this # sublime has no proper way to check if a view gained or lost input focus outside of this
@ -95,7 +105,7 @@ class CodempClientViewEventListener(sublime_plugin.ViewEventListener):
g.ACTIVE_CODEMP_VIEW = self.view.id() g.ACTIVE_CODEMP_VIEW = self.view.id()
# print("view {} activated".format(self.view.id())) # print("view {} activated".format(self.view.id()))
global TEXT_LISTENER global TEXT_LISTENER
TEXT_LISTENER.attach(self.view.buffer()) safe_listener_attach(TEXT_LISTENER, self.view.buffer())
def on_deactivated(self): def on_deactivated(self):
g.ACTIVE_CODEMP_VIEW = None g.ACTIVE_CODEMP_VIEW = None
@ -108,12 +118,13 @@ class CodempClientViewEventListener(sublime_plugin.ViewEventListener):
if self.view.id() == g.ACTIVE_CODEMP_VIEW: if self.view.id() == g.ACTIVE_CODEMP_VIEW:
safe_listener_detach(TEXT_LISTENER) safe_listener_detach(TEXT_LISTENER)
global CLIENT ws = client.get_workspace(self.view)
wsid = self.view.settings().get(g.CODEMP_WORKSPACE_ID) if ws is None:
vbuff = CLIENT[wsid].get_by_local(self.view.buffer_id()) return
vbuff.cleanup()
CLIENT.tm.stop(f"{g.BUFFCTL_TASK_PREFIX}-{vbuff.codemp_id}") vbuff = ws.get_by_local(self.view.buffer_id())
if vbuff is not None:
vbuff.cleanup()
class CodempClientTextChangeListener(sublime_plugin.TextChangeListener): class CodempClientTextChangeListener(sublime_plugin.TextChangeListener):
@ -131,9 +142,9 @@ class CodempClientTextChangeListener(sublime_plugin.TextChangeListener):
s[g.CODEMP_IGNORE_NEXT_TEXT_CHANGE] = False s[g.CODEMP_IGNORE_NEXT_TEXT_CHANGE] = False
return return
global CLIENT vbuff = client.get_buffer(self.buffer.primary_view())
vbuff = CLIENT[s[g.CODEMP_WORKSPACE_ID]].get_by_local(self.buffer.id()) if vbuff is not None:
CLIENT.send_buffer_change(changes, vbuff) vbuff.send_buffer_change(changes)
# Commands: # Commands:
@ -153,8 +164,7 @@ class CodempClientTextChangeListener(sublime_plugin.TextChangeListener):
############################################################################# #############################################################################
class CodempConnectCommand(sublime_plugin.WindowCommand): class CodempConnectCommand(sublime_plugin.WindowCommand):
def run(self, server_host): def run(self, server_host):
global CLIENT tm.dispatch(client.connect(server_host))
rt.dispatch(CLIENT.connect(server_host))
def input(self, args): def input(self, args):
if "server_host" not in args: if "server_host" not in args:
@ -174,8 +184,7 @@ async def JoinCommand(client: VirtualClient, workspace_id: str, buffer_id: str):
class CodempJoinCommand(sublime_plugin.WindowCommand): class CodempJoinCommand(sublime_plugin.WindowCommand):
def run(self, workspace_id, buffer_id): def run(self, workspace_id, buffer_id):
global CLIENT tm.dispatch(JoinCommand(client, workspace_id, buffer_id))
rt.dispatch(JoinCommand(CLIENT, workspace_id, buffer_id))
def input_description(self): def input_description(self):
return "Join:" return "Join:"
@ -189,8 +198,7 @@ class CodempJoinCommand(sublime_plugin.WindowCommand):
############################################################################# #############################################################################
class CodempJoinWorkspaceCommand(sublime_plugin.WindowCommand): class CodempJoinWorkspaceCommand(sublime_plugin.WindowCommand):
def run(self, workspace_id): def run(self, workspace_id):
global CLIENT tm.dispatch(client.join_workspace(workspace_id, "sublime3"))
rt.dispatch(CLIENT.join_workspace(workspace_id, "sublime3"))
def input_description(self): def input_description(self):
return "Join specific workspace" return "Join specific workspace"
@ -204,23 +212,21 @@ class CodempJoinWorkspaceCommand(sublime_plugin.WindowCommand):
############################################################################# #############################################################################
class CodempJoinBufferCommand(sublime_plugin.WindowCommand): class CodempJoinBufferCommand(sublime_plugin.WindowCommand):
def run(self, buffer_id): def run(self, buffer_id):
global CLIENT if client.active_workspace is None:
if CLIENT.active_workspace is None:
sublime.error_message( sublime.error_message(
"You haven't joined any worksapce yet. \ "You haven't joined any worksapce yet. \
use `Codemp: Join Workspace` or `Codemp: Join`" use `Codemp: Join Workspace` or `Codemp: Join`"
) )
return return
rt.dispatch(CLIENT.active_workspace.attach(buffer_id)) tm.dispatch(client.active_workspace.attach(buffer_id))
def input_description(self): def input_description(self):
return "Join buffer in the active workspace" return "Join buffer in the active workspace"
# This is awful, fix it # This is awful, fix it
def input(self, args): def input(self, args):
global CLIENT if client.active_workspace is None:
if CLIENT.active_workspace is None:
sublime.error_message( sublime.error_message(
"You haven't joined any worksapce yet. \ "You haven't joined any worksapce yet. \
use `Codemp: Join Workspace` or `Codemp: Join`" use `Codemp: Join Workspace` or `Codemp: Join`"
@ -228,7 +234,7 @@ class CodempJoinBufferCommand(sublime_plugin.WindowCommand):
return return
if "buffer_id" not in args: if "buffer_id" not in args:
existing_buffers = CLIENT.active_workspace.handle.filetree() existing_buffers = client.active_workspace.handle.filetree()
if len(existing_buffers) == 0: if len(existing_buffers) == 0:
return RawBufferId() return RawBufferId()
else: else:
@ -259,8 +265,7 @@ class ListBufferId(sublime_plugin.ListInputHandler):
return "buffer_id" return "buffer_id"
def list_items(self): def list_items(self):
global CLIENT return client.active_workspace.handle.filetree()
return CLIENT.active_workspace.handle.filetree()
def next_input(self, args): def next_input(self, args):
if "buffer_id" not in args: if "buffer_id" not in args:
@ -313,7 +318,7 @@ class RawBufferId(sublime_plugin.TextInputHandler):
############################################################################# #############################################################################
class CodempDisconnectCommand(sublime_plugin.WindowCommand): class CodempDisconnectCommand(sublime_plugin.WindowCommand):
def run(self): def run(self):
rt.sync(disconnect_client()) tm.sync(disconnect_client())
# Proxy Commands ( NOT USED, left just in case we need it again. ) # Proxy Commands ( NOT USED, left just in case we need it again. )

View file

@ -1,21 +1,29 @@
from typing import Optional from typing import Optional
import asyncio import asyncio
import Codemp.ext.sublime_asyncio as rt from ..ext import sublime_asyncio as rt
class TaskManager: class TaskManager:
def __init__(self, exit_handler): def __init__(self):
self.tasks = [] self.tasks = []
self.exit_handler_id = rt.acquire(exit_handler) self.runtime = rt
self.exit_handler_id = None
def acquire(self, exit_handler):
if self.exit_handler_id is None:
# don't allow multiple exit handlers
self.exit_handler_id = self.runtime.acquire(exit_handler)
return self.exit_handler_id
def release(self, at_exit): def release(self, at_exit):
rt.release(at_exit=at_exit, exit_handler_id=self.exit_handler_id) self.runtime.release(at_exit=at_exit, exit_handler_id=self.exit_handler_id)
def dispatch(self, coro, name): def dispatch(self, coro, name=None):
rt.dispatch(coro, self.store_named_lambda(name)) self.runtime.dispatch(coro, self.store_named_lambda(name))
def sync(self, coro): def sync(self, coro):
rt.sync(coro) self.runtime.sync(coro)
def remove_stopped(self): def remove_stopped(self):
self.tasks = list(filter(lambda T: not T.cancelled(), self.tasks)) self.tasks = list(filter(lambda T: not T.cancelled(), self.tasks))
@ -26,7 +34,7 @@ class TaskManager:
self.tasks.append(task) self.tasks.append(task)
self.remove_stopped() self.remove_stopped()
def store_named_lambda(self, name): def store_named_lambda(self, name=None):
def _store(task): def _store(task):
self.store(task, name) self.store(task, name)
@ -43,7 +51,7 @@ class TaskManager:
def pop_task(self, name) -> Optional: def pop_task(self, name) -> Optional:
idx = self.get_task_idx(name) idx = self.get_task_idx(name)
if id is not None: if id is not None:
return self.task.pop(idx) return self.tasks.pop(idx)
return None return None
async def _stop(self, task): async def _stop(self, task):
@ -56,8 +64,12 @@ class TaskManager:
def stop(self, name): def stop(self, name):
t = self.get_task(name) t = self.get_task(name)
if t is not None: if t is not None:
rt.dispatch(self._stop(t)) self.runtime.dispatch(self._stop(t))
def stop_all(self): def stop_all(self):
for task in self.tasks: for task in self.tasks:
rt.dispatch(self._stop(task)) self.runtime.dispatch(self._stop(task))
# singleton instance
tm = TaskManager()

View file

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional, Callable from typing import Optional
import sublime import sublime
import asyncio import asyncio
@ -8,11 +7,10 @@ import tempfile
import os import os
import shutil import shutil
from ..src import globals as g
import Codemp.src.globals as g from ..src.TaskManager import tm
from Codemp.src.wrappers import BufferController, Workspace, Client from ..src.wrappers import BufferController, Workspace, Client
from Codemp.src.utils import status_log, rowcol_to_region from ..src.utils import status_log, rowcol_to_region
from Codemp.src.TaskManager import TaskManager
class CodempLogger: class CodempLogger:
@ -22,7 +20,7 @@ class CodempLogger:
async def message(self): async def message(self):
return await self.handle.message() return await self.handle.message()
async def spawn_logger(self): async def log(self):
status_log("spinning up the logger...") status_log("spinning up the logger...")
try: try:
while msg := await self.handle.message(): while msg := await self.handle.message():
@ -44,12 +42,11 @@ class VirtualBuffer:
self, self,
workspace: VirtualWorkspace, workspace: VirtualWorkspace,
remote_id: str, remote_id: str,
view: sublime.View,
buffctl: BufferController, buffctl: BufferController,
): ):
self.view = view self.view = sublime.active_window().new_file()
self.codemp_id = remote_id self.codemp_id = remote_id
self.sublime_id = view.buffer_id() self.sublime_id = self.view.buffer_id()
self.workspace = workspace self.workspace = workspace
self.buffctl = buffctl self.buffctl = buffctl
@ -61,6 +58,11 @@ class VirtualBuffer:
self.view.retarget(self.tmpfile) self.view.retarget(self.tmpfile)
self.view.set_scratch(True) self.view.set_scratch(True)
tm.dispatch(
self.apply_bufferchange_task(),
f"{g.BUFFCTL_TASK_PREFIX}-{self.codemp_id}",
)
# mark the view as a codemp view # mark the view as a codemp view
s = self.view.settings() s = self.view.settings()
self.view.set_status(g.SUBLIME_STATUS_ID, "[Codemp]") self.view.set_status(g.SUBLIME_STATUS_ID, "[Codemp]")
@ -76,20 +78,74 @@ class VirtualBuffer:
del s[g.CODEMP_REMOTE_ID] del s[g.CODEMP_REMOTE_ID]
del s[g.CODEMP_WORKSPACE_ID] del s[g.CODEMP_WORKSPACE_ID]
self.view.erase_status(g.SUBLIME_STATUS_ID) self.view.erase_status(g.SUBLIME_STATUS_ID)
# this does nothing for now. figure out a way later
# self.view.erase_regions(g.SUBLIME_REGIONS_PREFIX) tm.stop(f"{g.BUFFCTL_TASK_PREFIX}-{self.codemp_id}")
status_log(f"cleaning up virtual buffer '{self.codemp_id}'") status_log(f"cleaning up virtual buffer '{self.codemp_id}'")
async def apply_bufferchange_task(self):
status_log(f"spinning up '{self.codemp_id}' buffer worker...")
try:
while text_change := await self.buffctl.recv():
if text_change.is_empty():
status_log("change is empty. skipping.")
continue
# In case a change arrives to a background buffer, just apply it.
# We are not listening on it. Otherwise, interrupt the listening
# to avoid echoing back the change just received.
if self.view.id() == g.ACTIVE_CODEMP_VIEW:
self.view.settings()[g.CODEMP_IGNORE_NEXT_TEXT_CHANGE] = True
# we need to go through a sublime text command, since the method,
# view.replace needs an edit token, that is obtained only when calling
# a textcommand associated with a view.
self.view.run_command(
"codemp_replace_text",
{
"start": text_change.start_incl,
"end": text_change.end_excl,
"content": text_change.content,
"change_id": self.view.change_id(),
},
)
except asyncio.CancelledError:
status_log(f"'{self.codemp_id}' buffer worker stopped...")
raise
except Exception as e:
status_log(f"buffer worker '{self.codemp_id}' crashed:\n{e}")
raise
def send_buffer_change(self, changes):
# we do not do any index checking, and trust sublime with providing the correct
# sequential indexing, assuming the changes are applied in the order they are received.
for change in changes:
region = sublime.Region(change.a.pt, change.b.pt)
status_log(
"sending txt change: Reg({} {}) -> '{}'".format(
region.begin(), region.end(), change.str
)
)
self.buffctl.send(region.begin(), region.end(), change.str)
def send_cursor(self, vws: VirtualWorkspace):
# TODO: only the last placed cursor/selection.
# status_log(f"sending cursor position in workspace: {vbuff.workspace.id}")
region = self.view.sel()[0]
start = self.view.rowcol(region.begin()) # only counts UTF8 chars
end = self.view.rowcol(region.end())
vws.curctl.send(self.codemp_id, start, end)
# A virtual workspace is a bridge class that aims to translate # A virtual workspace is a bridge class that aims to translate
# events that happen to the codemp workspaces into sublime actions # events that happen to the codemp workspaces into sublime actions
class VirtualWorkspace: class VirtualWorkspace:
def __init__(self, client: VirtualClient, workspace_id: str, handle: Workspace): def __init__(self, workspace_id: str, handle: Workspace):
self.id = workspace_id self.id = workspace_id
self.sublime_window = sublime.active_window() self.sublime_window = sublime.active_window()
self.client = client
self.handle = handle self.handle = handle
self.curctl = handle.cursor() self.curctl = handle.cursor()
self.isactive = False
# mapping remote ids -> local ids # mapping remote ids -> local ids
self.id_map: dict[str, str] = {} self.id_map: dict[str, str] = {}
@ -117,11 +173,9 @@ class VirtualWorkspace:
s[g.CODEMP_WINDOW_TAG] = True s[g.CODEMP_WINDOW_TAG] = True
s[g.CODEMP_WINDOW_WORKSPACES] = [self.id] s[g.CODEMP_WINDOW_WORKSPACES] = [self.id]
def add_buffer(self, remote_id: str, vbuff: VirtualBuffer):
self.id_map[remote_id] = vbuff.view.buffer_id()
self.active_buffers[vbuff.view.buffer_id()] = vbuff
def cleanup(self): def cleanup(self):
self.deactivate()
# the worskpace only cares about closing the various open views on its buffers. # the worskpace only cares about closing the various open views on its buffers.
# the event listener calls the cleanup code for each buffer independently on its own. # the event listener calls the cleanup code for each buffer independently on its own.
for vbuff in self.active_buffers.values(): for vbuff in self.active_buffers.values():
@ -132,7 +186,7 @@ class VirtualWorkspace:
d = self.sublime_window.project_data() d = self.sublime_window.project_data()
newf = list( newf = list(
filter( filter(
lambda F: F["name"] != f"{g.WORKSPACE_FOLDER_PREFIX}{self.id}", lambda f: f.get("name", "") != f"{g.WORKSPACE_FOLDER_PREFIX}{self.id}",
d["folders"], d["folders"],
) )
) )
@ -145,11 +199,38 @@ class VirtualWorkspace:
del s[g.CODEMP_WINDOW_TAG] del s[g.CODEMP_WINDOW_TAG]
del s[g.CODEMP_WINDOW_WORKSPACES] del s[g.CODEMP_WINDOW_WORKSPACES]
def activate(self):
tm.dispatch(
self.move_cursor_task(),
f"{g.CURCTL_TASK_PREFIX}-{self.id}",
)
self.isactive = True
def deactivate(self):
if self.isactive:
tm.stop(f"{g.CURCTL_TASK_PREFIX}-{self.id}")
self.isactive = False
def add_buffer(self, remote_id: str, vbuff: VirtualBuffer):
self.id_map[remote_id] = vbuff.view.buffer_id()
self.active_buffers[vbuff.view.buffer_id()] = vbuff
def get_by_local(self, local_id: str) -> Optional[VirtualBuffer]: def get_by_local(self, local_id: str) -> Optional[VirtualBuffer]:
return self.active_buffers.get(local_id) return self.active_buffers.get(local_id)
def get_by_remote(self, remote_id: str) -> Optional[VirtualBuffer]: def get_by_remote(self, remote_id: str) -> Optional[VirtualBuffer]:
return self.active_buffers.get(self.id_map.get(remote_id)) local_id = self.id_map.get(remote_id)
if local_id is None:
return
vbuff = self.active_buffers.get(local_id)
if vbuff is None:
status_log(
"[WARN] a local-remote buffer id pair was found but not the matching virtual buffer."
)
return
return vbuff
async def attach(self, id: str): async def attach(self, id: str):
if id is None: if id is None:
@ -170,31 +251,80 @@ class VirtualWorkspace:
status_log(f"error when attaching to buffer '{id}': {e}") status_log(f"error when attaching to buffer '{id}': {e}")
return return
view = self.sublime_window.new_file() vbuff = VirtualBuffer(self, id, buff_ctl)
vbuff = VirtualBuffer(self, id, view, buff_ctl)
self.add_buffer(id, vbuff) self.add_buffer(id, vbuff)
self.client.spawn_buffer_manager(vbuff)
# TODO! if the view is already active calling focus_view() will not trigger the on_activate # TODO! if the view is already active calling focus_view() will not trigger the on_activate
self.sublime_window.focus_view(view) self.sublime_window.focus_view(vbuff.view)
async def move_cursor_task(self):
status_log(f"spinning up cursor worker for workspace '{self.id}'...")
try:
while cursor_event := await self.curctl.recv():
vbuff = self.get_by_remote(cursor_event.buffer)
if vbuff is None:
continue
reg = rowcol_to_region(vbuff.view, cursor_event.start, cursor_event.end)
reg_flags = sublime.RegionFlags.DRAW_EMPTY # show cursors.
user_hash = hash(cursor_event.user)
vbuff.view.add_regions(
f"{g.SUBLIME_REGIONS_PREFIX}-{user_hash}",
[reg],
flags=reg_flags,
scope=g.REGIONS_COLORS[user_hash % len(g.REGIONS_COLORS)],
annotations=[cursor_event.user],
annotation_color=g.PALETTE[user_hash % len(g.PALETTE)],
)
except asyncio.CancelledError:
status_log(f"cursor worker for '{self.id}' stopped...")
raise
except Exception as e:
status_log(f"cursor worker '{self.id}' crashed:\n{e}")
raise
class VirtualClient: class VirtualClient:
def __init__(self, on_exit: Callable = None): def __init__(self):
self.handle: Client = Client() self.handle: Client = Client()
self.workspaces: dict[str, VirtualWorkspace] = {} self.workspaces: dict[str, VirtualWorkspace] = {}
self.active_workspace: VirtualWorkspace = None self.active_workspace: Optional[VirtualWorkspace] = None
self.tm = TaskManager(on_exit)
def __getitem__(self, key: str): def __getitem__(self, key: str):
return self.workspaces.get(key) return self.workspaces.get(key)
def make_active(self, ws: VirtualWorkspace): def get_workspace(self, view):
tag_id = view.settings().get(g.CODEMP_WORKSPACE_ID)
if tag_id is None:
return
ws = self.workspaces.get(tag_id)
if ws is None:
status_log(
"[WARN] a tag on the view was found but not a matching workspace."
)
return
return ws
def get_buffer(self, view):
ws = self.get_workspace(view)
return None if ws is None else ws.get_by_local(view.buffer_id())
def make_active(self, ws: VirtualWorkspace | None):
if self.active_workspace == ws:
return
if self.active_workspace is not None: if self.active_workspace is not None:
self.tm.stop(f"{g.CURCTL_TASK_PREFIX}-{self.active_workspace.id}") self.active_workspace.deactivate()
if ws is not None:
ws.activate()
self.active_workspace = ws self.active_workspace = ws
self.spawn_cursor_manager(ws)
async def connect(self, server_host: str): async def connect(self, server_host: str):
status_log(f"Connecting to {server_host}") status_log(f"Connecting to {server_host}")
@ -211,12 +341,14 @@ class VirtualClient:
async def join_workspace( async def join_workspace(
self, workspace_id: str, user="sublime2", password="lmaodefaultpassword" self, workspace_id: str, user="sublime2", password="lmaodefaultpassword"
) -> VirtualWorkspace: ) -> Optional[VirtualWorkspace]:
try: try:
status_log(f"Logging into workspace: '{workspace_id}'") status_log(f"Logging into workspace: '{workspace_id}'")
await self.handle.login(user, password, workspace_id) await self.handle.login(user, password, workspace_id)
except Exception as e: except Exception as e:
status_log(f"Failed to login to workspace '{workspace_id}'.\nerror: {e}", True) status_log(
f"Failed to login to workspace '{workspace_id}'.\nerror: {e}", True
)
return return
try: try:
@ -226,105 +358,11 @@ class VirtualClient:
status_log(f"Could not join workspace '{workspace_id}'.\nerror: {e}", True) status_log(f"Could not join workspace '{workspace_id}'.\nerror: {e}", True)
return return
vws = VirtualWorkspace(self, workspace_id, workspace_handle) vws = VirtualWorkspace(workspace_id, workspace_handle)
self.make_active(vws)
self.workspaces[workspace_id] = vws self.workspaces[workspace_id] = vws
self.make_active(vws)
return vws return vws
def spawn_cursor_manager(self, virtual_workspace: VirtualWorkspace):
async def move_cursor_task(vws):
status_log(f"spinning up cursor worker for workspace '{vws.id}'...")
try:
while cursor_event := await vws.curctl.recv():
vbuff = vws.get_by_remote(cursor_event.buffer)
if vbuff is None: client = VirtualClient()
continue
reg = rowcol_to_region(
vbuff.view, cursor_event.start, cursor_event.end
)
reg_flags = sublime.RegionFlags.DRAW_EMPTY # show cursors.
user_hash = hash(cursor_event.user)
vbuff.view.add_regions(
f"{g.SUBLIME_REGIONS_PREFIX}-{user_hash}",
[reg],
flags=reg_flags,
scope=g.REGIONS_COLORS[user_hash % len(g.REGIONS_COLORS)],
annotations=[cursor_event.user],
annotation_color=g.PALETTE[user_hash % len(g.PALETTE)],
)
except asyncio.CancelledError:
status_log(f"cursor worker for '{vws.id}' stopped...")
raise
except Exception as e:
status_log(f"cursor worker '{vws.id}' crashed:\n{e}")
raise
self.tm.dispatch(
move_cursor_task(virtual_workspace),
f"{g.CURCTL_TASK_PREFIX}-{virtual_workspace.id}",
)
def send_cursor(self, vbuff: VirtualBuffer):
# TODO: only the last placed cursor/selection.
# status_log(f"sending cursor position in workspace: {vbuff.workspace.id}")
region = vbuff.view.sel()[0]
start = vbuff.view.rowcol(region.begin()) # only counts UTF8 chars
end = vbuff.view.rowcol(region.end())
vbuff.workspace.curctl.send(vbuff.codemp_id, start, end)
def spawn_buffer_manager(self, vbuff: VirtualBuffer):
async def apply_buffer_change_task(vb):
status_log(f"spinning up '{vb.codemp_id}' buffer worker...")
try:
while text_change := await vb.buffctl.recv():
if text_change.is_empty():
status_log("change is empty. skipping.")
continue
# In case a change arrives to a background buffer, just apply it.
# We are not listening on it. Otherwise, interrupt the listening
# to avoid echoing back the change just received.
if vb.view.id() == g.ACTIVE_CODEMP_VIEW:
vb.view.settings()[g.CODEMP_IGNORE_NEXT_TEXT_CHANGE] = True
# we need to go through a sublime text command, since the method,
# view.replace needs an edit token, that is obtained only when calling
# a textcommand associated with a view.
vb.view.run_command(
"codemp_replace_text",
{
"start": text_change.start_incl,
"end": text_change.end_excl,
"content": text_change.content,
"change_id": vb.view.change_id(),
},
)
except asyncio.CancelledError:
status_log(f"'{vb.codemp_id}' buffer worker stopped...")
raise
except Exception as e:
status_log(f"buffer worker '{vb.codemp_id}' crashed:\n{e}")
raise
self.tm.dispatch(
apply_buffer_change_task(vbuff),
f"{g.BUFFCTL_TASK_PREFIX}-{vbuff.codemp_id}",
)
def send_buffer_change(self, changes, vbuff: VirtualBuffer):
# we do not do any index checking, and trust sublime with providing the correct
# sequential indexing, assuming the changes are applied in the order they are received.
for change in changes:
region = sublime.Region(change.a.pt, change.b.pt)
status_log(
"sending txt change: Reg({} {}) -> '{}'".format(
region.begin(), region.end(), change.str
)
)
vbuff.buffctl.send(region.begin(), region.end(), change.str)

View file

@ -12,7 +12,9 @@ WORKSPACE_FOLDER_PREFIX = "CODEMP::"
SUBLIME_REGIONS_PREFIX = "codemp-cursors" SUBLIME_REGIONS_PREFIX = "codemp-cursors"
SUBLIME_STATUS_ID = "z_codemp_buffer" SUBLIME_STATUS_ID = "z_codemp_buffer"
CODEMP_IGNORE_NEXT_TEXT_CHANGE = "codemp-skip-change-event" CODEMP_IGNORE_NEXT_TEXT_CHANGE = "codemp-skip-change-event"
ACTIVE_CODEMP_VIEW = None ACTIVE_CODEMP_VIEW = None
ACTIVE_CODEMP_WINDOW = None
PALETTE = [ PALETTE = [
"var(--redish)", "var(--redish)",

View file

@ -16,10 +16,15 @@ def rowcol_to_region(view, start, end):
def safe_listener_detach(txt_listener: sublime_plugin.TextChangeListener): def safe_listener_detach(txt_listener: sublime_plugin.TextChangeListener):
if txt_listener.is_attached(): if txt_listener is not None and txt_listener.is_attached():
txt_listener.detach() txt_listener.detach()
def safe_listener_attach(txt_listener: sublime_plugin.TextChangeListener, buffer):
if txt_listener is not None and not txt_listener.is_attached():
txt_listener.attach(buffer)
def get_contents(view): def get_contents(view):
r = sublime.Region(0, view.size()) r = sublime.Region(0, view.size())
return view.substr(r) return view.substr(r)