diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 4eda971..a013acb 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -91,9 +91,11 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "deep_compress_behavior": "continue", # 手动压缩行为:continue-注入并触发请求 / wait-仅插入等待用户 "silent_tool_disable": True, # 禁用工具时不向模型插入提示(默认开启) "enhanced_tool_display": True, # 增强工具显示 + "show_git_status_bar": True, # 是否显示输入栏上方 Git 状态栏 "versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite "agents_md_auto_inject": False, # AGENTS.md 自动注入开关 "allow_root_file_creation": False, # 允许在根目录创建文件开关 + "default_hide_workspace": False, # 默认隐藏工作区 "theme": "classic", # 主题配色: classic-经典/light-明亮/dark-暗黑 # 目标模式(Goal Mode) "goal_review_mode": "readonly", # readonly-仅读对话判断 / active-允许审核智能体跑只读命令取证 @@ -412,6 +414,12 @@ def sanitize_personalization_payload( else: base["enhanced_tool_display"] = bool(base.get("enhanced_tool_display", True)) + # Git 状态栏显示开关 + if "show_git_status_bar" in data: + base["show_git_status_bar"] = bool(data.get("show_git_status_bar")) + else: + base["show_git_status_bar"] = bool(base.get("show_git_status_bar", True)) + # 使用自定义称呼 if "use_custom_names" in data: base["use_custom_names"] = bool(data.get("use_custom_names")) @@ -430,6 +438,12 @@ def sanitize_personalization_payload( else: base["allow_root_file_creation"] = bool(base.get("allow_root_file_creation", False)) + # 默认隐藏工作区 + if "default_hide_workspace" in data: + base["default_hide_workspace"] = bool(data.get("default_hide_workspace")) + else: + base["default_hide_workspace"] = bool(base.get("default_hide_workspace", False)) + # 主题配色 theme_value = data.get("theme", base.get("theme")) if isinstance(theme_value, str) and theme_value in ALLOWED_THEMES: diff --git a/server/status.py b/server/status.py index c3f656a..c599497 100644 --- a/server/status.py +++ b/server/status.py @@ -2,6 +2,12 @@ from __future__ import annotations import time import re import os +import json +import shutil +import subprocess +import sys +import tempfile +import plistlib from pathlib import Path from flask import Blueprint, jsonify, request, send_file, session @@ -128,6 +134,546 @@ def _active_task_counts(username: str) -> dict: return running_by_workspace +def _run_project_git(project_path: Path, args: list[str]) -> tuple[bool, str]: + git_bin = shutil.which("git") + if not git_bin: + return False, "" + try: + proc = subprocess.run( + [git_bin, "-C", str(project_path), *args], + cwd=str(project_path), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=2, + check=False, + ) + except Exception: + return False, "" + return proc.returncode == 0, (proc.stdout or "").strip() + + +def _run_project_git_raw(project_path: Path, args: list[str], timeout: int = 4) -> tuple[bool, str]: + git_bin = shutil.which("git") + if not git_bin: + return False, "" + try: + proc = subprocess.run( + [git_bin, "-C", str(project_path), *args], + cwd=str(project_path), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=timeout, + check=False, + ) + except Exception: + return False, "" + return proc.returncode == 0, (proc.stdout or "") + + +def _sum_git_numstat(numstat_text: str) -> tuple[int, int]: + additions = 0 + deletions = 0 + for raw_line in (numstat_text or "").splitlines(): + parts = raw_line.split("\t", 2) + if len(parts) < 2: + continue + if parts[0].isdigit(): + additions += int(parts[0]) + if parts[1].isdigit(): + deletions += int(parts[1]) + return additions, deletions + + +def _paths_from_git_numstat(numstat_text: str) -> list[str]: + paths: list[str] = [] + for raw_line in (numstat_text or "").splitlines(): + parts = raw_line.split("\t") + if len(parts) < 3: + continue + path = parts[-1].strip() + if path: + paths.append(path) + return paths + + +def _count_text_lines(text: str) -> int: + if text == "": + return 0 + return len(text.splitlines()) + + +def _git_file_old_line_count(repo_root: Path, rel_path: str) -> int: + ok, text = _run_project_git_raw(repo_root, ["show", f"HEAD:{rel_path}"], timeout=3) + if ok: + return _count_text_lines(text) + target = (repo_root / rel_path).resolve() + try: + if target.exists() and repo_root in target.parents: + return _count_text_lines(target.read_text(encoding="utf-8", errors="ignore")) + except Exception: + pass + return 0 + + +def _git_file_lines(repo_root: Path, rel_path: str, ref: str = "HEAD") -> list[str]: + ok, text = _run_project_git_raw(repo_root, ["show", f"{ref}:{rel_path}"], timeout=3) + if ok: + return text.splitlines() + return [] + + +def _working_file_lines(repo_root: Path, rel_path: str) -> list[str]: + target = (repo_root / rel_path).resolve() + try: + if target.exists() and repo_root in target.parents: + return target.read_text(encoding="utf-8", errors="ignore").splitlines() + except Exception: + pass + return _git_file_lines(repo_root, rel_path) + + +def _parse_hunk_header(header: str) -> tuple[int, int, int, int]: + match = re.match(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?", header) + if not match: + return 0, 0, 0, 0 + old_start = int(match.group(1)) + old_count = int(match.group(2) or "1") + new_start = int(match.group(3)) + new_count = int(match.group(4) or "1") + return old_start, old_count, new_start, new_count + + +def _parse_git_diff(repo_root: Path, diff_text: str, fold_context_map: dict[str, dict[str, int]] | None = None) -> list[dict]: + files: list[dict] = [] + current: dict | None = None + current_hunk: dict | None = None + old_line = 0 + new_line = 0 + previous_old_end = 0 + previous_new_end = 0 + + def finish_hunk(): + nonlocal current_hunk, current, previous_old_end, previous_new_end + if not current_hunk or not current: + current_hunk = None + return + path = current.get("path") or current.get("old_path") or "" + old_start = current_hunk.get("old_start") or 0 + old_count = current_hunk.get("old_count") or 0 + new_start = current_hunk.get("new_start") or 0 + new_count = current_hunk.get("new_count") or 0 + current_hunk["before_hidden"] = max(0, int(old_start) - int(previous_old_end) - 1) + before_fold_key = f"before:{old_start}" + current_hunk["before_fold_key"] = before_fold_key + requested_extra = int((fold_context_map or {}).get(path, {}).get(before_fold_key, 0) or 0) + if requested_extra > 0 and current_hunk["before_hidden"] > 0: + add_count = min(current_hunk["before_hidden"], requested_extra) + old_lines = _git_file_lines(repo_root, current.get("old_path") or path) + new_lines = _working_file_lines(repo_root, path) + injected = [] + for idx in range(add_count): + old_no = int(old_start) - add_count + idx + new_no = int(new_start) - add_count + idx + text = "" + if 0 < new_no <= len(new_lines): + text = new_lines[new_no - 1] + elif 0 < old_no <= len(old_lines): + text = old_lines[old_no - 1] + injected.append({"kind": "context", "old_line": old_no, "new_line": new_no, "text": text}) + current_hunk["lines"] = injected + current_hunk.get("lines", []) + current_hunk["before_hidden"] -= add_count + previous_old_end = int(old_start) + int(old_count) - 1 + previous_new_end = int(new_start) + int(new_count) - 1 + current["hunks"].append(current_hunk) + current_hunk = None + + def finish_file(): + nonlocal current, previous_old_end, previous_new_end + finish_hunk() + if not current: + return + old_total = _git_file_old_line_count(repo_root, current.get("old_path") or current.get("path") or "") + current["after_hidden"] = max(0, old_total - int(previous_old_end)) + current["after_fold_key"] = "after" + path = current.get("path") or current.get("old_path") or "" + requested_extra = int((fold_context_map or {}).get(path, {}).get("after", 0) or 0) + if requested_extra > 0 and current["after_hidden"] > 0 and current.get("hunks"): + add_count = min(current["after_hidden"], requested_extra) + old_lines = _git_file_lines(repo_root, current.get("old_path") or path) + new_lines = _working_file_lines(repo_root, path) + injected = [] + for idx in range(add_count): + old_no = int(previous_old_end) + 1 + idx + new_no = int(previous_new_end) + 1 + idx + text = "" + if 0 < new_no <= len(new_lines): + text = new_lines[new_no - 1] + elif 0 < old_no <= len(old_lines): + text = old_lines[old_no - 1] + injected.append({"kind": "context", "old_line": old_no, "new_line": new_no, "text": text}) + current["hunks"][-1]["lines"].extend(injected) + current["after_hidden"] -= add_count + files.append(current) + current = None + previous_old_end = 0 + previous_new_end = 0 + + for raw in diff_text.splitlines(): + if raw.startswith("diff --git "): + finish_file() + current = { + "path": "", + "old_path": "", + "additions": 0, + "deletions": 0, + "hunks": [], + "after_hidden": 0, + } + previous_old_end = 0 + continue + if current is None: + continue + if raw.startswith("--- "): + old_path = raw[4:].strip() + current["old_path"] = "" if old_path == "/dev/null" else re.sub(r"^a/", "", old_path) + continue + if raw.startswith("+++ "): + new_path = raw[4:].strip() + current["path"] = current["old_path"] if new_path == "/dev/null" else re.sub(r"^b/", "", new_path) + continue + if raw.startswith("@@ "): + finish_hunk() + old_start, old_count, new_start, new_count = _parse_hunk_header(raw) + current_hunk = { + "header": raw, + "old_start": old_start, + "old_count": old_count, + "new_start": new_start, + "new_count": new_count, + "before_hidden": 0, + "lines": [], + } + old_line = old_start + new_line = new_start + continue + if current_hunk is None: + continue + marker = raw[:1] + if marker not in {" ", "+", "-"}: + continue + text = raw[1:] + line = {"kind": "context", "old_line": old_line, "new_line": new_line, "text": text} + if marker == "+": + line = {"kind": "add", "old_line": None, "new_line": new_line, "text": text} + new_line += 1 + current["additions"] += 1 + elif marker == "-": + line = {"kind": "delete", "old_line": old_line, "new_line": None, "text": text} + old_line += 1 + current["deletions"] += 1 + else: + old_line += 1 + new_line += 1 + current_hunk["lines"].append(line) + finish_file() + return [item for item in files if item.get("path") and item.get("hunks")] + + +def _open_path_in_file_manager(path: Path) -> bool: + target = path.expanduser().resolve() + if not target.exists(): + return False + try: + if sys.platform == "darwin": + subprocess.Popen(["open", str(target)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + if os.name == "nt": + os.startfile(str(target)) # type: ignore[attr-defined] + return True + opener = shutil.which("xdg-open") + if opener: + subprocess.Popen([opener, str(target)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + except Exception: + return False + return False + + +def _resolve_project_git_file(workspace, rel_path: str) -> tuple[Path | None, str]: + project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve() + ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"]) + repo_root = Path(root_text).expanduser().resolve() if ok and root_text else project_path + try: + target = (repo_root / rel_path).expanduser().resolve() + target.relative_to(repo_root) + except Exception: + return None, "文件路径无效" + if not target.exists() or not target.is_file(): + return None, "文件不存在" + return target, "" + + +def _mac_open_app_candidates(file_path: Path | None = None) -> list[dict]: + app_paths: list[Path] = [] + default_app_path = "" + if file_path is not None: + try: + from AppKit import NSWorkspace + from Foundation import NSURL + file_url = NSURL.fileURLWithPath_(str(file_path)) + workspace = NSWorkspace.sharedWorkspace() + urls = workspace.URLsForApplicationsToOpenURL_(file_url) or [] + app_paths.extend(Path(str(url.path())) for url in urls if url and url.path()) + default_url = workspace.URLForApplicationToOpenURL_(file_url) + if default_url and default_url.path(): + default_app_path = str(Path(str(default_url.path())).expanduser().absolute()) + except Exception: + app_paths = [] + if not app_paths: + # 只在 LaunchServices 不可用时做很小的兜底;正常路径必须使用系统针对该文件的结果。 + for base in ["/System/Applications/TextEdit.app", "/Applications/Xcode.app", "/Applications/Cursor.app"]: + path = Path(base).expanduser() + if path.exists(): + app_paths.append(path) + seen = set() + apps: list[dict] = [] + for path in app_paths: + try: + visible_path = path.expanduser().absolute() + except Exception: + continue + path_text = str(visible_path) + if ( + path_text in seen + or "__pycache__" in path_text + or "/Library/Application Support/" in path_text + or "/System/Library/PrivateFrameworks/" in path_text + or "/System/Library/CoreServices/" in path_text + or "/System/Library/Services/" in path_text + ): + continue + info = _read_mac_app_info(visible_path) + label = info.get("label") or visible_path.stem + bundle_id = str(info.get("bundle_id") or "") + if bundle_id == "com.microsoft.VSCode" or label == "Code": + label = visible_path.stem + label_lower = label.lower() + if label_lower.endswith("agent") or label_lower in {"finder", "system settings"}: + continue + seen.add(path_text) + apps.append({ + "id": path_text, + "label": f"{label}(默认)" if default_app_path and path_text == default_app_path else label, + "bundle_id": bundle_id, + "icon_url": f"/api/project/app-icon?app_id={path_text}", + "rank": 0 if default_app_path and path_text == default_app_path else 1, + }) + apps.sort(key=lambda item: (item.get("rank", 1000), item.get("label", "").lower())) + return [{k: v for k, v in item.items() if k != "rank"} for item in apps[:40]] + + +def _read_mac_app_info(app_path: Path) -> dict: + info_path = app_path / "Contents" / "Info.plist" + try: + data = plistlib.loads(info_path.read_bytes()) + except Exception: + data = {} + return { + "label": data.get("CFBundleDisplayName") or data.get("CFBundleName") or app_path.stem, + "bundle_id": data.get("CFBundleIdentifier") or "", + "icon_file": data.get("CFBundleIconFile") or "", + } + + +def _mac_app_icon_path(app_path: Path) -> Path | None: + info = _read_mac_app_info(app_path) + icon_name = str(info.get("icon_file") or "").strip() + resources = app_path / "Contents" / "Resources" + candidates = [] + if icon_name: + candidates.append(resources / icon_name) + if not icon_name.endswith(".icns"): + candidates.append(resources / f"{icon_name}.icns") + candidates.extend(resources.glob("*.icns")) + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def _windows_open_app_candidates(file_path: Path | None = None) -> list[dict]: + if os.name != "nt" or file_path is None: + return [] + try: + import ctypes + import shlex + import winreg + except Exception: + return [] + + ext = file_path.suffix.lower() + if not ext: + return [] + + def expand_env(value: str) -> str: + try: + return os.path.expandvars(value) + except Exception: + return value + + def parse_command_exe(command: str) -> str: + command = expand_env((command or "").strip()) + if not command: + return "" + try: + parts = shlex.split(command, posix=False) + if parts: + return parts[0].strip('"') + except Exception: + pass + if command.startswith('"'): + end = command.find('"', 1) + return command[1:end] if end > 1 else "" + return command.split(" ", 1)[0].strip('"') + + def read_reg_value(root, subkey: str, value_name: str = "") -> str: + try: + with winreg.OpenKey(root, subkey) as key: + value, _ = winreg.QueryValueEx(key, value_name) + return str(value or "") + except Exception: + return "" + + def enum_subkey_values(root, subkey: str) -> list[str]: + values: list[str] = [] + try: + with winreg.OpenKey(root, subkey) as key: + idx = 0 + while True: + try: + name, value, _ = winreg.EnumValue(key, idx) + idx += 1 + if name and name != "MRUList": + values.append(str(value or name)) + except OSError: + break + except Exception: + pass + return values + + def command_for_progid(progid: str) -> str: + for root in (winreg.HKEY_CURRENT_USER, winreg.HKEY_CLASSES_ROOT, winreg.HKEY_LOCAL_MACHINE): + for prefix in ("Software\\Classes\\", ""): + command = read_reg_value(root, f"{prefix}{progid}\\shell\\open\\command") + if command: + return command + return "" + + def app_label(exe: Path, default: bool = False) -> str: + stem = exe.stem + label_map = { + "notepad": "记事本", + "code": "Visual Studio Code", + "cursor": "Cursor", + "wordpad": "写字板", + } + label = label_map.get(stem.lower(), stem) + return f"{label}(默认)" if default else label + + candidates: list[tuple[str, bool]] = [] + + # 默认打开程序:Windows Shell 关联查询,等价于系统“打开方式”的默认项。 + try: + ASSOCF_NONE = 0 + ASSOCSTR_EXECUTABLE = 2 + buffer_len = ctypes.c_ulong(0) + ctypes.windll.Shlwapi.AssocQueryStringW( + ASSOCF_NONE, ASSOCSTR_EXECUTABLE, ext, None, None, ctypes.byref(buffer_len) + ) + if buffer_len.value > 0: + buffer = ctypes.create_unicode_buffer(buffer_len.value) + result = ctypes.windll.Shlwapi.AssocQueryStringW( + ASSOCF_NONE, ASSOCSTR_EXECUTABLE, ext, None, buffer, ctypes.byref(buffer_len) + ) + if result == 0 and buffer.value: + candidates.append((buffer.value, True)) + except Exception: + pass + + progids = set() + for root, subkey in ( + (winreg.HKEY_CURRENT_USER, f"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\{ext}\\OpenWithProgids"), + (winreg.HKEY_CLASSES_ROOT, f"{ext}\\OpenWithProgids"), + ): + progids.update(enum_subkey_values(root, subkey)) + default_progid = read_reg_value(winreg.HKEY_CLASSES_ROOT, ext) + if default_progid: + progids.add(default_progid) + user_choice = read_reg_value( + winreg.HKEY_CURRENT_USER, + f"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\{ext}\\UserChoice", + "ProgId", + ) + if user_choice: + progids.add(user_choice) + + for progid in progids: + command = command_for_progid(progid) + exe = parse_command_exe(command) + if exe: + candidates.append((exe, False)) + + app_names = enum_subkey_values( + winreg.HKEY_CURRENT_USER, + f"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\{ext}\\OpenWithList", + ) + app_names += enum_subkey_values( + winreg.HKEY_CLASSES_ROOT, + f"{ext}\\OpenWithList", + ) + for app_name in app_names: + app_key = f"Applications\\{app_name}\\shell\\open\\command" + command = read_reg_value(winreg.HKEY_CLASSES_ROOT, app_key) + exe = parse_command_exe(command) + if exe: + candidates.append((exe, False)) + + seen = set() + apps = [] + for exe, is_default in candidates: + try: + path = Path(expand_env(exe)).expanduser().resolve() + except Exception: + continue + key = str(path).lower() + if key in seen or not path.exists() or path.suffix.lower() != ".exe": + continue + seen.add(key) + apps.append({"id": str(path), "label": app_label(path, is_default)}) + return apps[:40] + + +def _open_file_with_app(file_path: Path, app_id: str) -> bool: + try: + if sys.platform == "darwin": + app_path = Path(app_id).expanduser().resolve() + if not app_path.exists() or app_path.suffix != ".app": + return False + subprocess.Popen(["open", "-a", str(app_path), str(file_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + if os.name == "nt": + exe_path = Path(app_id).expanduser().resolve() + if not exe_path.exists() or exe_path.suffix.lower() != ".exe": + return False + subprocess.Popen([str(exe_path), str(file_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + except Exception: + return False + return False + + @status_bp.route('/api/health') @api_login_required def get_health(): @@ -248,6 +794,229 @@ def get_status(terminal, workspace, username): return jsonify(status) +@status_bp.route('/api/project/git-summary') +@api_login_required +@with_terminal +def get_project_git_summary(terminal, workspace, username): + """返回当前项目自身 git 仓库概览(不使用隐藏版本管理 git)。""" + project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve() + if not project_path.exists(): + return jsonify({"success": True, "data": {"has_git": False}}) + + ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"]) + if not ok or not root_text: + return jsonify({"success": True, "data": {"has_git": False}}) + + repo_root = Path(root_text).expanduser().resolve() + ok, branch = _run_project_git(repo_root, ["branch", "--show-current"]) + if not ok or not branch: + ok, branch = _run_project_git(repo_root, ["rev-parse", "--short", "HEAD"]) + branch = f"HEAD {branch}" if ok and branch else "HEAD" + _, upstream = _run_project_git(repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) + + _, numstat_text = _run_project_git(repo_root, ["diff", "--numstat", "HEAD", "--"]) + additions, deletions = _sum_git_numstat(numstat_text) + + return jsonify({ + "success": True, + "data": { + "has_git": True, + "project_name": repo_root.name, + "project_path": str(repo_root), + "branch": branch, + "upstream": upstream, + "additions": additions, + "deletions": deletions, + } + }) + + +@status_bp.route('/api/project/git-diff') +@api_login_required +@with_terminal +def get_project_git_diff(terminal, workspace, username): + """返回当前项目自身 git 工作区 diff,用于前端侧边栏展示。""" + project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve() + if not project_path.exists(): + return jsonify({"success": True, "data": {"has_git": False, "files": []}}) + + ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"]) + if not ok or not root_text: + return jsonify({"success": True, "data": {"has_git": False, "files": []}}) + + repo_root = Path(root_text).expanduser().resolve() + try: + context_lines = int(request.args.get("context", "3") or 3) + except Exception: + context_lines = 3 + context_lines = max(0, min(200, context_lines)) + context_map: dict[str, int] = {} + raw_context_map = request.args.get("context_map", "").strip() + if raw_context_map: + try: + parsed = json.loads(raw_context_map) + if isinstance(parsed, dict): + for key, value in parsed.items(): + if not isinstance(key, str) or not key: + continue + try: + context_map[key] = max(0, min(200, int(value))) + except Exception: + continue + except Exception: + context_map = {} + fold_context_map: dict[str, dict[str, int]] = {} + raw_fold_context_map = request.args.get("fold_context_map", "").strip() + if raw_fold_context_map: + try: + parsed = json.loads(raw_fold_context_map) + if isinstance(parsed, dict): + for path_key, folds in parsed.items(): + if not isinstance(path_key, str) or not isinstance(folds, dict): + continue + safe_folds: dict[str, int] = {} + for fold_key, value in folds.items(): + if not isinstance(fold_key, str) or not fold_key: + continue + try: + safe_folds[fold_key] = max(0, min(200, int(value))) + except Exception: + continue + if safe_folds: + fold_context_map[path_key] = safe_folds + except Exception: + fold_context_map = {} + + ok, branch = _run_project_git(repo_root, ["branch", "--show-current"]) + if not ok or not branch: + ok, branch = _run_project_git(repo_root, ["rev-parse", "--short", "HEAD"]) + branch = f"HEAD {branch}" if ok and branch else "HEAD" + _, upstream = _run_project_git(repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) + _, numstat_text = _run_project_git(repo_root, ["diff", "--numstat", "HEAD", "--"]) + additions, deletions = _sum_git_numstat(numstat_text) + diff_text = "" + if context_map: + parts: list[str] = [] + for rel_path in _paths_from_git_numstat(numstat_text): + per_file_context = context_map.get(rel_path, context_lines) + ok, file_diff = _run_project_git_raw( + repo_root, + ["--no-pager", "diff", f"--unified={per_file_context}", "HEAD", "--", rel_path], + timeout=8, + ) + if ok and file_diff: + parts.append(file_diff) + diff_text = "\n".join(parts) + else: + ok, diff_text = _run_project_git_raw(repo_root, [f"--no-pager", "diff", f"--unified={context_lines}", "HEAD", "--"], timeout=8) + if not ok: + diff_text = "" + + return jsonify({ + "success": True, + "data": { + "has_git": True, + "project_name": repo_root.name, + "project_path": str(repo_root), + "branch": branch, + "upstream": upstream, + "additions": additions, + "deletions": deletions, + "context": context_lines, + "files": _parse_git_diff(repo_root, diff_text, fold_context_map=fold_context_map), + } + }) + + +@status_bp.route('/api/project/open-in-file-manager', methods=['POST']) +@api_login_required +@with_terminal +def open_project_in_file_manager(terminal, workspace, username): + project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve() + ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"]) + target = Path(root_text).expanduser().resolve() if ok and root_text else project_path + if _open_path_in_file_manager(target): + return jsonify({"success": True}) + return jsonify({"success": False, "error": "无法打开文件管理器"}), 500 + + +@status_bp.route('/api/project/file-open-apps') +@api_login_required +@with_terminal +def list_project_file_open_apps(terminal, workspace, username): + if not _is_host_mode_request(): + return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403 + rel_path = request.args.get("path", "").strip() + target, error = _resolve_project_git_file(workspace, rel_path) + if not target: + return jsonify({"success": False, "error": error or "文件路径无效"}), 400 + if sys.platform == "darwin": + apps = _mac_open_app_candidates(target) + elif os.name == "nt": + apps = _windows_open_app_candidates(target) + else: + apps = [] + return jsonify({"success": True, "data": {"apps": apps}}) + + +@status_bp.route('/api/project/app-icon') +@api_login_required +def get_project_open_app_icon(): + if not _is_host_mode_request(): + return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403 + app_id = request.args.get("app_id", "").strip() + if sys.platform != "darwin" or not app_id: + return jsonify({"success": False, "error": "应用图标不可用"}), 404 + try: + app_path = Path(app_id).expanduser().resolve() + if not app_path.exists() or app_path.suffix != ".app": + return jsonify({"success": False, "error": "应用不存在"}), 404 + icon_path = _mac_app_icon_path(app_path) + if not icon_path: + return jsonify({"success": False, "error": "应用图标不存在"}), 404 + cache_dir = Path(tempfile.gettempdir()) / "agents_app_icons" + cache_dir.mkdir(parents=True, exist_ok=True) + out_path = cache_dir / f"{abs(hash(str(app_path)))}.png" + if not out_path.exists() or out_path.stat().st_mtime < icon_path.stat().st_mtime: + sips = shutil.which("sips") + if not sips: + return jsonify({"success": False, "error": "图标转换工具不可用"}), 404 + subprocess.run( + [sips, "-s", "format", "png", str(icon_path), "--out", str(out_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + check=False, + ) + if out_path.exists(): + return send_file(str(out_path), mimetype="image/png") + except Exception: + pass + return jsonify({"success": False, "error": "应用图标不可用"}), 404 + + +@status_bp.route('/api/project/open-file-with-app', methods=['POST']) +@api_login_required +@with_terminal +def open_project_file_with_app(terminal, workspace, username): + if not _is_host_mode_request(): + return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403 + payload = request.get_json(silent=True) or {} + rel_path = str(payload.get("path") or "").strip() + app_id = str(payload.get("app_id") or "").strip() + if not app_id: + return jsonify({"success": False, "error": "未选择应用"}), 400 + target, error = _resolve_project_git_file(workspace, rel_path) + if not target: + return jsonify({"success": False, "error": error or "文件路径无效"}), 400 + available = _mac_open_app_candidates(target) if sys.platform == "darwin" else _windows_open_app_candidates(target) if os.name == "nt" else [] + if not any(item.get("id") == app_id for item in available): + return jsonify({"success": False, "error": "应用不可用"}), 400 + if _open_file_with_app(target, app_id): + return jsonify({"success": True}) + return jsonify({"success": False, "error": "打开文件失败"}), 500 + + @status_bp.route('/api/host/workspaces') @api_login_required def list_host_workspaces(): diff --git a/static/icons/file-pen.svg b/static/icons/file-pen.svg new file mode 100644 index 0000000..aa5cc3a --- /dev/null +++ b/static/icons/file-pen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/src/App.vue b/static/src/App.vue index 427b0ee..9be8e59 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -304,6 +304,7 @@ :current-context-tokens="currentContextTokens" :versioning-enabled="versioningEnabled" :runtime-queued-messages="runtimeQueuedMessages" + :project-git-summary="projectGitSummary" :user-question-minimized="userQuestionMinimized" :pending-user-question-count="pendingUserQuestions.length" :goal-mode-armed="goalModeArmed" @@ -346,11 +347,31 @@ @composer-height-change="handleComposerHeightChange" @toggle-goal-mode="handleToggleGoalMode" @open-goal-dialog="goalDialogOpen = true" + @open-git-changes-panel="openGitChangesPanel" />
+