|
#!/usr/bin/env python3 |
|
# -*- coding: utf-8 -*- |
|
|
|
""" |
|
VS Code window management tool (Windows only) |
|
|
|
- Parse the output of `code --status` and collect the list of window titles that are listed in Workspace Stats. |
|
- Default workspace = a window whose title is **not** listed in Workspace Stats. |
|
- Keep one candidate for the default workspace and close all other VS Code windows via WM_CLOSE. |
|
- If no candidate exists, launch VS Code with an empty window. |
|
|
|
Dependencies: None (standard library only) |
|
""" |
|
|
|
import sys |
|
import os |
|
import json |
|
import re |
|
import subprocess |
|
from typing import List, Tuple, Optional, Dict |
|
|
|
# ---------- Windows API (ctypes) ---------- |
|
import ctypes |
|
from ctypes import wintypes |
|
|
|
user32 = ctypes.WinDLL('user32', use_last_error=True) |
|
EnumWindows = user32.EnumWindows |
|
EnumWindowsProc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) |
|
IsWindowVisible = user32.IsWindowVisible |
|
GetWindowTextW = user32.GetWindowTextW |
|
GetWindowTextLengthW = user32.GetWindowTextLengthW |
|
GetClassNameW = user32.GetClassNameW |
|
GetWindowThreadProcessId = user32.GetWindowThreadProcessId |
|
SendMessageW = user32.SendMessageW |
|
|
|
WM_CLOSE = 0x0010 |
|
|
|
# Class names / title characteristics commonly seen on VS Code top-level windows |
|
VCODE_WINDOW_CLASS_CANDIDATES = { |
|
"Chrome_WidgetWin_1", # Electron/Chromium family (VS Code main) |
|
"Chrome_WidgetWin_0", |
|
} |
|
|
|
# Conservative list of keywords that tend to appear in titles |
|
VCODE_TITLE_KEYWORDS = [ |
|
"Visual Studio Code", # official name |
|
"VS Code", # occasional abbreviation |
|
"Code - OSS", # OSS build |
|
] |
|
|
|
# ---------- `code --status` parsing (ported/included parser) ---------- |
|
def read_code_status_text() -> str: |
|
"""If stdin is empty, execute `code --status` and capture it. Try encodings in order.""" |
|
# data = sys.stdin.read() |
|
# if data.strip(): |
|
# return data |
|
|
|
# # Try the 'code' command first |
|
# try: |
|
# out = subprocess.check_output(["code", "--status"]) |
|
# for enc in ("utf-8", "cp932", "mbcs"): |
|
# try: |
|
# return out.decode(enc) |
|
# except UnicodeDecodeError: |
|
# continue |
|
# return out.decode(errors="replace") |
|
# except Exception: |
|
# pass |
|
|
|
# Directly launch Code.exe + cli.js (replicating the provided function in Python) |
|
candidates = [] |
|
pf = r"C:\Program Files" |
|
pf86 = r"C:\Program Files (x86)" |
|
candidates.append(os.path.join(pf, "Microsoft VS Code")) |
|
candidates.append(os.path.join(pf86, "Microsoft VS Code")) |
|
|
|
for base in candidates: |
|
code_exe = os.path.join(base, "Code.exe") |
|
cli_js = os.path.join(base, "resources", "app", "out", "cli.js") |
|
if os.path.exists(code_exe) and os.path.exists(cli_js): |
|
env = os.environ.copy() |
|
env["VSCODE_DEV"] = "" |
|
env["ELECTRON_RUN_AS_NODE"] = "1" |
|
try: |
|
out = subprocess.check_output([code_exe, cli_js, "--status"], env=env) |
|
for enc in ("utf-8", "cp932", "mbcs"): |
|
try: |
|
return out.decode(enc) |
|
except UnicodeDecodeError: |
|
continue |
|
return out.decode(errors="replace") |
|
except Exception as e: |
|
last_err = e |
|
continue |
|
|
|
print("Failed to run `code --status` via both `code` and Code.exe.", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
def parse_headers(lines, i): |
|
headers, gpu, in_gpu = {}, {}, False |
|
while i < len(lines): |
|
line = lines[i].rstrip("\n") |
|
if not line.strip(): |
|
j = i + 1 |
|
while j < len(lines) and not lines[j].strip(): |
|
j += 1 |
|
if j < len(lines) and (lines[j].lstrip().startswith("CPU %") or lines[j].startswith("Workspace Stats:")): |
|
i = j |
|
break |
|
i += 1 |
|
continue |
|
|
|
if in_gpu: |
|
if re.match(r"^\s{2,}\S", line): |
|
k, v = [s.strip() for s in re.split(r":\s+", line.strip(), maxsplit=1)] if ":" in line else (line.strip(), "") |
|
gpu[k] = v |
|
i += 1 |
|
continue |
|
else: |
|
headers["GPU Status"] = gpu |
|
in_gpu = False |
|
|
|
m = re.match(r"^([A-Za-z ]+):\s+(.*)$", line) |
|
if m: |
|
key, val = m.group(1).strip(), m.group(2).strip() |
|
if key == "GPU Status": |
|
in_gpu, gpu = True, {} |
|
else: |
|
headers[key] = val |
|
i += 1 |
|
continue |
|
|
|
if headers: |
|
break |
|
else: |
|
i += 1 |
|
|
|
if in_gpu: |
|
headers["GPU Status"] = gpu |
|
return headers, i |
|
|
|
def parse_process_table(lines, i): |
|
while i < len(lines) and not lines[i].lstrip().startswith("CPU %"): |
|
if lines[i].startswith("Workspace Stats:"): |
|
return [], i |
|
i += 1 |
|
if i >= len(lines): return [], i |
|
i += 1 |
|
row_re = re.compile(r"^(?P<indent>\s*)(?P<cpu>-?\d+)\s+(?P<mem>-?\d+)\s+(?P<pid>-?\d+)\s+(?P<proc>.+?)\s*$") |
|
stack, root = [], [] |
|
|
|
def mk(cpu, mem, pid, proc, lvl): |
|
return {"cpu_percent": int(cpu), "mem_mb": int(mem), "pid": int(pid), "process": proc, "_lvl": lvl, "children": []} |
|
|
|
while i < len(lines): |
|
line = lines[i].rstrip("\n") |
|
if not line.strip(): |
|
i += 1; continue |
|
if line.startswith("Workspace Stats:"): |
|
break |
|
m = row_re.match(line) |
|
if not m: break |
|
lvl = len(m.group("indent")) // 2 |
|
node = mk(m.group("cpu"), m.group("mem"), m.group("pid"), m.group("proc"), lvl) |
|
|
|
if not stack: |
|
root.append(node); stack.append(node) |
|
else: |
|
while stack and stack[-1]["_lvl"] >= lvl: |
|
stack.pop() |
|
(stack[-1]["children"] if stack else root).append(node) |
|
stack.append(node) |
|
i += 1 |
|
|
|
def strip(nodes): |
|
for n in nodes: |
|
n.pop("_lvl", None) |
|
if n["children"]: strip(n["children"]) |
|
else: n.pop("children", None) |
|
strip(root) |
|
return root, i |
|
|
|
def parse_workspace_stats(lines, i): |
|
while i < len(lines) and not lines[i].startswith("Workspace Stats:"): |
|
i += 1 |
|
if i >= len(lines): return None, i |
|
i += 1 # skip header |
|
|
|
windows = [] |
|
current_window = None |
|
current_folder = None |
|
|
|
win_re = re.compile(r'^\|\s+Window\s+\((?P<title>.+)\)\s*$') |
|
fol_re = re.compile(r'^\|\s{2,}Folder\s+\((?P<name>.+)\):\s+(?P<count>\d+)\s+files\s*$') |
|
types_re = re.compile(r'^\|\s{4,}File types:\s*(?P<rest>.+)$') |
|
conf_re = re.compile(r'^\|\s{4,}Conf files:\s*(?P<rest>.*)$') |
|
|
|
def parse_kv_counts(s): |
|
out = {} |
|
for tok in re.split(r"\s+", s.strip()): |
|
m = re.match(r'(?P<key>[^()]+)\((?P<n>\d+)\)$', tok.strip(',')) |
|
if m: |
|
out[m.group("key")] = int(m.group("n")) |
|
return out |
|
|
|
while i < len(lines): |
|
line = lines[i].rstrip("\n") |
|
if not line.strip(): |
|
i += 1; continue |
|
if not line.startswith("|"): |
|
break |
|
|
|
m = win_re.match(line) |
|
if m: |
|
current_window = {"title": m.group("title"), "folders": []} |
|
windows.append(current_window) |
|
current_folder = None |
|
i += 1; continue |
|
|
|
m = fol_re.match(line) |
|
if m and current_window is not None: |
|
current_folder = { |
|
"name": m.group("name"), |
|
"file_count": int(m.group("count")), |
|
"file_types": {}, |
|
"conf_files": [] |
|
} |
|
current_window["folders"].append(current_folder) |
|
i += 1; continue |
|
|
|
m = types_re.match(line) |
|
if m and current_folder is not None: |
|
current_folder["file_types"] = parse_kv_counts(m.group("rest")) |
|
i += 1; continue |
|
|
|
m = conf_re.match(line) |
|
if m and current_folder is not None: |
|
rest = m.group("rest").strip() |
|
current_folder["conf_files"] = [s.strip() for s in re.split(r"\s+", rest) if s.strip()] if rest else [] |
|
i += 1; continue |
|
|
|
i += 1 |
|
|
|
return {"windows": windows}, i |
|
|
|
def postprocess(headers): |
|
h = dict(headers) |
|
m = re.match(r"^(?P<label>.+?)\s+(?P{ver}[0-9.]+)\s*\((?P<meta>.+)\)$".replace("{","<").replace("}",">"), h.get("Version","")) |
|
if m: |
|
commit, btime = None, None |
|
for p in (s.strip() for s in m.group("meta").split(",")): |
|
if re.fullmatch(r"[0-9a-f]{7,}", p): commit = p |
|
elif re.search(r"\d{4}-\d{2}-\d{2}T", p): btime = p |
|
h["Version"] = {"name": m.group("label"), "version": m.group("ver"), "commit": commit, "build_time": btime, "raw": headers.get("Version","")} |
|
m = re.match(r"^(?P<os>[A-Za-z_]+)\s*(?P<arch>x64|arm64)?\s*(?P<ver>[\w\.\-]+)?$", h.get("OS Version","")) |
|
if m: |
|
h["OS Version"] = {"os": m.group("os"), "arch": m.group("arch"), "version": m.group("ver"), "raw": headers.get("OS Version","")} |
|
m = re.match(r"^(?P<model>.+?)\s*\((?P<count>\d+)\s*x\s*(?P<speed>\d+)\)$", h.get("CPUs","")) |
|
if m: |
|
h["CPUs"] = {"model": m.group("model").strip(), "count": int(m.group("count")), "speed_mhz": int(m.group("speed")), "raw": headers.get("CPUs","")} |
|
m = re.match(r"^(?P<total>[\d\.]+)GB\s*\((?P<free>[\d\.]+)GB free\)$", h.get("Memory (System)","")) |
|
if m: |
|
h["Memory (System)"] = {"total_gb": float(m.group("total")), "free_gb": float(m.group("free")), "raw": headers.get("Memory (System)","")} |
|
if isinstance(h.get("Process Argv"), str) and h["Process Argv"]: |
|
h["Process Argv"] = [a for a in re.split(r"\s+", h["Process Argv"].strip()) if a] |
|
return h |
|
|
|
def parse_code_status_to_json(text: str) -> Dict: |
|
lines = text.splitlines() |
|
headers, idx = parse_headers(lines, 0) |
|
processes, idx = parse_process_table(lines, idx) |
|
workspace, _ = parse_workspace_stats(lines, idx) |
|
return { |
|
"headers": postprocess(headers), |
|
"processes": processes, |
|
"workspace_stats": workspace, |
|
"raw": text |
|
} |
|
|
|
# ---------- Enumerating VS Code windows ---------- |
|
def _get_window_text(hwnd: int) -> str: |
|
length = GetWindowTextLengthW(hwnd) |
|
if length == 0: |
|
# Sometimes the title is empty/zero |
|
buf = ctypes.create_unicode_buffer(512) |
|
GetWindowTextW(hwnd, buf, 512) |
|
return buf.value |
|
buf = ctypes.create_unicode_buffer(length + 1) |
|
GetWindowTextW(hwnd, buf, length + 1) |
|
return buf.value |
|
|
|
def _get_window_class(hwnd: int) -> str: |
|
buf = ctypes.create_unicode_buffer(256) |
|
GetClassNameW(hwnd, buf, 256) |
|
return buf.value |
|
|
|
def _get_window_pid(hwnd: int) -> int: |
|
pid = wintypes.DWORD() |
|
GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) |
|
return int(pid.value) |
|
|
|
def _is_vscode_window(hwnd: int) -> bool: |
|
if not IsWindowVisible(hwnd): |
|
return False |
|
cls = _get_window_class(hwnd) |
|
if cls not in VCODE_WINDOW_CLASS_CANDIDATES: |
|
# Eliminate non-VS Code Electron/Chromium windows |
|
return False |
|
title = _get_window_text(hwnd) |
|
if not title: |
|
return False |
|
# Accept if the title looks like VS Code |
|
return any(k in title for k in VCODE_TITLE_KEYWORDS) |
|
|
|
def enum_vscode_windows() -> List[int]: |
|
hwnds: List[int] = [] |
|
def _callback(hwnd, lparam): |
|
try: |
|
if _is_vscode_window(hwnd): |
|
hwnds.append(hwnd) |
|
except Exception: |
|
pass |
|
return True |
|
EnumWindows(EnumWindowsProc(_callback), 0) |
|
return hwnds |
|
|
|
# ---------- Core logic ---------- |
|
def extract_workspace_titles(status_json: Dict) -> List[str]: |
|
"""Return the array of window titles listed in Workspace Stats.""" |
|
ws = status_json.get("workspace_stats") or {} |
|
wins = ws.get("windows") or [] |
|
titles = [] |
|
for w in wins: |
|
t = (w.get("title") or "").strip() |
|
if t: |
|
titles.append(t) |
|
return titles |
|
|
|
def choose_default_workspace(hwnds: List[int], non_default_titles: List[str]) -> Optional[int]: |
|
""" |
|
We treat "listed in Workspace Stats" as non-default. |
|
A VS Code window whose title is **not** in that list is considered the default candidate. |
|
""" |
|
non_default_set = {t.strip() for t in non_default_titles} |
|
for hwnd in hwnds: |
|
title = _get_window_text(hwnd).strip() |
|
if title and title not in non_default_set: |
|
return hwnd |
|
return None |
|
|
|
def close_other_vscode_windows(keep_hwnd: Optional[int], hwnds: List[int]) -> List[int]: |
|
"""Send WM_CLOSE to all except keep_hwnd. Return the HWNDs we attempted to close.""" |
|
closed = [] |
|
for h in hwnds: |
|
if keep_hwnd is not None and h == keep_hwnd: |
|
continue |
|
# Send only (may not close immediately) |
|
SendMessageW(h, WM_CLOSE, 0, 0) |
|
closed.append(h) |
|
return closed |
|
|
|
def start_vscode_empty_window(): |
|
""" |
|
Launch VS Code with an empty window. |
|
- Try 'code --new-window' first |
|
- If that fails, use Code.exe + cli.js with --new-window |
|
""" |
|
# 1) code command |
|
# try: |
|
# subprocess.Popen(["code", "--new-window"]) |
|
# return |
|
# except Exception: |
|
# pass |
|
|
|
# 2) Code.exe + cli.js |
|
pf = r"C:\Program Files" |
|
pf86 = r"C:\Program Files (x86)" |
|
for base in (os.path.join(pf, "Microsoft VS Code"), |
|
os.path.join(pf86, "Microsoft VS Code")): |
|
code_exe = os.path.join(base, "Code.exe") |
|
cli_js = os.path.join(base, "resources", "app", "out", "cli.js") |
|
if os.path.exists(code_exe) and os.path.exists(cli_js): |
|
env = os.environ.copy() |
|
env["VSCODE_DEV"] = "" |
|
env["ELECTRON_RUN_AS_NODE"] = "1" |
|
subprocess.Popen([code_exe, cli_js, "--new-window"], env=env) |
|
return |
|
|
|
raise RuntimeError("Failed to launch VS Code (Code.exe/cli.js not found).") |
|
|
|
def main(): |
|
# 1) Get and parse `code --status` |
|
status_text = read_code_status_text() |
|
status_json = parse_code_status_to_json(status_text) |
|
ws_titles = extract_workspace_titles(status_json) |
|
|
|
# 2) Enumerate current VS Code windows |
|
hwnds = enum_vscode_windows() |
|
|
|
# 3) Choose the default workspace candidate |
|
keep = choose_default_workspace(hwnds, ws_titles) |
|
|
|
# 4) Default behavior |
|
if keep is not None: |
|
closed = close_other_vscode_windows(keep, hwnds) |
|
print(json.dumps({ |
|
"action": "kept_default_window_and_closed_others", |
|
"kept_hwnd": keep, |
|
"closed_hwnds": closed, |
|
"kept_title": _get_window_text(keep), |
|
"workspace_titles_from_status": ws_titles |
|
}, ensure_ascii=False, indent=2)) |
|
else: |
|
# No default exists → create a new one |
|
start_vscode_empty_window() |
|
# Close all existing ones (per requirement: "keep one and close the rest" — close those existing before the new one) |
|
closed = close_other_vscode_windows(None, hwnds) |
|
print(json.dumps({ |
|
"action": "launched_new_default_window_and_closed_all_existing", |
|
"closed_hwnds": closed, |
|
"workspace_titles_from_status": ws_titles |
|
}, ensure_ascii=False, indent=2)) |
|
|
|
if __name__ == "__main__": |
|
try: |
|
main() |
|
except Exception as e: |
|
print(json.dumps({"error": str(e)}, ensure_ascii=False)) |
|
sys.exit(1) |