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>
7404 lines
266 KiB
Python
7404 lines
266 KiB
Python
"""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,
|
||
)
|