Files
sessions/sublime/sessions/commands.py
Myeongseon Choi 927b685059 fix(eager_hydrate): dedicated thread + Rust parallelism — unblock hydrate_open_file after reconnect
Symptom (debug-trace.log post-reconnect)
----------------------------------------

Background queue grew to 8 tasks across 46s with zero `queue.dequeue`
events. ``hydrate_open_file`` (prioritize=true, fires when user opens
a file) was head-of-line-blocked behind the running ``eager_hydrate``,
so opening a remote file after reconnect did not trigger sync.

Root cause
----------

PR-B made eager_hydrate a single synchronous Rust call that loops
sequentially through every placeholder (each ``file_open`` round-trip
is ~50–500ms; with N≈100 placeholders the worker thread is occupied
for tens of seconds — minutes if the helper is loaded). The shared
``_BACKGROUND_TASK_QUEUE`` worker has no preemption, so user-facing
``hydrate_open_file`` cannot run until eager_hydrate finishes.

Fix 1 — dedicated thread per cache_key (Python)
-----------------------------------------------

* ``_schedule_eager_hydrate_if_needed`` now runs the pass on its own
  daemon thread, not via ``_run_in_background``. The shared background
  worker is freed for ``hydrate_open_file`` / ``open_file_refresh_*`` /
  ``sessions.refresh_git_state``.
* Per-key in-flight set ``_EAGER_HYDRATE_INFLIGHT`` preserves the
  dedupe-by-cache_key semantics the old ``task_key`` provided. Same
  cache_key triggered twice while the first pass is running emits a
  ``mirror.eager_hydrate_skip_inflight`` trace and returns.
* Lint #2 stays satisfied — no new ``_*_TASK_QUEUE = deque()`` is
  introduced; the new lane is a per-key set + dedicated thread.

Fix 2 — N-way parallelism inside Rust apply pass
------------------------------------------------

* ``run_apply_pass`` accepts a ``parallelism`` parameter. Per batch,
  spawns up to ``parallelism`` workers via ``thread::scope`` that pull
  placeholders from a shared work queue and call
  ``file_open::run_file_open_transaction`` concurrently. The broker
  multiplexes by envelope id, so concurrent file/read is safe.
* Per-placeholder logic factored into ``process_placeholder`` (atomic
  counters for skipped/failed, mutex-guarded ``Vec<Value>`` for
  hydrated entries — no dirty-read hazard).
* ``parallelism = 1`` retains the strictly sequential PR-B behaviour
  for tests / single-thread debugging; tiny batches take a fast path
  that avoids scope/Mutex overhead.
* Default from ``commands.py``: ``parallelism=8``. Cuts the wall-clock
  of a 50-placeholder pass roughly linearly until per-placeholder
  latency becomes helper-bound rather than round-trip-bound.

Fix 3 — tighten per-placeholder timeout
---------------------------------------

* ``timeout_ms`` for eager_hydrate file_opens drops from 30 s to 10 s.
  Eager hydrate is best-effort; placeholders that miss a pass simply
  re-run on the next sync. The smaller cap stops a stuck helper from
  blocking the dedicated thread for minutes.

Tests
-----

1298 Python tests pass, 89 Rust unit tests pass, ``cargo clippy
--workspace -- -D warnings`` green, boundary lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:31:07 +09:00

7404 lines
266 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Sublime command skeletons for Sessions connect flows."""
from __future__ import annotations
import datetime as _datetime
import hashlib
import importlib
import json
import shlex
import shutil
import sys
import threading
import time
from collections import deque
from copy import deepcopy
from dataclasses import dataclass, replace
from pathlib import Path, PurePosixPath
from typing import (
Callable,
Dict,
Iterable,
List,
Mapping,
Optional,
Sequence,
Set,
Tuple,
)
from . import _rust_ffi
from .connect_preflight import (
ConnectPreflightError,
ConnectStatus,
RemoteRootMissingError,
SessionHelperStartError,
validate_host_alias,
validate_remote_root,
)
from .connect_progress import ConnectProgressPanel
from .diagnostics import ( # noqa: F401
dedupe_diagnostic_records,
diagnostic_records_from_helper_payload,
format_diagnostic_records_for_panel,
inline_presentations_from_mapped_diagnostics,
map_diagnostics_batch,
output_panel_plan_from_tool_execution_result,
unopened_files_summary,
)
from .eager_hydrate import (
DEFAULT_BATCH_SIZE as _EAGER_HYDRATE_BATCH_SIZE,
)
from .eager_hydrate import (
DEFAULT_BATCH_SLEEP_S as _EAGER_HYDRATE_BATCH_SLEEP_S,
)
from .file_state import (
FileOpenGuardrails,
OpenFileResult,
OpenOutcome,
RemotePathMappingError,
RemoteToLocalCacheMapper,
open_guard_reason_for_remote_metadata,
)
from .lsp_project_wiring import (
collect_lsp_diagnostics_snapshot,
disable_stale_managed_lsp_rows_on_disk,
existing_managed_broker_sockets,
explain_lsp_attach_blockers,
format_lsp_diagnostics_panel_text,
push_project_data_to_window,
refresh_project_file_lsp_block,
trace_lsp_workspace_activation,
)
from .managed_remote_extension_catalog import BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG
from .python_interpreter_browser import ( # noqa: F401
list_remote_directory,
)
from .python_interpreter_registry import ( # noqa: F401
detect_venv_interpreters,
probe_interpreter_version,
read_active_interpreter,
)
from .recent_state import (
QuickPanelItemModel,
RecentWorkspace,
RecentWorkspaceStore,
RemoteHostPlatformStore,
RemoteLinuxPlatformTag,
host_quick_panel_items,
recent_workspace_quick_panel_items,
)
from .remote import (
RemoteDirectoryEntry,
RemoteFileKind,
RemoteFileMetadata,
RemoteListDirectoryRequest,
RemoteReadFileRequest,
RemoteWatchFilesRequest,
RemoteWriteErrorCode,
RemoteWriteFileRequest,
ToolFailureCategory,
evaluate_directory_entries,
)
from .remote_tool_wiring import (
build_python_format_tool_execution_request,
build_python_lint_tool_execution_request,
)
from .settings_model import (
RemoteExtensionSpec,
SessionsSettings,
load_sessions_settings_from_sublime,
sync_mode_bool,
)
from .sidebar_project_folders import (
merge_sessions_sidebar_folder,
remove_sessions_sidebar_folder,
sessions_sidebar_folder_caption,
)
from .ssh_config import SshHostEntry, load_ssh_config_host_entries
from .ssh_file_transport import (
RemoteCacheMirrorOptions,
RemoteExecOnceResult,
_try_resolved_local_bridge_binary_path,
bridge_handshake_info,
bridge_session_is_active,
clear_bridge_handshake_listeners,
execute_remote_cache_mirror,
execute_remote_exec_once,
execute_remote_list_directory,
execute_remote_read_file,
execute_remote_stat_file,
execute_remote_watch_files,
execute_remote_write_file,
merge_mirror_ignore_patterns,
open_remote_file_into_local_cache,
register_bridge_handshake_listener,
register_transport_trace_listener,
reset_bridge_for_host,
shutdown_all_persistent_bridges,
)
from .ssh_runner import (
format_ssh_transport_error,
log_ssh_failure_if_debug,
run_ssh_remote_command,
ssh_prompt_callback,
)
from .ssh_tool_runtime import execute_remote_tool_request
from .workspace_state import (
PROJECT_SETTINGS_KEY,
WorkspaceBootstrapPlan,
WorkspaceIdentity,
clear_deferred_directory,
connect_workspace,
default_local_paths,
deferred_directories_for,
record_deferred_directories,
)
_REMOTE_DIRECTORY_EXPLORER_LAYOUT = {
"cols": [0.0, 0.26, 1.0],
"rows": [0.0, 1.0],
"cells": [[0, 0, 1, 1], [1, 0, 2, 1]],
}
try:
sublime = importlib.import_module("sublime")
sublime_plugin = importlib.import_module("sublime_plugin")
except ImportError: # pragma: no cover
class _FallbackSublime:
"""Fallback `sublime` module used for unit tests."""
_sessions_test_sync = True
@staticmethod
def cache_path() -> str:
"""Return a temporary cache path for unit tests."""
return "/tmp"
@staticmethod
def status_message(message: str) -> None:
"""Consume status messages during unit tests."""
_ = message
@staticmethod
def error_message(message: str) -> None:
"""Consume modal errors during unit tests (real Sublime shows a dialog)."""
_ = message
@staticmethod
def windows() -> Sequence[object]:
"""Return no open windows during unit tests."""
return []
@staticmethod
def Region(a: int, b: int) -> Tuple[int, int]:
"""Return a tuple-shaped stand-in for ``sublime.Region`` in tests."""
return (a, b)
@staticmethod
def set_timeout(callback, delay_ms: int = 0) -> None:
"""Run deferred UI callbacks immediately in unit tests."""
_ = delay_ms
callback()
class _FallbackWindowCommand:
"""Fallback `WindowCommand` base used for unit tests."""
def __init__(self, window: object) -> None:
"""Store the fake window instance for tests."""
self.window = window
class _FallbackTextCommand:
"""Fallback `TextCommand` base used for unit tests."""
def __init__(self, view: object) -> None:
"""Store the fake view instance for tests."""
self.view = view
class _FallbackEventListener:
"""Fallback `EventListener` base used for unit tests."""
class _FallbackSublimePlugin:
"""Fallback `sublime_plugin` namespace used for unit tests."""
WindowCommand = _FallbackWindowCommand
TextCommand = _FallbackTextCommand
EventListener = _FallbackEventListener
sublime = _FallbackSublime()
sublime_plugin = _FallbackSublimePlugin()
_CONNECTED_HOSTS_BY_WINDOW_ID: Dict[int, str] = {}
# host_alias -> sublime window ids that still need the persistent bridge for that host.
_BRIDGE_HOST_WINDOW_IDS: Dict[str, Set[int]] = {}
# View ids currently fetching remote bytes for a mirrored zero-byte placeholder.
_HYDRATE_IN_FLIGHT: Set[int] = set()
# Local paths recently reverted by hydrate; maps path → monotonic timestamp.
# open_file_refresh skips these for a short cooldown to avoid double-revert crashes.
_HYDRATE_REVERT_COOLDOWN: Dict[str, float] = {}
_HYDRATE_REVERT_COOLDOWN_S = 10.0
_ACTIVE_REFRESH_DEBOUNCE_S = 0.4
_ACTIVE_REFRESH_VIEW_TS: Dict[int, float] = {}
# Workspace cache keys currently running a sidebar mirror pass.
_MIRROR_SYNC_IN_FLIGHT: Set[str] = set()
# Window identities with scheduled periodic mirror refresh loops.
_MIRROR_AUTO_REFRESH_WINDOWS: Set[int] = set()
_MIRROR_AUTO_REFRESH_PRIMED: Set[int] = set()
# Cache keys with an active auto-refresh loop (multi-window dedup).
_MIRROR_AUTO_REFRESH_CACHE_KEYS: Set[str] = set()
# Per-cache-key consecutive mirror-sync failure count, used to back the
# auto-refresh loop off when sync keeps timing out (e.g. AWS SSM tunnel).
# Reset to 0 on the next success; capped via _AUTO_REFRESH_BACKOFF_MAX_EXP.
_MIRROR_AUTO_REFRESH_FAIL_COUNT: Dict[str, int] = {}
_AUTO_REFRESH_BACKOFF_MAX_EXP = 4 # multipliers: 1, 2, 4, 8, 16x
# Window identities with active remote file-watch loops.
_OPEN_FILE_WATCH_WINDOWS: Set[int] = set()
# Cache keys with an active remote file-watch loop (multi-window dedup).
_OPEN_FILE_WATCH_CACHE_KEYS: Set[str] = set()
# Remote absolute paths recently written by a Sessions-initiated save.
# Maps ``remote_path → monotonic timestamp``. The remote watch loop and
# the active-tab revalidate skip entries inside ``_RECENT_SELF_SAVE_COOLDOWN_S``
# so the self-triggered ``file/watch`` event from our own push does not
# bounce back into Sublime as an external "reloading <path>" reload.
# (Regression of the v0.5.5 quietening — see Cluster D1.)
_RECENT_SELF_SAVE_REMOTE_PATHS: Dict[str, float] = {}
_RECENT_SELF_SAVE_COOLDOWN_S = 5.0
_LSP_PROJECT_REFRESH_LAST: Dict[Tuple[int, str], str] = {}
# Remember the last attach-blocker string we surfaced as a diagnostics
# output panel per window, so a persistent blocker across repeated
# ``on_activated`` events doesn't re-pop the panel. Keyed by
# ``_window_identity(window)`` → blocker string. Cleared when the
# blocker resolves.
_LSP_DIAG_LAST_BLOCKER_BY_WINDOW: Dict[int, str] = {}
_BACKGROUND_TASK_QUEUE = deque()
_BACKGROUND_TASK_LOCK = threading.Lock()
_BACKGROUND_TASK_EVENT = threading.Event()
_BACKGROUND_WORKER_STARTED = False
_BACKGROUND_PENDING_KEYS: Set[str] = set()
_BACKGROUND_INFLIGHT_KEYS: Set[str] = set()
# Monotonic token for "Remote workspace connect" (quick panel host pick).
# Wave 2 PR 16 (PR-A core): the token + in-flight host now live in
# ``sessions_native::orchestrator`` (process-wide singleton). Older
# ``_connect_selected_host_async`` calls compare their captured token via
# :func:`_rust_ffi.is_connect_token_stale` and abort once stale.
_MIRROR_TASK_QUEUE = deque()
_MIRROR_TASK_LOCK = threading.Lock()
_MIRROR_TASK_EVENT = threading.Event()
_MIRROR_WORKER_STARTED = False
_BACKGROUND_QUEUE_MAX = 128
_MIRROR_QUEUE_MAX = 8
# Eager-hydrate runs on a dedicated thread per cache_key so its long
# pass (sequential file_open transactions over many placeholders) cannot
# block ``hydrate_open_file`` (a ``prioritize=True`` background task that
# fires every time the user opens a file). Lint #2 grandfathers the
# `_BACKGROUND_TASK_QUEUE` / `_MIRROR_TASK_QUEUE` deques in this module
# but explicitly bans new ones in ``commands_*.py`` split modules — we
# stay within the spirit by NOT introducing a third queue, only a per-
# key in-flight set.
_EAGER_HYDRATE_INFLIGHT: Set[str] = set()
_EAGER_HYDRATE_INFLIGHT_LOCK = threading.Lock()
def _mirror_queue_pressure(queue_size: int, dropped: int) -> str:
return _rust_ffi.mirror_queue_pressure(
queue_size=queue_size,
dropped=dropped,
queue_max=_MIRROR_QUEUE_MAX,
)
def _mirror_queue_tail_labels(max_tail: int = 8) -> list[str]:
with _MIRROR_TASK_LOCK:
items = list(_MIRROR_TASK_QUEUE)
labels = [getattr(t[0], "__name__", repr(t[0])) for t in items]
return _rust_ffi.queue_tail_labels(labels, max_tail=max_tail)
def _background_queue_pressure(queue_size: int, dropped: int) -> str:
return _rust_ffi.background_queue_pressure(
queue_size=queue_size,
dropped=dropped,
queue_max=_BACKGROUND_QUEUE_MAX,
)
def _background_queue_tail_labels(max_tail: int = 12) -> list[str]:
with _BACKGROUND_TASK_LOCK:
items = list(_BACKGROUND_TASK_QUEUE)
labels = [str(item[2]) for item in items]
return _rust_ffi.queue_tail_labels(labels, max_tail=max_tail)
def _describe_ongoing_remote_connect_work() -> Optional[str]:
"""Summarize another quick-panel remote connect that is pending or running.
Used to confirm before preempting; returns ``None`` when nothing would be cancelled.
"""
pending_hosts: List[str] = []
with _BACKGROUND_TASK_LOCK:
for target, args, _label, _task_key in _BACKGROUND_TASK_QUEUE:
if target is not _connect_selected_host_async:
continue
if len(args) < 3:
continue
prior = args[2]
if isinstance(prior, str) and prior.strip():
pending_hosts.append(prior.strip())
inflight_host = _rust_ffi.connect_inflight_host()
parts: List[str] = []
if inflight_host:
parts.append("in progress: {}".format(inflight_host))
if pending_hosts:
unique = []
for h in pending_hosts:
if h not in unique:
unique.append(h)
parts.append("queued: {}".format(", ".join(unique)))
if not parts:
return None
return "; ".join(parts)
def _preempt_connect_session_for_new_remote_request() -> int:
"""Cancel other pending host connects and interrupt an in-flight one.
The background worker runs connect tasks sequentially; without this, a slow
SSH attempt blocks every later ``_run_in_background`` task. Bumping the
generation makes older ``_connect_selected_host_async`` calls no-op after their
current blocking step, and ``reset_bridge_for_host`` on the superseded host
tears down the stuck ``local_bridge`` child so the worker can proceed.
Wave 2 PR 16: token + in-flight host live in
``sessions_native::orchestrator`` (process-wide singleton).
"""
token = _rust_ffi.bump_connect_generation()
inflight_host = _rust_ffi.connect_inflight_host()
pruned_hosts: List[str] = []
with _BACKGROUND_TASK_LOCK:
kept: deque = deque()
while _BACKGROUND_TASK_QUEUE:
entry = _BACKGROUND_TASK_QUEUE.popleft()
target, args, label, task_key = entry
if target is _connect_selected_host_async:
if task_key:
_BACKGROUND_PENDING_KEYS.discard(task_key)
if len(args) >= 3:
prior = args[2]
if isinstance(prior, str) and prior.strip():
pruned_hosts.append(prior.strip())
continue
kept.append(entry)
_BACKGROUND_TASK_QUEUE.extendleft(reversed(kept))
reset_host: Optional[str] = None
if inflight_host:
reset_host = inflight_host
reset_bridge_for_host(inflight_host)
if pruned_hosts or reset_host is not None:
_trace_event(
"connect.preempt",
new_token=token,
pruned_hosts=pruned_hosts,
reset_bridge_host=reset_host,
)
return token
def _connect_generation_is_stale(connect_token: int) -> bool:
"""Return True if a newer remote connect was scheduled after ``connect_token``."""
return _rust_ffi.is_connect_token_stale(connect_token)
_HYDRATE_REQUEST_SERIAL_BY_WORKSPACE: Dict[str, int] = {}
_HYDRATE_REQUEST_LOCK = threading.Lock()
# When the Rust bridge is unavailable, hydrate uses SSH alongside mirror passes;
# tight timeouts produced spurious disconnects under mux contention.
_HYDRATE_OPEN_STAT_TIMEOUT_S = 15.0
_HYDRATE_OPEN_READ_TIMEOUT_S = 30.0
# Sidebar mirror (SSH tree/list) vs placeholder hydrate (stat/cat): same host often
# shares one effective SSH lane; pause mirror between dirs while hydrate runs.
#
# Wave 2 PR 16: depth + paused-host set live in
# ``sessions_native::orchestrator``. Python keeps the per-host
# ``threading.Event`` map (the mirror worker still blocks on ``ev.wait()``
# at Sublime's UI/IO boundary; flipping that needs a Python-side handle).
_SSH_MIRROR_LANE_LOCK = threading.Lock()
_SSH_MIRROR_GO_BY_HOST: Dict[str, threading.Event] = {}
def _begin_interactive_ssh_lane(host_alias: str) -> None:
with _SSH_MIRROR_LANE_LOCK:
ev = _SSH_MIRROR_GO_BY_HOST.get(host_alias)
if ev is None:
ev = threading.Event()
ev.set()
_SSH_MIRROR_GO_BY_HOST[host_alias] = ev
depth = _rust_ffi.enter_interactive_lane(host_alias)
if depth == 1:
ev.clear()
_trace_event("ssh.interactive_lane.enter", host_alias=host_alias)
def _end_interactive_ssh_lane(host_alias: str) -> None:
depth = _rust_ffi.exit_interactive_lane(host_alias)
if depth <= 0:
with _SSH_MIRROR_LANE_LOCK:
ev = _SSH_MIRROR_GO_BY_HOST.get(host_alias)
if ev is not None:
ev.set()
_trace_event("ssh.interactive_lane.exit", host_alias=host_alias)
@dataclass(frozen=True)
class _DirectoryBrowseItem:
trigger: str
details: str
action: str
remote_path: str
@dataclass(frozen=True)
class _RemoteFileBrowseItem:
trigger: str
details: str
action: str
remote_path: str
@dataclass(frozen=True)
class _RemoteTreeEntry:
label: str
action: str
remote_path: str
def _project_settings_key():
return PROJECT_SETTINGS_KEY
class SessionsConnectRemoteWorkspaceCommand(sublime_plugin.WindowCommand):
"""Connect to a remote host via SSH config before choosing a folder."""
def run(self) -> None:
"""Open the host selection quick panel for a new remote host session."""
settings = SessionsSettings()
host_entries = _load_host_entries(settings)
if not host_entries:
_status_message("No SSH config hosts are available for Sessions.")
return
host_items = host_quick_panel_items(host_entries)
self.window.show_quick_panel(
[_quick_panel_row(item) for item in host_items],
lambda selected_index: self._on_host_selected(
selected_index, host_items, settings
),
)
def _on_host_selected(
self,
selected_index: int,
host_items: Sequence[QuickPanelItemModel],
settings: SessionsSettings,
) -> None:
if selected_index < 0:
return
host_item = host_items[selected_index]
host_alias = host_item.host_alias or ""
_status_message("Connecting to SSH host {}...".format(host_alias))
summary = _describe_ongoing_remote_connect_work()
if summary is not None:
dialog = getattr(sublime, "ok_cancel_dialog", None)
if callable(dialog):
message = (
"Another Sessions remote connect is already running:\n"
"{}\n\n"
"Stop it and start connecting to '{}' instead?"
).format(summary, host_alias)
if not dialog(
message,
ok_title="Switch connect",
cancel_title="Keep current",
):
return
_schedule_connect_selected_host_async(self.window, settings, host_alias)
def _schedule_connect_selected_host_async(
window: object,
settings: SessionsSettings,
host_alias: str,
) -> None:
connect_token = _preempt_connect_session_for_new_remote_request()
_run_in_background(
_connect_selected_host_async,
window,
settings,
host_alias,
connect_token,
)
def _connect_selected_host_async(
window: object,
settings: SessionsSettings,
host_alias: str,
connect_token: int,
) -> None:
_rust_ffi.set_connect_inflight(connect_token, host_alias)
# Open a visible "Sessions Connect" panel for this host-connect attempt.
# This is the flow the user hits from "Sessions: Connect to host" — the
# v0.4.11 hook on ``_connect_selected_workspace`` didn't cover it, which
# is why the panel never appeared on the reported Windows run. Panel
# lifecycle is stop-on-finally; success/failure lines are written at
# each exit.
_connect_progress = ConnectProgressPanel(window, host_alias)
_connect_progress.start()
try:
if _connect_generation_is_stale(connect_token):
_trace_event(
"connect.preempted",
phase="before_ssh",
host_alias=host_alias,
connect_token=connect_token,
)
_connect_progress.failure("preempted (before_ssh)")
return
try:
def _ssh_secret_prompt(prompt: str):
return _prompt_for_ssh_secret(window, prompt)
with ssh_prompt_callback(_ssh_secret_prompt):
if _connect_generation_is_stale(connect_token):
_trace_event(
"connect.preempted",
phase="before_connect_selected_host",
host_alias=host_alias,
connect_token=connect_token,
)
_connect_progress.failure(
"preempted (before_connect_selected_host)"
)
return
_connect_selected_host(settings, host_alias)
if _connect_generation_is_stale(connect_token):
_trace_event(
"connect.preempted",
phase="after_connect_selected_host",
host_alias=host_alias,
connect_token=connect_token,
)
_connect_progress.failure("preempted (after_connect_selected_host)")
return
detected_platform = _determine_remote_linux_platform(
settings, host_alias
)
except ConnectPreflightError as error:
if _connect_generation_is_stale(connect_token):
_trace_event(
"connect.preempted",
phase="connect_preflight_error",
host_alias=host_alias,
connect_token=connect_token,
)
_connect_progress.failure("preempted (preflight_error)")
return
detail = error.detail
_set_timeout(
lambda: _emit_status(ConnectStatus(kind="disconnected", detail=detail))
)
_connect_progress.failure(detail)
return
except Exception as exc:
if _connect_generation_is_stale(connect_token):
_trace_event(
"connect.preempted",
phase="exception_suppressed",
host_alias=host_alias,
connect_token=connect_token,
)
_connect_progress.failure("preempted (exception)")
return
_connect_progress.failure("unexpected: {}".format(exc))
raise
if _connect_generation_is_stale(connect_token):
_trace_event(
"connect.preempted",
phase="after_platform",
host_alias=host_alias,
connect_token=connect_token,
)
_connect_progress.failure("preempted (after_platform)")
return
_connect_progress.success(
"host reachable (platform={})".format(detected_platform or "?")
)
_set_timeout(
lambda: _finish_connected_host(
window,
settings,
host_alias,
detected_platform=detected_platform,
)
)
finally:
_connect_progress.stop()
_rust_ffi.clear_connect_inflight_if(connect_token)
def _finish_connected_host(
window: object,
settings: SessionsSettings,
host_alias: str,
*,
detected_platform: Optional[RemoteLinuxPlatformTag],
) -> None:
cached_platform = _remote_platform_store(settings).get(host_alias)
if cached_platform is None and detected_platform is not None:
_remember_remote_platform(settings, host_alias, detected_platform)
cached_platform = detected_platform
if cached_platform is None:
_emit_status(
ConnectStatus(
kind="disconnected",
detail=(
"Could not detect remote Linux platform for {}. "
"SSH must reach the host so uname can run "
"(supported: Linux x86_64 or aarch64)."
).format(host_alias),
)
)
return
_open_connected_host_window(window, settings, host_alias)
class SessionsOpenRemoteFolderCommand(sublime_plugin.WindowCommand):
"""Choose and materialize a remote folder after connecting to a host."""
def run(self) -> None:
"""Open a remote-root picker for the currently connected host."""
settings = SessionsSettings()
host_alias = _connected_host_alias(self.window)
if host_alias is None:
_status_message("Connect to an SSH host before choosing a remote folder.")
return
window = self.window
_run_in_background(
_open_remote_folder_resolve_start,
window,
settings,
host_alias,
)
def _open_remote_folder_resolve_start(
window: object,
settings: SessionsSettings,
host_alias: str,
) -> None:
try:
starting_directory = _starting_directory_for_open_remote_folder(
settings, host_alias
)
except ConnectPreflightError as error:
detail = error.detail
_set_timeout(
lambda: _emit_status(ConnectStatus(kind="disconnected", detail=detail))
)
return
_set_timeout(
lambda: _browse_remote_directory(
window,
settings,
host_alias,
starting_directory,
)
)
class SessionsOpenRemoteTreeCommand(sublime_plugin.WindowCommand):
"""Open or refresh the dedicated remote tree view for the workspace."""
def run(self, remote_directory: str = "") -> None:
"""Show the remote tree view for the current workspace directory."""
settings = SessionsSettings()
context = _workspace_context(self.window, settings)
if context is None:
return
target_directory = (remote_directory or "").strip()
if not target_directory:
target_directory = context.recent_entry.remote_root
_open_remote_tree_for_workspace(self.window, context, target_directory)
class SessionsOpenRemoteDirectoryExplorerCommand(sublime_plugin.WindowCommand):
"""Legacy split layout + scratch tree; prefer Sync Remote Tree to Sidebar."""
def run(self) -> None:
"""Open the workspace remote tree beside a dedicated editor column."""
settings = SessionsSettings()
context = _workspace_context(self.window, settings)
if context is None:
return
if not _apply_remote_directory_explorer_layout(self.window):
_status_message(
"Sessions could not apply the remote directory explorer layout."
)
return
target_directory = context.recent_entry.remote_root
_open_remote_tree_for_workspace(
self.window,
context,
target_directory,
editor_target_group=1,
)
class SessionsCloseRemoteFileCommand(sublime_plugin.WindowCommand):
"""Close a remote-backed cache file from the tree selection or the active editor."""
def run(self) -> None:
"""Close the selected tree file or the focused remote cache buffer."""
settings = SessionsSettings()
context = _workspace_context(self.window, settings)
if context is None:
return
view = _active_view(self.window)
if view is None:
return
if _is_remote_tree_view(view):
_close_open_remote_file_for_tree_row(self.window, context, view, row=-1)
return
if _close_active_remote_cache_view(self.window, view, context):
return
_status_message("Active file is not a Sessions remote cache file.")
_AUTO_MIRROR_DEPTH_SOURCES: frozenset[str] = frozenset(
{"auto", "auto_open_folder", "auto_refresh"}
)
@dataclass(frozen=True)
class _NormalizedSyncRoot:
"""Inputs needed to run a remote-tree mirror pass.
Captures the workspace fields read off ``_WorkspaceContext`` plus
the resolved ``RemoteCacheMirrorOptions`` so the mirror background
work can read them as plain values without re-reading settings on
the worker thread.
"""
cache_key: str
host_alias: str
remote_root: str
cache_root: Path
mirror_opts: RemoteCacheMirrorOptions
source: str
force_full_sync: bool
def _precheck_and_normalize_sync_root(
context: _WorkspaceContext,
*,
source: str,
force_full_sync: bool,
) -> Optional[_NormalizedSyncRoot]:
"""Guard against an in-flight mirror and snapshot the sync inputs.
Emits the same ``Sessions: …`` status messages as the inline guard
used to (running and already-running variants) and returns ``None``
when the caller should bail. The success path also marks
``_MIRROR_SYNC_IN_FLIGHT`` and traces ``sync.started`` so the rest
of the orchestrator does not re-check those preconditions.
"""
cache_key = context.cache_key
if cache_key in _MIRROR_SYNC_IN_FLIGHT:
_trace_event("sync.skipped_inflight", cache_key=cache_key, source=source)
if source == "manual":
_status_message("Sessions: remote tree mirror already running…")
return None
if source == "manual":
_status_message("Sessions: mirroring remote tree…")
mirror_opts = _mirror_options_from_sublime_settings(source=source)
_MIRROR_SYNC_IN_FLIGHT.add(cache_key)
_trace_event(
"sync.started",
cache_key=cache_key,
source=source,
force_full_sync=force_full_sync,
max_traversal_depth=mirror_opts.max_traversal_depth,
)
return _NormalizedSyncRoot(
cache_key=cache_key,
host_alias=context.recent_entry.host_alias,
remote_root=context.recent_entry.remote_root,
cache_root=context.local_cache_root,
mirror_opts=mirror_opts,
source=source,
force_full_sync=force_full_sync,
)
def _finish_sidebar_merge(
window: object,
host_alias: str,
remote_root: str,
cache_root: Path,
) -> None:
"""Merge the cache root into the sidebar and show it on the UI thread.
Pure side-effect: read project data, ask
``merge_sessions_sidebar_folder`` to produce updated project data,
push it back via ``set_project_data``, optionally reveal the
sidebar, and queue a follow-up nudge to coerce sidebar focus once
Sublime has re-rendered.
"""
caption = sessions_sidebar_folder_caption(host_alias, remote_root)
pdata = merge_sessions_sidebar_folder(
window.project_data(),
cache_root,
caption,
)
set_project = getattr(window, "set_project_data", None)
if callable(set_project):
set_project(pdata)
if _mirror_show_sidebar_after_sync():
show_sidebar = getattr(window, "set_sidebar_visible", None)
if callable(show_sidebar):
show_sidebar(True)
_set_timeout(
lambda w=window: _coerce_sidebar_after_project_merge(w),
100,
)
def _sync_remote_tree_to_sidebar_for_context(
window: object,
context: _WorkspaceContext,
*,
source: str = "manual",
force_full_sync: bool = False,
allow_spawn: bool = True,
) -> None:
"""Mirror remote listings into cache and merge the sidebar folder."""
sync_root = _precheck_and_normalize_sync_root(
context, source=source, force_full_sync=force_full_sync
)
if sync_root is None:
return
cache_key = sync_root.cache_key
host_alias = sync_root.host_alias
remote_root = sync_root.remote_root
cache_root = sync_root.cache_root
mirror_opts = sync_root.mirror_opts
def work() -> None:
try:
normalized_root = validate_remote_root(remote_root)
except ConnectPreflightError as exc:
failure_detail = exc.detail
def fail() -> None:
_MIRROR_SYNC_IN_FLIGHT.discard(cache_key)
_emit_status(ConnectStatus(kind="disconnected", detail=failure_detail))
_set_timeout(fail, 0)
return
two_phase = (
(not force_full_sync)
and _mirror_fast_sidebar_first_sync()
and mirror_opts.max_traversal_depth > 1
)
def merge_sidebar_and_show() -> None:
_finish_sidebar_merge(window, host_alias, remote_root, cache_root)
def run_cache_mirror(options: RemoteCacheMirrorOptions):
return execute_remote_cache_mirror(
host_alias,
remote_root=normalized_root,
local_files_root=cache_root,
options=options,
allow_spawn=allow_spawn,
)
if two_phase:
shallow_opts = replace(mirror_opts, max_traversal_depth=1)
shallow = run_cache_mirror(shallow_opts)
if not shallow.ok:
_record_auto_refresh_failure(cache_key)
_trace_event(
"sync.shallow_failed",
cache_key=cache_key,
detail=shallow.error_detail or "",
)
detail = shallow.error_detail or "Remote tree mirror failed."
def fail_shallow() -> None:
_MIRROR_SYNC_IN_FLIGHT.discard(cache_key)
_emit_status(ConnectStatus(kind="disconnected", detail=detail))
_set_timeout(fail_shallow, 0)
return
def finish_shallow() -> None:
merge_sidebar_and_show()
# Suppress the running commentary for auto-refresh ticks
# (source="auto"): the loop fires every few seconds and
# was drowning the console during slow-network reconnect
# storms. Initial connect ("auto_refresh" prime) and
# manual refresh still narrate.
if source == "auto":
return
suffix = ""
if shallow.truncated_by_entry_limit:
suffix = " (truncated: entry limit)"
_emit_status(
ConnectStatus(
kind="ready",
detail=(
"Sidebar: top level ready ({} entries, {} dirs, "
"{} file stubs{}). Deepening mirror…"
).format(
shallow.entries_scanned,
shallow.directories_created,
shallow.file_placeholders_created,
suffix,
),
)
)
_set_timeout(finish_shallow, 0)
_MIRROR_SYNC_IN_FLIGHT.discard(cache_key)
_trace_event("sync.shallow_done", cache_key=cache_key)
_enqueue_deep_sync(
window, context, source="auto_deepen", allow_spawn=allow_spawn
)
return
result = run_cache_mirror(mirror_opts)
def finish() -> None:
_MIRROR_SYNC_IN_FLIGHT.discard(cache_key)
if not result.ok:
_record_auto_refresh_failure(cache_key)
_trace_event(
"sync.failed",
cache_key=cache_key,
source=source,
detail=result.error_detail or "",
)
detail = result.error_detail or "Remote tree mirror failed."
if two_phase:
_emit_status(
ConnectStatus(
kind="warning",
detail=(
"Top level is in the sidebar, but "
"the deep mirror failed: {}"
).format(detail),
)
)
else:
_emit_status(ConnectStatus(kind="disconnected", detail=detail))
return
_record_auto_refresh_success(cache_key)
if not two_phase:
merge_sidebar_and_show()
else:
_set_timeout(
lambda w=window: _coerce_sidebar_after_project_merge(w),
100,
)
suffix_parts: list[str] = []
if result.truncated_by_entry_limit:
suffix_parts.append("truncated: entry limit")
if result.aborted_by_failure_budget:
suffix_parts.append("aborted: write-failure budget tripped")
deferred_list = record_deferred_directories(
cache_key, result.deferred_directories
)
if deferred_list:
suffix_parts.append(
(
"{} deferred dir(s) — run Sessions: Expand Deferred Directory"
).format(len(deferred_list))
)
suffix = " ({})".format("; ".join(suffix_parts)) if suffix_parts else ""
status_kind = "warning" if result.aborted_by_failure_budget else "ready"
if source == "manual" or result.aborted_by_failure_budget:
_emit_status(
ConnectStatus(
kind=status_kind,
detail=(
"Sidebar mirror: {} entries, {} dirs, {} file stubs{}."
).format(
result.entries_scanned,
result.directories_created,
result.file_placeholders_created,
suffix,
),
)
)
_trace_event(
"sync.done",
cache_key=cache_key,
source=source,
entries=result.entries_scanned,
deferred=len(deferred_list),
aborted_by_failure_budget=result.aborted_by_failure_budget,
)
# Deep mirror just surfaced a fresh batch of placeholders — many
# of them are subproject build manifests (pyproject.toml, etc.)
# that weren't on disk when activation fired its first pass.
_schedule_eager_hydrate_if_needed(window, context)
# Track G v0 auto-trigger: every sync.done re-pulls each
# repo's ``.git`` so Sublime Merge / sgit always see the
# current remote refs. The background queue dedups by
# ``task_key`` so overlapping syncs collapse to one run.
# No-op when the user opted out via
# ``sessions_mirror_ignore_patterns``.
_schedule_track_g_refresh_if_needed(window, context)
_set_timeout(finish, 0)
_run_mirror_in_background(work)
class SessionsSyncRemoteTreeToSidebarCommand(sublime_plugin.WindowCommand):
"""Mirror remote listings into the cache; add cache folder to the project."""
def run(self, source: str = "manual") -> None:
"""Run one mirror pass (manual or auto source)."""
settings = SessionsSettings()
context = _workspace_context(self.window, settings)
if context is None:
return
effective_source = source or "manual"
if effective_source != "manual" and not _workspace_runtime_connected(
self.window, context
):
_trace_event(
"sync.auto_skipped_disconnected",
cache_key=context.cache_key,
source=effective_source,
)
return
_sync_remote_tree_to_sidebar_for_context(
self.window,
context,
source=effective_source,
allow_spawn=(effective_source == "manual"),
)
class SessionsRemoveSidebarMirrorFolderCommand(sublime_plugin.WindowCommand):
"""Drop the Sessions cache path from the window's project ``folders`` list."""
def run(self) -> None:
"""Remove the mirrored cache folder entry without deleting cache files."""
settings = SessionsSettings()
context = _workspace_context(self.window, settings)
if context is None:
return
window = self.window
pdata = remove_sessions_sidebar_folder(
window.project_data(),
context.local_cache_root,
)
set_project = getattr(window, "set_project_data", None)
if callable(set_project):
set_project(pdata)
_status_message("Sessions sidebar folder removed from the project.")
def _resolve_sidebar_remote_path(
context: _WorkspaceContext,
paths: Optional[List[str]],
) -> Optional[str]:
"""Map the first selected sidebar path to its remote counterpart."""
if not paths:
return None
mapper = RemoteToLocalCacheMapper(
workspace_cache_key=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
files_cache_root=context.local_cache_root,
)
for raw in paths:
try:
candidate = Path(raw)
except TypeError:
continue
remote = mapper.remote_path_for_local_cache_file(candidate)
if remote:
return remote
return None
class SessionsExpandDeferredDirectoryCommand(sublime_plugin.WindowCommand):
"""Mirror one previously-deferred directory with ``max_dir_fanout = 0``.
Scope is a single remote directory so the per-expand write burst still
honours ``max_entries`` (1000 by default) and the write token bucket.
Triggered either by palette entry (no args → quick panel of known
deferred directories) or the sidebar context entry (``paths`` kwarg).
"""
def run(
self,
remote_path: Optional[str] = None,
paths: Optional[List[str]] = None,
dirs: Optional[List[str]] = None,
files: Optional[List[str]] = None,
) -> None:
"""Expand a previously deferred directory.
Args:
remote_path: Optional explicit remote absolute path to expand.
When omitted and no sidebar paths are given, show a quick
panel of all currently deferred directories for this
workspace.
paths: Sublime Side Bar context-menu kwarg carrying clicked
paths (combined across dirs + files).
dirs: Sublime Side Bar context-menu kwarg carrying directory
paths only — some ST builds populate this alone.
files: Sublime Side Bar context-menu kwarg carrying file
paths only — rare for this command but accepted for
symmetry.
"""
settings = SessionsSettings()
context = _workspace_context(self.window, settings)
if context is None:
_trace_event(
"expand.invoked",
source="no_workspace_context",
remote_path=remote_path,
paths=paths,
dirs=dirs,
files=files,
)
return
sidebar_paths = paths or dirs or files
# Diagnostic trace: surface exactly what Sublime handed us so the
# right-click → wrong-path case can be diagnosed from the trace
# log alone (Cluster D2 / 2026-04-26 retest).
_trace_event(
"expand.invoked",
source="sidebar"
if sidebar_paths
else ("remote_path" if remote_path else "palette"),
cache_key=context.cache_key,
remote_path=remote_path,
paths=paths,
dirs=dirs,
files=files,
cache_root=str(context.local_cache_root),
)
if sidebar_paths:
resolved = _resolve_sidebar_remote_path(context, sidebar_paths)
_trace_event(
"expand.sidebar_resolved",
cache_key=context.cache_key,
input_paths=sidebar_paths,
resolved_remote_path=resolved,
)
if resolved is None:
_status_message("Not a Sessions remote path.")
return
self._expand_remote_path(context, resolved)
return
if remote_path:
self._expand_remote_path(context, remote_path)
return
deferred = deferred_directories_for(context.cache_key)
_trace_event(
"expand.quick_panel_deferred",
cache_key=context.cache_key,
deferred=list(deferred),
mirror_in_flight=context.cache_key in _MIRROR_SYNC_IN_FLIGHT,
)
if not deferred:
# We deliberately do *not* claim anything will "appear" here —
# nothing is being scheduled and ``expand.begin`` is not about
# to fire. The previous wording promised a future expand that
# never happened (Cluster D2 on the 2026-04-25 retest). Now
# the message describes the present state and tells the user
# what to do.
if context.cache_key in _MIRROR_SYNC_IN_FLIGHT:
_status_message(
"No deferred directories to expand yet — the mirror is "
"still deepening. Re-run after it finishes."
)
else:
_status_message("No deferred directories to expand.")
return
items = [[str(path), "Expand this directory"] for path in deferred]
def on_select(choice: int) -> None:
if choice < 0 or choice >= len(deferred):
return
self._expand_remote_path(context, deferred[choice])
quick = getattr(self.window, "show_quick_panel", None)
if callable(quick):
quick(items, on_select)
# Declaring ``is_visible`` + ``is_enabled`` that accept the sidebar kwargs
# is what tells Sublime to populate ``paths`` / ``dirs`` / ``files`` for
# this command when invoked from the Side Bar context menu. Without at
# least one of these methods, the menu entry runs with all kwargs empty
# and the command falls through to the deferred-dir quick panel — the
# exact bug reported in the v0.5.5 round.
def is_visible(
self,
remote_path: Optional[str] = None,
paths: Optional[List[str]] = None,
dirs: Optional[List[str]] = None,
files: Optional[List[str]] = None,
) -> bool:
"""Menu entry always visible; resolution happens at run time."""
return True
def is_enabled(
self,
remote_path: Optional[str] = None,
paths: Optional[List[str]] = None,
dirs: Optional[List[str]] = None,
files: Optional[List[str]] = None,
) -> bool:
"""Menu entry always enabled; permission + path checks run at run time."""
return True
# Threshold above which a single expand is large enough that the
# 1000-entry cap is likely to truncate the listing. Surfaced as a
# finish-time UX warning so the user knows only a slice is mirrored.
# Tracked under Cluster D2 (2026-04-25 retest).
_LARGE_EXPAND_WARN_ENTRIES = 5000
def _expand_remote_path(self, context: _WorkspaceContext, remote_path: str) -> None:
host_alias = context.recent_entry.host_alias
cache_root = context.local_cache_root
# Per-subdirectory mirror destination: the Rust mirror engine
# writes each entry as ``local_files_root.join(rel(entry,
# remote_root))``. If the caller passes the workspace cache
# root verbatim, expanding e.g. ``/home/mschoi/.conda`` lands
# ``.conda``'s children directly in ``<cache_root>/`` (as if
# they were workspace-root entries). Mapping the remote path
# through ``RemoteToLocalCacheMapper`` first gives us the
# subtree-specific destination ``<cache_root>/.conda/`` so
# the mirror lands children at the correct cache offset.
# v0.6.12 test pass repro: picking ``.conda`` from the
# quick panel filled the workspace root with ``condabin/``
# ``etc/`` etc., then a refresh pruned them — visible-data-
# destruction the EDR notice in §A.6 flagged.
mapper = RemoteToLocalCacheMapper(
workspace_cache_key=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
files_cache_root=cache_root,
)
base_opts = _mirror_options_from_sublime_settings(source="manual")
# Hard cap still applies (1000 by default) so even an unbounded
# fanout expand cannot produce an uncapped write burst. Prune is
# disabled so expand never deletes cache entries.
expand_opts = replace(
base_opts,
max_dir_fanout=0,
prune_missing=False,
)
# Status message is now emitted only here, on the path that
# actually schedules ``work()`` — so the user only sees a
# progress hint when ``expand.begin`` is genuinely about to
# fire (Cluster D2). Earlier branches that bail without
# scheduling stay silent or print a state-only message.
_status_message("Expanding {}".format(remote_path))
def work() -> None:
_trace_event(
"expand.begin",
remote_path=remote_path,
host_alias=host_alias,
)
try:
normalized_root = validate_remote_root(remote_path)
except ConnectPreflightError as exc:
detail = exc.detail
_trace_event(
"expand.validation_failed",
remote_path=remote_path,
detail=detail,
)
_set_timeout(
lambda: _status_message("Expand failed: {}".format(detail)),
0,
)
return
try:
local_subroot = mapper.local_path_for_remote_file(normalized_root)
except RemotePathMappingError as exc:
detail = str(exc)
_trace_event(
"expand.mapping_failed",
remote_path=normalized_root,
detail=detail,
)
_set_timeout(
lambda: _status_message(
"Expand failed: cannot map {} into the workspace cache "
"({}).".format(normalized_root, detail)
),
0,
)
return
_trace_event(
"expand.local_destination",
remote_path=normalized_root,
local_files_root=str(local_subroot),
)
result = execute_remote_cache_mirror(
host_alias,
remote_root=normalized_root,
local_files_root=local_subroot,
options=expand_opts,
allow_spawn=True,
)
_trace_event(
"expand.done",
remote_path=remote_path,
ok=result.ok,
entries=result.entries_scanned,
dirs=result.directories_created,
placeholders=result.file_placeholders_created,
deferred=list(result.deferred_directories),
aborted_by_failure_budget=result.aborted_by_failure_budget,
truncated=result.truncated_by_entry_limit,
error_detail=result.error_detail,
)
def finish() -> None:
if not result.ok:
_status_message(
"Expand failed: {}".format(
result.error_detail or "unknown error"
)
)
return
# If the expand itself re-deferred the target (still too
# large even with fanout=0? only possible if entry cap tripped)
# leave the entry in place; otherwise clear it.
if remote_path not in result.deferred_directories:
clear_deferred_directory(context.cache_key, remote_path)
suffix_parts: list[str] = []
if result.truncated_by_entry_limit:
suffix_parts.append("truncated by entry cap")
if result.aborted_by_failure_budget:
suffix_parts.append("write-failure budget tripped")
# Large-dir hint (Cluster D2): when the listed children
# blow past ~5k, warn the user that only a slice was
# mirrored even though the expand returned ``ok``. The
# 1000-entry cap is what bounds the per-burst write count,
# so ``entries_scanned > 5000`` is a strong signal that
# the whole subtree is not in cache yet — the user should
# re-run on subdirs to pull the rest.
if (
result.entries_scanned
> SessionsExpandDeferredDirectoryCommand._LARGE_EXPAND_WARN_ENTRIES
):
suffix_parts.append(
"{} entries listed — re-run on subdirs to pull the rest".format(
result.entries_scanned
)
)
suffix = " ({})".format("; ".join(suffix_parts)) if suffix_parts else ""
_status_message(
"Expanded {}: {} entries, {} dirs, {} file stubs{}.".format(
remote_path,
result.entries_scanned,
result.directories_created,
result.file_placeholders_created,
suffix,
)
)
_set_timeout(finish, 0)
_run_mirror_in_background(work)
class SessionsRemoteTreeOpenSelectionCommand(sublime_plugin.WindowCommand):
"""Open the selected file or browse the selected directory in the tree view."""
def run(self, row: int = -1) -> None:
"""Activate the current remote tree selection."""
settings = SessionsSettings()
context = _workspace_context(self.window, settings)
if context is None:
return
view = _active_view(self.window)
if view is None or not _is_remote_tree_view(view):
_status_message("Focus the Sessions Remote Tree view first.")
return
_open_selected_remote_tree_entry(self.window, context, view, row=row)
class SessionsRemoteTreeRefreshCommand(sublime_plugin.WindowCommand):
"""Refresh the currently focused remote tree view."""
def run(self) -> None:
"""Re-read the current remote directory into the tree view."""
settings = SessionsSettings()
context = _workspace_context(self.window, settings)
if context is None:
return
view = _active_view(self.window)
if view is None or not _is_remote_tree_view(view):
_status_message("Focus the Sessions Remote Tree view first.")
return
current_directory = _remote_tree_directory(view)
if current_directory is None:
_status_message(
"The current tree view does not have a directory to refresh."
)
return
editor_target_group = _remote_tree_editor_group(view)
_open_remote_tree_for_workspace(
self.window,
context,
current_directory,
editor_target_group=editor_target_group,
)
class SessionsRemoteTreeActivateCommand(sublime_plugin.TextCommand):
"""Text-command wrapper used to activate the current tree entry with Enter."""
def run(self, edit) -> None:
"""Open the selected tree entry from the current view."""
_ = edit
window = _view_window(self.view)
if window is None:
return
settings = SessionsSettings()
context = _workspace_context(window, settings)
if context is None or not _is_remote_tree_view(self.view):
return
_open_selected_remote_tree_entry(window, context, self.view, row=-1)
class SessionsRemoteTreeEventListener(sublime_plugin.EventListener):
"""Keyboard affordances for the dedicated Sessions remote tree view."""
def on_text_command(self, view, command_name: str, args):
"""Map Enter to the tree activation command when focused on the tree."""
_ = args
if not _is_remote_tree_view(view):
return None
if command_name == "insert":
return ("sessions_remote_tree_activate", {})
return None
@dataclass(frozen=True)
class _HydratePreflight:
"""Local-only inputs needed to start a sidebar placeholder hydrate.
Captures the validated view + window + cache path so the rest of
the pipeline does not have to re-walk the view's API to recover
them. Holding the values in a frozen record also makes it explicit
that ``_should_hydrate_placeholder`` performed every Sublime-API
call up front.
"""
window: object
path: Path
def _should_hydrate_placeholder(view: object) -> Optional[_HydratePreflight]:
"""Return preflight inputs when ``view`` should hydrate, else ``None``.
Pure guard: the only state it touches is reading view attributes,
the cache-file ``stat`` (to skip materialized files), and the
user's mirror-hydration setting. Returns ``None`` whenever any
guard rejects, so the caller can early-exit symmetrically with
the original inline code.
"""
if not _mirror_hydrate_placeholders_on_open():
return None
if _is_remote_tree_view(view):
return None
window_fn = getattr(view, "window", None)
window = window_fn() if callable(window_fn) else None
if window is None:
return None
file_name = _view_file_name(view)
if not file_name:
return None
path = Path(file_name)
# Skip only when the cache copy is already materialized with content.
# Fetch is needed in two cases:
# (a) zero-byte placeholder created by sidebar tree sync — historical
# case that gave this function its name.
# (b) file does not exist on disk at all — happens when the Sublime
# LSP plugin calls ``window.open_file(cache_path)`` on a goto-
# definition target whose cache entry was never pre-populated
# (the plugin uses the Python API, not ``run_command``, so
# ``SessionsOnDemandFetchListener.on_window_command`` doesn't see
# it). Without handling (b) here, goto-def to unmirrored files
# silently opens an empty buffer.
try:
if path.is_file() and path.stat().st_size > 0:
return None
except OSError:
return None
# Defense for "user is creating a new file under the cache dir via Save As"
# — a dirty view means the user has pending unsaved edits we must not
# clobber via the REMOTE_NOT_FOUND destructive path
# (``_remove_local_remote_cache_mirror_path`` + ``_close_open_views_for_abs_path``).
is_dirty_fn = getattr(view, "is_dirty", None)
if callable(is_dirty_fn):
try:
if is_dirty_fn():
return None
except Exception: # pragma: no cover - defensive
return None
return _HydratePreflight(window=window, path=path)
@dataclass(frozen=True)
class _HydratePrecheckOutcome:
"""Result of the per-hydrate remote stat + open-guard precheck.
``proceed`` is true when the caller should continue with
``open_remote_file_into_local_cache``; otherwise the caller has
already had an in-flight cleanup scheduled and should return
immediately. ``stat_metadata`` is informational only — the
open-guard verdict has already been folded into ``proceed``.
"""
proceed: bool
stat_metadata: Optional[RemoteFileMetadata]
def _precheck_remote_file_openability(
*,
cache_key: str,
host_alias: str,
remote: str,
path_str: str,
on_skip: Callable[[], None],
) -> _HydratePrecheckOutcome:
"""Stat the remote file and apply ``open_guard_reason_for_remote_metadata``.
Emits the same ``hydrate.precheck_*`` traces the inline code did
and, on a transport error or a guard-block, calls ``on_skip`` to
queue the in-flight cleanup before returning ``proceed=False``.
Missing-or-unavailable remotes still return ``proceed=True`` —
the caller forwards them to ``open_remote_file_into_local_cache``
which surfaces the ``REMOTE_NOT_FOUND`` outcome to the UI.
"""
precheck_started_at = time.monotonic()
_trace_event(
"hydrate.precheck_start",
cache_key=cache_key,
local_path=path_str,
timeout_s=_HYDRATE_OPEN_STAT_TIMEOUT_S,
)
try:
stat_metadata = execute_remote_stat_file(
host_alias,
remote,
timeout_s=_HYDRATE_OPEN_STAT_TIMEOUT_S,
)
except SessionHelperStartError as error:
_trace_event(
"hydrate.precheck_transport_error",
cache_key=cache_key,
local_path=path_str,
detail=error.detail,
elapsed_ms=int((time.monotonic() - precheck_started_at) * 1000),
)
on_skip()
return _HydratePrecheckOutcome(proceed=False, stat_metadata=None)
_trace_event(
"hydrate.precheck_done",
cache_key=cache_key,
local_path=path_str,
elapsed_ms=int((time.monotonic() - precheck_started_at) * 1000),
exists=stat_metadata is not None,
)
if stat_metadata is None:
_trace_event(
"hydrate.precheck_missing_or_unavailable",
cache_key=cache_key,
local_path=path_str,
)
return _HydratePrecheckOutcome(proceed=True, stat_metadata=None)
blocked = open_guard_reason_for_remote_metadata(
stat_metadata,
FileOpenGuardrails(),
)
if blocked is not None:
_trace_event(
"hydrate.precheck_blocked",
cache_key=cache_key,
local_path=path_str,
reason=getattr(blocked, "value", ""),
)
on_skip()
return _HydratePrecheckOutcome(proceed=False, stat_metadata=stat_metadata)
return _HydratePrecheckOutcome(proceed=True, stat_metadata=stat_metadata)
def _apply_hydrate_result(
*,
window: object,
view_id: int,
opened: OpenFileResult,
path: Path,
path_str: str,
remote: str,
) -> None:
"""Apply the open-into-cache outcome to the live view (UI thread).
Pure UI side effect: clears the in-flight flag, validates that
the view is still showing the same path, then dispatches per
``OpenOutcome`` to the same toasts/dialogs the inline ``finish``
closure produced.
"""
_HYDRATE_IN_FLIGHT.discard(view_id)
view_ctor = getattr(sublime, "View", None)
if not callable(view_ctor):
return
try:
current = view_ctor(view_id)
except (TypeError, ValueError, RuntimeError):
return
is_valid = getattr(current, "is_valid", None)
if not callable(is_valid) or not is_valid():
return
if _view_file_name(current) != path_str:
return
is_dirty = getattr(current, "is_dirty", None)
if callable(is_dirty) and is_dirty():
return
if opened.outcome is OpenOutcome.OK:
if opened.remote_metadata is not None:
_write_remote_metadata_sidecar(path, opened.remote_metadata)
_HYDRATE_REVERT_COOLDOWN[path_str] = time.monotonic()
run_command = getattr(current, "run_command", None)
if callable(run_command):
run_command("revert")
return
if opened.outcome is OpenOutcome.TRANSPORT_ERROR:
detail = opened.detail or "Could not download remote file."
_emit_status(ConnectStatus(kind="disconnected", detail=detail))
return
if opened.outcome is OpenOutcome.REMOTE_NOT_FOUND:
# Data-loss guard: only delete the local cache copy when we KNOW
# we previously fetched it from the remote (sidecar present).
# A missing sidecar means this path is a brand-new local buffer
# the user just saved into the cache mirror — destroying it on a
# remote-stat 404 would silently lose the user's work.
if not _has_remote_metadata_sidecar(path):
_emit_status(
ConnectStatus(
kind="warning",
detail=(
"Remote path {} not found; kept local-only file at {}."
).format(remote, path),
)
)
return
_alert_stale_remote_path_removed(remote)
_remove_local_remote_cache_mirror_path(path)
_close_open_views_for_abs_path(window, path)
_emit_status(
ConnectStatus(
kind="warning",
detail=("Remote path {} no longer exists; removed stale cache.").format(
remote
),
)
)
return
if opened.outcome is OpenOutcome.BLOCKED_BINARY_HEURISTIC:
_status_message("Remote file looks binary and was not opened.")
return
if opened.outcome is OpenOutcome.BLOCKED_BY_POLICY:
reason = opened.unsupported_reason
if reason is None:
_status_message("Remote file open was blocked.")
else:
_status_message(_open_blocked_reason_message(reason))
return
_status_message("Remote file open was blocked.")
def _schedule_sidebar_placeholder_hydrate(view: object) -> None:
"""If ``view`` is a zero-byte Sessions cache file, download remote content."""
preflight = _should_hydrate_placeholder(view)
if preflight is None:
return
window = preflight.window
path = preflight.path
settings = SessionsSettings()
context = _workspace_context(window, settings, missing_detail_message=False)
if context is None:
return
# Same rationale as ``on_activated_async`` — don't fire remote fetches
# for placeholder/missing files on restored tabs until the user has
# explicitly reconnected. LSP goto-def (the other caller for missing
# files) always runs after an established connection, so this gate
# doesn't break that flow.
if not _workspace_runtime_connected(window, context):
return
mapper = RemoteToLocalCacheMapper(
workspace_cache_key=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
files_cache_root=context.local_cache_root,
)
remote = mapper.remote_path_for_local_cache_file(path)
if remote is None:
return
view_id_fn = getattr(view, "id", None)
view_id = view_id_fn() if callable(view_id_fn) else -1
if view_id < 0:
return
if view_id in _HYDRATE_IN_FLIGHT:
return
_HYDRATE_IN_FLIGHT.add(view_id)
host_alias = context.recent_entry.host_alias
path_str = str(path)
with _HYDRATE_REQUEST_LOCK:
request_serial = (
_HYDRATE_REQUEST_SERIAL_BY_WORKSPACE.get(context.cache_key, 0) + 1
)
_HYDRATE_REQUEST_SERIAL_BY_WORKSPACE[context.cache_key] = request_serial
def schedule_in_flight_cleanup() -> None:
_set_timeout(lambda: _HYDRATE_IN_FLIGHT.discard(view_id), 0)
def work() -> None:
with _HYDRATE_REQUEST_LOCK:
latest_serial = _HYDRATE_REQUEST_SERIAL_BY_WORKSPACE.get(
context.cache_key, 0
)
if request_serial != latest_serial:
_trace_event(
"hydrate.skip_stale",
cache_key=context.cache_key,
local_path=path_str,
request_serial=request_serial,
latest_serial=latest_serial,
)
schedule_in_flight_cleanup()
return
active_view = _active_view(window)
if active_view is None or _view_file_name(active_view) != path_str:
_trace_event(
"hydrate.skip_not_active",
cache_key=context.cache_key,
local_path=path_str,
request_serial=request_serial,
)
schedule_in_flight_cleanup()
return
_begin_interactive_ssh_lane(host_alias)
try:
precheck = _precheck_remote_file_openability(
cache_key=context.cache_key,
host_alias=host_alias,
remote=remote,
path_str=path_str,
on_skip=schedule_in_flight_cleanup,
)
if not precheck.proceed:
return
opened = open_remote_file_into_local_cache(
host_alias,
remote_absolute_path=remote,
local_cache_path=path,
read_timeout_s=_HYDRATE_OPEN_READ_TIMEOUT_S,
)
finally:
_end_interactive_ssh_lane(host_alias)
def finish() -> None:
_apply_hydrate_result(
window=window,
view_id=view_id,
opened=opened,
path=path,
path_str=path_str,
remote=remote,
)
_set_timeout(finish, 50)
_run_in_background(
work,
prioritize=True,
task_key="hydrate:{}".format(path_str),
task_label="hydrate_open_file",
)
class SessionsSidebarPlaceholderHydrateListener(sublime_plugin.EventListener):
"""Pull remote file bytes when a mirrored placeholder is opened from the sidebar."""
def on_load(self, view) -> None:
"""Start a background download for empty cache files without metadata."""
_schedule_sidebar_placeholder_hydrate(view)
class SessionsBridgeLifecycleListener(sublime_plugin.EventListener):
"""Stop ``local_bridge`` when the last Sublime window using a host closes.
Requires Sublime Text 4 build **4138+** where ``on_pre_close_window`` fires
reliably on Windows and macOS.
"""
def on_pre_close_window(self, window) -> None:
"""Release bridge refs so ``local_bridge.exe`` can exit (Windows file locks)."""
_bridge_release_refs_for_closing_window(window)
class SessionsWorkspaceActivationListener(sublime_plugin.EventListener):
"""Resume mirror refresh for workspace windows activated later."""
def on_activated(self, view) -> None:
"""Ensure activated Sessions windows keep background mirror refresh alive."""
_schedule_sidebar_placeholder_hydrate(view)
window_fn = getattr(view, "window", None)
window = window_fn() if callable(window_fn) else None
if window is None:
return
_apply_workspace_window_title(window)
_trace_lsp_workspace_activation_if_sessions(window, view)
_ensure_workspace_auto_refresh(window)
def on_activated_async(self, view) -> None:
"""Fast revalidate the active remote tab on focus switches."""
window_fn = getattr(view, "window", None)
window = window_fn() if callable(window_fn) else None
if window is None:
return
view_id_fn = getattr(view, "id", None)
view_id = view_id_fn() if callable(view_id_fn) else -1
if view_id >= 0:
now = time.monotonic()
last = _ACTIVE_REFRESH_VIEW_TS.get(view_id, 0.0)
if now - last < _ACTIVE_REFRESH_DEBOUNCE_S:
return
_ACTIVE_REFRESH_VIEW_TS[view_id] = now
settings = SessionsSettings()
context = _workspace_context(window, settings, missing_detail_message=False)
if context is None:
return
# Block the auto-refresh side-effect when the workspace window has
# been restored (from a previous Sublime session) but the user has
# not yet reconnected. Without this gate, ``on_activated_async``
# fires for every restored tab and schedules bridge/helper work
# (download session_helper, ssh-push, file.stat) before the user
# does anything — seen as unexpected noise in the trace log.
if not _workspace_runtime_connected(window, context):
return
window_key = _window_identity(window)
_run_in_background(
_check_and_reload_active_remote_view,
window,
context,
task_key="open_file_refresh_active:{}".format(window_key),
task_label="open_file_refresh_active",
)
class SessionsLspNavigationListener(sublime_plugin.EventListener):
"""Trace LSP navigation commands for cross-file definition debugging."""
_LSP_NAV_TRACE_COMMANDS = frozenset(
{
"lsp_symbol_definition",
"lsp_symbol_declaration",
"lsp_symbol_implementation",
"lsp_symbol_type_definition",
}
)
def on_post_window_command(self, window, command_name, args):
"""Emit a compact snapshot after LSP symbol navigation commands."""
if command_name not in self._LSP_NAV_TRACE_COMMANDS:
return
settings = SessionsSettings()
context = _workspace_context(window, settings, missing_detail_message=False)
if context is None or not _workspace_runtime_connected(window, context):
return
if not isinstance(context, _WorkspaceContext):
return
view = _active_view(window)
active_file = _view_file_name(view) if view is not None else None
snapshot = collect_lsp_diagnostics_snapshot(
host_alias=context.recent_entry.host_alias,
workspace_id=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
local_cache_root=str(context.local_cache_root),
active_file=active_file,
)
snapshot["lsp_command"] = command_name
snapshot["lsp_command_args"] = args
_trace_event("lsp.navigation_post_command", **snapshot)
_ON_DEMAND_FETCH_BYPASS = threading.local()
class SessionsOnDemandFetchListener(sublime_plugin.EventListener):
"""Intercept ``open_file`` to on-demand fetch remote files not yet in cache."""
def on_window_command(self, window, command_name, args):
"""Redirect ``open_file`` for cache-mapped paths missing on disk."""
if command_name != "open_file":
return None
if getattr(_ON_DEMAND_FETCH_BYPASS, "active", False):
return None
file_path = (args or {}).get("file", "")
if not isinstance(file_path, str) or not file_path:
return None
settings = SessionsSettings()
context = _workspace_context(window, settings, missing_detail_message=False)
if context is None:
return None
mapper = RemoteToLocalCacheMapper(
workspace_cache_key=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
files_cache_root=context.local_cache_root,
)
local_path = Path(file_path)
# Case 1: path is under workspace cache but file missing on disk
remote = mapper.remote_path_for_local_cache_file(local_path)
if remote is not None:
try:
if local_path.is_file():
return None
except OSError:
return None
self._fetch_then_open(window, context, remote, local_path, args)
return ("noop", {})
# Case 2: path looks like a remote absolute path (POSIX)
if file_path.startswith("/") and not local_path.is_file():
extern_local = mapper.local_path_for_external_remote_file(file_path)
try:
if extern_local.is_file() and extern_local.stat().st_size > 0:
replacement = dict(args) if args else {}
replacement["file"] = str(extern_local)
return ("open_file", replacement)
except OSError:
pass
self._fetch_then_open(window, context, file_path, extern_local, args)
return ("noop", {})
return None
@staticmethod
def _fetch_then_open(
window: object,
context: _WorkspaceContext,
remote_path: str,
local_path: Path,
original_args: object,
) -> None:
host_alias = context.recent_entry.host_alias
local_str = str(local_path)
group = (
(original_args or {}).get("group")
if isinstance(original_args, dict)
else None
)
def work() -> None:
opened = open_remote_file_into_local_cache(
host_alias,
remote_absolute_path=remote_path,
local_cache_path=local_path,
read_timeout_s=30.0,
)
def finish() -> None:
if opened.outcome is OpenOutcome.OK:
if opened.remote_metadata is not None:
_write_remote_metadata_sidecar(
local_path, opened.remote_metadata
)
_ON_DEMAND_FETCH_BYPASS.active = True
try:
open_args: Dict[str, object] = {"file": local_str}
if group is not None:
open_args["group"] = group
run_cmd = getattr(window, "run_command", None)
if callable(run_cmd):
run_cmd("open_file", open_args)
finally:
_ON_DEMAND_FETCH_BYPASS.active = False
return
detail = opened.detail or "Could not fetch remote file."
_emit_status(ConnectStatus(kind="warning", detail=detail))
_set_timeout(finish, 0)
_run_in_background(
work,
prioritize=True,
task_key="on_demand_fetch:{}".format(local_str),
task_label="on_demand_fetch",
)
# Initial body for a new User buffer created by ``edit_settings`` (right pane).
_SESSIONS_USER_SETTINGS_NEW_BODY = "{}\n"
def _sessions_package_folder_name() -> str:
"""Return the package directory name under ``packages_path()`` (e.g. ``Sessions``).
This must match the folder Sublime mounts for the plugin, not a repo parent
like ``sublime``. See :func:`_sessions_settings_base_file_resource`.
"""
module_path = Path(__file__).resolve()
packages_fn = getattr(sublime, "packages_path", None)
if callable(packages_fn):
packages_root = Path(packages_fn()).resolve()
try:
rel = module_path.relative_to(packages_root)
return str(rel.parts[0])
except ValueError:
pass
try:
for entry in sorted(packages_root.iterdir()):
if not entry.is_dir():
continue
candidate = entry / "sessions" / "commands.py"
if not candidate.is_file():
continue
resolved = candidate.resolve()
if resolved == module_path:
return entry.name
try:
if resolved.samefile(module_path):
return entry.name
except OSError:
continue
except OSError:
pass
for parent in module_path.parents:
settings_file = parent / "Sessions.sublime-settings"
plugin_file = parent / "plugin.py"
if settings_file.is_file() and plugin_file.is_file():
return parent.name
return "Sessions"
def _sessions_settings_base_file_resource() -> str:
"""Return ``Packages/<folder>/Sessions.sublime-settings`` (tests / diagnostics)."""
return "Packages/{}/Sessions.sublime-settings".format(
_sessions_package_folder_name()
)
class SessionsOpenSettingsCommand(sublime_plugin.WindowCommand):
"""Open Sessions default/user settings via core ``edit_settings``."""
def run(self) -> None:
"""Run built-in ``edit_settings`` on this window (paired default + user)."""
folder = _sessions_package_folder_name()
base_file = "${{packages}}/{}/Sessions.sublime-settings".format(folder)
edit_args: Dict[str, object] = {
"base_file": base_file,
"default": _SESSIONS_USER_SETTINGS_NEW_BODY,
}
run_command = getattr(self.window, "run_command", None)
if callable(run_command):
run_command("edit_settings", edit_args)
class SessionsOpenLocalSshConfigCommand(sublime_plugin.WindowCommand):
"""Open the local SSH config used by Sessions connections."""
def run(self) -> None:
"""Open ``SessionsSettings.ssh_config_path`` in the current window."""
settings = SessionsSettings()
target = str(settings.ssh_config_path.expanduser())
run_command = getattr(self.window, "run_command", None)
if callable(run_command):
run_command("open_file", {"file": target})
class SessionsOpenRecentRemoteWorkspaceCommand(sublime_plugin.WindowCommand):
"""Open a recent remote workspace from local metadata."""
def run(self) -> None:
"""Open the recent-workspace quick panel."""
settings = SessionsSettings()
recent_store = _recent_store(settings)
recent_items = recent_workspace_quick_panel_items(recent_store.load_index())
if not recent_items:
_status_message("No recent Sessions workspaces are available.")
return
self.window.show_quick_panel(
[_quick_panel_row(item) for item in recent_items],
lambda selected_index: self._on_recent_selected(
selected_index,
recent_store.load_index().entries,
settings,
),
)
def _on_recent_selected(
self,
selected_index: int,
recent_entries: Sequence[RecentWorkspace],
settings: SessionsSettings,
) -> None:
if selected_index < 0:
return
selected_entry = recent_entries[selected_index]
_connect_selected_workspace(
self.window,
settings,
selected_entry.host_alias,
selected_entry.remote_root,
cache_key=selected_entry.cache_key,
)
class SessionsReconnectCurrentWorkspaceCommand(sublime_plugin.WindowCommand):
"""Reconnect the current Sessions workspace from project metadata."""
def run(self) -> None:
"""Reconnect the current Sessions workspace if metadata is available."""
settings = SessionsSettings()
workspace_key = _current_workspace_key(self.window.project_data())
if workspace_key is None:
_status_message("No current Sessions workspace metadata is available.")
return
recent_store = _recent_store(settings)
recent_entry = _recent_entry_for_cache_key(
recent_store.load_index().entries,
workspace_key,
)
if recent_entry is None:
_status_message("No recent Sessions entry matches the current workspace.")
return
_status_message("Reconnecting to {}".format(recent_entry.host_alias))
reset_bridge_for_host(recent_entry.host_alias)
if bool(getattr(sublime, "_sessions_test_sync", False)):
_reconnect_workspace_async(self.window, settings, recent_entry)
else:
threading.Thread(
target=_reconnect_workspace_async,
args=(self.window, settings, recent_entry),
daemon=True,
).start()
def _reconnect_workspace_async(
window: object,
settings: SessionsSettings,
recent_entry: RecentWorkspace,
) -> None:
# Reconnect skips ``_connect_selected_host_async`` (which is where the
# first-time-connect ConnectProgressPanel lives), so we need our own
# panel here — otherwise the user sees no feedback during the slow SSH
# handshake of the reconnect flow.
progress = ConnectProgressPanel(window, recent_entry.host_alias)
progress.start()
try:
with ssh_prompt_callback(lambda prompt: _prompt_for_ssh_secret(window, prompt)):
_connect_selected_workspace(
window,
settings,
recent_entry.host_alias,
recent_entry.remote_root,
cache_key=recent_entry.cache_key,
)
progress.success("reconnect complete")
except ConnectPreflightError as error:
detail = error.detail
_set_timeout(
lambda: _emit_status(ConnectStatus(kind="disconnected", detail=detail))
)
progress.failure(detail)
except Exception as exc:
progress.failure("unexpected: {}".format(exc))
raise
finally:
progress.stop()
class SessionsRemotePythonToolPrepareCommand(sublime_plugin.WindowCommand):
"""Assemble ``tool/format`` or ``tool/lint`` params without invoking the helper."""
def run(
self,
kind: str = "lint",
remote_file: str = "/tmp/sessions_tool_prepare.py",
remote_cwd: str = "/tmp",
request_id: str = "prepare",
) -> None:
"""Echo JSON-serializable tool params to the status line for wiring checks."""
from .remote import tool_execution_params_for_envelope
normalized = (kind or "").strip().lower()
if normalized not in ("format", "lint"):
_status_message(
"Remote tool prepare: kind must be 'format' or 'lint' "
"(defaults are lint on /tmp for palette dry-runs)."
)
return
primary = (remote_file or "").strip()
cwd = (remote_cwd or "").strip()
if not primary or not cwd:
_status_message("Remote tool prepare: remote_file and remote_cwd required.")
return
if normalized == "format":
request = build_python_format_tool_execution_request(
request_id=request_id,
primary_remote_path=primary,
working_directory_remote=cwd,
)
else:
request = build_python_lint_tool_execution_request(
request_id=request_id,
primary_remote_path=primary,
working_directory_remote=cwd,
)
payload = tool_execution_params_for_envelope(request)
preview = json.dumps(payload, sort_keys=True)
if len(preview) > 240:
preview = preview[:240] + ""
_status_message("Remote tool prepare: {}".format(preview))
class SessionsRunRemotePythonToolCommand(sublime_plugin.WindowCommand):
"""Run a remote python formatter or linter for the current workspace."""
def run(
self,
kind: str = "lint",
remote_file: str = "",
use_ruff_formatter: bool = False,
) -> None:
"""Execute the requested tool run and present output inline and in a panel."""
settings = SessionsSettings()
context = _workspace_context(self.window, settings)
if context is None:
return
normalized_kind = (kind or "").strip().lower()
if normalized_kind not in ("format", "lint"):
_status_message("Remote python tool kind must be 'format' or 'lint'.")
return
remote_path = _tool_target_remote_file(self.window, context, remote_file)
if remote_path is None:
return
if normalized_kind == "format" and _active_view_is_dirty(self.window):
_status_message(
"Formatter run blocked because the current buffer has unsaved "
"local edits."
)
return
_run_remote_python_tool_for_workspace(
self.window,
context,
normalized_kind,
remote_path,
use_ruff_formatter=use_ruff_formatter,
)
class SessionsInstallRemoteExtensionCommand(sublime_plugin.WindowCommand):
"""Install one configured remote LSP server via bridge ``exec/once``."""
def run(self) -> None:
"""Probe the catalog and offer only servers that are not installed remotely."""
settings = load_sessions_settings_from_sublime()
context = _workspace_context(self.window, settings)
if context is None:
return
specs = context.settings.remote_extensions
if not specs:
_status_message("No remote LSP server catalog is available.")
return
active_python = read_active_interpreter(self.window)
status_map = _remote_extension_install_status_map(
context, specs, active_python=active_python
)
candidates = [spec for spec in specs if not status_map.get(spec.id, False)]
if not candidates:
_status_message("All remote LSP catalog entries are already installed.")
return
rows = [
[spec.label, "id={} (not installed)".format(spec.id)] for spec in candidates
]
self.window.show_quick_panel(
rows,
lambda selected: _on_select_install_remote_extension(
self.window,
context,
tuple(candidates),
selected,
),
)
class SessionsRemoveRemoteExtensionCommand(sublime_plugin.WindowCommand):
"""Remove one configured remote LSP server via bridge ``exec/once``."""
def run(self) -> None:
"""Probe the catalog and offer only servers that are installed remotely."""
settings = load_sessions_settings_from_sublime()
context = _workspace_context(self.window, settings)
if context is None:
return
specs = context.settings.remote_extensions
if not specs:
_status_message("No remote LSP server catalog is available.")
return
active_python = read_active_interpreter(self.window)
status_map = _remote_extension_install_status_map(
context, specs, active_python=active_python
)
candidates = [spec for spec in specs if status_map.get(spec.id, False)]
if not candidates:
_status_message("No remote LSP catalog entries are installed on the host.")
return
rows = [
[spec.label, "id={} (installed)".format(spec.id)] for spec in candidates
]
self.window.show_quick_panel(
rows,
lambda selected: _on_select_remove_remote_extension(
self.window,
context,
tuple(candidates),
selected,
),
)
class SessionsRemoteExtensionStatusCommand(sublime_plugin.WindowCommand):
"""Show install status for configured remote LSP servers."""
def run(self) -> None:
"""Render configured remote LSP install status in an output panel."""
settings = load_sessions_settings_from_sublime()
context = _workspace_context(self.window, settings)
if context is None:
return
specs = context.settings.remote_extensions
if not specs:
_status_message("No remote LSP server catalog is available.")
return
active_python = read_active_interpreter(self.window)
state_map = _remote_extension_install_state_map(
context, specs, active_python=active_python
)
lines = [
"Note: This catalog is install/remove + probe over the bridge (exec/once).",
"Save-time ruff/pyright for mirrored .py files uses the same login-shell",
"wrapper as these probes (bash -lc / zsh -lic) so PATH matches.",
"Settings: sessions_remote_python_auto_diagnostics_on_save and",
"sessions_remote_python_tool_pipeline — still need an active bridge.",
"If the bridge disconnects, reconnect before save diagnostics.",
"Third-party Sublime LSP: use Package Settings → LSP, or your "
".sublime-project settings per the LSP package docs (not Sessions "
"remote_extensions).",
"",
"Remote LSP install catalog:",
"",
]
installed_count = 0
for spec in specs:
state = state_map.get(spec.id, _EXTENSION_STATE_NOT_INSTALLED)
if state == _EXTENSION_STATE_INSTALLED:
installed_count += 1
lines.append(
"- {label} [{sid}] : {state}".format(
label=spec.label,
sid=spec.id,
state=state,
)
)
_show_output_panel(
self.window,
"sessions_remote_extensions",
"\n".join(lines) + "\n",
)
_status_message(
"Remote LSP status: {}/{} installed.".format(installed_count, len(specs))
)
def _on_select_install_remote_extension(
window: object,
context: "_WorkspaceContext",
specs: Sequence[RemoteExtensionSpec],
selected: int,
) -> None:
if selected < 0 or selected >= len(specs):
return
spec = specs[selected]
_status_message("Installing remote LSP server {}...".format(spec.label))
_run_in_background(_install_remote_extension_async, window, context, spec)
def _on_select_remove_remote_extension(
window: object,
context: "_WorkspaceContext",
specs: Sequence[RemoteExtensionSpec],
selected: int,
) -> None:
if selected < 0 or selected >= len(specs):
return
spec = specs[selected]
_status_message("Removing remote LSP server {}...".format(spec.label))
_run_in_background(_remove_remote_extension_async, window, context, spec)
def _remote_extension_log(line: str) -> None:
"""Emit one LSP-related line to the Sublime console (stderr)."""
print("[Sessions LSP] {}".format(line), file=sys.stderr, flush=True)
def _remote_extension_exec_argv(argv: Sequence[str]) -> List[str]:
"""Build argv for remote LSP install/remove/probe exec/once.
Delegates to :func:`remote_exec_argv_via_login_shell` so behavior matches
save-time format/lint (see module docstring there).
"""
from .remote_shell_exec import remote_exec_argv_via_login_shell
return remote_exec_argv_via_login_shell(argv)
def _substitute_active_python_placeholder(
argv: Sequence[str], active_python: Optional[str]
) -> Tuple[str, ...]:
"""Replace ``{ACTIVE_PYTHON}`` with the supplied path in every argv entry.
``active_python`` of ``None`` collapses the token to an empty string so
debugger-kind scripts fail fast with their internal ``[ -z "..." ]`` check
instead of executing against an unselected interpreter.
"""
token = "{ACTIVE_PYTHON}"
value = active_python or ""
return tuple(part.replace(token, value) for part in argv)
def _is_debugger_kind_spec_id(spec_id: str) -> bool:
"""Return ``True`` when ``spec_id`` is a ``kind="debugger"`` catalog entry."""
for entry in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG:
if entry.install_catalog_id == spec_id and entry.kind == "debugger":
return True
return False
def _maybe_lsp_prerequisite_error_dialog(
spec: RemoteExtensionSpec,
action: str,
result: RemoteExecOnceResult,
) -> None:
"""Modal hint when failure is clearly missing OS tooling (not PATH-only glitches).
Skips timed-out runs (often network) and generic failures without diagnostic text.
Does not run for the \"install exited 0 but probe still missing\" case (handled
separately): that path is treated as PATH/probe mismatch, not missing packages.
"""
if result.timed_out:
return
blob = ((result.stderr or "") + "\n" + (result.stdout or "")).strip()
if not blob:
return
text = blob.lower()
lines: List[str] = []
if spec.id == "rust-analyzer" or "rustup" in text:
if ("rustup" in text and "not found" in text) or (
"command not found" in text and "rustup" in text
):
lines.append(
"rust-analyzer: rustup was not found on the remote host.\n"
"Install Rust from https://rustup.rs, then reconnect over SSH."
)
if spec.id in ("pyright-langserver", "ruff"):
if "no module named pip" in text or (
"python3" in text and ("not found" in text or "no such file" in text)
):
lines.append(
"{}: Python 3 or pip is missing on the remote host.\n"
"Install python3-pip (or equivalent), then retry.".format(spec.label)
)
if "could not install" in text and "sessions:" in text:
lines.append(
"{}: pip, ensurepip, and get-pip all failed.\n"
"Fix pip on the host or check network/proxy.".format(spec.label)
)
if "curl" in text and (
"could not resolve host" in text
or "connection refused" in text
or "failed to connect" in text
):
lines.append(
"Downloading get-pip.py with curl failed (network error).\n"
"Check proxy and firewall settings on the remote host."
)
if not lines:
return
footer = "(See the status line and Console stderr for full output.)"
sublime.error_message(
"Sessions — remote LSP {} failed\n\n{}\n\n{}".format(
action,
"\n\n".join(lines),
footer,
)
)
def _install_remote_extension_async(
window: object,
context: "_WorkspaceContext",
spec: RemoteExtensionSpec,
) -> None:
cwd = _remote_extension_spec_cwd(context, spec)
_remote_extension_log(
"install begin host={} id={} cwd={}".format(
context.recent_entry.host_alias,
spec.id,
cwd,
)
)
is_debugger = _is_debugger_kind_spec_id(spec.id)
active_python = read_active_interpreter(window) if is_debugger else None
if is_debugger and not active_python:
_set_timeout(
lambda: _emit_status(
ConnectStatus(
kind="warning",
detail=(
"Sessions: select a Python interpreter first "
"(Sessions: Select Python Interpreter)."
),
)
)
)
return
install_argv = _substitute_active_python_placeholder(
spec.install_argv, active_python
)
try:
result = execute_remote_exec_once(
context.recent_entry.host_alias,
argv=_remote_extension_exec_argv(install_argv),
cwd=cwd,
timeout_ms=120_000,
)
except SessionHelperStartError as error:
_remote_extension_log("install bridge error: {}".format(error.detail))
_set_timeout(
lambda d=error.detail: _emit_status(
ConnectStatus(kind="disconnected", detail=d)
)
)
return
err_tail = (result.stderr or "").strip()
if len(err_tail) > 600:
err_tail = err_tail[:600] + ""
_remote_extension_log(
"install exec done exit={} timed_out={} stderr_len={}".format(
result.exit_code,
result.timed_out,
len((result.stderr or "").strip()),
)
)
if err_tail:
_remote_extension_log(
"install stderr tail: {}".format(err_tail.replace("\n", " | "))
)
if result.timed_out or result.exit_code != 0:
detail = _remote_extension_exec_failure_detail("install", spec, result)
def _report_install_failure(
d: str = detail,
sp: RemoteExtensionSpec = spec,
rs: RemoteExecOnceResult = result,
) -> None:
_maybe_lsp_prerequisite_error_dialog(sp, "install", rs)
_emit_status(ConnectStatus(kind="warning", detail=d))
_set_timeout(_report_install_failure)
return
installed = _probe_remote_extension_installed(
context, spec, active_python=active_python
)
_remote_extension_log(
"install post-probe installed={} id={}".format(installed, spec.id)
)
if not installed:
_set_timeout(
lambda: _emit_status(
ConnectStatus(
kind="warning",
detail=(
"Install command finished for {} but probe "
"still reports missing."
).format(spec.label),
)
)
)
return
_set_timeout(
lambda w=window, sp=spec: _maybe_seed_project_lsp_save_preferences_from_global(
w, sp
)
)
_set_timeout(
lambda: _emit_status(
ConnectStatus(
kind="ready",
detail="Remote LSP server installed: {}".format(spec.label),
)
)
)
def _managed_extension_project_client_keys_for_spec(
spec: RemoteExtensionSpec,
) -> Tuple[str, ...]:
"""Return managed + legacy project LSP client keys for one catalog spec id.
Non-LSP kinds (``debugger``) have no Sublime-LSP client rows to manage, so
we return an empty tuple for them; only LSP-kind catalog matches contribute
client keys.
"""
for entry in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG:
if entry.install_catalog_id == spec.id:
if entry.kind != "lsp" or entry.project_client_key is None:
return ()
return (entry.project_client_key,) + tuple(entry.legacy_project_client_keys)
return (spec.id,)
def _global_lsp_save_preference(key: str) -> Tuple[object, Optional[str]]:
"""Return global LSP save preference and source settings file name."""
load_settings = getattr(sublime, "load_settings", None)
if not callable(load_settings):
return (None, None)
for settings_name in ("Preferences.sublime-settings", "LSP.sublime-settings"):
settings_obj = load_settings(settings_name)
get_value = getattr(settings_obj, "get", None)
if not callable(get_value):
continue
value = get_value(key)
if value is not None:
return (deepcopy(value), settings_name)
return (None, None)
def _maybe_seed_project_lsp_save_preferences_from_global(
window: object, spec: RemoteExtensionSpec
) -> None:
"""Add missing LSP save keys once, without overwriting existing project values."""
project_data_fn = getattr(window, "project_data", None)
set_project_data_fn = getattr(window, "set_project_data", None)
if not callable(project_data_fn) or not callable(set_project_data_fn):
return
project_data = project_data_fn() or {}
if not isinstance(project_data, dict):
return
settings = project_data.get("settings")
if not isinstance(settings, dict):
return
lsp_root = settings.get("LSP")
if not isinstance(lsp_root, dict):
return
candidate_keys = set(_managed_extension_project_client_keys_for_spec(spec))
if not any(isinstance(k, str) and k in candidate_keys for k in lsp_root.keys()):
return
changed = False
next_settings = dict(settings)
copied_from: Dict[str, str] = {}
if "lsp_format_on_save" not in next_settings:
value, source = _global_lsp_save_preference("lsp_format_on_save")
if value is not None:
next_settings["lsp_format_on_save"] = value
if isinstance(source, str) and source:
copied_from["lsp_format_on_save"] = source
changed = True
if "lsp_code_actions_on_save" not in next_settings:
value, source = _global_lsp_save_preference("lsp_code_actions_on_save")
if value is not None:
next_settings["lsp_code_actions_on_save"] = value
if isinstance(source, str) and source:
copied_from["lsp_code_actions_on_save"] = source
changed = True
if not changed:
return
next_project_data = dict(project_data)
next_project_data["settings"] = next_settings
set_project_data_fn(next_project_data)
_trace_event(
"lsp.save_preferences_seeded",
spec_id=spec.id,
copied_keys=tuple(sorted(copied_from.keys())),
copied_from=copied_from,
)
def _remove_remote_extension_async(
window: object,
context: "_WorkspaceContext",
spec: RemoteExtensionSpec,
) -> None:
cwd = _remote_extension_spec_cwd(context, spec)
_remote_extension_log(
"remove begin host={} id={} cwd={}".format(
context.recent_entry.host_alias,
spec.id,
cwd,
)
)
is_debugger = _is_debugger_kind_spec_id(spec.id)
active_python = read_active_interpreter(window) if is_debugger else None
remove_argv = _substitute_active_python_placeholder(spec.remove_argv, active_python)
try:
result = execute_remote_exec_once(
context.recent_entry.host_alias,
argv=_remote_extension_exec_argv(remove_argv),
cwd=cwd,
timeout_ms=120_000,
)
except SessionHelperStartError as error:
_remote_extension_log("remove bridge error: {}".format(error.detail))
_set_timeout(
lambda d=error.detail: _emit_status(
ConnectStatus(kind="disconnected", detail=d)
)
)
return
if result.timed_out or result.exit_code != 0:
detail = _remote_extension_exec_failure_detail("remove", spec, result)
def _report_remove_failure(
d: str = detail,
sp: RemoteExtensionSpec = spec,
rs: RemoteExecOnceResult = result,
) -> None:
_maybe_lsp_prerequisite_error_dialog(sp, "remove", rs)
_emit_status(ConnectStatus(kind="warning", detail=d))
_set_timeout(_report_remove_failure)
return
_remote_extension_log(
"remove exec done exit={} timed_out={}".format(
result.exit_code,
result.timed_out,
)
)
installed = _probe_remote_extension_installed(
context, spec, active_python=active_python
)
if installed:
_set_timeout(
lambda: _emit_status(
ConnectStatus(
kind="warning",
detail=(
"Remove command finished for {} but probe "
"still reports installed."
).format(spec.label),
)
)
)
return
_set_timeout(
lambda: _emit_status(
ConnectStatus(
kind="ready",
detail="Remote LSP server removed: {}".format(spec.label),
)
)
)
def _remote_extension_install_status_map(
context: "_WorkspaceContext",
specs: Sequence[RemoteExtensionSpec],
*,
active_python: Optional[str] = None,
) -> Dict[str, bool]:
return {
spec.id: _probe_remote_extension_installed(
context, spec, active_python=active_python
)
for spec in specs
}
# Tri-state probe verdict strings used by the ``Remote Extension Status``
# render helper. Kept as user-facing strings (not an enum) because they're
# only read by ``_render_remote_extension_status_line`` and tests asserting
# the exact wording users see.
_EXTENSION_STATE_INSTALLED = "installed"
_EXTENSION_STATE_NOT_INSTALLED = "not installed"
_EXTENSION_STATE_UNUSABLE = "installed but unusable"
def _probe_remote_extension_state(
context: "_WorkspaceContext",
spec: RemoteExtensionSpec,
*,
active_python: Optional[str] = None,
) -> str:
"""Return the tri-state install label for one catalog entry.
Unlike :func:`_probe_remote_extension_installed` (which collapses the
outcome into a boolean for install/remove pickers), this helper
distinguishes between a binary the shell couldn't find at all
(``not installed``) and one that ran but couldn't satisfy the probe
(``installed but unusable`` — e.g. printed usage text because our
probe argv drifted, or the runtime is mismatched). The three labels
are the strings users see in the status output panel.
"""
probe_argv: Sequence[str] = (
spec.probe_argv if spec.probe_argv else (spec.id, "--version")
)
if _is_debugger_kind_spec_id(spec.id):
probe_argv = _substitute_active_python_placeholder(probe_argv, active_python)
try:
result = execute_remote_exec_once(
context.recent_entry.host_alias,
argv=_remote_extension_exec_argv(probe_argv),
cwd=_remote_extension_spec_cwd(context, spec),
timeout_ms=15_000,
)
except SessionHelperStartError as error:
_remote_extension_log(
"probe bridge error id={}: {}".format(spec.id, error.detail)
)
return _EXTENSION_STATE_NOT_INSTALLED
if not result.timed_out and result.exit_code == 0:
return _EXTENSION_STATE_INSTALLED
if (
spec.id == "pyright-langserver"
and "connection input stream is not set" in (result.stderr or "").lower()
):
try:
fallback = execute_remote_exec_once(
context.recent_entry.host_alias,
argv=_remote_extension_exec_argv(("pyright", "--version")),
cwd=_remote_extension_spec_cwd(context, spec),
timeout_ms=15_000,
)
except SessionHelperStartError as error:
_remote_extension_log(
"probe bridge error id={} (fallback): {}".format(spec.id, error.detail)
)
return _EXTENSION_STATE_NOT_INSTALLED
if not fallback.timed_out and fallback.exit_code == 0:
return _EXTENSION_STATE_INSTALLED
# 127 means the shell couldn't locate the binary at all. A timeout
# collapses to "not installed" because we never got a verdict. Any
# other non-zero code means the binary exists but the probe failed —
# that's the "installed but unusable" state we care to surface.
if result.timed_out or result.exit_code == 127:
return _EXTENSION_STATE_NOT_INSTALLED
return _EXTENSION_STATE_UNUSABLE
def _remote_extension_install_state_map(
context: "_WorkspaceContext",
specs: Sequence[RemoteExtensionSpec],
*,
active_python: Optional[str] = None,
) -> Dict[str, str]:
"""Return ``{spec.id: tri-state label}`` for every spec in ``specs``."""
return {
spec.id: _probe_remote_extension_state(
context, spec, active_python=active_python
)
for spec in specs
}
def _probe_remote_extension_installed(
context: "_WorkspaceContext",
spec: RemoteExtensionSpec,
*,
active_python: Optional[str] = None,
) -> bool:
probe_argv: Sequence[str] = (
spec.probe_argv if spec.probe_argv else (spec.id, "--version")
)
if _is_debugger_kind_spec_id(spec.id):
probe_argv = _substitute_active_python_placeholder(probe_argv, active_python)
try:
result = execute_remote_exec_once(
context.recent_entry.host_alias,
argv=_remote_extension_exec_argv(probe_argv),
cwd=_remote_extension_spec_cwd(context, spec),
timeout_ms=15_000,
)
except SessionHelperStartError as error:
_remote_extension_log(
"probe bridge error id={}: {}".format(spec.id, error.detail)
)
return False
ok = not result.timed_out and result.exit_code == 0
# Pyright's language-server binary may return a non-zero exit for
# "--version" on some releases. Treat that signature as probe-argv
# mismatch and retry with the CLI version probe.
if (
not ok
and spec.id == "pyright-langserver"
and "connection input stream is not set" in (result.stderr or "").lower()
):
try:
fallback = execute_remote_exec_once(
context.recent_entry.host_alias,
argv=_remote_extension_exec_argv(("pyright", "--version")),
cwd=_remote_extension_spec_cwd(context, spec),
timeout_ms=15_000,
)
except SessionHelperStartError as error:
_remote_extension_log(
"probe bridge error id={} (fallback): {}".format(spec.id, error.detail)
)
return False
ok = not fallback.timed_out and fallback.exit_code == 0
if ok:
_remote_extension_log(
"probe fallback succeeded id={} via pyright".format(spec.id)
)
return True
if not ok:
tail = (result.stderr or "").strip().replace("\n", " | ")
if len(tail) > 400:
tail = tail[:400] + ""
_remote_extension_log(
"probe failed id={} exit={} timed_out={} tail={}".format(
spec.id, result.exit_code, result.timed_out, tail or "(no stderr)"
)
)
return ok
def _remote_extension_spec_cwd(
context: "_WorkspaceContext",
spec: RemoteExtensionSpec,
) -> str:
return spec.cwd or context.recent_entry.remote_root
def _remote_extension_exec_failure_detail(
action: str,
spec: RemoteExtensionSpec,
result: object,
) -> str:
if result.timed_out:
return "Remote LSP {} timed out for {}.".format(action, spec.label)
stderr = result.stderr.strip()
if stderr:
return "Remote LSP {} failed for {}: {}".format(action, spec.label, stderr)
return "Remote LSP {} failed for {} (exit {}).".format(
action, spec.label, result.exit_code
)
def _connect_selected_workspace(
window: object,
settings: SessionsSettings,
host_alias: str,
remote_root: str,
cache_key: Optional[str] = None,
) -> None:
connect_t0 = time.perf_counter()
def _connect_elapsed_ms() -> int:
return int((time.perf_counter() - connect_t0) * 1000)
# Connect progress panel lives in ``_connect_selected_host_async`` — the
# outer bg task that wraps the slow SSH handshake. This workspace-open
# path runs after the host session is already up and is normally fast,
# so duplicating the panel here just leaves a second stuck panel on the
# materialized workspace window (reported on Windows).
_trace_event(
"connect.begin",
host_alias=host_alias,
remote_root=remote_root,
elapsed_ms=0,
)
host_entries = _load_host_entries(settings)
local_paths = default_local_paths(sublime.cache_path(), settings)
try:
validate_host_alias(host_alias, host_entries)
normalized_remote_root = validate_remote_root(remote_root)
resolved_cache_key = (
cache_key
or WorkspaceIdentity(host_alias, normalized_remote_root).cache_key()
)
opened_window = _workspace_window_for_key(window, resolved_cache_key)
if opened_window is not None:
_focus_existing_workspace_window(opened_window)
_emit_status(
ConnectStatus(
kind="warning",
detail="Workspace is already open; moved focus to that window.",
)
)
ctx = _workspace_context(
opened_window, settings, missing_detail_message=False
)
if ctx is not None:
_set_timeout(
lambda: _sync_remote_tree_to_sidebar_for_context(
opened_window,
ctx,
source="auto_refresh",
)
)
return
_probe_remote_workspace(host_alias, normalized_remote_root)
_trace_event(
"connect.phase",
phase="preflight_probe_ok",
elapsed_ms=_connect_elapsed_ms(),
cache_key=resolved_cache_key,
host_alias=host_alias,
remote_root=normalized_remote_root,
)
except ConnectPreflightError as error:
_trace_event(
"connect.phase",
phase="preflight_failed",
elapsed_ms=_connect_elapsed_ms(),
detail=error.detail,
)
_emit_status(ConnectStatus(kind="disconnected", detail=error.detail))
return
plan = WorkspaceBootstrapPlan(
cache_root=local_paths.cache_root,
project_root=local_paths.project_root,
host_alias=host_alias,
remote_root=normalized_remote_root,
cache_key=resolved_cache_key,
)
recent_store = RecentWorkspaceStore(local_paths.recent_workspace_store_path)
result = connect_workspace(plan, recent_store)
_trace_event(
"connect.phase",
phase="materialize_done",
elapsed_ms=_connect_elapsed_ms(),
cache_key=resolved_cache_key,
project_file=str(result.materialized_workspace.project_file_path),
)
_forget_connected_host(window)
_open_materialized_workspace(
window, result.materialized_workspace.project_file_path
)
# Without this the workspace window is materialized but
# ``_CONNECTED_HOSTS_BY_WINDOW_ID`` stays empty — every downstream
# check that guards on ``_workspace_runtime_connected`` then quietly
# returns False. Observable symptom: ``_trace_lsp_workspace_activation_
# if_sessions`` bails before emitting any ``lsp.*`` trace, and
# ``_refresh_sessions_managed_remote_extension_project`` never writes the
# managed ``settings.LSP`` block, so pyright/rust-analyzer run against
# their default (local) commands and only see the cache directory.
_remember_connected_host(window, host_alias)
_trace_event(
"connect.phase",
phase="project_window_opened",
elapsed_ms=_connect_elapsed_ms(),
cache_key=resolved_cache_key,
)
tree_context = _WorkspaceContext(
settings=settings,
recent_entry=result.recent_workspace,
cache_key=resolved_cache_key,
local_cache_root=local_paths.cache_root / resolved_cache_key,
)
# Explicit refresh after the workspace window is live — don't rely on
# ``on_activated`` firing (not guaranteed in every Sublime flow, e.g.
# when the workspace is reopened into the same focused window).
_set_timeout(
lambda: _refresh_sessions_managed_remote_extension_project(
window, tree_context, source="connect"
)
)
_set_timeout(
lambda: _open_remote_tree_for_workspace(
window,
tree_context,
normalized_remote_root,
emit_ready_status=False,
)
)
_set_timeout(
lambda: _sync_remote_tree_to_sidebar_for_context(
window,
tree_context,
source="auto_open_folder",
)
)
_start_mirror_auto_refresh_loop(window, cache_key=resolved_cache_key)
_trace_event(
"connect.phase",
phase="scheduled_sidebar_sync",
elapsed_ms=_connect_elapsed_ms(),
cache_key=resolved_cache_key,
mirror_deepening_delay_ms=_mirror_deepening_delay_ms(),
)
_emit_status(
ConnectStatus(
kind="ready",
detail="Prepared Sessions workspace at {}".format(
result.materialized_workspace.project_file_path
),
)
)
_bridge_window_add_ref(window, host_alias)
def _load_host_entries(settings: SessionsSettings) -> Tuple[SshHostEntry, ...]:
return load_ssh_config_host_entries(settings.ssh_config_path)
def _connect_selected_host(settings: SessionsSettings, host_alias: str) -> None:
host_entries = _load_host_entries(settings)
validate_host_alias(host_alias, host_entries)
# Rust bridge needs a cached linux-x86_64 / linux-aarch64 tag to resolve the
# session_helper download URL. Probe over plain SSH first (same session the user
# just authenticated), then persist before starting the bridge.
cached_before = _remote_platform_store(settings).get(host_alias)
platform_tag = _determine_remote_linux_platform(settings, host_alias)
if platform_tag is not None and cached_before is None:
_remember_remote_platform(settings, host_alias, platform_tag)
if platform_tag is None:
return
_ensure_bridge_session(host_alias)
def _recent_store(settings: SessionsSettings) -> RecentWorkspaceStore:
local_paths = default_local_paths(sublime.cache_path(), settings)
return RecentWorkspaceStore(local_paths.recent_workspace_store_path)
def _remote_platform_store(settings: SessionsSettings) -> RemoteHostPlatformStore:
local_paths = default_local_paths(sublime.cache_path(), settings)
return RemoteHostPlatformStore(local_paths.remote_platform_store_path)
def _recent_entry_for_cache_key(
entries: Sequence[RecentWorkspace],
cache_key: str,
) -> Optional[RecentWorkspace]:
for entry in entries:
if entry.cache_key == cache_key:
return entry
return None
def _current_workspace_key(project_data: Optional[dict[str, object]]) -> Optional[str]:
if not project_data:
return None
settings = project_data.get("settings", {})
if not isinstance(settings, dict):
return None
workspace_key = settings.get(_project_settings_key())
return workspace_key if isinstance(workspace_key, str) else None
def _quick_panel_row(item: QuickPanelItemModel) -> list[str]:
return [item.trigger, item.details]
def _read_text_if_present(path: Path) -> Optional[str]:
if not path.exists():
return None
return path.read_text(encoding="utf-8")
def _dot_git_excluded_from_mirror() -> bool:
"""Return ``True`` when the user's ``sessions_mirror_ignore_patterns``
includes ``.git`` — i.e. they explicitly opted out of Track G's
Sublime Merge integration. Default is ``False``.
Used by the auto-trigger to short-circuit cleanly instead of running
discovery / fetch / materialise against a workspace where ``.git``
was never mirrored to the local cache.
"""
load_settings = getattr(sublime, "load_settings", None)
if not callable(load_settings):
return False
stored = load_settings("Sessions.sublime-settings")
getter = getattr(stored, "get", None)
if not callable(getter):
return False
raw = getter("sessions_mirror_ignore_patterns", [])
if not isinstance(raw, list):
return False
for entry in raw:
if isinstance(entry, str) and entry.strip() == ".git":
return True
return False
def _mirror_options_from_sublime_settings(
*,
source: Optional[str] = None,
) -> RemoteCacheMirrorOptions:
"""Load mirror limits from ``Sessions.sublime-settings`` when the API exists."""
load_settings = getattr(sublime, "load_settings", None)
if not callable(load_settings):
return RemoteCacheMirrorOptions(
ignore_patterns=merge_mirror_ignore_patterns(()),
)
stored = load_settings("Sessions.sublime-settings")
getter = getattr(stored, "get", None)
if not callable(getter):
return RemoteCacheMirrorOptions(
ignore_patterns=merge_mirror_ignore_patterns(()),
)
raw_ignore = getter("sessions_mirror_ignore_patterns", [])
ignore_patterns: Tuple[str, ...] = ()
if isinstance(raw_ignore, list):
ignore_patterns = tuple(
str(item).strip()
for item in raw_ignore
if isinstance(item, str) and str(item).strip()
)
ignore_patterns = merge_mirror_ignore_patterns(ignore_patterns)
max_depth = int(getter("sessions_mirror_max_traversal_depth", 5))
is_auto_source = source in _AUTO_MIRROR_DEPTH_SOURCES
if is_auto_source:
try:
auto_cap = int(getter("sessions_mirror_auto_deepen_max_depth", 2))
except (TypeError, ValueError):
auto_cap = 2
if auto_cap > 0:
max_depth = min(max_depth, auto_cap)
user_prune = bool(getter("sessions_mirror_prune_stale_cache", True))
auto_prune_allowed = bool(getter("sessions_mirror_auto_prune_stale_cache", False))
# Auto sources never produce the "many creates + many deletes" pattern
# unless the user explicitly allows it via the auto-prune knob.
prune_missing = user_prune and (auto_prune_allowed or not is_auto_source)
try:
fanout_cap = max(0, int(getter("sessions_mirror_max_dir_fanout", 100)))
except (TypeError, ValueError):
fanout_cap = 100
try:
wps_cap = max(0, int(getter("sessions_mirror_writes_per_second_cap", 40)))
except (TypeError, ValueError):
wps_cap = 40
return RemoteCacheMirrorOptions(
max_traversal_depth=max_depth,
max_entries=int(getter("sessions_mirror_max_entries", 1000)),
include_files=sync_mode_bool(getter, "sessions_mirror_include_files", True),
ignore_patterns=ignore_patterns,
prune_missing=prune_missing,
max_dir_fanout=fanout_cap,
writes_per_second_cap=wps_cap,
)
def _mirror_show_sidebar_after_sync() -> bool:
"""Return whether sync should force the sidebar visible."""
load_settings = getattr(sublime, "load_settings", None)
if not callable(load_settings):
return True
stored = load_settings("Sessions.sublime-settings")
getter = getattr(stored, "get", None)
if not callable(getter):
return True
return bool(getter("sessions_mirror_show_sidebar_after_sync", True))
def _mirror_hydrate_placeholders_on_open() -> bool:
"""Return whether opening a zero-byte cache file should pull from the remote."""
load_settings = getattr(sublime, "load_settings", None)
if not callable(load_settings):
return True
stored = load_settings("Sessions.sublime-settings")
getter = getattr(stored, "get", None)
if not callable(getter):
return True
return bool(getter("sessions_mirror_hydrate_placeholders_on_open", True))
def _mirror_fast_sidebar_first_sync() -> bool:
"""Mirror workspace top level first, then deepen (same background job)."""
load_settings = getattr(sublime, "load_settings", None)
if not callable(load_settings):
return True
stored = load_settings("Sessions.sublime-settings")
getter = getattr(stored, "get", None)
if not callable(getter):
return True
return bool(getter("sessions_mirror_fast_sidebar_first_sync", True))
def _mirror_auto_refresh_enabled() -> bool:
"""Return whether periodic remote-tree refresh should run automatically."""
load_settings = getattr(sublime, "load_settings", None)
if not callable(load_settings):
return True
stored = load_settings("Sessions.sublime-settings")
getter = getattr(stored, "get", None)
if not callable(getter):
return True
return sync_mode_bool(getter, "sessions_mirror_auto_refresh", True)
def _mirror_auto_refresh_interval_ms() -> int:
"""Return periodic refresh interval in milliseconds (clamped)."""
load_settings = getattr(sublime, "load_settings", None)
if not callable(load_settings):
return 15_000
stored = load_settings("Sessions.sublime-settings")
getter = getattr(stored, "get", None)
if not callable(getter):
return 15_000
try:
seconds = int(getter("sessions_mirror_auto_refresh_interval_seconds", 15))
except (TypeError, ValueError):
seconds = 15
return max(10_000, seconds * 1000)
def _record_auto_refresh_failure(cache_key: str) -> None:
"""Bump consecutive-failure count for ``cache_key`` (auto-refresh backoff)."""
if not cache_key:
return
current = _MIRROR_AUTO_REFRESH_FAIL_COUNT.get(cache_key, 0)
_MIRROR_AUTO_REFRESH_FAIL_COUNT[cache_key] = current + 1
def _record_auto_refresh_success(cache_key: str) -> None:
"""Clear consecutive-failure count for ``cache_key`` after a successful sync."""
if not cache_key:
return
_MIRROR_AUTO_REFRESH_FAIL_COUNT.pop(cache_key, None)
def _auto_refresh_backoff_multiplier(cache_key: str) -> int:
"""Return the backoff multiplier (1, 2, 4, 8, 16, …) for ``cache_key``."""
if not cache_key:
return 1
fails = _MIRROR_AUTO_REFRESH_FAIL_COUNT.get(cache_key, 0)
if fails <= 0:
return 1
exp = min(fails, _AUTO_REFRESH_BACKOFF_MAX_EXP)
return 1 << exp
def _mirror_deepening_delay_ms() -> int:
"""Return deep-sync delay after shallow sidebar attach in milliseconds."""
if bool(getattr(sublime, "_sessions_test_sync", False)):
return 0
load_settings = getattr(sublime, "load_settings", None)
if not callable(load_settings):
return 1500
stored = load_settings("Sessions.sublime-settings")
getter = getattr(stored, "get", None)
if not callable(getter):
return 1500
try:
delay = int(getter("sessions_mirror_deepening_delay_ms", 1500))
except (TypeError, ValueError):
delay = 1500
return max(0, delay)
def _enqueue_deep_sync(
window: object,
context: _WorkspaceContext,
*,
source: str,
allow_spawn: bool = True,
) -> None:
"""Queue deep sync as lower-priority background work."""
delay_ms = _mirror_deepening_delay_ms()
with _MIRROR_TASK_LOCK:
mirror_depth = len(_MIRROR_TASK_QUEUE)
mirror_tail = [
getattr(t[0], "__name__", repr(t[0])) for t in list(_MIRROR_TASK_QUEUE)[-8:]
]
_trace_event(
"sync.deep_enqueued",
cache_key=context.cache_key,
source=source,
delay_ms=delay_ms,
mirror_queue_len=mirror_depth,
mirror_queue_max=_MIRROR_QUEUE_MAX,
mirror_pressure=_mirror_queue_pressure(mirror_depth, 0),
mirror_queue_tail=mirror_tail,
)
def trigger() -> None:
_sync_remote_tree_to_sidebar_for_context(
window,
context,
source=source,
force_full_sync=True,
allow_spawn=allow_spawn,
)
if delay_ms <= 0:
_run_mirror_in_background(trigger)
return
_set_timeout(lambda: _run_mirror_in_background(trigger), delay_ms)
def _start_mirror_auto_refresh_loop(window: object, cache_key: str = "") -> None:
"""Start a per-window periodic mirror refresh loop if enabled.
When multiple windows share the same workspace (cache_key), only the first
window starts a loop. The ``_MIRROR_SYNC_IN_FLIGHT`` gate still prevents
concurrent execution, but cache-key dedup avoids queueing duplicate work.
"""
if not _mirror_auto_refresh_enabled():
return
window_key = _window_identity(window)
if window_key in _MIRROR_AUTO_REFRESH_WINDOWS:
return
if cache_key and cache_key in _MIRROR_AUTO_REFRESH_CACHE_KEYS:
return
_MIRROR_AUTO_REFRESH_WINDOWS.add(window_key)
if cache_key:
_MIRROR_AUTO_REFRESH_CACHE_KEYS.add(cache_key)
def tick() -> None:
if window_key not in _MIRROR_AUTO_REFRESH_WINDOWS:
if cache_key:
_MIRROR_AUTO_REFRESH_CACHE_KEYS.discard(cache_key)
return
if not any(_is_same_window(candidate, window) for candidate in _open_windows()):
_MIRROR_AUTO_REFRESH_WINDOWS.discard(window_key)
if cache_key:
_MIRROR_AUTO_REFRESH_CACHE_KEYS.discard(cache_key)
return
run_command = getattr(window, "run_command", None)
if callable(run_command):
run_command("sessions_sync_remote_tree_to_sidebar", {"source": "auto"})
# Back off the loop when sync keeps failing — slow tunnels (AWS SSM)
# routinely time out the deep mirror, and re-firing every interval
# piles work onto an already-stuck helper. Multiplier resets on the
# first successful sync via _record_auto_refresh_success.
multiplier = _auto_refresh_backoff_multiplier(cache_key)
_set_timeout(tick, _mirror_auto_refresh_interval_ms() * multiplier)
_set_timeout(tick, 0)
def _ensure_workspace_auto_refresh(window: object) -> None:
settings = SessionsSettings()
context = _workspace_context(window, settings, missing_detail_message=False)
if context is None:
return
if not _workspace_runtime_connected(window, context):
return
cache_key = context.cache_key
window_key = _window_identity(window)
if window_key not in _MIRROR_AUTO_REFRESH_PRIMED:
_MIRROR_AUTO_REFRESH_PRIMED.add(window_key)
_set_timeout(
lambda: _sync_remote_tree_to_sidebar_for_context(
window,
context,
source="auto_refresh",
allow_spawn=False,
),
0,
)
_start_mirror_auto_refresh_loop(window, cache_key=cache_key)
_start_open_file_watch_loop(window, cache_key=cache_key)
_schedule_eager_hydrate_if_needed(window, context)
def _schedule_eager_hydrate_if_needed(
window: object,
context: "_WorkspaceContext",
) -> None:
"""Run one eager-hydrate pass on a dedicated thread per ``cache_key``.
Called from both workspace activation and sync.done. A per-key
in-flight set dedupes parallel triggers (already-running pass
re-entries are dropped). Each run is idempotent — already-hydrated
placeholders count as ``skipped_existing``. Running on its own
thread instead of the shared background worker means a long pass
(sequential ``file_open`` transactions over many placeholders) cannot
block ``hydrate_open_file`` tasks queued by user file opens.
"""
merged = load_sessions_settings_from_sublime()
basenames = tuple(merged.mirror_eager_hydrate_basenames)
if not basenames:
return
cache_key = context.cache_key
if bool(getattr(sublime, "_sessions_test_sync", False)):
_eager_hydrate_workspace(window, context, basenames)
return
with _EAGER_HYDRATE_INFLIGHT_LOCK:
if cache_key in _EAGER_HYDRATE_INFLIGHT:
_trace_event("mirror.eager_hydrate_skip_inflight", cache_key=cache_key)
return
_EAGER_HYDRATE_INFLIGHT.add(cache_key)
def _run() -> None:
try:
_eager_hydrate_workspace(window, context, basenames)
except Exception:
_trace_event("mirror.eager_hydrate_thread_error", cache_key=cache_key)
print("[Sessions] Eager-hydrate thread failed.", file=sys.stderr)
finally:
with _EAGER_HYDRATE_INFLIGHT_LOCK:
_EAGER_HYDRATE_INFLIGHT.discard(cache_key)
threading.Thread(
target=_run,
daemon=True,
name="sessions-eager-hydrate-{}".format(cache_key),
).start()
def _eager_hydrate_workspace(
window: object,
context: "_WorkspaceContext",
basenames: Tuple[str, ...],
) -> None:
"""Hydrate a bounded batch of placeholder build-graph files.
Wave 2 PR-B (PR 17): the apply pass body runs in Rust
(``sessions_native::eager_hydrate::run_apply_pass``). One Rust
round-trip drives candidate discovery, batch pacing, re-check, and
the per-placeholder ``file_open`` transaction. Python persists
sidecar metadata for entries Rust marked ``hydrated``.
"""
limits = FileOpenGuardrails()
summary = _rust_ffi.eager_hydrate_apply(
cache_root=str(context.local_cache_root),
host_alias=context.recent_entry.host_alias,
remote_workspace_root=context.recent_entry.remote_root,
allowed_basenames=basenames,
batch_size=_EAGER_HYDRATE_BATCH_SIZE,
batch_sleep_ms=int(_EAGER_HYDRATE_BATCH_SLEEP_S * 1000),
max_open_bytes=limits.max_open_bytes,
binary_probe_bytes=limits.binary_probe_bytes,
allow_empty=limits.allow_empty_files,
# Per-placeholder timeout: 10s caps a stuck helper at a fraction
# of the prior 30s budget. Eager hydrate is best-effort —
# placeholders that miss a pass simply re-run on the next sync.
timeout_ms=10_000,
# PR-B.1: 8-way concurrency per batch saturates a healthy
# broker session without overwhelming the remote helper.
parallelism=8,
)
hydrated_entries = summary.get("hydrated", [])
for entry in hydrated_entries:
local_str = entry.get("local_path")
meta_dict = entry.get("metadata")
if not isinstance(local_str, str) or not isinstance(meta_dict, dict):
continue
kind_str = str(meta_dict.get("kind", RemoteFileKind.REGULAR_FILE.value))
try:
kind = RemoteFileKind(kind_str)
except ValueError:
kind = RemoteFileKind.OTHER
unix_mode_raw = meta_dict.get("unix_mode")
meta = RemoteFileMetadata(
mtime_ns=int(meta_dict.get("mtime_ns", 0)),
size_bytes=int(meta_dict.get("size_bytes", 0)),
kind=kind,
unix_mode=int(unix_mode_raw) if unix_mode_raw is not None else None,
)
_write_remote_metadata_sidecar(Path(local_str), meta)
_trace_event(
"mirror.eager_hydrate_done",
cache_key=context.cache_key,
hydrated=len(hydrated_entries),
skipped_existing=int(summary.get("skipped_existing", 0)),
failed=int(summary.get("failed", 0)),
)
def _workspace_runtime_connected(
window: object,
context: "_WorkspaceContext",
) -> bool:
"""Return True only when this window has an active host+bridge session."""
recent_entry = getattr(context, "recent_entry", None)
host_alias = getattr(recent_entry, "host_alias", None)
if not isinstance(host_alias, str) or not host_alias:
return False
connected = _connected_host_alias(window)
if connected != host_alias:
return False
return bridge_session_is_active(host_alias)
def register_sessions_transport_hooks() -> None:
"""Wire transport observers (bridge handshake → project LSP refresh)."""
register_bridge_handshake_listener(_on_persistent_bridge_handshake_ready)
_install_auto_reconnect_listeners_once()
_disable_pre_handshake_managed_lsp_rows_on_open_windows()
def _disable_pre_handshake_managed_lsp_rows_on_open_windows() -> None:
"""Force ``enabled: false`` on every Sessions ``.sublime-project`` at boot.
Sublime's LSP package reads the project file directly when the window
activates, so any managed LSP row left ``enabled: true`` from the
previous Sublime PID will spawn ``local_bridge lsp-stdio`` against a
broker socket whose path encodes the dead PID
(``sessions-local-bridge-<host>-<pid>.sock``). The helper exits 1
immediately, the LSP package retries 5x in 180s, and pyright/ruff get
permanently disabled for the session — observable as the LSP crash
storm at boot. Disabling rows on disk before any view activation gives
the bridge handshake time to complete; once
:func:`_on_persistent_bridge_handshake_ready` fires it rewrites the
same rows with ``enabled: true`` and the live broker socket, then
triggers ``lsp_restart_server`` so pyright/ruff attach cleanly.
Best-effort: any I/O / JSON error is swallowed (traced) so a malformed
user project file cannot block plugin load.
"""
windows_fn = getattr(sublime, "windows", None)
if not callable(windows_fn):
return
try:
windows_iter = list(windows_fn())
except Exception as exc: # noqa: BLE001 — Sublime API edge cases at boot
_trace_event(
"lsp.pre_handshake_disable_failed",
stage="enumerate_windows",
error=repr(exc),
)
return
for window in windows_iter:
try:
_disable_pre_handshake_managed_lsp_rows_for_window(window)
except Exception as exc: # noqa: BLE001 — best-effort per window
_trace_event(
"lsp.pre_handshake_disable_failed",
stage="per_window",
error=repr(exc),
)
def _disable_pre_handshake_managed_lsp_rows_for_window(window: object) -> None:
"""Run :func:`disable_stale_managed_lsp_rows_on_disk` for one window."""
project_file_name_fn = getattr(window, "project_file_name", None)
project_path_str = (
project_file_name_fn() if callable(project_file_name_fn) else None
)
if not isinstance(project_path_str, str) or not project_path_str.strip():
return
project_path = Path(project_path_str)
if not project_path.is_file():
return
settings = SessionsSettings()
context = _workspace_context(window, settings, missing_detail_message=False)
if context is None:
return
host_alias = getattr(context.recent_entry, "host_alias", "") or ""
handshake = bridge_handshake_info(host_alias) if host_alias else None
live_socket: Optional[str] = None
if isinstance(handshake, dict):
candidate = handshake.get("broker_socket")
if isinstance(candidate, str) and candidate.strip():
live_socket = candidate.strip()
flipped = disable_stale_managed_lsp_rows_on_disk(
project_path,
live_broker_socket=live_socket,
)
if flipped:
_trace_event(
"lsp.pre_handshake_disable_applied",
host_alias=host_alias,
cache_key=context.cache_key,
disabled_clients=flipped,
live_broker_socket=live_socket,
)
def _on_persistent_bridge_handshake_ready(host_alias: str) -> None:
"""Push managed ``settings.LSP`` rows after the broker socket is known."""
if bool(getattr(sublime, "_sessions_test_sync", False)):
return
windows_fn = getattr(sublime, "windows", None)
if not callable(windows_fn):
return
for window in windows_fn():
_maybe_push_remote_extension_after_handshake(window, host_alias)
def _maybe_push_remote_extension_after_handshake(
window: object, host_alias: str
) -> None:
settings = SessionsSettings()
context = _workspace_context(window, settings, missing_detail_message=False)
if context is None or context.recent_entry.host_alias != host_alias:
return
if _connected_host_alias(window) != host_alias:
return
_refresh_sessions_managed_remote_extension_project(
window, context, source="handshake"
)
def _refresh_sessions_managed_remote_extension_project(
window: object,
context: _WorkspaceContext,
*,
source: str,
) -> Optional[str]:
"""Merge ``local_bridge lsp-stdio`` client commands into the project file.
Uses the ``.sublime-project`` file's actual on-disk broker sockets (not
an in-memory ``_LSP_PROJECT_REFRESH_LAST`` snapshot) as the source of
truth for "is the stored command already correct?". A pre-v0.4.2 session
would write ``--bridge-socket ""`` (empty, due to the handshake unwrap
bug); after upgrade the in-memory cache is empty so it would try a
write — but if we skip based on matching non-disk state, we can leave
a stale command in place. Disk comparison guarantees self-heal.
When the stored broker socket changes (or was missing/empty), also
send ``lsp_restart_server`` for each managed client so the running
pyright/rust-analyzer reconnects via the new ``local_bridge lsp-stdio``
command. Without this, the LSP plugin keeps the previous (stale)
command alive — which is how "go-to-def doesn't recognize uncached
files" manifests: pyright falls back to the local cache workspace and
sees only a handful of files.
"""
host_alias = getattr(context.recent_entry, "host_alias", "<unknown>")
if not _workspace_runtime_connected(window, context):
connected = _connected_host_alias(window)
_trace_event(
"lsp.project_refresh_skipped",
source=source,
host_alias=host_alias,
detail="runtime_not_connected",
connected_host_alias=connected,
bridge_session_active=bridge_session_is_active(host_alias),
)
return "runtime_not_connected"
bridge_path = _try_resolved_local_bridge_binary_path()
handshake = bridge_handshake_info(host_alias) or {}
blocker = explain_lsp_attach_blockers(
host_alias=host_alias,
handshake=handshake,
bridge_path=bridge_path,
)
if blocker:
_trace_event(
"lsp.project_refresh_skipped",
source=source,
host_alias=host_alias,
detail=blocker,
)
return blocker
broker_socket = str(handshake.get("broker_socket") or "")
project_file_name_fn = getattr(window, "project_file_name", None)
project_path_str = (
project_file_name_fn() if callable(project_file_name_fn) else None
)
if not isinstance(project_path_str, str) or not project_path_str.strip():
msg = (
"Sessions: cannot update LSP project settings without a saved "
".sublime-project path on disk."
)
_trace_event("lsp.project_refresh_skipped", source=source, detail=msg)
return msg
if bridge_path is None:
return "no_bridge"
project_path = Path(project_path_str)
disk_sockets = existing_managed_broker_sockets(project_path)
disk_values = {broker for _, broker in disk_sockets}
disk_matches_current = (
bool(disk_sockets)
and bool(broker_socket)
and all(value == broker_socket for value in disk_values)
)
window_key = _window_identity(window)
cache_key = context.cache_key
last_broker = _LSP_PROJECT_REFRESH_LAST.get((window_key, cache_key))
if disk_matches_current and last_broker == broker_socket:
_trace_event(
"lsp.project_refresh_skipped",
source=source,
host_alias=host_alias,
cache_key=cache_key,
detail="disk_matches_current",
broker_socket=broker_socket,
)
return None
if not disk_matches_current and disk_sockets:
_trace_event(
"lsp.project_refresh_stale_detected",
source=source,
host_alias=host_alias,
cache_key=cache_key,
current_broker_socket=broker_socket,
on_disk_broker_sockets=sorted(disk_values),
managed_client_keys=[key for key, _ in disk_sockets],
)
# Gate "enabled" on a live broker socket. On Windows the
# PersistentBroker is not yet implemented (Track W1), so the handshake
# always reports an empty ``broker_socket`` — writing ``enabled: True``
# in that case ships an LSP row with ``--bridge-socket ""``, which
# exits 1 the moment Sublime's LSP package spawns it, and the package
# retries 5 times in 180 s before disabling the client (visible as the
# "LSP-pyright crashed" boot-storm). When the broker becomes live —
# either because we're on Unix or because W1 lands — the next refresh
# flips this flag back to True automatically.
managed_lsp_enabled = bool(broker_socket and broker_socket.strip())
merged = refresh_project_file_lsp_block(
project_path,
bridge_path=str(bridge_path),
broker_socket=broker_socket,
workspace_id=cache_key,
remote_workspace_root=context.recent_entry.remote_root,
host_alias=host_alias,
local_cache_root=str(context.local_cache_root),
active_python_path=read_active_interpreter(window),
managed_lsp_enabled=managed_lsp_enabled,
)
push_project_data_to_window(window, merged)
_LSP_PROJECT_REFRESH_LAST[(window_key, cache_key)] = broker_socket
_trace_event(
"lsp.project_refresh_applied",
source=source,
host_alias=host_alias,
cache_key=cache_key,
broker_socket=broker_socket,
on_disk_broker_sockets_before=sorted(disk_values),
)
# Stale-to-fresh transition: tell the LSP plugin to restart the managed
# servers so the running pyright/rust-analyzer reconnects via the new
# ``local_bridge lsp-stdio`` command.
if not disk_matches_current and broker_socket:
_restart_managed_lsp_servers(
window,
[
entry.project_client_key
for entry in BUILTIN_MANAGED_REMOTE_EXTENSION_CATALOG
if entry.kind == "lsp" and entry.project_client_key is not None
],
reason="broker_socket_rewritten",
)
return None
def _restart_managed_lsp_servers(
window: object,
client_keys: Iterable[str],
*,
reason: str,
) -> None:
"""Issue ``lsp_restart_server`` for each managed client on *window*."""
run_command = getattr(window, "run_command", None)
if not callable(run_command):
return
for key in client_keys:
try:
run_command("lsp_restart_server", {"config_name": key})
except Exception as exc: # noqa: BLE001 — best-effort restart
_trace_event(
"lsp.managed_server_restart_failed",
client=key,
reason=reason,
error=repr(exc),
)
continue
_trace_event(
"lsp.managed_server_restart",
client=key,
reason=reason,
)
def _trace_lsp_workspace_activation_if_sessions(
window: object,
view: object,
) -> None:
"""Log LSP attach preconditions when a Sessions workspace window activates."""
settings = SessionsSettings()
context = _workspace_context(window, settings, missing_detail_message=False)
if context is None or not _workspace_runtime_connected(window, context):
return
if not isinstance(context, _WorkspaceContext):
return
active_file = _view_file_name(view)
trace_lsp_workspace_activation(
host_alias=context.recent_entry.host_alias,
workspace_id=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
local_cache_root=str(context.local_cache_root),
active_file=active_file,
phase="workspace_activated",
)
bridge = _try_resolved_local_bridge_binary_path()
handshake = bridge_handshake_info(context.recent_entry.host_alias) or {}
blocker = explain_lsp_attach_blockers(
host_alias=context.recent_entry.host_alias,
handshake=handshake,
bridge_path=bridge,
)
if blocker:
# Every Sublime focus-switch re-runs ``on_activated`` and hits this
# branch; previous builds called ``_show_output_panel`` here
# unconditionally, so a persistent blocker (e.g. broker_socket not
# yet resolved) repeatedly popped the ``sessions_lsp_diagnostics``
# panel and blocked the bottom-panel slot the user wanted for
# terminal / build output. Now the panel auto-opens only when the
# blocker *changes*, so recurring activations are silent. The
# ``Sessions: Diagnose LSP Workspace`` command still shows the
# full panel on demand.
window_key = _window_identity(window)
previous = _LSP_DIAG_LAST_BLOCKER_BY_WINDOW.get(window_key)
_status_message(blocker)
_trace_event("lsp.attach_blocker", detail=blocker)
if previous != blocker:
_LSP_DIAG_LAST_BLOCKER_BY_WINDOW[window_key] = blocker
snapshot = collect_lsp_diagnostics_snapshot(
host_alias=context.recent_entry.host_alias,
workspace_id=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
local_cache_root=str(context.local_cache_root),
active_file=active_file,
)
detail = blocker + "\n\n" + format_lsp_diagnostics_panel_text(snapshot)
_show_output_panel(window, "sessions_lsp_diagnostics", detail)
else:
_LSP_DIAG_LAST_BLOCKER_BY_WINDOW.pop(_window_identity(window), None)
_refresh_sessions_managed_remote_extension_project(
window, context, source="activation"
)
def _collect_open_remote_views(
window: object,
context: _WorkspaceContext,
) -> List[Tuple[object, Path, str]]:
"""Return ``(view, local_cache_path, remote_path)`` for open remote tabs."""
views_fn = getattr(window, "views", None)
if not callable(views_fn):
return []
mapper = RemoteToLocalCacheMapper(
workspace_cache_key=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
files_cache_root=context.local_cache_root,
)
result: List[Tuple[object, Path, str]] = []
for view in views_fn():
if _is_remote_tree_view(view):
continue
file_name = _view_file_name(view)
if not file_name:
continue
local_path = Path(file_name)
remote = mapper.remote_path_for_local_cache_file(local_path)
if remote is None:
continue
result.append((view, local_path, remote))
return result
def _mark_recent_self_save(remote_path: str) -> None:
"""Record ``remote_path`` as just-written by us so watch echoes get suppressed."""
if not remote_path:
return
_RECENT_SELF_SAVE_REMOTE_PATHS[remote_path] = time.monotonic()
def _is_recent_self_save(remote_path: str) -> bool:
"""Return ``True`` when our own save is still inside the suppression window."""
if not remote_path:
return False
ts = _RECENT_SELF_SAVE_REMOTE_PATHS.get(remote_path)
if ts is None:
return False
if time.monotonic() - ts < _RECENT_SELF_SAVE_COOLDOWN_S:
return True
_RECENT_SELF_SAVE_REMOTE_PATHS.pop(remote_path, None)
return False
def _filter_self_save_paths(remote_paths: Set[str]) -> Set[str]:
"""Drop remote paths still inside the self-save cooldown window."""
return {path for path in remote_paths if not _is_recent_self_save(path)}
def _check_and_reload_remote_view_entry(
view: object,
local_path: Path,
remote_path: str,
context: _WorkspaceContext,
) -> None:
"""Stat one open remote view and reload when remote mtime changed."""
if _is_recent_self_save(remote_path):
# Our own ``execute_remote_write_file`` triggers ``file/watch`` on
# the remote; the resulting changed_paths echo back into Sublime
# before the sidecar metadata catches up, which Sublime renders as
# an external "reloading <path>" line on the console (the v0.5.5
# quietening regressed via this race). Skip the entry while the
# self-save cooldown window is still open.
_trace_event(
"open_file_refresh.self_save_suppressed",
remote_path=remote_path,
local_path=str(local_path),
)
return
host_alias = context.recent_entry.host_alias
view_id_fn = getattr(view, "id", None)
view_id = view_id_fn() if callable(view_id_fn) else -1
if view_id in _HYDRATE_IN_FLIGHT:
return
local_str = str(local_path)
cooldown_ts = _HYDRATE_REVERT_COOLDOWN.get(local_str)
if cooldown_ts is not None:
if time.monotonic() - cooldown_ts < _HYDRATE_REVERT_COOLDOWN_S:
return
_HYDRATE_REVERT_COOLDOWN.pop(local_str, None)
is_dirty = getattr(view, "is_dirty", None)
if callable(is_dirty) and is_dirty():
return
sidecar_meta = _read_remote_metadata_sidecar(local_path)
if sidecar_meta is None:
return
try:
current_meta = execute_remote_stat_file(
host_alias, remote_path, timeout_s=10.0, allow_spawn=False
)
except (SessionHelperStartError, Exception):
return
if current_meta is None or current_meta.mtime_ns == sidecar_meta.mtime_ns:
return
try:
read_result = execute_remote_read_file(
host_alias,
RemoteReadFileRequest(remote_absolute_path=remote_path),
timeout_s=15.0,
allow_spawn=False,
)
except (SessionHelperStartError, Exception):
return
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_bytes(read_result.body)
_write_remote_metadata_sidecar(local_path, read_result.metadata)
_trace_event(
"open_file_refresh.reloaded",
remote_path=remote_path,
local_path=str(local_path),
old_mtime_ns=sidecar_meta.mtime_ns,
new_mtime_ns=read_result.metadata.mtime_ns,
)
view_id_fn = getattr(view, "id", None)
view_id = view_id_fn() if callable(view_id_fn) else -1
lp = str(local_path)
def _revert_view(vid: int = view_id, path_str: str = lp) -> None:
view_ctor = getattr(sublime, "View", None)
if not callable(view_ctor):
return
try:
v = view_ctor(vid)
except (TypeError, ValueError, RuntimeError):
return
is_valid = getattr(v, "is_valid", None)
if not callable(is_valid) or not is_valid():
return
if _view_file_name(v) != path_str:
return
dirty_fn = getattr(v, "is_dirty", None)
if callable(dirty_fn) and dirty_fn():
return
run_cmd = getattr(v, "run_command", None)
if callable(run_cmd):
run_cmd("revert")
_set_timeout(_revert_view, 0)
def _check_and_reload_remote_views(
window: object,
context: _WorkspaceContext,
) -> None:
"""Stat open remote files and reload any that changed on the remote side."""
entries = _collect_open_remote_views(window, context)
if not entries:
return
for view, local_path, remote_path in entries:
_check_and_reload_remote_view_entry(view, local_path, remote_path, context)
def _check_and_reload_active_remote_view(
window: object,
context: _WorkspaceContext,
) -> None:
"""Fast-path: revalidate only the currently active remote tab."""
active = _active_view(window)
if active is None or _is_remote_tree_view(active):
return
file_name = _view_file_name(active)
if not file_name:
return
mapper = RemoteToLocalCacheMapper(
workspace_cache_key=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
files_cache_root=context.local_cache_root,
)
local_path = Path(file_name)
remote = mapper.remote_path_for_local_cache_file(local_path)
if remote is None:
return
_check_and_reload_remote_view_entry(active, local_path, remote, context)
def _reload_changed_remote_views(
window: object,
context: _WorkspaceContext,
changed_paths: Set[str],
) -> None:
"""Reload only open views whose remote paths changed by watch events."""
if not changed_paths:
return
# Drop any self-save echoes early so we never even collect the open
# views just to bail later. ``_check_and_reload_remote_view_entry`` has
# the same guard so the suppression still holds for callers that bypass
# this filter.
effective = _filter_self_save_paths(changed_paths)
if not effective:
return
for view, local_path, remote_path in _collect_open_remote_views(window, context):
if remote_path not in effective:
continue
_check_and_reload_remote_view_entry(view, local_path, remote_path, context)
def _start_open_file_watch_loop(window: object, cache_key: str = "") -> None:
"""Start one inotify-backed watch loop per workspace cache key."""
settings = SessionsSettings()
initial_ctx = _workspace_context(window, settings, missing_detail_message=False)
if initial_ctx is None or not _workspace_runtime_connected(window, initial_ctx):
return
window_key = _window_identity(window)
if window_key in _OPEN_FILE_WATCH_WINDOWS:
return
if cache_key and cache_key in _OPEN_FILE_WATCH_CACHE_KEYS:
return
_OPEN_FILE_WATCH_WINDOWS.add(window_key)
if cache_key:
_OPEN_FILE_WATCH_CACHE_KEYS.add(cache_key)
def worker() -> None:
while True:
if window_key not in _OPEN_FILE_WATCH_WINDOWS:
break
if not any(_is_same_window(c, window) for c in _open_windows()):
break
settings = SessionsSettings()
ctx = _workspace_context(window, settings, missing_detail_message=False)
if ctx is None:
break
if not _workspace_runtime_connected(window, ctx):
break
entries = _collect_open_remote_views(window, ctx)
remote_paths = tuple(sorted({remote for _, _, remote in entries}))
if not remote_paths:
time.sleep(0.5)
continue
try:
watched = execute_remote_watch_files(
ctx.recent_entry.host_alias,
RemoteWatchFilesRequest(
remote_paths=remote_paths,
timeout_ms=30_000,
),
allow_spawn=False,
)
except SessionHelperStartError:
break
changed = set(watched.changed_paths)
if not changed:
continue
_set_timeout(
lambda c=ctx, d=changed: _reload_changed_remote_views(window, c, d),
0,
)
_OPEN_FILE_WATCH_WINDOWS.discard(window_key)
if cache_key:
_OPEN_FILE_WATCH_CACHE_KEYS.discard(cache_key)
threading.Thread(
target=worker,
name="sessions-open-file-watch-{}".format(window_key),
daemon=True,
).start()
def _coerce_sidebar_after_project_merge(window: object) -> None:
"""Best-effort refresh after ``set_project_data`` updates ``folders``."""
run_command = getattr(window, "run_command", None)
if not callable(run_command):
return
for cmd in ("refresh_folder_list", "refresh_side_bar"):
try:
run_command(cmd)
except (TypeError, ValueError, RuntimeError):
pass
def _workspace_window_display_name(host_alias: str, remote_root: str) -> str:
"""Return window/project title in VSCode-like SSH format."""
base = remote_root.rstrip("/").split("/")[-1] or "root"
host = host_alias.strip() or "host"
return "{} [SSH: {}]".format(base, host)
def _apply_workspace_window_title(window: object) -> None:
"""Keep project_data.name aligned with current workspace host/root."""
settings = SessionsSettings()
context = _workspace_context(window, settings, missing_detail_message=False)
if context is None:
return
recent_entry = getattr(context, "recent_entry", None)
host_alias = getattr(recent_entry, "host_alias", None)
remote_root = getattr(recent_entry, "remote_root", None)
if not isinstance(host_alias, str) or not isinstance(remote_root, str):
return
project_data_fn = getattr(window, "project_data", None)
set_project_fn = getattr(window, "set_project_data", None)
if not callable(project_data_fn) or not callable(set_project_fn):
return
current = project_data_fn() or {}
if not isinstance(current, dict):
return
desired = _workspace_window_display_name(
host_alias,
remote_root,
)
if current.get("name") == desired:
return
updated = dict(current)
updated["name"] = desired
set_project_fn(updated)
def _status_message(message: str) -> None:
sublime.status_message(message)
def _emit_status(status: ConnectStatus) -> None:
_status_message(status.message)
print("[Sessions] {}".format(status.message))
_trace_event("status", message=status.message)
def _set_timeout(callback, delay_ms: int = 0) -> None:
set_timeout = getattr(sublime, "set_timeout", None)
if callable(set_timeout):
set_timeout(callback, delay_ms)
return
callback()
def _run_in_background(
target,
*args,
prioritize: bool = False,
task_key: Optional[str] = None,
task_label: Optional[str] = None,
) -> None:
if bool(getattr(sublime, "_sessions_test_sync", False)):
target(*args)
return
_start_background_worker_if_needed()
with _BACKGROUND_TASK_LOCK:
task_name = task_label or getattr(target, "__name__", repr(target))
if task_key and task_key in _BACKGROUND_PENDING_KEYS:
if prioritize and _promote_pending_background_task_locked(task_key):
_trace_event(
"queue.promote",
task=task_name,
task_key=task_key,
queue_size=len(_BACKGROUND_TASK_QUEUE),
)
_BACKGROUND_TASK_EVENT.set()
return
_trace_event(
"queue.skip_duplicate",
task=task_name,
task_key=task_key,
)
return
if task_key and task_key in _BACKGROUND_INFLIGHT_KEYS:
_trace_event(
"queue.skip_duplicate",
task=task_name,
task_key=task_key,
)
return
if task_key:
_BACKGROUND_PENDING_KEYS.add(task_key)
task = (
target,
args,
task_name,
task_key,
)
if prioritize:
_BACKGROUND_TASK_QUEUE.appendleft(task)
else:
_BACKGROUND_TASK_QUEUE.append(task)
dropped = 0
while len(_BACKGROUND_TASK_QUEUE) > _BACKGROUND_QUEUE_MAX:
dropped_task = _BACKGROUND_TASK_QUEUE.pop()
dropped_key = dropped_task[3]
if dropped_key:
_BACKGROUND_PENDING_KEYS.discard(dropped_key)
dropped += 1
queue_size = len(_BACKGROUND_TASK_QUEUE)
bg_tail = [str(item[2]) for item in list(_BACKGROUND_TASK_QUEUE)[-12:]]
qfields: dict[str, object] = {
"task": task[2],
"task_key": task_key,
"prioritize": prioritize,
"queue_size": queue_size,
"dropped": dropped,
"background_queue_max": _BACKGROUND_QUEUE_MAX,
"background_pressure": _background_queue_pressure(queue_size, dropped),
}
if dropped > 0 or _background_queue_pressure(queue_size, dropped) != "normal":
qfields["background_queue_tail"] = bg_tail
_trace_event("queue.enqueue", **qfields)
if dropped > 0:
_trace_event(
"queue.tasks_dropped",
dropped=dropped,
background_queue_max=_BACKGROUND_QUEUE_MAX,
background_queue_size_after=queue_size,
background_queue_tail=bg_tail,
)
_BACKGROUND_TASK_EVENT.set()
def _promote_pending_background_task_locked(task_key: str) -> bool:
"""Move pending task_key to queue front while lock is held."""
for idx, queued in enumerate(_BACKGROUND_TASK_QUEUE):
queued_key = queued[3]
if queued_key != task_key:
continue
if idx == 0:
return True
del _BACKGROUND_TASK_QUEUE[idx]
_BACKGROUND_TASK_QUEUE.appendleft(queued)
return True
return False
def _run_mirror_in_background(target, *args) -> None:
if bool(getattr(sublime, "_sessions_test_sync", False)):
target(*args)
return
_start_mirror_worker_if_needed()
with _MIRROR_TASK_LOCK:
task_name = getattr(target, "__name__", repr(target))
if task_name == "trigger":
already_pending = any(
getattr(existing_target, "__name__", repr(existing_target)) == "trigger"
for existing_target, _ in _MIRROR_TASK_QUEUE
)
if already_pending:
_trace_event(
"mirror_queue.skip_duplicate",
task=task_name,
queue_size=len(_MIRROR_TASK_QUEUE),
)
return
_MIRROR_TASK_QUEUE.append((target, args))
dropped = 0
while len(_MIRROR_TASK_QUEUE) > _MIRROR_QUEUE_MAX:
_MIRROR_TASK_QUEUE.popleft()
dropped += 1
queue_size = len(_MIRROR_TASK_QUEUE)
mirror_tail = [
getattr(t[0], "__name__", repr(t[0])) for t in list(_MIRROR_TASK_QUEUE)[-8:]
]
mfields: dict[str, object] = {
"task": task_name,
"queue_size": queue_size,
"dropped": dropped,
"mirror_queue_max": _MIRROR_QUEUE_MAX,
"mirror_pressure": _mirror_queue_pressure(queue_size, dropped),
}
if dropped > 0 or _mirror_queue_pressure(queue_size, dropped) != "normal":
mfields["mirror_queue_tail"] = mirror_tail
_trace_event("mirror_queue.enqueue", **mfields)
if dropped > 0:
_trace_event(
"mirror_queue.tasks_dropped",
dropped=dropped,
mirror_queue_max=_MIRROR_QUEUE_MAX,
mirror_queue_size_after=queue_size,
mirror_queue_tail=mirror_tail,
)
_MIRROR_TASK_EVENT.set()
def _start_background_worker_if_needed() -> None:
global _BACKGROUND_WORKER_STARTED
if _BACKGROUND_WORKER_STARTED:
return
_BACKGROUND_WORKER_STARTED = True
threading.Thread(target=_background_worker_loop, daemon=True).start()
def _start_mirror_worker_if_needed() -> None:
global _MIRROR_WORKER_STARTED
if _MIRROR_WORKER_STARTED:
return
_MIRROR_WORKER_STARTED = True
threading.Thread(target=_mirror_worker_loop, daemon=True).start()
def _background_worker_loop() -> None:
while True:
_BACKGROUND_TASK_EVENT.wait()
while True:
with _BACKGROUND_TASK_LOCK:
if not _BACKGROUND_TASK_QUEUE:
_BACKGROUND_TASK_EVENT.clear()
break
target, args, label, task_key = _BACKGROUND_TASK_QUEUE.popleft()
if task_key:
_BACKGROUND_PENDING_KEYS.discard(task_key)
_BACKGROUND_INFLIGHT_KEYS.add(task_key)
remaining = len(_BACKGROUND_TASK_QUEUE)
_trace_event(
"queue.dequeue",
task=label,
task_key=task_key,
queue_remaining=remaining,
)
try:
started = time.monotonic()
target(*args)
elapsed_ms = int((time.monotonic() - started) * 1000)
_trace_event(
"queue.done",
task=label,
task_key=task_key,
elapsed_ms=elapsed_ms,
)
except Exception:
print("[Sessions] Background task failed.", file=sys.stderr)
_trace_event(
"queue.error",
task=label,
task_key=task_key,
error="exception",
)
finally:
if task_key:
with _BACKGROUND_TASK_LOCK:
_BACKGROUND_INFLIGHT_KEYS.discard(task_key)
def _mirror_worker_loop() -> None:
while True:
_MIRROR_TASK_EVENT.wait()
while True:
with _MIRROR_TASK_LOCK:
if not _MIRROR_TASK_QUEUE:
_MIRROR_TASK_EVENT.clear()
break
target, args = _MIRROR_TASK_QUEUE.popleft()
remaining = len(_MIRROR_TASK_QUEUE)
_trace_event(
"mirror_queue.dequeue",
task=getattr(target, "__name__", repr(target)),
queue_remaining=remaining,
)
try:
started = time.monotonic()
target(*args)
elapsed_ms = int((time.monotonic() - started) * 1000)
_trace_event(
"mirror_queue.done",
task=getattr(target, "__name__", repr(target)),
elapsed_ms=elapsed_ms,
)
except Exception:
print("[Sessions] Mirror background task failed.", file=sys.stderr)
_trace_event(
"mirror_queue.error",
task=getattr(target, "__name__", repr(target)),
error="exception",
)
def _trace_enabled() -> bool:
load_settings = getattr(sublime, "load_settings", None)
if not callable(load_settings):
return False
stored = load_settings("Sessions.sublime-settings")
getter = getattr(stored, "get", None)
if not callable(getter):
return False
return bool(getter("sessions_debug_trace_enabled", False))
def _trace_log_path() -> Path:
return Path(sublime.cache_path()) / "Sessions" / "logs" / "debug-trace.log"
def _trace_event(event: str, **fields: object) -> None:
if not _trace_enabled():
return
try:
# Mirror the ``ssh_file_transport`` trace shape: include both the
# epoch ``ts`` (machine-readable correlation across logs) and a
# human-readable local-time ``time`` string. Without ``time``,
# half of the trace lines were unreadable at a glance and post
# mortems had to convert epochs by hand.
now = time.time()
human_time = _datetime.datetime.fromtimestamp(now).strftime(
"%Y-%m-%d %H:%M:%S.%f"
)[:-3]
payload = {
"ts": round(now, 3),
"time": human_time,
"event": event,
**fields,
}
path = _trace_log_path()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fp:
fp.write(json.dumps(payload, ensure_ascii=True) + "\n")
except Exception:
return
def _prompt_for_ssh_secret(window: object, prompt_text: str) -> Optional[str]:
done = threading.Event()
state: Dict[str, Optional[str]] = {"value": None}
is_device_code = _is_device_code_prompt(prompt_text)
def show_panel() -> None:
if is_device_code:
_show_device_code_dialog(window, prompt_text, done, state)
else:
panel = window.show_input_panel(
prompt_text or "SSH verification code:",
"",
lambda value: _finish_ssh_secret_prompt(done, state, value),
None,
lambda: _finish_ssh_secret_prompt(done, state, None),
)
_mark_input_panel_as_password(panel)
_set_timeout(show_panel)
done.wait()
return state["value"]
_DEVICE_CODE_URL_PATTERNS = (
"microsoft.com/devicelogin",
"devicelogin",
"aka.ms/devicelogin",
)
def _is_device_code_prompt(prompt_text: str) -> bool:
"""Detect SSH prompts that require browser-based device code auth."""
lower = (prompt_text or "").lower()
return any(pattern in lower for pattern in _DEVICE_CODE_URL_PATTERNS)
def _show_device_code_dialog(
window: object,
prompt_text: str,
done: threading.Event,
state: Dict[str, Optional[str]],
) -> None:
"""Show a readable dialog for device-code auth, then wait for Enter."""
ok_dialog = getattr(sublime, "ok_cancel_dialog", None)
if callable(ok_dialog):
confirmed = ok_dialog(
prompt_text + "\n\nComplete sign-in in your browser, then press OK.",
"OK",
)
if confirmed:
_finish_ssh_secret_prompt(done, state, "")
else:
_finish_ssh_secret_prompt(done, state, None)
else:
panel = window.show_input_panel(
prompt_text + " (press Enter after browser sign-in)",
"",
lambda value: _finish_ssh_secret_prompt(done, state, value),
None,
lambda: _finish_ssh_secret_prompt(done, state, None),
)
_ = panel
def _finish_ssh_secret_prompt(
done: threading.Event,
state: Dict[str, Optional[str]],
value: Optional[str],
) -> None:
state["value"] = value
done.set()
def _mark_input_panel_as_password(panel: object) -> None:
if panel is None:
return
settings = getattr(panel, "settings", None)
if not callable(settings):
return
panel_settings = settings()
setter = getattr(panel_settings, "set", None)
if callable(setter):
setter("password", True)
@dataclass(frozen=True)
class _WorkspaceContext:
settings: SessionsSettings
recent_entry: RecentWorkspace
cache_key: str
local_cache_root: Path
def _workspace_context(
window: object,
settings: SessionsSettings,
*,
missing_detail_message: bool = True,
) -> Optional[_WorkspaceContext]:
workspace_key = _current_workspace_key(window.project_data())
if workspace_key is None:
if missing_detail_message:
_status_message("No current Sessions workspace metadata is available.")
return None
recent_entry = _recent_entry_for_cache_key(
_recent_store(settings).load_index().entries,
workspace_key,
)
if recent_entry is None:
if missing_detail_message:
_status_message("No recent Sessions entry matches the current workspace.")
return None
local_paths = default_local_paths(sublime.cache_path(), settings)
return _WorkspaceContext(
settings=settings,
recent_entry=recent_entry,
cache_key=workspace_key,
local_cache_root=local_paths.cache_root / workspace_key,
)
def _remote_save_target_after_local_save(
view: object,
window: object,
) -> Optional[Tuple[object, _WorkspaceContext, str]]:
"""If ``view`` saved a file under the current workspace cache, return save args.
Used after a normal Sublime save so the mirrored remote file can be pushed
without requiring a separate palette command.
Args:
view: The view that just finished saving.
window: The window that owns ``view``.
Returns:
``(window, context, normalized_remote_absolute_path)`` when the saved
path maps into the active Sessions workspace cache, otherwise ``None``.
Raises:
None.
"""
if _is_remote_tree_view(view):
return None
settings = SessionsSettings()
context = _workspace_context(window, settings, missing_detail_message=False)
if context is None:
return None
file_name = _view_file_name(view)
if not file_name:
return None
mapper = RemoteToLocalCacheMapper(
workspace_cache_key=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
files_cache_root=context.local_cache_root,
)
local_path = Path(file_name)
if mapper.is_external_cache_path(local_path):
return None
remote = mapper.remote_path_for_local_cache_file(local_path)
if remote is None:
return None
return (window, context, remote)
def _handle_save_conflict(
window: object,
context: _WorkspaceContext,
remote_file: str,
local_cache_path: Path,
remote_metadata: Optional[RemoteFileMetadata],
) -> None:
"""Show a quick-panel so the user can choose how to resolve a save conflict.
Choices:
Overwrite — push local content, ignoring the newer remote version.
Reload — discard local edits and pull the current remote version.
Cancel — do nothing.
"""
items = [
["Overwrite remote", "Push your local version (remote changes will be lost)"],
[
"Reload from remote",
"Discard local edits and download the current remote file",
],
["Cancel", "Keep both versions as-is (no action)"],
]
def _on_select(idx: int) -> None:
if idx < 0 or idx == 2:
_emit_status(
ConnectStatus(kind="warning", detail="Save conflict: cancelled.")
)
return
if idx == 0:
_force_overwrite_remote(
context, remote_file, local_cache_path, remote_metadata
)
elif idx == 1:
_reload_from_remote(window, context, remote_file, local_cache_path)
show_qp = getattr(window, "show_quick_panel", None)
if callable(show_qp):
_set_timeout(lambda: show_qp(items, _on_select), 0)
else:
_emit_status(
ConnectStatus(
kind="warning",
detail="Remote file changed since local copy; quick panel unavailable.",
)
)
def _force_overwrite_remote(
context: _WorkspaceContext,
remote_file: str,
local_cache_path: Path,
remote_metadata: Optional[RemoteFileMetadata],
) -> None:
"""Write local content to the remote, bypassing metadata check."""
body = local_cache_path.read_bytes()
digest = hashlib.sha256(body).hexdigest()
# Same self-save suppression as the regular save path; force-overwrite
# also triggers a remote ``file/watch`` event we must not echo into a
# Sublime "reloading" reload.
_mark_recent_self_save(remote_file)
write_result = execute_remote_write_file(
context.recent_entry.host_alias,
RemoteWriteFileRequest(
remote_absolute_path=remote_file,
content=body,
expected_remote_metadata=remote_metadata,
),
)
if write_result.ok and write_result.updated_metadata is not None:
_write_remote_metadata_sidecar(
local_cache_path,
write_result.updated_metadata,
last_pushed_sha256=digest,
)
_mark_recent_self_save(remote_file)
_emit_status(
ConnectStatus(
kind="ready",
detail="Overwritten remote file {}".format(remote_file),
)
)
return
if write_result.error_code is RemoteWriteErrorCode.TRANSPORT_ERROR:
_emit_status(
ConnectStatus(
kind="disconnected",
detail=(
write_result.error_message or "Remote file save failed over SSH."
),
)
)
return
_emit_status(
ConnectStatus(
kind="warning",
detail=write_result.error_message or "Remote overwrite was rejected.",
)
)
def _reload_from_remote(
window: object,
context: _WorkspaceContext,
remote_file: str,
local_cache_path: Path,
) -> None:
"""Download the current remote version, overwrite the local cache, and revert."""
try:
read_result = execute_remote_read_file(
context.recent_entry.host_alias,
RemoteReadFileRequest(
remote_absolute_path=remote_file,
),
)
except SessionHelperStartError as error:
_emit_status(ConnectStatus(kind="disconnected", detail=error.detail))
return
local_cache_path.write_bytes(read_result.body)
_write_remote_metadata_sidecar(local_cache_path, read_result.metadata)
def _revert_open_view() -> None:
find_fn = getattr(window, "find_open_file", None)
if not callable(find_fn):
return
view = find_fn(str(local_cache_path))
if view is None:
return
run_cmd = getattr(view, "run_command", None)
if callable(run_cmd):
run_cmd("revert")
_set_timeout(_revert_open_view, 50)
_emit_status(
ConnectStatus(
kind="ready",
detail="Reloaded remote file {}".format(remote_file),
)
)
def _remote_metadata_sidecar_path(local_cache_path: Path) -> Path:
sessions_root, cache_key, relative_file = _cache_file_identity(local_cache_path)
if sessions_root is None or cache_key is None or relative_file is None:
return local_cache_path.parent / ".{}.sessions-meta.json".format(
local_cache_path.name
)
suffix = "".join(relative_file.suffixes)
stem = relative_file.name
if suffix and stem.endswith(suffix):
stem = stem[: -len(suffix)]
leaf_name = "{}{}.sessions-meta.json".format(stem, suffix)
parent_parts = relative_file.parts[:-1]
return (
sessions_root
/ "state"
/ "local"
/ "remote-file-metadata"
/ cache_key
/ Path(*parent_parts)
/ leaf_name
)
def _cache_file_identity(
local_cache_path: Path,
) -> Tuple[Optional[Path], Optional[str], Optional[Path]]:
parts = local_cache_path.parts
for idx, part in enumerate(parts):
if part != "cache":
continue
if idx == 0 or idx + 2 >= len(parts):
continue
if parts[idx - 1] != "Sessions":
continue
sessions_root = Path(*parts[:idx])
cache_key = parts[idx + 1]
relative_file = Path(*parts[idx + 2 :])
return sessions_root, cache_key, relative_file
return None, None, None
def _remote_metadata_sidecar_legacy_path(local_cache_path: Path) -> Path:
return local_cache_path.parent / "{}.sessions-meta.json".format(
local_cache_path.name
)
def _remove_local_remote_cache_mirror_path(local_cache_path: Path) -> None:
"""Remove a mirrored file or directory tree and its metadata sidecar."""
side = _remote_metadata_sidecar_path(local_cache_path)
legacy_side = _remote_metadata_sidecar_legacy_path(local_cache_path)
try:
if side.is_file():
side.unlink()
except OSError:
pass
if legacy_side != side:
try:
if legacy_side.is_file():
legacy_side.unlink()
except OSError:
pass
try:
if local_cache_path.is_dir():
shutil.rmtree(local_cache_path)
elif local_cache_path.is_file() or local_cache_path.is_symlink():
local_cache_path.unlink()
except OSError:
pass
def _alert_stale_remote_path_removed(remote_display: str) -> None:
dialog = getattr(sublime, "message_dialog", None)
text = (
"This path no longer exists on the remote host (it may have been "
"deleted). The stale local cache copy will be removed.\n\n{}"
).format(remote_display)
if callable(dialog):
dialog(text)
def _close_open_views_for_abs_path(window: object, file_path: Path) -> None:
find_open = getattr(window, "find_open_file", None)
if not callable(find_open):
return
view = find_open(str(file_path))
if view is None:
return
focus = getattr(window, "focus_view", None)
if callable(focus):
focus(view)
run_command = getattr(window, "run_command", None)
if callable(run_command):
run_command("close", {})
def _has_remote_metadata_sidecar(local_cache_path: Path) -> bool:
"""Return True iff a metadata sidecar file exists for ``local_cache_path``.
Used by the destructive REMOTE_NOT_FOUND branches as a "did Sessions
ever fetch this from the remote?" marker. Only sidecar *presence*
matters here — degenerate / unparseable payloads still count: a
sidecar on disk means the file went through the fetch pipeline at
least once, so the local copy is replaceable from upstream and the
delete-then-warn path is safe. The absence of any sidecar means the
user created the file locally (Save As / new buffer) and deleting
would lose work.
"""
sidecar = _remote_metadata_sidecar_path(local_cache_path)
if sidecar.is_file():
return True
legacy = _remote_metadata_sidecar_legacy_path(local_cache_path)
return legacy.is_file()
def _read_last_pushed_sha256(local_cache_path: Path) -> Optional[str]:
"""Return last recorded SHA-256 hex of bytes uploaded for this cache file."""
sidecar = _remote_metadata_sidecar_path(local_cache_path)
if not sidecar.is_file():
legacy = _remote_metadata_sidecar_legacy_path(local_cache_path)
if not legacy.is_file():
return None
sidecar = legacy
try:
payload = json.loads(sidecar.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return None
if not isinstance(payload, dict):
return None
val = payload.get("last_pushed_sha256")
if not isinstance(val, str) or len(val) != 64:
return None
if not set(val) <= set("0123456789abcdef"):
return None
return val
def _write_remote_metadata_sidecar(
local_cache_path: Path,
metadata: RemoteFileMetadata,
*,
last_pushed_sha256: Optional[str] = None,
) -> None:
sidecar = _remote_metadata_sidecar_path(local_cache_path)
sidecar.parent.mkdir(parents=True, exist_ok=True)
payload = {
"mtime_ns": metadata.mtime_ns,
"size_bytes": metadata.size_bytes,
"kind": metadata.kind.value,
"unix_mode": metadata.unix_mode,
}
if last_pushed_sha256 is not None:
payload["last_pushed_sha256"] = last_pushed_sha256
sidecar.write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def _read_remote_metadata_sidecar(
local_cache_path: Path,
) -> Optional[RemoteFileMetadata]:
sidecar = _remote_metadata_sidecar_path(local_cache_path)
if not sidecar.is_file():
legacy = _remote_metadata_sidecar_legacy_path(local_cache_path)
if not legacy.is_file():
return None
sidecar = legacy
if sidecar == _remote_metadata_sidecar_legacy_path(local_cache_path):
migrated = _remote_metadata_sidecar_path(local_cache_path)
if migrated != sidecar:
try:
migrated.parent.mkdir(parents=True, exist_ok=True)
migrated.write_text(
sidecar.read_text(encoding="utf-8"), encoding="utf-8"
)
sidecar.unlink()
sidecar = migrated
except OSError:
pass
try:
payload = json.loads(sidecar.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return None
if not isinstance(payload, dict):
return None
kind_value = payload.get("kind", RemoteFileKind.REGULAR_FILE.value)
if kind_value not in {kind.value for kind in RemoteFileKind}:
return None
return RemoteFileMetadata(
mtime_ns=int(payload["mtime_ns"]),
size_bytes=int(payload["size_bytes"]),
kind=RemoteFileKind(kind_value),
unix_mode=(
payload.get("unix_mode") if payload.get("unix_mode") is not None else None
),
)
def _tool_target_remote_file(
window: object,
context: _WorkspaceContext,
remote_file: str,
) -> Optional[str]:
remote_text = (remote_file or "").strip()
if remote_text:
try:
return _resolve_workspace_remote_file(
context.recent_entry.remote_root,
remote_text,
)
except ConnectPreflightError as error:
_emit_status(ConnectStatus(kind="disconnected", detail=error.detail))
return None
active_remote = _active_remote_file_for_workspace(window, context)
if active_remote is not None:
return active_remote
_status_message(
"Open a cached workspace file first or pass a remote_file argument explicitly."
)
return None
def _active_remote_file_for_workspace(
window: object,
context: _WorkspaceContext,
) -> Optional[str]:
view = _active_view(window)
if view is None:
return None
file_name = _view_file_name(view)
if not file_name:
return None
mapper = RemoteToLocalCacheMapper(
workspace_cache_key=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
files_cache_root=context.local_cache_root,
)
return mapper.remote_path_for_local_cache_file(Path(file_name))
def _active_view(window: object) -> Optional[object]:
active_view = getattr(window, "active_view", None)
if callable(active_view):
return active_view()
return None
def _view_file_name(view: object) -> Optional[str]:
file_name = getattr(view, "file_name", None)
if callable(file_name):
value = file_name()
return value if isinstance(value, str) and value else None
return None
def _active_view_is_dirty(window: object) -> bool:
view = _active_view(window)
if view is None:
return False
dirty = getattr(view, "is_dirty", None)
return bool(dirty()) if callable(dirty) else False
def _run_remote_python_tool_for_workspace(
window: object,
context: _WorkspaceContext,
normalized_kind: str,
remote_path: str,
*,
use_ruff_formatter: bool,
) -> None:
working_directory = str(PurePosixPath(remote_path).parent)
override = context.settings.toolchain_override_for("python")
if normalized_kind == "format":
request = build_python_format_tool_execution_request(
request_id="format",
primary_remote_path=remote_path,
working_directory_remote=working_directory,
toolchain_override=override,
use_ruff_formatter=use_ruff_formatter,
)
else:
request = build_python_lint_tool_execution_request(
request_id="lint",
primary_remote_path=remote_path,
working_directory_remote=working_directory,
toolchain_override=override,
)
try:
result = execute_remote_tool_request(context.recent_entry.host_alias, request)
except SessionHelperStartError as error:
_emit_status(ConnectStatus(kind="disconnected", detail=error.detail))
return
_present_remote_tool_result(
window,
context,
request,
result,
normalized_kind=normalized_kind,
)
def _present_remote_tool_result(
window: object,
context: _WorkspaceContext,
request: object,
result: object,
*,
normalized_kind: str,
) -> None:
mapper = RemoteToLocalCacheMapper(
workspace_cache_key=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
files_cache_root=context.local_cache_root,
)
mapped = map_diagnostics_batch(
mapper,
list(diagnostic_records_from_helper_payload(result.diagnostics)),
working_directory_remote=request.working_directory_remote,
)
inline_presentations = inline_presentations_from_mapped_diagnostics(mapped)
_apply_inline_diagnostics(window, inline_presentations)
footer_lines = _remote_tool_footer_lines(
result,
mapped,
current_local_file=_view_file_name(_active_view(window)),
)
plan = output_panel_plan_from_tool_execution_result(result, request.kind)
_show_output_panel(
window,
"sessions_remote_tool",
_panel_text_from_plan(plan, footer_lines),
)
if normalized_kind in {"format", "source action"} and (
result.failure_category is ToolFailureCategory.SUCCESS
):
_refresh_local_cache_after_format(window, context, request.primary_remote_path)
if normalized_kind == "format":
_emit_status(
ConnectStatus(
kind="ready",
detail="Remote formatter completed for {}".format(
request.primary_remote_path
),
)
)
else:
_emit_status(
ConnectStatus(
kind="ready",
detail="Remote source action completed for {}".format(
request.primary_remote_path
),
)
)
return
if result.failure_category is ToolFailureCategory.TOOL_NOT_FOUND:
_emit_status(
ConnectStatus(
kind="warning",
detail=result.tool_not_found_hint or "Remote tool is missing on PATH.",
)
)
return
if result.failure_category is ToolFailureCategory.TIMEOUT:
_emit_status(
ConnectStatus(
kind="warning",
detail="Remote tool timed out before it finished.",
)
)
return
if result.failure_category is ToolFailureCategory.SUCCESS:
_emit_status(
ConnectStatus(
kind="ready",
detail="Remote {} completed for {}".format(
normalized_kind,
request.primary_remote_path,
),
)
)
return
_emit_status(
ConnectStatus(
kind="warning",
detail="Remote {} finished with exit code {}.".format(
normalized_kind,
result.exit_code,
),
)
)
def _remote_tool_footer_lines(
result: object,
mapped: Sequence[object],
*,
current_local_file: Optional[str],
) -> Tuple[str, ...]:
lines = []
summary = unopened_files_summary(mapped)
if summary:
parts = ["{}={}".format(key, value) for key, value in sorted(summary.items())]
lines.append("Unopened/unmapped diagnostics: {}".format(", ".join(parts)))
unopened_mapped = 0
if current_local_file:
unopened_mapped = sum(
1
for item in mapped
if (
item.local_path is not None
and str(item.local_path) != current_local_file
)
)
if unopened_mapped:
lines.append(
"Diagnostics also mention {} unopened cache file(s).".format(
unopened_mapped
)
)
if result.tool_not_found_hint:
lines.append(result.tool_not_found_hint)
return tuple(lines)
def _panel_text_from_plan(plan: object, footer_lines: Sequence[str]) -> str:
chunks = []
for section in plan.sections:
body = section.body.strip()
if not body:
continue
chunks.append("{}\n{}".format(section.title, body))
if footer_lines:
chunks.append("Notes\n{}".format("\n".join(footer_lines)))
text = "\n\n".join(chunks).strip()
return text + ("\n" if text else "")
def _show_output_panel(window: object, panel_name: str, text: str) -> None:
create_output_panel = getattr(window, "create_output_panel", None)
if not callable(create_output_panel):
return
panel = create_output_panel(panel_name)
set_content = getattr(panel, "set_content", None)
if callable(set_content):
set_content(text)
else:
run_command = getattr(panel, "run_command", None)
if callable(run_command):
run_command("select_all", {})
run_command("left_delete", {})
run_command("append", {"characters": text})
def _focus_end() -> None:
rc = getattr(panel, "run_command", None)
if callable(rc):
rc("move_to", {"to": "eof", "extend": False})
run_command = getattr(window, "run_command", None)
if callable(run_command):
run_command("show_panel", {"panel": "output.{}".format(panel_name)})
_set_timeout(_focus_end, 0)
def _apply_inline_diagnostics(
window: object,
presentations: Sequence[object],
) -> None:
view = _active_view(window)
if view is None:
return
current_file = _view_file_name(view)
if not current_file:
return
matching = [item for item in presentations if item.local_file_path == current_file]
if not matching:
return
region_factory = getattr(sublime, "Region", None)
text_point = getattr(view, "text_point", None)
add_regions = getattr(view, "add_regions", None)
if not callable(add_regions):
return
grouped: Dict[str, list] = {}
for item in matching:
if callable(region_factory) and callable(text_point):
a = text_point(item.start_row, item.start_col)
b = text_point(item.end_row, item.end_col)
region = region_factory(a, b)
else:
region = (
item.start_row,
item.start_col,
item.end_row,
item.end_col,
)
grouped.setdefault(item.scope or "region.yellowish", []).append(region)
for scope, regions in grouped.items():
key = "sessions_diagnostics_{}".format(scope.replace(".", "_"))
add_regions(key, regions, scope, "", 0)
def _refresh_local_cache_after_format(
window: object,
context: _WorkspaceContext,
remote_path: str,
) -> None:
mapper = RemoteToLocalCacheMapper(
workspace_cache_key=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
files_cache_root=context.local_cache_root,
)
local_cache_path = mapper.local_path_for_remote_file(remote_path)
host_alias = context.recent_entry.host_alias
def work() -> None:
_begin_interactive_ssh_lane(host_alias)
try:
opened = open_remote_file_into_local_cache(
host_alias,
remote_absolute_path=remote_path,
local_cache_path=local_cache_path,
)
finally:
_end_interactive_ssh_lane(host_alias)
def finish() -> None:
if opened.outcome is OpenOutcome.REMOTE_NOT_FOUND:
_emit_status(
ConnectStatus(
kind="warning",
detail=(
"Remote path {} no longer exists; skipped cache refresh."
).format(remote_path),
)
)
return
if opened.outcome is OpenOutcome.OK and opened.remote_metadata is not None:
_write_remote_metadata_sidecar(local_cache_path, opened.remote_metadata)
_set_timeout(finish, 0)
_run_mirror_in_background(work)
def _probe_host_session(host_alias: str) -> None:
result = run_ssh_remote_command(host_alias, ("true",))
log_ssh_failure_if_debug(result, "host_probe")
if result.returncode == 0:
return
raise SessionHelperStartError(
format_ssh_transport_error(
result,
"Unable to open an SSH session for the selected host.",
)
)
def _ensure_bridge_session(host_alias: str) -> None:
"""Start the persistent bridge session for a host.
Callers must ensure a remote Linux platform tag is cached for ``host_alias``
(see ``_connect_selected_host``) so the helper binary URL can be resolved before
the bridge starts. After the bridge is up, the handshake supplies ``remote_home``
and ``arch`` without extra SSH calls for that path.
"""
from .ssh_file_transport import execute_remote_list_directory
try:
execute_remote_list_directory(
host_alias,
RemoteListDirectoryRequest(remote_directory="/"),
)
except SessionHelperStartError as error:
raise SessionHelperStartError(
"Unable to establish bridge session for {}: {}".format(
host_alias, error.detail
)
) from error
def _determine_remote_linux_platform(
settings: SessionsSettings,
host_alias: str,
) -> Optional[RemoteLinuxPlatformTag]:
cached = _remote_platform_store(settings).get(host_alias)
if cached is not None:
return cached
return _platform_from_handshake(host_alias) or _probe_remote_linux_platform(
host_alias
)
def _platform_from_handshake(
host_alias: str,
) -> Optional[RemoteLinuxPlatformTag]:
"""Derive the platform tag from the bridge handshake ``arch`` field."""
handshake = bridge_handshake_info(host_alias)
if handshake is None:
return None
arch = (handshake.get("arch") or "").lower()
if arch in ("x86_64", "amd64"):
return "linux-x86_64"
if arch in ("aarch64", "arm64"):
return "linux-aarch64"
return None
def _probe_remote_linux_platform(
host_alias: str,
) -> Optional[RemoteLinuxPlatformTag]:
result = run_ssh_remote_command(
host_alias,
("sh", "-lc", "uname -s && uname -m"),
)
log_ssh_failure_if_debug(result, "remote_platform_probe")
if result.returncode != 0:
return None
lines = [
line.strip() for line in (result.stdout or "").splitlines() if line.strip()
]
if len(lines) < 2:
return None
system_name = lines[0].lower()
machine_name = lines[1].lower()
if system_name != "linux":
return None
if machine_name in ("x86_64", "amd64"):
return "linux-x86_64"
if machine_name in ("aarch64", "arm64"):
return "linux-aarch64"
return None
def _remember_remote_platform(
settings: SessionsSettings,
host_alias: str,
platform_tag: RemoteLinuxPlatformTag,
) -> None:
_remote_platform_store(settings).record(host_alias, platform_tag)
def _probe_remote_workspace(host_alias: str, remote_root: str) -> None:
normalized_remote_root = validate_remote_root(remote_root)
try:
metadata = execute_remote_stat_file(
host_alias,
normalized_remote_root,
timeout_s=8.0,
)
except SessionHelperStartError as exc:
raise RemoteRootMissingError(
"Unable to verify the selected remote root."
) from exc
if metadata is None:
raise RemoteRootMissingError("Selected remote root no longer exists.")
if metadata.kind != RemoteFileKind.DIRECTORY:
raise RemoteRootMissingError("Selected remote root is not a directory.")
def _browse_remote_directory(
window: object,
settings: SessionsSettings,
host_alias: str,
remote_directory: str,
) -> None:
try:
normalized_directory = validate_remote_root(remote_directory)
except ConnectPreflightError as error:
_emit_status(ConnectStatus(kind="disconnected", detail=error.detail))
return
def work() -> None:
try:
directory_entries = _list_remote_directory(host_alias, normalized_directory)
except ConnectPreflightError as error:
detail = error.detail
_set_timeout(
lambda: _emit_status(ConnectStatus(kind="disconnected", detail=detail)),
0,
)
return
browse_items = _directory_browse_items(normalized_directory, directory_entries)
_set_timeout(
lambda: window.show_quick_panel(
[_browse_panel_row(item) for item in browse_items],
lambda selected_index: _handle_browse_selection(
window,
settings,
host_alias,
selected_index,
browse_items,
),
),
0,
)
_run_in_background(work)
def _handle_browse_selection(
window: object,
settings: SessionsSettings,
host_alias: str,
selected_index: int,
browse_items: Sequence[_DirectoryBrowseItem],
) -> None:
if selected_index < 0:
return
selected_item = browse_items[selected_index]
if selected_item.action == "open":
_connect_selected_workspace(
window,
settings,
host_alias,
selected_item.remote_path,
)
return
if selected_item.action == "manual":
_open_remote_root_input_panel(window, settings, host_alias)
return
_browse_remote_directory(
window,
settings,
host_alias,
selected_item.remote_path,
)
def _browse_remote_file_for_workspace(
window: object,
context: _WorkspaceContext,
remote_directory: str,
) -> bool:
try:
normalized_directory = validate_remote_root(remote_directory)
except ConnectPreflightError as error:
_emit_status(ConnectStatus(kind="disconnected", detail=error.detail))
return False
def work() -> None:
try:
directory_entries = _list_remote_directory(
context.recent_entry.host_alias,
normalized_directory,
)
except ConnectPreflightError as error:
detail = error.detail
_set_timeout(
lambda: _emit_status(ConnectStatus(kind="disconnected", detail=detail)),
0,
)
return
browse_items = _remote_file_browse_items(
normalized_directory, directory_entries
)
_set_timeout(
lambda: window.show_quick_panel(
[_remote_file_browse_panel_row(item) for item in browse_items],
lambda selected_index: _handle_remote_file_browse_selection(
window,
context,
selected_index,
browse_items,
),
),
0,
)
_run_in_background(work)
return True
def _handle_remote_file_browse_selection(
window: object,
context: _WorkspaceContext,
selected_index: int,
browse_items: Sequence[_RemoteFileBrowseItem],
) -> None:
if selected_index < 0:
return
selected_item = browse_items[selected_index]
if selected_item.action == "manual":
window.show_input_panel(
"Remote file:",
"",
lambda value: _open_remote_file_for_workspace(window, context, value),
None,
None,
)
return
if selected_item.action == "browse":
_browse_remote_file_for_workspace(window, context, selected_item.remote_path)
return
_open_remote_file_for_workspace(window, context, selected_item.remote_path)
def _remote_file_browse_items(
current_directory: str,
entries: Sequence[RemoteDirectoryEntry],
) -> Tuple[_RemoteFileBrowseItem, ...]:
visible_entries = evaluate_directory_entries(tuple(entries)).visible_entries
directory_entries = tuple(
entry for entry in visible_entries if entry.kind is RemoteFileKind.DIRECTORY
)
file_entries = tuple(
entry for entry in visible_entries if entry.kind is RemoteFileKind.REGULAR_FILE
)
items = [
_RemoteFileBrowseItem(
trigger="Enter remote file path...",
details="Type a file path manually",
action="manual",
remote_path=current_directory,
)
]
parent_directory = _parent_remote_directory(current_directory)
if parent_directory is not None:
items.append(
_RemoteFileBrowseItem(
trigger="../",
details="Browse parent folder {}".format(parent_directory),
action="browse",
remote_path=parent_directory,
)
)
items.extend(
_RemoteFileBrowseItem(
trigger="{}/".format(entry.name.rstrip("/")),
details=entry.remote_absolute_path,
action="browse",
remote_path=entry.remote_absolute_path,
)
for entry in directory_entries
)
items.extend(
_RemoteFileBrowseItem(
trigger=entry.name,
details=entry.remote_absolute_path,
action="open",
remote_path=entry.remote_absolute_path,
)
for entry in file_entries
)
return tuple(items)
def _remote_file_browse_panel_row(item: _RemoteFileBrowseItem) -> list[str]:
return [item.trigger, item.details]
def _focus_window_group(window: object, group: int) -> None:
"""Move input focus to ``group`` when the window API exposes ``focus_group``."""
focus_fn = getattr(window, "focus_group", None)
if callable(focus_fn):
focus_fn(group)
def _apply_remote_directory_explorer_layout(window: object) -> bool:
run_command = getattr(window, "run_command", None)
if not callable(run_command):
return False
try:
run_command("set_layout", _REMOTE_DIRECTORY_EXPLORER_LAYOUT)
except Exception:
return False
_focus_window_group(window, 0)
return True
def _ensure_remote_tree_in_explorer_column(window: object, view: object) -> None:
"""Move the tree scratch view into group 0 for split explorer layouts."""
get_idx = getattr(window, "get_view_index", None)
set_idx = getattr(window, "set_view_index", None)
focus_view = getattr(window, "focus_view", None)
if not callable(get_idx) or not callable(set_idx):
return
group, index = get_idx(view)
_ = index
if group != 0:
set_idx(view, 0, 0)
_focus_window_group(window, 0)
if callable(focus_view):
focus_view(view)
def _iter_window_views(window: object) -> List[object]:
views_fn = getattr(window, "views", None)
if callable(views_fn):
raw = views_fn()
return list(raw) if raw is not None else []
return []
def _find_remote_tree_view_for_workspace(
window: object,
context: _WorkspaceContext,
) -> Optional[object]:
"""Return an existing remote tree scratch view for this workspace, if any."""
for candidate in _iter_window_views(window):
if not _is_remote_tree_view(candidate):
continue
key = _view_setting(candidate, "sessions_remote_tree_workspace_key")
if key == context.cache_key:
return candidate
return None
def _close_open_remote_file_for_tree_row(
window: object,
context: _WorkspaceContext,
view: object,
*,
row: int,
) -> None:
entry = _remote_tree_entry_for_view_row(view, row=row)
if entry is None:
_status_message("Select a remote tree entry first.")
return
if entry.action != "open":
_status_message("Select a remote file in the tree to close its editor tab.")
return
remote_text = (entry.remote_path or "").strip()
if not remote_text:
return
mapper = RemoteToLocalCacheMapper(
workspace_cache_key=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
files_cache_root=context.local_cache_root,
)
try:
normalized_remote_file = _resolve_workspace_remote_file(
context.recent_entry.remote_root,
remote_text,
)
local_cache_path = mapper.local_path_for_remote_file(normalized_remote_file)
except (ConnectPreflightError, RemotePathMappingError):
_status_message("Could not map the selected tree file to the local cache.")
return
target = str(local_cache_path.resolve())
closed = False
for candidate in _iter_window_views(window):
file_name = _view_file_name(candidate)
if not file_name:
continue
try:
if str(Path(file_name).resolve()) != target:
continue
except OSError:
if file_name != str(local_cache_path):
continue
close_fn = getattr(candidate, "close", None)
if callable(close_fn):
close_fn()
closed = True
if not closed:
_status_message("That remote file is not open in this window.")
def _close_active_remote_cache_view(
window: object,
view: object,
context: _WorkspaceContext,
) -> bool:
file_name = _view_file_name(view)
if not file_name:
return False
mapper = RemoteToLocalCacheMapper(
workspace_cache_key=context.cache_key,
remote_workspace_root=context.recent_entry.remote_root,
files_cache_root=context.local_cache_root,
)
remote = mapper.remote_path_for_local_cache_file(Path(file_name))
if remote is None:
return False
close_fn = getattr(view, "close", None)
if callable(close_fn):
close_fn()
return True
return False
def _open_remote_tree_for_workspace(
window: object,
context: _WorkspaceContext,
remote_directory: str,
*,
emit_ready_status: bool = True,
editor_target_group: Optional[int] = None,
) -> None:
try:
normalized_directory = validate_remote_root(remote_directory)
directory_entries = _list_remote_directory(
context.recent_entry.host_alias,
normalized_directory,
)
except ConnectPreflightError as error:
_emit_status(ConnectStatus(kind="disconnected", detail=error.detail))
return
entries = _remote_tree_entries(normalized_directory, directory_entries)
if editor_target_group is not None:
_focus_window_group(window, 0)
view = _ensure_remote_tree_view(
window,
context,
explorer_tree_group=0 if editor_target_group is not None else None,
)
_render_remote_tree_view(
view,
context,
current_directory=normalized_directory,
entries=entries,
editor_target_group=editor_target_group,
)
if editor_target_group is not None:
_ensure_remote_tree_in_explorer_column(window, view)
if emit_ready_status:
_emit_status(
ConnectStatus(
kind="ready",
detail="Remote tree ready for {}. Press Enter to open.".format(
normalized_directory
),
)
)
def _remote_tree_entries(
current_directory: str,
entries: Sequence[RemoteDirectoryEntry],
) -> Tuple[_RemoteTreeEntry, ...]:
visible_entries = evaluate_directory_entries(tuple(entries)).visible_entries
directory_entries = tuple(
entry for entry in visible_entries if entry.kind is RemoteFileKind.DIRECTORY
)
file_entries = tuple(
entry for entry in visible_entries if entry.kind is RemoteFileKind.REGULAR_FILE
)
items = []
parent_directory = _parent_remote_directory(current_directory)
if parent_directory is not None:
items.append(
_RemoteTreeEntry(
label="../",
action="browse",
remote_path=parent_directory,
)
)
items.extend(
_RemoteTreeEntry(
label="{}/".format(entry.name.rstrip("/")),
action="browse",
remote_path=entry.remote_absolute_path,
)
for entry in directory_entries
)
items.extend(
_RemoteTreeEntry(
label=entry.name,
action="open",
remote_path=entry.remote_absolute_path,
)
for entry in file_entries
)
return tuple(items)
def _ensure_remote_tree_view(
window: object,
context: _WorkspaceContext,
*,
explorer_tree_group: Optional[int] = None,
) -> object:
existing = _find_remote_tree_view_for_workspace(window, context)
if existing is not None:
return existing
if explorer_tree_group is not None:
_focus_window_group(window, explorer_tree_group)
new_file = getattr(window, "new_file", None)
if callable(new_file):
created = new_file()
if created is not None:
return created
raise RuntimeError("Window cannot create a remote tree view.")
def _render_remote_tree_view(
view: object,
context: _WorkspaceContext,
*,
current_directory: str,
entries: Sequence[_RemoteTreeEntry],
editor_target_group: Optional[int] = None,
) -> None:
set_name = getattr(view, "set_name", None)
if callable(set_name):
set_name("Sessions Remote Tree")
set_scratch = getattr(view, "set_scratch", None)
if callable(set_scratch):
set_scratch(True)
set_read_only = getattr(view, "set_read_only", None)
if callable(set_read_only):
set_read_only(False)
_set_view_text(
view,
_remote_tree_text(
host_alias=context.recent_entry.host_alias,
remote_root=context.recent_entry.remote_root,
current_directory=current_directory,
entries=entries,
),
)
_set_view_setting(view, "sessions_remote_tree", True)
_set_view_setting(view, "sessions_remote_tree_workspace_key", context.cache_key)
_set_view_setting(view, "sessions_remote_tree_directory", current_directory)
_set_view_setting(
view,
"sessions_remote_tree_entries",
_remote_tree_entry_payloads(entries),
)
_set_view_setting(view, "sessions_remote_tree_start_row", 5)
if editor_target_group is not None:
_set_view_setting(
view,
"sessions_remote_tree_editor_group",
editor_target_group,
)
else:
_erase_view_setting(view, "sessions_remote_tree_editor_group")
if callable(set_read_only):
set_read_only(True)
def _remote_tree_text(
*,
host_alias: str,
remote_root: str,
current_directory: str,
entries: Sequence[_RemoteTreeEntry],
) -> str:
lines = [
"Sessions Remote Tree",
"Host: {}".format(host_alias),
"Workspace: {}".format(remote_root),
"Directory: {}".format(current_directory),
"",
]
if entries:
lines.extend(entry.label for entry in entries)
else:
lines.append("(empty directory)")
lines.append("")
lines.append("Press Enter on a row to open a file or browse a directory.")
return "\n".join(lines) + "\n"
def _remote_tree_entry_payloads(entries: Sequence[_RemoteTreeEntry]) -> list[dict]:
return [
{
"label": entry.label,
"action": entry.action,
"remote_path": entry.remote_path,
}
for entry in entries
]
def _is_remote_tree_view(view: object) -> bool:
return bool(_view_setting(view, "sessions_remote_tree"))
def _remote_tree_directory(view: object) -> Optional[str]:
directory = _view_setting(view, "sessions_remote_tree_directory")
return directory if isinstance(directory, str) and directory else None
def _remote_tree_editor_group(view: object) -> Optional[int]:
group = _view_setting(view, "sessions_remote_tree_editor_group")
return group if isinstance(group, int) else None
def _remote_tree_entry_for_view_row(
view: object,
*,
row: int,
) -> Optional[_RemoteTreeEntry]:
selected_row = row if row >= 0 else _selected_row_for_view(view)
start_row = _view_setting(view, "sessions_remote_tree_start_row")
payload = _view_setting(view, "sessions_remote_tree_entries")
if not isinstance(start_row, int) or not isinstance(payload, list):
return None
index = selected_row - start_row
if index < 0 or index >= len(payload):
return None
raw = payload[index]
if not isinstance(raw, dict):
return None
action = raw.get("action")
remote_path = raw.get("remote_path")
label = raw.get("label")
if not isinstance(action, str) or not isinstance(remote_path, str):
return None
return _RemoteTreeEntry(
label=label if isinstance(label, str) else remote_path,
action=action,
remote_path=remote_path,
)
def _open_selected_remote_tree_entry(
window: object,
context: _WorkspaceContext,
view: object,
*,
row: int,
) -> None:
entry = _remote_tree_entry_for_view_row(view, row=row)
if entry is None:
_status_message("Select a remote tree entry first.")
return
editor_target_group = _remote_tree_editor_group(view)
if entry.action == "browse":
_open_remote_tree_for_workspace(
window,
context,
entry.remote_path,
editor_target_group=editor_target_group,
)
return
_open_remote_file_for_workspace(
window,
context,
entry.remote_path,
editor_group=editor_target_group,
)
def _selected_row_for_view(view: object) -> int:
selected_row = getattr(view, "selected_row", None)
if callable(selected_row):
value = selected_row()
if isinstance(value, int):
return value
sel = getattr(view, "sel", None)
rowcol = getattr(view, "rowcol", None)
if callable(sel) and callable(rowcol):
selection = sel()
if selection:
first = selection[0]
begin = getattr(first, "begin", None)
point = begin() if callable(begin) else first[0]
row, _ = rowcol(point)
return int(row)
return 0
def _set_view_text(view: object, text: str) -> None:
set_content = getattr(view, "set_content", None)
if callable(set_content):
set_content(text)
return
run_command = getattr(view, "run_command", None)
if callable(run_command):
run_command("select_all")
run_command("left_delete")
run_command("append", {"characters": text})
def _set_view_setting(view: object, key: str, value: object) -> None:
settings = getattr(view, "settings", None)
if not callable(settings):
return
view_settings = settings()
setter = getattr(view_settings, "set", None)
if callable(setter):
setter(key, value)
def _erase_view_setting(view: object, key: str) -> None:
settings = getattr(view, "settings", None)
if not callable(settings):
return
view_settings = settings()
eraser = getattr(view_settings, "erase", None)
if callable(eraser):
eraser(key)
return
setter = getattr(view_settings, "set", None)
if callable(setter):
setter(key, None)
def _view_setting(view: object, key: str) -> object:
settings = getattr(view, "settings", None)
if not callable(settings):
return None
view_settings = settings()
getter = getattr(view_settings, "get", None)
if callable(getter):
return getter(key)
values = getattr(view_settings, "values", None)
if isinstance(values, dict):
return values.get(key)
return None
def _view_window(view: object) -> Optional[object]:
window = getattr(view, "window", None)
if callable(window):
return window()
return None
def _directory_browse_items(
current_directory: str,
entries: Sequence[RemoteDirectoryEntry],
) -> Tuple[_DirectoryBrowseItem, ...]:
visible_entries = evaluate_directory_entries(tuple(entries)).visible_entries
directory_entries = tuple(
entry for entry in visible_entries if entry.kind is RemoteFileKind.DIRECTORY
)
items = [
_DirectoryBrowseItem(
trigger="Open {}".format(current_directory),
details="Use this folder as the Sessions workspace root",
action="open",
remote_path=current_directory,
)
]
items.append(
_DirectoryBrowseItem(
trigger="Enter remote root...",
details="Type a workspace path manually",
action="manual",
remote_path=current_directory,
)
)
parent_directory = _parent_remote_directory(current_directory)
if parent_directory is not None:
items.append(
_DirectoryBrowseItem(
trigger="../",
details="Browse parent folder {}".format(parent_directory),
action="browse",
remote_path=parent_directory,
)
)
items.extend(
_DirectoryBrowseItem(
trigger="{}/".format(entry.name.rstrip("/")),
details=entry.remote_absolute_path,
action="browse",
remote_path=entry.remote_absolute_path,
)
for entry in directory_entries
)
return tuple(items)
def _browse_panel_row(item: _DirectoryBrowseItem) -> list[str]:
return [item.trigger, item.details]
def _parent_remote_directory(remote_directory: str) -> Optional[str]:
normalized = validate_remote_root(remote_directory)
if normalized == "/":
return None
parent = str(PurePosixPath(normalized).parent)
return parent if parent.startswith("/") else "/"
def _list_remote_directory(
host_alias: str,
remote_directory: str,
) -> Tuple[RemoteDirectoryEntry, ...]:
normalized_directory = validate_remote_root(remote_directory)
listed = execute_remote_list_directory(
host_alias,
RemoteListDirectoryRequest(remote_directory=normalized_directory),
)
return listed.entries
def _default_remote_workspace_directory(host_alias: str) -> str:
handshake = bridge_handshake_info(host_alias)
if handshake is not None:
remote_home = handshake.get("remote_home")
if isinstance(remote_home, str) and remote_home.startswith("/"):
return validate_remote_root(remote_home)
result = run_ssh_remote_command(host_alias, ("sh", "-lc", 'printf %s "$HOME"'))
log_ssh_failure_if_debug(result, "remote_home")
if result.returncode != 0:
raise SessionHelperStartError(
format_ssh_transport_error(
result,
"Unable to determine the remote home directory.",
)
)
home_directory = result.stdout.strip() or "/"
return validate_remote_root(home_directory)
def _remote_path_is_directory(host_alias: str, remote_path: str) -> bool:
"""Return True when the bridge reports that ``remote_path`` is a directory."""
try:
normalized_path = validate_remote_root(remote_path)
except ConnectPreflightError:
return False
try:
metadata = execute_remote_stat_file(
host_alias,
normalized_path,
timeout_s=5.0,
)
except SessionHelperStartError:
return False
if metadata is None:
return False
return metadata.kind == RemoteFileKind.DIRECTORY
def _starting_directory_for_open_remote_folder(
settings: SessionsSettings,
host_alias: str,
) -> str:
"""Always start remote-folder browsing from the remote HOME directory."""
_ = settings
return _default_remote_workspace_directory(host_alias)
def _open_remote_root_input_panel(
window: object,
settings: SessionsSettings,
host_alias: str,
) -> None:
window.show_input_panel(
"Remote root:",
"",
lambda remote_root: _connect_selected_workspace(
window,
settings,
host_alias,
remote_root,
),
None,
None,
)
def _connect_auto_open_remote_folder() -> bool:
"""Return whether connect should open the folder picker automatically."""
load_settings = getattr(sublime, "load_settings", None)
if not callable(load_settings):
return True
stored = load_settings("Sessions.sublime-settings")
getter = getattr(stored, "get", None)
if not callable(getter):
return True
return sync_mode_bool(getter, "sessions_connect_auto_open_remote_folder", True)
def _sessions_reset_workspace_ui_state_on_open() -> bool:
load_settings = getattr(sublime, "load_settings", None)
if not callable(load_settings):
return True
stored = load_settings("Sessions.sublime-settings")
getter = getattr(stored, "get", None)
if not callable(getter):
return True
return bool(getter("sessions_reset_workspace_ui_state_on_open", True))
def _bridge_window_add_ref(window: object, host_alias: str) -> None:
"""Record that ``window`` still uses the persistent bridge for ``host_alias``."""
if not host_alias:
return
_BRIDGE_HOST_WINDOW_IDS.setdefault(host_alias, set()).add(_window_identity(window))
def _bridge_window_drop_ref(window: object, host_alias: str) -> None:
"""Drop one window's bridge use; kill the bridge when no windows remain."""
if not host_alias:
return
window_key = _window_identity(window)
bucket = _BRIDGE_HOST_WINDOW_IDS.get(host_alias)
if not bucket:
return
bucket.discard(window_key)
if not bucket:
_BRIDGE_HOST_WINDOW_IDS.pop(host_alias, None)
reset_bridge_for_host(host_alias)
def _bridge_release_refs_for_closing_window(window: object) -> None:
"""Release bridge ownership for a Sublime window that is being destroyed."""
hosts: Set[str] = set()
connected = _connected_host_alias(window)
if connected:
hosts.add(connected)
try:
settings = SessionsSettings()
ctx = _workspace_context(window, settings, missing_detail_message=False)
if ctx is not None:
hosts.add(ctx.recent_entry.host_alias)
except (ConnectPreflightError, TypeError, AttributeError, ValueError, OSError):
pass
for host_alias in hosts:
_bridge_window_drop_ref(window, host_alias)
def sessions_plugin_shutdown() -> None:
"""Tear down transports when the package unloads or Sublime exits."""
_BRIDGE_HOST_WINDOW_IDS.clear()
clear_bridge_handshake_listeners()
shutdown_all_persistent_bridges()
_marimo_session_manager().stop_all()
def _open_connected_host_window(
source_window: object,
settings: SessionsSettings,
host_alias: str,
) -> None:
"""Open a fresh window for host-attached state and prompt folder selection."""
run_command = getattr(source_window, "run_command", None)
def finish() -> None:
target = source_window
windows = _open_windows()
if windows:
target = windows[-1]
# Sublime's ``new_window`` command spawns the window without
# always claiming OS-level z-order — on macOS especially the
# source window keeps focus and the fresh window opens
# *behind* it, so the user sees no visible change after
# connecting and the Open-Remote-Folder quick panel surfaces
# behind their other Sublime window. Calling
# ``bring_to_front`` (and re-focusing the active view) is the
# same dance ``_focus_existing_workspace_window`` runs when
# we route to an already-open workspace; it has to fire on
# the new window too. Safe on Sublime builds without the API
# because the helper guards on attribute presence.
_focus_existing_workspace_window(target)
_remember_connected_host(target, host_alias)
auto_open = _connect_auto_open_remote_folder()
if auto_open:
_emit_status(
ConnectStatus(
kind="ready",
detail=(
"Connected to SSH host {}. Opening Open Remote Folder…"
).format(host_alias),
)
)
target_run = getattr(target, "run_command", None)
if callable(target_run):
target_run("sessions_open_remote_folder", {})
return
_emit_status(
ConnectStatus(
kind="ready",
detail=(
"Connected to SSH host {}. "
"Run Open Remote Folder to choose a workspace root."
).format(host_alias),
)
)
if callable(run_command):
run_command("new_window", {})
# Two-phase delay: the first 0 ms tick lets ``new_window`` finish
# creating the window so ``_open_windows()[-1]`` resolves to the
# right target; the second 50 ms tick gives the OS WM time to
# accept ``bring_to_front`` (on macOS, calling it within the same
# event-loop turn as ``new_window`` is racy and the window stays
# behind). Empirically 50 ms is enough on Sonoma + Sublime 4192.
_set_timeout(finish, 50)
return
finish()
def _remember_connected_host(window: object, host_alias: str) -> None:
window_key = _window_identity(window)
previous = _CONNECTED_HOSTS_BY_WINDOW_ID.get(window_key)
if previous and previous != host_alias:
_bridge_window_drop_ref(window, previous)
_CONNECTED_HOSTS_BY_WINDOW_ID[window_key] = host_alias
persisted = _load_connected_host_state()
persisted[str(window_key)] = host_alias
_write_connected_host_state(persisted)
_bridge_window_add_ref(window, host_alias)
_auto_reconnect_register(window, host_alias)
def _connected_host_alias(window: object) -> Optional[str]:
window_key = _window_identity(window)
in_memory = _CONNECTED_HOSTS_BY_WINDOW_ID.get(window_key)
if in_memory:
return in_memory
persisted = _load_connected_host_state().get(str(window_key))
if isinstance(persisted, str) and persisted:
_CONNECTED_HOSTS_BY_WINDOW_ID[window_key] = persisted
return persisted
return None
def _forget_connected_host(window: object) -> None:
window_key = _window_identity(window)
_CONNECTED_HOSTS_BY_WINDOW_ID.pop(window_key, None)
persisted = _load_connected_host_state()
if str(window_key) in persisted:
persisted.pop(str(window_key), None)
_write_connected_host_state(persisted)
_auto_reconnect_unregister(window)
# ---------------------------------------------------------------------------
# Auto-reconnect manager (Cluster D2 / 2026-04-26 follow-up to v0.6.11)
# ---------------------------------------------------------------------------
#
# Goal: when a workspace was explicitly connected at least once and then the
# bridge / helper dies mid-session (network blip, remote helper killed,
# Rust collector_error), revive the connection automatically with
# exponential backoff. Cold starts (Sublime restart) STILL stay silent —
# v0.6.11's contract that a fresh window does not auto-spawn until the
# user runs ``Sessions: Reconnect Current Workspace`` is preserved.
#
# Trigger: subscribe to the transport-trace stream and look for the three
# Rust-side disconnect events. ``bridge.session_reset`` is excluded
# because it fires when *we* call ``reset_bridge_for_host`` during our
# own reconnect; including it would loop. Pending hosts are deduplicated
# so back-to-back collector_error spam (Rust can fire it more than once
# per death) collapses to one scheduled retry.
#
# Backoff: 1s, 2s, 5s, 10s, 30s, then 30s cap. Reset on a successful
# handshake. Give up entirely after ``_AUTO_RECONNECT_MAX_ATTEMPTS``
# attempts so a permanent SSH-config break doesn't loop forever.
_AUTO_RECONNECT_HOSTS_BY_WINDOW_ID: Dict[int, str] = {}
_AUTO_RECONNECT_PENDING_BY_HOST: Set[str] = set()
_AUTO_RECONNECT_ATTEMPTS_BY_HOST: Dict[str, int] = {}
_AUTO_RECONNECT_BACKOFF_S: Tuple[float, ...] = (1.0, 2.0, 5.0, 10.0, 30.0)
_AUTO_RECONNECT_MAX_ATTEMPTS = 12 # ~5 + 7×30s ≈ 3.5 min of retries
_AUTO_RECONNECT_DISCONNECT_EVENTS = frozenset(
{
# Python-side signal, emitted by ``ssh_file_transport`` when the
# Rust broker reports BROKEN_PIPE / SESSION_MISSING on the next
# request after a dead helper. This carries ``host_alias`` and is
# the actual cross-process event Python sees — the
# ``bridge.rust.*`` events the helper writes go to a separate
# diag log file (gated by ``SESSIONS_BRIDGE_DIAG_LOG``) and never
# reach the transport-trace listener stream, so subscribing to
# them here was a no-op (regression: v0.6.12 reconnect listener
# never fired in the helper-kill repro because the only Rust
# event reaching Python was the request's Broken-pipe error,
# surfaced as ``bridge.request_broken_pipe``).
"bridge.request_broken_pipe",
}
)
_AUTO_RECONNECT_LISTENER_INSTALLED = False
def _auto_reconnect_register(window: object, host_alias: str) -> None:
"""Mark ``window``+``host`` as eligible for auto-reconnect on disconnect."""
if not host_alias:
return
_AUTO_RECONNECT_HOSTS_BY_WINDOW_ID[_window_identity(window)] = host_alias
_AUTO_RECONNECT_ATTEMPTS_BY_HOST.pop(host_alias, None)
def _auto_reconnect_unregister(window: object) -> None:
"""Stop auto-reconnect tracking for a window (explicit disconnect / close)."""
host = _AUTO_RECONNECT_HOSTS_BY_WINDOW_ID.pop(_window_identity(window), None)
if host is None:
return
if host not in _AUTO_RECONNECT_HOSTS_BY_WINDOW_ID.values():
# No other window is keeping this host alive — drop accumulated state
# so the next explicit connect starts from a clean slate.
_AUTO_RECONNECT_PENDING_BY_HOST.discard(host)
_AUTO_RECONNECT_ATTEMPTS_BY_HOST.pop(host, None)
def _auto_reconnect_window_for_host(host_alias: str) -> Optional[object]:
"""Return one Sublime window currently registered for ``host_alias``."""
target_keys = [
win_id
for win_id, host in _AUTO_RECONNECT_HOSTS_BY_WINDOW_ID.items()
if host == host_alias
]
if not target_keys:
return None
windows_fn = getattr(sublime, "windows", None)
if not callable(windows_fn):
return None
try:
live_windows = list(windows_fn())
except Exception: # noqa: BLE001 — defensive at API edges
return None
for window in live_windows:
if _window_identity(window) in target_keys:
return window
return None
def _auto_reconnect_on_transport_trace(
event: str, fields: Mapping[str, object]
) -> None:
"""Listener bound to ssh_file_transport's trace stream."""
if event not in _AUTO_RECONNECT_DISCONNECT_EVENTS:
return
host_alias = str(fields.get("host_alias") or "").strip()
if not host_alias:
return
if host_alias not in _AUTO_RECONNECT_HOSTS_BY_WINDOW_ID.values():
return
if host_alias in _AUTO_RECONNECT_PENDING_BY_HOST:
return
_schedule_auto_reconnect(host_alias)
def _schedule_auto_reconnect(host_alias: str) -> None:
"""Queue the next auto-reconnect attempt for ``host_alias`` with backoff."""
attempt = _AUTO_RECONNECT_ATTEMPTS_BY_HOST.get(host_alias, 0) + 1
if attempt > _AUTO_RECONNECT_MAX_ATTEMPTS:
_trace_event(
"auto_reconnect.gave_up",
host_alias=host_alias,
attempts=attempt - 1,
)
# Leave attempts at the cap so the next explicit reconnect resets
# via ``_auto_reconnect_register``; meanwhile pending stays clear.
_AUTO_RECONNECT_PENDING_BY_HOST.discard(host_alias)
return
_AUTO_RECONNECT_ATTEMPTS_BY_HOST[host_alias] = attempt
_AUTO_RECONNECT_PENDING_BY_HOST.add(host_alias)
delay_s = _AUTO_RECONNECT_BACKOFF_S[
min(attempt - 1, len(_AUTO_RECONNECT_BACKOFF_S) - 1)
]
_trace_event(
"auto_reconnect.scheduled",
host_alias=host_alias,
attempt=attempt,
delay_s=delay_s,
)
_set_timeout(
lambda: _fire_auto_reconnect(host_alias, attempt),
int(delay_s * 1000),
)
def _fire_auto_reconnect(host_alias: str, attempt: int) -> None:
"""Body of one scheduled reconnect attempt."""
_AUTO_RECONNECT_PENDING_BY_HOST.discard(host_alias)
if host_alias not in _AUTO_RECONNECT_HOSTS_BY_WINDOW_ID.values():
# User explicitly disconnected during the wait — stand down.
_AUTO_RECONNECT_ATTEMPTS_BY_HOST.pop(host_alias, None)
return
if bridge_session_is_active(host_alias):
# Bridge came back on its own (e.g. user clicked Reconnect during
# the backoff). Treat the slot as resolved.
_AUTO_RECONNECT_ATTEMPTS_BY_HOST.pop(host_alias, None)
return
window = _auto_reconnect_window_for_host(host_alias)
if window is None:
# No live window we can drive the reconnect through — wait for the
# next disconnect event (which will retrigger) or give up via the
# max-attempts gate above.
return
settings = SessionsSettings()
workspace_key = _current_workspace_key(window.project_data())
if workspace_key is None:
return
recent_store = _recent_store(settings)
recent_entry = _recent_entry_for_cache_key(
recent_store.load_index().entries,
workspace_key,
)
if recent_entry is None or recent_entry.host_alias != host_alias:
return
_trace_event(
"auto_reconnect.fire",
host_alias=host_alias,
attempt=attempt,
)
_emit_status(
ConnectStatus(
kind="warning",
detail=(
"Sessions: lost connection to {} — auto-reconnecting (attempt {})…"
).format(host_alias, attempt),
)
)
threading.Thread(
target=_reconnect_workspace_async,
args=(window, settings, recent_entry),
daemon=True,
).start()
def _auto_reconnect_on_handshake_ready(host_alias: str) -> None:
"""Reset backoff on a successful handshake (manual or auto driven)."""
_AUTO_RECONNECT_ATTEMPTS_BY_HOST.pop(host_alias, None)
_AUTO_RECONNECT_PENDING_BY_HOST.discard(host_alias)
def _install_auto_reconnect_listeners_once() -> None:
"""Idempotent setup: bind listeners exactly once per plugin load."""
global _AUTO_RECONNECT_LISTENER_INSTALLED
if _AUTO_RECONNECT_LISTENER_INSTALLED:
return
register_transport_trace_listener(_auto_reconnect_on_transport_trace)
register_bridge_handshake_listener(_auto_reconnect_on_handshake_ready)
_AUTO_RECONNECT_LISTENER_INSTALLED = True
def _open_materialized_workspace(window: object, project_file_path: Path) -> None:
if _sessions_reset_workspace_ui_state_on_open():
workspace_path = project_file_path.with_suffix(".sublime-workspace")
try:
if workspace_path.is_file():
workspace_path.unlink()
except OSError:
pass
project_path = str(project_file_path)
# Window-reuse path: when ``window`` already holds a Sessions
# workspace, swap the project_data in place instead of letting
# ``open_project_or_workspace`` spawn a new window. The on-disk
# ``.sublime-project`` was just materialised by the connect flow,
# so we read its content and apply it to the existing window —
# the sidebar folder, settings.LSP, and ``settings`` blocks all
# update without losing the user's tab layout. Bridge ownership
# for the previous host is dropped here so a same-window
# swap to a different host doesn't leak the old persistent
# bridge — ``_remember_connected_host`` re-adds the new one
# after this returns.
if _try_reuse_window_for_workspace(window, project_file_path):
return
run_command = getattr(window, "run_command", None)
if callable(run_command):
run_command("open_project_or_workspace", {"file": project_path})
return
open_file = getattr(window, "open_file", None)
if callable(open_file):
open_file(project_path)
def _try_reuse_window_for_workspace(window: object, project_file_path: Path) -> bool:
"""Swap an existing Sessions window to ``project_file_path`` in place.
The swap is restricted to the **same** workspace (same
``sessions_workspace_key``) — i.e. the
``Sessions: Reconnect Current Workspace`` path. Switching to a
DIFFERENT workspace falls through to ``open_project_or_workspace``
so Sublime spawns a new window with a clean sidebar; otherwise the
old workspace's sidebar folder lingers next to the new one (the
v0.6.12 test pass reproduced this with three accumulated sidebar
entries after switching between two remote folders on the same
host).
Returns True iff the swap was applied; the caller must then skip the
standard ``open_project_or_workspace`` call. Returns False (caller
falls back to opening a new window) when:
* The window object lacks the project_data setters/getters Sessions
relies on (defensive — never seen in real Sublime).
* The window currently has no project (a brand-new empty window) —
the regular ``open_project_or_workspace`` lands cleanly there
already, no need for the in-place swap dance.
* The window holds a DIFFERENT Sessions workspace than the one
we're materializing (see above — opening a new window is the
cleaner UX).
* The on-disk project file can't be read or parsed.
"""
project_data_fn = getattr(window, "project_data", None)
set_project_fn = getattr(window, "set_project_data", None)
if not callable(project_data_fn) or not callable(set_project_fn):
return False
try:
current = project_data_fn()
except (TypeError, RuntimeError):
return False
if not isinstance(current, dict):
return False
settings_block = current.get("settings")
if not isinstance(settings_block, dict):
return False
current_workspace_key = settings_block.get(PROJECT_SETTINGS_KEY)
if not isinstance(current_workspace_key, str) or not current_workspace_key:
return False
try:
new_data = json.loads(project_file_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return False
if not isinstance(new_data, dict):
return False
new_settings = new_data.get("settings")
new_workspace_key = (
new_settings.get(PROJECT_SETTINGS_KEY)
if isinstance(new_settings, dict)
else None
)
if new_workspace_key != current_workspace_key:
# Switching workspaces — let Sublime spawn a fresh window so the
# sidebar starts empty and there's no ambiguity over which
# folder is "the workspace root".
return False
# Same workspace, re-applying. Drop the persistent bridge ref the
# previous run held so a stale bridge process doesn't linger;
# ``_remember_connected_host`` will re-add a ref for the new host
# once the connect flow continues.
previous_host = _connected_host_alias(window)
if previous_host:
_bridge_window_drop_ref(window, previous_host)
set_project_fn(new_data)
_coerce_sidebar_after_project_merge(window)
return True
def _open_local_cache_file(
window: object,
file_path: Path,
*,
editor_group: Optional[int] = None,
) -> None:
file_name = str(file_path)
run_command = getattr(window, "run_command", None)
if callable(run_command):
args: Dict[str, object] = {"file": file_name}
if editor_group is not None:
args["group"] = editor_group
run_command("open_file", args)
return
open_file = getattr(window, "open_file", None)
if callable(open_file):
if editor_group is not None:
try:
open_file(file_name, group=editor_group)
except TypeError:
open_file(file_name)
else:
open_file(file_name)
def _workspace_window_for_key(
current_window: object,
cache_key: str,
) -> Optional[object]:
for window in _open_windows():
if _is_same_window(window, current_window):
continue
if _current_workspace_key(window.project_data()) == cache_key:
return window
return None
def _focus_existing_workspace_window(window: object) -> None:
bring_to_front = getattr(window, "bring_to_front", None)
if callable(bring_to_front):
try:
bring_to_front()
except Exception:
pass
active_view = getattr(window, "active_view", None)
focus_view = getattr(window, "focus_view", None)
if callable(active_view) and callable(focus_view):
try:
current = active_view()
if current is not None:
focus_view(current)
except Exception:
pass
def _open_windows() -> Sequence[object]:
windows = getattr(sublime, "windows", None)
if not callable(windows):
return ()
return windows()
def _is_same_window(left: object, right: object) -> bool:
left_id = _window_id(left)
right_id = _window_id(right)
if left_id is not None and right_id is not None:
return left_id == right_id
return left is right
def _window_id(window: object) -> Optional[int]:
candidate = getattr(window, "id", None)
if not callable(candidate):
return None
window_id = candidate()
return window_id if isinstance(window_id, int) else None
def _window_identity(window: object) -> int:
window_id = _window_id(window)
return window_id if window_id is not None else id(window)
def _connected_host_state_path() -> Path:
local_paths = default_local_paths(sublime.cache_path(), SessionsSettings())
return local_paths.state_root / "local" / "connected-hosts.json"
def _load_connected_host_state() -> Dict[str, str]:
path = _connected_host_state_path()
if not path.is_file():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(payload, dict):
return {}
return {
str(key): value
for key, value in payload.items()
if isinstance(key, str) and isinstance(value, str) and value
}
def _write_connected_host_state(mapping: Dict[str, str]) -> None:
path = _connected_host_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(mapping, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
class SessionsOpenRemoteTerminalCommand(sublime_plugin.WindowCommand):
"""Open a Terminus pane SSH'd into the workspace's remote root.
Scope is intentionally narrow: a fresh ad-hoc shell for short, simple
commands (``ls``, ``git status``, running a script). No view-reuse cache,
no session multiplexing. ``auto_close=False`` so the pane survives
an unexpected shell exit — without it a flash-close hides whatever
error the shell printed on its way out, which is the only signal the
user has to diagnose dotfile breakage or remote-root vanish. For
long-running workflows the user is expected to open
their own external terminal.
"""
def run(self) -> None:
"""Open a Terminus pane attached to the workspace's remote root."""
settings = SessionsSettings()
context = _workspace_context(self.window, settings)
if context is None:
return
host_alias = context.recent_entry.host_alias
remote_root = context.recent_entry.remote_root
run_command = getattr(self.window, "run_command", None)
if not callable(run_command):
_status_message("No terminal command is available in this Sublime build.")
return
find_resources = getattr(sublime, "find_resources", None)
has_terminus = False
if callable(find_resources):
try:
has_terminus = bool(find_resources("Terminus.sublime-settings"))
except (TypeError, ValueError, RuntimeError):
has_terminus = False
if not has_terminus:
_status_message(
"Sessions: install the Terminus package to use Open Remote Terminal."
)
return
# ``exec </dev/tty >/dev/tty 2>/dev/tty`` first: pin the shell's
# three standard fds to the SSH-allocated pty (``ssh -t``)
# before anything else. Without this pin, a brief Terminus pty
# handshake race can leave the shell with an fd that's already
# signalling EOF on its first read, which kills an interactive
# zsh/bash before the prompt even renders. ``</dev/tty``
# bypasses whatever stdio Terminus connected and goes straight
# to the controlling terminal.
#
# ``-il`` not ``-l``: ``-i`` forces interactive mode so the
# shell doesn't fall back to "non-interactive, exit at first
# EOF" semantics. Combined with the explicit ``</dev/tty``
# pin, this covers both the fd race and the interactivity
# detection path.
#
# ``;`` not ``&&``: if ``cd`` fails (remote_root vanished,
# perm change, mount unavailable) the shell still spawns
# instead of exiting on cd's non-zero exit. ``cd``'s stderr
# stays visible inside the pane and the user lands in
# ``$HOME``.
remote_invocation = (
"exec </dev/tty >/dev/tty 2>/dev/tty; cd {}; exec ${{SHELL:-/bin/sh}} -il"
).format(shlex.quote(remote_root))
# ``panel_name`` makes Terminus open the shell as a panel
# docked at the bottom of the active window. Without it
# Terminus defaults to a new tab in the editor pane group,
# which lands the SSH session next to the user's open files
# and visually displaces them — exactly the regression we
# had with the pre-Terminus external-terminal path. Single
# well-known panel name keeps successive invocations reusing
# one slot instead of stacking new panels.
#
# ``auto_close=False``: if the remote shell dies for any
# reason (dotfile breakage, ``cd`` to a vanished mount,
# SSH disconnect), keep the pane visible so the user can
# read the exit message instead of watching the panel
# flash-close with no diagnostic. Costs one Ctrl+W on
# normal ``exit`` — worth it for the broken-path UX.
run_command(
"terminus_open",
{
"cmd": ["ssh", "-t", host_alias, remote_invocation],
"cwd": str(context.local_cache_root),
"title": "ssh {}:{}".format(host_alias, remote_root),
"auto_close": False,
"panel_name": "Sessions Terminus",
},
)
_status_message(
"Sessions: opening terminal for {}:{}".format(host_alias, remote_root)
)
def _schedule_track_g_refresh_if_needed(
window: object,
context: "_WorkspaceContext",
) -> None:
"""Refresh Track G state for the workspace after every mirror sync.
Without this hook, ``.git`` directories arrive in the local mirror as
Sessions stubs (the mirror walks ``.git/`` and creates 0-byte
placeholders for the files inside). Sublime Text's built-in git
integration discovers those ``.git`` dirs, tries to read
``.git/index``, and prints ``unable to open index: Failed to read
index header`` to the console — a confusing first impression even
though the workspace itself is fine.
G2 fetches the real ``.git`` content, G3 sets skip-worktree on clean
tracked files + pulls dirty content, G4 installs the post-checkout
hook. Overlapping calls collapse via the background queue's
``task_key`` dedup, so the tar+extract per repo runs at most once at
a time per workspace.
"""
if _dot_git_excluded_from_mirror():
return
_run_track_g_refresh(window, context)
_track_g_remote_ref_fingerprints: Dict[str, str] = {}
"""Per-repo cache: ``host_alias::remote_root → SHA1(for-each-ref + HEAD)``.
Lets ``_run_track_g_refresh`` skip the heavy 26 MB+ ``.git`` tar pull
when the remote ref state is unchanged since the last successful
fetch. The first refresh after editor restart misses the cache and
pays the full fetch — every refresh after that is a single
sub-second bridge call until the user (or a teammate) actually
moves a ref."""
def _track_g_fingerprint_key(host_alias: str, repo) -> str: # noqa: ANN001
return "{}::{}".format(host_alias, repo.remote_root)
def _probe_remote_ref_fingerprint(host_alias: str, repo) -> Optional[str]: # noqa: ANN001
"""Return a stable digest of the remote repo's ref state, or ``None``.
Cheap probe: ``for-each-ref`` outputs ``<sha> <refname>`` per line
(typically a few KB even for repos with thousands of refs); pair
it with ``rev-parse HEAD`` so a detached-HEAD move flips the
fingerprint too. Single ``exec/once`` round-trip per repo, so the
skip path is dominated by SSH RTT — orders of magnitude cheaper
than the 26 MB tar pull when the answer is "nothing changed".
"""
from .ssh_file_transport import execute_remote_exec_once
try:
result = execute_remote_exec_once(
host_alias,
[
"bash",
"-c",
"set -o pipefail; "
"git -C {root} for-each-ref --format='%(objectname) %(refname)'; "
"printf 'HEAD '; git -C {root} rev-parse HEAD".format(
root=_shell_quote_for_remote(repo.remote_root)
),
],
cwd=repo.remote_root,
timeout_ms=15_000,
)
except Exception: # noqa: BLE001 — probe failure shouldn't break refresh.
return None
if result.timed_out or result.exit_code != 0:
return None
payload = (result.stdout or "").encode("utf-8", errors="replace")
return hashlib.sha1(payload).hexdigest()
def _shell_quote_for_remote(value: str) -> str:
"""POSIX single-quote ``value`` for safe interpolation into ``bash -c``."""
return "'" + value.replace("'", "'\\''") + "'"
def _run_track_g_refresh(
window: object,
context: "_WorkspaceContext",
) -> None:
"""Discover repos, apply pending checkouts, pull each ``.git``, materialise.
Order matters and is load-bearing:
1. ``apply_pending_checkout`` runs *before* the tar fetch. The
checkout marker lives in ``.git/SESSIONS_PENDING_CHECKOUT``;
``fetch_remote_dot_git`` wipes the entire local ``.git`` to
extract the fresh tarball over it, so reading the marker has
to happen *before* the wipe — otherwise the proxy silently
no-ops and the user's branch switch is reverted on the next
refresh.
2. The fetch then carries the now-on-the-new-branch remote state
back into the local mirror, so ``HEAD`` and ``refs/heads/*``
agree with what Sublime Merge displays.
3. ``install_post_checkout_hook`` runs *after* the fetch because
the wipe destroys ``.git/hooks/post-checkout`` along with
everything else. Re-installing afterwards keeps the hook live
for the next user-driven branch switch.
Silent on success — this fires after every mirror sync, so a status
message would spam the user. Failures still surface so a broken
Track G state stays visible.
"""
from .git_branch_proxy import (
apply_pending_checkout,
install_post_checkout_hook,
read_pending_checkout,
)
from .git_dot_git_sync import fetch_remote_dot_git
from .git_materialise import materialise_working_tree
from .git_repo_discovery import discover_git_repos
host_alias = context.recent_entry.host_alias
local_cache_root = context.local_cache_root
remote_root = context.recent_entry.remote_root
def work() -> None:
all_repos = discover_git_repos(local_cache_root, remote_root)
if not all_repos:
return
# Track G v0 only handles ``regular`` ``.git`` directories; the
# ``worktree`` (``.git`` is a file pointing at the parent's
# ``worktrees/<name>`` dir) shape lands in v1 along with the
# ``gitdir`` chase. Filter at the caller so we don't fire one
# ``git.dot_git_fetch`` event per worktree per refresh — under
# ``.claude/worktrees/agent-*`` that easily reaches 16+ entries
# and the trace gets impossible to read. One summary event keeps
# post-mortem visibility.
repos = tuple(repo for repo in all_repos if repo.kind == "regular")
worktree_skipped = len(all_repos) - len(repos)
if worktree_skipped:
_trace_event(
"git.discovery_summary",
host_alias=host_alias,
regular_count=len(repos),
worktree_skipped=worktree_skipped,
detail=(
"Worktree (.git file) repos aren't supported in Track G v0; "
"open the repo's main clone instead."
),
)
if not repos:
return
ok_repos = 0
failed: list[str] = []
for repo in repos:
# Step 1: drain the post-checkout marker BEFORE the fetch
# wipes ``.git``. ``apply_pending_checkout`` is a no-op
# when no marker is queued.
proxy_result = apply_pending_checkout(host_alias, repo)
_trace_event(
"git.checkout_proxy",
host_alias=host_alias,
remote_root=repo.remote_root,
proxied=proxy_result.proxied,
ok=proxy_result.ok,
new_head=proxy_result.new_head,
error_detail=proxy_result.error_detail,
)
if proxy_result.proxied and not proxy_result.ok:
failed.append(
"{} (branch switch refused): {}".format(
repo.remote_root,
proxy_result.error_detail or "(no detail)",
)
)
continue
# Step 2: skip the heavy tar pull when remote ref state
# is unchanged AND no marker is still queued (a queued
# marker means the proxy was deferred and we need to
# re-pull on the next refresh anyway). v0.7.18 introduced
# always-refresh on every ``sync.done``; without this
# gate every ~50 s sync.done re-pulls 26+ MB of ``.git``
# for a busy repo, which the user perceives as buffering.
cache_key = _track_g_fingerprint_key(host_alias, repo)
previous_fp = _track_g_remote_ref_fingerprints.get(cache_key)
current_fp = _probe_remote_ref_fingerprint(host_alias, repo)
marker_queued = read_pending_checkout(repo.local_root / ".git") is not None
local_dot_git_present = (repo.local_root / ".git" / "HEAD").is_file()
if (
current_fp is not None
and previous_fp == current_fp
and not marker_queued
and local_dot_git_present
):
_trace_event(
"git.dot_git_fetch_skipped",
host_alias=host_alias,
remote_root=repo.remote_root,
kind=repo.kind,
fingerprint=current_fp,
)
ok_repos += 1
continue
fetch_result = fetch_remote_dot_git(host_alias, repo)
_trace_event(
"git.dot_git_fetch",
host_alias=host_alias,
remote_root=repo.remote_root,
kind=repo.kind,
ok=fetch_result.ok,
bytes_received=fetch_result.bytes_received,
error_detail=fetch_result.error_detail,
)
if not fetch_result.ok:
# Fetch failed — drop any cached fingerprint so the
# next refresh retries instead of skipping on a stale
# match.
_track_g_remote_ref_fingerprints.pop(cache_key, None)
failed.append(
"{}: {}".format(
repo.remote_root,
fetch_result.error_detail or "(no detail)",
)
)
continue
if current_fp is not None:
_track_g_remote_ref_fingerprints[cache_key] = current_fp
# Step 3: re-install the post-checkout hook now that the
# fetch has wiped ``.git/hooks/``. Idempotent — no rewrite
# if the script content matches.
try:
install_post_checkout_hook(repo.local_root / ".git")
except OSError as error:
_trace_event(
"git.hook_install_failed",
host_alias=host_alias,
remote_root=repo.remote_root,
error=str(error),
)
materialise_result = materialise_working_tree(host_alias, repo)
_trace_event(
"git.materialise",
host_alias=host_alias,
remote_root=repo.remote_root,
ok=materialise_result.ok,
skip_worktree_set=materialise_result.skip_worktree_set,
files_fetched=materialise_result.files_fetched,
error_detail=materialise_result.error_detail,
)
if not materialise_result.ok:
failed.append(
"{} (.git ok, materialise failed): {}".format(
repo.remote_root,
materialise_result.error_detail or "(no detail)",
)
)
continue
ok_repos += 1
def finish() -> None:
if failed:
_status_message(
"Sessions: refreshed {}/{} git repos; failures: {}".format(
ok_repos, len(repos), "; ".join(failed)
)
)
_set_timeout(finish, 0)
_run_in_background(
work,
task_label="sessions.refresh_git_state",
task_key="sessions_refresh_git_state:{}".format(context.cache_key),
)
# ---------------------------------------------------------------------------
# Submodule re-exports.
# ---------------------------------------------------------------------------
#
# Imports happen at the bottom so each submodule sees a fully-initialized
# ``commands`` module via ``from . import commands as _root`` — tests that
# patch ``commands.X`` still land on the call site because submodules look
# the symbol up through ``_root`` rather than binding locally at import time.
from .commands_file_actions import ( # noqa: E402, F401
_OPEN_REQUEST_LOCK,
_OPEN_REQUEST_SERIAL_BY_WORKSPACE,
SessionsDeleteRemoteFileCommand,
SessionsOpenRemoteFileCommand,
SessionsRemoteCachedFileSaveListener,
SessionsSaveRemoteFileCommand,
_delete_remote_file_for_workspace,
_open_blocked_reason_message,
_open_remote_file_for_workspace,
_resolve_workspace_remote_file,
_resolve_workspace_remote_target,
_ResolvedRemoteFileTarget,
_save_remote_file_for_workspace,
_should_prioritize_remote_open,
)
from .commands_python_pipeline import ( # noqa: E402, F401
_DAP_DEBUG_PORT,
_DAP_LAUNCH_NAME,
_DEBUG_PANEL_NAME,
_OPEN_DIAG_DEBOUNCE_S,
_OPEN_DIAG_VIEW_TS,
_SELECT_PYTHON_BROWSE_SENTINEL,
_SELECT_PYTHON_CLEAR_SENTINEL,
_SELECT_PYTHON_MANUAL_SENTINEL,
SessionsClearPythonInterpreterCommand,
SessionsOpenRemoteMarimoCommand,
SessionsPythonInterpreterStatusListener,
SessionsRemotePythonPipelineListener,
SessionsSelectPythonInterpreterCommand,
SessionsSetupRemoteDebuggingCommand,
SessionsStopRemoteMarimoCommand,
_active_view_remote_notebook_path,
_apply_active_python_change,
_browse_remote_for_python_interpreter,
_build_python_lsp_save_source_action_requests,
_build_sessions_dap_attach_config,
_collect_remote_python_pipeline_results,
_effective_sessions_settings_for_remote_python,
_erase_active_python_status,
_home_dir_for_host,
_list_remote_directory_task,
_marimo_session_manager,
_maybe_schedule_remote_python_pipeline_after_cache_push,
_merge_sessions_dap_config,
_open_remote_marimo_in_browser,
_present_merged_remote_python_pipeline,
_probe_active_python_version_task,
_prompt_manual_python_interpreter,
_remote_python_pipeline_targets,
_render_remote_debug_instructions,
_run_format_then_pipeline_after_cache_push_async,
_run_remote_python_pipeline_async,
_run_remote_ruff_format_after_save_async,
_schedule_format_then_pipeline_after_cache_push,
_schedule_remote_python_pipeline,
_schedule_remote_ruff_format_after_workspace_save,
_select_python_interpreter_task,
_set_active_python_status,
_show_python_interpreter_quick_panel,
_show_remote_browser_quick_panel,
_write_output_panel,
)