Skip to content

Commit

Permalink
Generalize file-based RPC command client beyond VSCode (#956)
Browse files Browse the repository at this point in the history
# What is this

Split command-client so that other application can reuse the logic for
of command server for RPC using existing VS code Command-Client.

## Reasoning
In slack advised by aegis, when discussing RPC mechanism for Visual
Studio.

"
if you replicate the vscode file rpc, please try to port the existing
code as precisely as possible, without changing the sequence of file
reads/writes or anything, because we spent a while making that work well
"

Initially copied and pasted existing code, wrote the Visual Studio side
of things, then thought that best port is re-use of existing code

## Discussion
* If this PR goes ahead is Command-Client in the correct location?
Probably not, but was not sure were to place it and wanted to float this
idea before continuing with anymore work.
* Should the methods not be renamed so that they are not so VSCode
specific? Yes probably, for the moment I'm happy with the way things
work and there is existing code which would need updating (Cursorless)
which I did not want to touch as part of this PR.

## Apologies

I've installed pre-commit but have not gotten it working correctly as of
yet, but wanted to get your opinion on, whether this is this a valid
direction of travel.

Co-authored-by: johneffo <johneffo@gmailcom.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Michael Arntzenius <daekharel@gmail.com>
Co-authored-by: Pokey Rule <755842+pokey@users.noreply.github.com>
  • Loading branch information
5 people committed Sep 10, 2022
1 parent 1bc4b0d commit 9e664a1
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 56 deletions.
94 changes: 38 additions & 56 deletions apps/vscode/command_client/command_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
# to remove it
STALE_TIMEOUT_MS = 60_000

# The amount of time to wait for VSCode to perform a command, in seconds
VSCODE_COMMAND_TIMEOUT_SECONDS = 3.0
# The amount of time to wait for application to perform a command, in seconds
RPC_COMMAND_TIMEOUT_SECONDS = 3.0

# When doing exponential back off waiting for vscode to perform a command, how
# When doing exponential back off waiting for application to perform a command, how
# long to sleep the first time
MINIMUM_SLEEP_TIME_SECONDS = 0.0005

Expand All @@ -26,13 +26,12 @@

mod = Module()

global_ctx = Context()
ctx = Context()
mac_ctx = Context()
linux_ctx = Context()

ctx.matches = r"""
app: vscode
tag: user.command_client
"""
mac_ctx.matches = r"""
os: mac
Expand All @@ -49,11 +48,8 @@ def __repr__(self):
return "<argument not set>"


def run_vscode_command_by_command_palette(command_id: str):
"""Execute command via command palette. Preserves the clipboard."""
actions.user.command_palette()
actions.user.paste(command_id)
actions.key("enter")
class NoFileServerException(Exception):
pass


def write_json_exclusive(path: Path, body: Any):
Expand Down Expand Up @@ -123,22 +119,23 @@ def handle_existing_request_file(path):
robust_unlink(path)


def run_vscode_command(
def run_command(
command_id: str,
*args: str,
*args,
wait_for_finish: bool = False,
return_command_output: bool = False,
):
"""Runs a VSCode command, using command server if available
"""Runs a command, using command server if available
Args:
command_id (str): The ID of the VSCode command to run
command_id (str): The ID of the command to run.
args: The arguments to the command.
wait_for_finish (bool, optional): Whether to wait for the command to finish before returning. Defaults to False.
return_command_output (bool, optional): Whether to return the output of the command. Defaults to False.
Raises:
Exception: If there is an issue with the file-based communication, or
VSCode raises an exception
application raises an exception
Returns:
Object: The response from the command, if requested.
Expand All @@ -152,9 +149,7 @@ def run_vscode_command(
if not communication_dir_path.exists():
if args or return_command_output:
raise Exception("Must use command-server extension for advanced commands")
print("Communication dir not found; falling back to command palette")
run_vscode_command_by_command_palette(command_id)
return
raise NoFileServerException("Communication directory not found")

request_path = communication_dir_path / "request.json"
response_path = communication_dir_path / "response.json"
Expand All @@ -180,9 +175,9 @@ def run_vscode_command(
print("WARNING: Found old response file")
robust_unlink(response_path)

# Then, perform keystroke telling VSCode to execute the command in the
# request file. Because only the active VSCode instance will accept
# keypresses, we can be sure that the active VSCode instance will be the
# Then, perform keystroke telling application to execute the command in the
# request file. Because only the active application instance will accept
# keypresses, we can be sure that the active application instance will be the
# one to execute the command.
actions.user.trigger_command_server_command_execution()

Expand Down Expand Up @@ -221,7 +216,7 @@ def get_communication_dir_path():
if hasattr(os, "getuid"):
suffix = f"-{os.getuid()}"

return Path(gettempdir()) / f"vscode-command-server{suffix}"
return Path(gettempdir()) / f"{actions.user.command_server_directory()}{suffix}"


def robust_unlink(path: Path):
Expand Down Expand Up @@ -261,7 +256,7 @@ def read_json_with_timeout(path: str) -> Any:
Returns:
Any: The json-decoded contents of the file
"""
timeout_time = time.perf_counter() + VSCODE_COMMAND_TIMEOUT_SECONDS
timeout_time = time.perf_counter() + RPC_COMMAND_TIMEOUT_SECONDS
sleep_time = MINIMUM_SLEEP_TIME_SECONDS
while True:
try:
Expand Down Expand Up @@ -289,28 +284,16 @@ def read_json_with_timeout(path: str) -> Any:

@mod.action_class
class Actions:
def vscode(command_id: str):
"""Execute command via vscode command server, if available, or fallback
to command palette."""
run_vscode_command(command_id)

def vscode_and_wait(command_id: str):
"""Execute command via vscode command server, if available, and wait
for command to finish. If command server not available, uses command
palette and doesn't guarantee that it will wait for command to
finish."""
run_vscode_command(command_id, wait_for_finish=True)

def vscode_with_plugin(
def run_rpc_command(
command_id: str,
arg1: Any = NotSet,
arg2: Any = NotSet,
arg3: Any = NotSet,
arg4: Any = NotSet,
arg5: Any = NotSet,
):
"""Execute command via vscode command server."""
run_vscode_command(
"""Execute command via RPC."""
run_command(
command_id,
arg1,
arg2,
Expand All @@ -319,16 +302,16 @@ def vscode_with_plugin(
arg5,
)

def vscode_with_plugin_and_wait(
def run_rpc_command_and_wait(
command_id: str,
arg1: Any = NotSet,
arg2: Any = NotSet,
arg3: Any = NotSet,
arg4: Any = NotSet,
arg5: Any = NotSet,
):
"""Execute command via vscode command server and wait for command to finish."""
run_vscode_command(
"""Execute command via application command server and wait for command to finish."""
run_command(
command_id,
arg1,
arg2,
Expand All @@ -338,16 +321,16 @@ def vscode_with_plugin_and_wait(
wait_for_finish=True,
)

def vscode_get(
def run_rpc_command_get(
command_id: str,
arg1: Any = NotSet,
arg2: Any = NotSet,
arg3: Any = NotSet,
arg4: Any = NotSet,
arg5: Any = NotSet,
) -> Any:
"""Execute command via vscode command server and return command output."""
return run_vscode_command(
"""Execute command via application command server and return command output."""
return run_command(
command_id,
arg1,
arg2,
Expand All @@ -357,16 +340,25 @@ def vscode_get(
return_command_output=True,
)

def command_server_directory() -> str:
"""Return the directory of the command server"""

def trigger_command_server_command_execution():
"""Issue keystroke to trigger command server to execute command that
was written to the file. For internal use only"""
actions.key("ctrl-shift-f17")

def emit_pre_phrase_signal() -> bool:
"""Touches a file to indicate that a phrase is about to begin execution"""
"""
If in an application supporting the command client, returns True
and touches a file to indicate that a phrase is beginning execution.
Otherwise does nothing and returns False.
"""
return False

def did_emit_pre_phrase_signal() -> bool:
"""Indicates whether the pre-phrase signal was emitted at the start of this phrase"""
# NB: This action is used by cursorless; please don't delete it :)
return did_emit_pre_phrase_signal


Expand All @@ -382,20 +374,10 @@ def trigger_command_server_command_execution():
actions.key("ctrl-shift-alt-p")


@global_ctx.action_class("user")
class GlobalUserActions:
def emit_pre_phrase_signal() -> bool:
# NB: We explicitly define a noop version of this action in the global
# context here so that it doesn't do anything before phrases if you're not
# in vscode.
return False


@ctx.action_class("user")
class UserActions:
def emit_pre_phrase_signal() -> bool:
def emit_pre_phrase_signal():
get_signal_path("prePhrase").touch()

return True


Expand Down
20 changes: 20 additions & 0 deletions apps/vscode/command_client/command_client_tag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from talon import Module

mod = Module()

mod.tag(
"command_client", desc="For applications which implement file-based RPC with Talon"
)


@mod.action_class
class Actions:
def command_server_directory() -> str:
"""
The dirctory which contains the files required for communication between
the application and Talon. This is the only function which absolutely
must be implemented for any application using the command-client. Each
application that supports file-based RPC should use its own directory
name. Note that this action should only return a name; the parent
directory is determined by the core command client code.
"""
15 changes: 15 additions & 0 deletions apps/vscode/command_client/visual_studio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from talon import Context

ctx = Context()

ctx.matches = r"""
app: visual_studio
"""

ctx.tags = ["user.command_client"]


@ctx.action_class("user")
class VisualStudioActions:
def command_server_directory() -> str:
return "visual-studio-command-server"
92 changes: 92 additions & 0 deletions apps/vscode/command_client/vscode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from pickle import FALSE
from typing import Any

from talon import Context, Module, actions

from .command_client import NoFileServerException, NotSet, run_command

ctx = Context()

ctx.matches = r"""
app: vscode
"""
ctx.tags = ["user.command_client"]
mod = Module()


def command_server_or_client_fallback(command_id: str, wait: bool):
"""Execute command via command server, falling back to command palette if directory not present."""
try:
run_command(command_id, wait_for_finish=wait)
except NoFileServerException:
actions.user.command_palette()
actions.user.paste(command_id)
actions.key("enter")
print(
"Command server directory not found; falling back to command palette. For better performance, install the VSCode extension for Talon: https://marketplace.visualstudio.com/items?itemName=pokey.talon"
)


@ctx.action_class("user")
class VsCodeAction:
def command_server_directory() -> str:
return "vscode-command-server"


@mod.action_class
class Actions:
def vscode(command_id: str):
"""Execute command via vscode command server, if available, or fallback
to command palette."""
command_server_or_client_fallback(command_id, False)

def vscode_and_wait(command_id: str):
"""Execute command via vscode command server, if available, and wait
for command to finish. If command server not available, uses command
palette and doesn't guarantee that it will wait for command to
finish."""
command_server_or_client_fallback(command_id, True)

# These commands are shims, to provide backwards compatibility, they may be removed in the fuuture.
# Prefer the run_command... version in command_client.
def vscode_with_plugin(
command_id: str,
arg1: Any = NotSet,
arg2: Any = NotSet,
arg3: Any = NotSet,
arg4: Any = NotSet,
arg5: Any = NotSet,
):
"""Execute command via vscode command server."""
actions.user.run_rpc_command(
command_id,
arg1,
arg2,
arg3,
arg4,
arg5,
)

def vscode_with_plugin_and_wait(
command_id: str,
arg1: Any = NotSet,
arg2: Any = NotSet,
arg3: Any = NotSet,
arg4: Any = NotSet,
arg5: Any = NotSet,
):
"""Execute command via vscode command server and wait for command to finish."""
actions.user.run_rpc_command_and_wait(command_id, arg1, arg2, arg3, arg4, arg5)

def vscode_get(
command_id: str,
arg1: Any = NotSet,
arg2: Any = NotSet,
arg3: Any = NotSet,
arg4: Any = NotSet,
arg5: Any = NotSet,
) -> Any:
"""Execute command via vscode command server and return command output."""
return actions.user.run_rpc_command_get(
command_id, arg1, arg2, arg3, arg4, arg5
)

0 comments on commit 9e664a1

Please sign in to comment.