feat(ui): add project git status panel
This commit is contained in:
parent
6d5630c820
commit
6f78277a03
@ -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:
|
||||
|
||||
769
server/status.py
769
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():
|
||||
|
||||
1
static/icons/file-pen.svg
Normal file
1
static/icons/file-pen.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M12.5 22H18a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v9.5"/><path d="M14 2v4a2 2 0 0 0 2 2h4m-6.622 7.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z"/></svg>
|
||||
|
After Width: | Height: | Size: 426 B |
@ -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"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<div
|
||||
v-if="!isMobileViewport && rightCollapsed"
|
||||
v-if="!isMobileViewport && gitChangesPanelOpen"
|
||||
class="resize-handle resize-handle--git-changes"
|
||||
@mousedown="startResize('right', $event)"
|
||||
></div>
|
||||
<transition name="git-changes-panel-slide">
|
||||
<GitChangesPanel
|
||||
v-if="!isMobileViewport && gitChangesPanelOpen"
|
||||
:width="Math.min(rightWidth || 420, 600)"
|
||||
:loading="gitChangesLoading"
|
||||
:error="gitChangesError"
|
||||
:diff="gitChangesDiff"
|
||||
:fold-contexts="gitChangesFoldContexts"
|
||||
:host-mode="versioningHostMode"
|
||||
@close="closeGitChangesPanel"
|
||||
@expand-context="expandGitChangesContext"
|
||||
@reset-context="resetGitChangesContext"
|
||||
/>
|
||||
</transition>
|
||||
<div
|
||||
v-if="!isMobileViewport && rightCollapsed && !gitChangesPanelOpen"
|
||||
class="resize-handle"
|
||||
@mousedown="startResize('right', $event)"
|
||||
></div>
|
||||
|
||||
@ -3,6 +3,7 @@ import ChatArea from '../components/chat/ChatArea.vue';
|
||||
import ConversationSidebar from '../components/sidebar/ConversationSidebar.vue';
|
||||
import LeftPanel from '../components/panels/LeftPanel.vue';
|
||||
import ToolApprovalPanel from '../components/panels/ToolApprovalPanel.vue';
|
||||
import GitChangesPanel from '../components/panels/GitChangesPanel.vue';
|
||||
import TokenDrawer from '../components/token/TokenDrawer.vue';
|
||||
import QuickMenu from '../components/input/QuickMenu.vue';
|
||||
import InputComposer from '../components/input/InputComposer.vue';
|
||||
@ -48,6 +49,7 @@ export const appComponents = {
|
||||
ConversationSidebar,
|
||||
LeftPanel,
|
||||
ToolApprovalPanel,
|
||||
GitChangesPanel,
|
||||
TokenDrawer,
|
||||
PersonalizationDrawer,
|
||||
QuickMenu,
|
||||
|
||||
@ -41,6 +41,7 @@ export async function mounted() {
|
||||
|
||||
// 立即加载初始数据(并行获取状态,优先同步运行模式)
|
||||
const initialDataPromise = this.loadInitialData();
|
||||
this.refreshProjectGitSummary?.();
|
||||
this.startConnectionHeartbeat();
|
||||
this.checkTutorialPrompt();
|
||||
if (initialDataPromise && typeof initialDataPromise.then === 'function') {
|
||||
|
||||
@ -130,6 +130,10 @@ export const conversationMethods = {
|
||||
this._autoRelockCooldownUntil = 0;
|
||||
this.$nextTick(() => {
|
||||
this.ensureScrollListener();
|
||||
const composerRef = typeof this.getInputComposerRef === 'function' ? this.getInputComposerRef() : null;
|
||||
if (composerRef && typeof composerRef.emitComposerHeight === 'function') {
|
||||
composerRef.emitComposerHeight();
|
||||
}
|
||||
});
|
||||
|
||||
// 重置已加载对话标记,便于后续重新加载新对话历史
|
||||
@ -334,6 +338,7 @@ export const conversationMethods = {
|
||||
// 2. 更新当前对话信息
|
||||
this.skipConversationHistoryReload = true;
|
||||
this.currentConversationId = conversationId;
|
||||
this.refreshProjectGitSummary?.();
|
||||
this.currentConversationTitle = result.title;
|
||||
this.titleReady = true;
|
||||
this.suppressTitleTyping = false;
|
||||
|
||||
@ -1299,6 +1299,10 @@ export const taskPollingMethods = {
|
||||
|
||||
if (data.status) {
|
||||
targetAction.tool.status = data.status;
|
||||
const terminalStatuses = new Set(['completed', 'failed', 'timeout', 'terminated', 'cancelled', 'canceled']);
|
||||
if (terminalStatuses.has(String(data.status))) {
|
||||
this.refreshProjectGitSummary?.();
|
||||
}
|
||||
}
|
||||
if (data.result !== undefined) {
|
||||
targetAction.tool.result = data.result;
|
||||
|
||||
@ -154,6 +154,121 @@ function parseSystemNoticeLabel(rawContent: any): string | null {
|
||||
}
|
||||
|
||||
export const uiMethods = {
|
||||
async refreshProjectGitSummary() {
|
||||
try {
|
||||
const resp = await fetch('/api/project/git-summary');
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !payload?.success) {
|
||||
this.projectGitSummary = null;
|
||||
this.gitChangesDiff = null;
|
||||
return;
|
||||
}
|
||||
this.projectGitSummary = payload.data || null;
|
||||
if (this.gitChangesPanelOpen) {
|
||||
this.loadGitChangesDiff?.();
|
||||
}
|
||||
} catch (_) {
|
||||
this.projectGitSummary = null;
|
||||
this.gitChangesDiff = null;
|
||||
}
|
||||
},
|
||||
|
||||
async loadGitChangesDiff() {
|
||||
if (!this.gitChangesPanelOpen) return;
|
||||
const hasExistingDiff = Boolean(this.gitChangesDiff?.has_git);
|
||||
this.gitChangesLoading = true;
|
||||
this.gitChangesError = '';
|
||||
try {
|
||||
const context = Math.max(0, Math.min(200, Number(this.gitChangesContext || 3)));
|
||||
const foldContextMap =
|
||||
this.gitChangesFoldContexts && typeof this.gitChangesFoldContexts === 'object'
|
||||
? this.gitChangesFoldContexts
|
||||
: {};
|
||||
const params = new URLSearchParams();
|
||||
params.set('context', String(context));
|
||||
params.set('_', String(Date.now()));
|
||||
if (Object.keys(foldContextMap).length > 0) {
|
||||
params.set('fold_context_map', JSON.stringify(foldContextMap));
|
||||
}
|
||||
const resp = await fetch(
|
||||
`/api/project/git-diff?${params.toString()}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !payload?.success) {
|
||||
this.gitChangesError = payload?.error || '加载 Git 变更失败';
|
||||
if (!hasExistingDiff) {
|
||||
this.gitChangesDiff = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.gitChangesDiff = payload.data || null;
|
||||
if (!this.gitChangesDiff?.has_git) {
|
||||
this.gitChangesPanelOpen = false;
|
||||
}
|
||||
} catch (error) {
|
||||
this.gitChangesError = '加载 Git 变更失败';
|
||||
if (!hasExistingDiff) {
|
||||
this.gitChangesDiff = null;
|
||||
}
|
||||
} finally {
|
||||
this.gitChangesLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
openGitChangesPanel() {
|
||||
if (!this.projectGitSummary?.has_git) return;
|
||||
this.gitChangesPanelOpen = !this.gitChangesPanelOpen;
|
||||
if (this.gitChangesPanelOpen) {
|
||||
this.gitChangesContext = 3;
|
||||
this.gitChangesFileContexts = {};
|
||||
this.gitChangesFoldContexts = {};
|
||||
this.loadGitChangesDiff?.();
|
||||
}
|
||||
},
|
||||
|
||||
closeGitChangesPanel() {
|
||||
this.gitChangesPanelOpen = false;
|
||||
},
|
||||
|
||||
expandGitChangesContext(payload) {
|
||||
const path = typeof payload === 'object' ? payload?.path : payload;
|
||||
const foldKey = typeof payload === 'object' ? payload?.foldKey : '';
|
||||
const filePath = String(path || '').trim();
|
||||
const key = String(foldKey || '').trim();
|
||||
if (!filePath || !key) return;
|
||||
const currentMap =
|
||||
this.gitChangesFoldContexts && typeof this.gitChangesFoldContexts === 'object'
|
||||
? this.gitChangesFoldContexts
|
||||
: {};
|
||||
const fileMap = currentMap[filePath] && typeof currentMap[filePath] === 'object' ? currentMap[filePath] : {};
|
||||
const current = Number(fileMap[key] || 0);
|
||||
this.gitChangesFoldContexts = {
|
||||
...currentMap,
|
||||
[filePath]: {
|
||||
...fileMap,
|
||||
[key]: Math.min(200, (Number.isFinite(current) ? current : 0) + 20)
|
||||
}
|
||||
};
|
||||
this.loadGitChangesDiff?.();
|
||||
},
|
||||
|
||||
resetGitChangesContext(path) {
|
||||
const filePath = String(path || '').trim();
|
||||
if (!filePath) return;
|
||||
const currentMap =
|
||||
this.gitChangesFoldContexts && typeof this.gitChangesFoldContexts === 'object'
|
||||
? this.gitChangesFoldContexts
|
||||
: {};
|
||||
if (!currentMap[filePath]) {
|
||||
return;
|
||||
}
|
||||
const nextMap = { ...currentMap };
|
||||
delete nextMap[filePath];
|
||||
this.gitChangesFoldContexts = nextMap;
|
||||
this.loadGitChangesDiff?.();
|
||||
},
|
||||
|
||||
clearLocalTaskUiState(reason = 'safe-navigation') {
|
||||
// 只清理当前浏览器视图里的运行态/工具态,不取消后端任务。
|
||||
// 用于切换对话/工作区后,避免旧任务残留让新视图的发送按钮显示为“停止”。
|
||||
@ -1084,6 +1199,7 @@ export const uiMethods = {
|
||||
: (switchedLabel || targetId),
|
||||
type: 'success'
|
||||
});
|
||||
this.refreshProjectGitSummary?.();
|
||||
} catch (error) {
|
||||
this.conversations = previousConversationList;
|
||||
this.searchActive = previousSearchActive;
|
||||
|
||||
@ -36,6 +36,14 @@ export function dataState() {
|
||||
runtimeQueueAutoSendInProgress: false,
|
||||
runtimeQueueSyncLockKey: '',
|
||||
runtimeQueueSyncLockUntil: 0,
|
||||
projectGitSummary: null,
|
||||
gitChangesPanelOpen: false,
|
||||
gitChangesLoading: false,
|
||||
gitChangesError: '',
|
||||
gitChangesContext: 3,
|
||||
gitChangesFileContexts: {},
|
||||
gitChangesFoldContexts: {},
|
||||
gitChangesDiff: null,
|
||||
// 输入区动态保留高度(用于同步扩大消息区可滚动范围)
|
||||
composerReservedHeight: 80,
|
||||
// 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次
|
||||
|
||||
@ -592,6 +592,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-bottom-spacer" aria-hidden="true"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -1,24 +1,69 @@
|
||||
<template>
|
||||
<div class="input-area compact-input-area" ref="inputAreaRoot">
|
||||
<div class="stadium-input-wrapper" ref="stadiumShellOuter">
|
||||
<transition name="goal-banner-fade">
|
||||
<transition name="floating-status-motion">
|
||||
<div
|
||||
v-if="goalModeArmed || goalRunning || goalCompleted"
|
||||
class="goal-mode-banner"
|
||||
:class="{
|
||||
'goal-mode-banner--running': goalRunning,
|
||||
'goal-mode-banner--done': goalCompleted && !goalRunning,
|
||||
'goal-mode-banner--clickable': goalRunning || goalCompleted,
|
||||
'goal-mode-banner--collapsed': goalBannerCollapsed
|
||||
}"
|
||||
:title="goalBannerCollapsed ? goalBannerTitle : undefined"
|
||||
@click.stop="goalRunning || goalCompleted ? $emit('open-goal-dialog') : null"
|
||||
v-if="floatingStatusVisible"
|
||||
class="floating-project-status"
|
||||
@click.stop
|
||||
>
|
||||
<span class="goal-mode-banner__dot" aria-hidden="true"></span>
|
||||
<span class="goal-mode-banner__text">
|
||||
<template v-if="goalRunning">目标模式运行中</template>
|
||||
<template v-else-if="goalCompleted">目标模式完成</template>
|
||||
<template v-else>目标模式已就绪</template>
|
||||
<span v-if="projectGitSummaryForRender" class="floating-project-status__left">
|
||||
<span class="floating-project-status__menu-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="floating-project-status__button floating-project-status__button--project"
|
||||
:class="{ 'floating-project-status__button--active': projectGitMenuOpen === 'project' }"
|
||||
@click.stop="toggleProjectGitMenu('project')"
|
||||
>
|
||||
{{ projectGitSummaryForRender.projectName }}
|
||||
</button>
|
||||
<div
|
||||
v-if="projectGitMenuOpen === 'project'"
|
||||
class="floating-project-status__submenu floating-project-status__submenu--project"
|
||||
@click.stop
|
||||
>
|
||||
<button type="button" @click.stop="openProjectInFileManager">在文件管理器中打开</button>
|
||||
<button type="button" @click.stop="copyProjectPath">复制地址</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="floating-project-status__menu-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="floating-project-status__button floating-project-status__button--branch"
|
||||
:class="{ 'floating-project-status__button--active': projectGitMenuOpen === 'branch' }"
|
||||
@click.stop="toggleProjectGitMenu('branch')"
|
||||
>
|
||||
{{ projectGitSummaryForRender.branch }}
|
||||
</button>
|
||||
<div
|
||||
v-if="projectGitMenuOpen === 'branch'"
|
||||
class="floating-project-status__submenu floating-project-status__submenu--branch"
|
||||
@click.stop
|
||||
>
|
||||
<button type="button" @click.stop="copyProjectBranch">复制分支名称</button>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
<span class="floating-project-status__right">
|
||||
<span v-if="goalRunning" class="floating-project-status__notice">目标模式运行中</span>
|
||||
<span v-else-if="goalModeArmed" class="floating-project-status__notice">目标模式就绪</span>
|
||||
<button
|
||||
v-if="userQuestionMinimized && pendingUserQuestionCount > 0"
|
||||
type="button"
|
||||
class="floating-project-status__notice floating-project-status__notice--button"
|
||||
@click.stop="$emit('restore-user-question')"
|
||||
>
|
||||
等待回答问题
|
||||
</button>
|
||||
<button
|
||||
v-if="projectGitSummaryForRender"
|
||||
type="button"
|
||||
class="floating-project-status__stats"
|
||||
@click.stop="$emit('open-git-changes-panel')"
|
||||
>
|
||||
<span class="floating-project-status__add">+{{ projectGitSummaryForRender.additions }}</span>
|
||||
<span class="floating-project-status__del">-{{ projectGitSummaryForRender.deletions }}</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</transition>
|
||||
@ -167,15 +212,6 @@
|
||||
<span v-if="showStopIcon" class="stop-icon"></span>
|
||||
<span v-else class="send-icon"></span>
|
||||
</button>
|
||||
<button
|
||||
v-if="userQuestionMinimized && pendingUserQuestionCount > 0"
|
||||
type="button"
|
||||
class="user-question-mini-dot"
|
||||
aria-label="打开待回答问题"
|
||||
@click.stop="$emit('restore-user-question')"
|
||||
>
|
||||
{{ pendingUserQuestionCount }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -372,7 +408,8 @@ const emit = defineEmits([
|
||||
'composer-height-change',
|
||||
'restore-user-question',
|
||||
'toggle-goal-mode',
|
||||
'open-goal-dialog'
|
||||
'open-goal-dialog',
|
||||
'open-git-changes-panel'
|
||||
]);
|
||||
|
||||
const props = defineProps<{
|
||||
@ -425,6 +462,14 @@ const props = defineProps<{
|
||||
currentContextTokens: number;
|
||||
versioningEnabled?: boolean;
|
||||
runtimeQueuedMessages?: Array<{ id: string; text: string }>;
|
||||
projectGitSummary?: {
|
||||
has_git?: boolean;
|
||||
project_name?: string;
|
||||
project_path?: string;
|
||||
branch?: string;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
} | null;
|
||||
userQuestionMinimized?: boolean;
|
||||
pendingUserQuestionCount?: number;
|
||||
goalModeArmed?: boolean;
|
||||
@ -495,6 +540,69 @@ const runtimeQueuedMessagesForRender = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
const projectGitSummaryForRender = computed(() => {
|
||||
if (personalizationStore?.form?.show_git_status_bar === false) return null;
|
||||
const summary = props.projectGitSummary;
|
||||
if (!summary?.has_git) return null;
|
||||
const projectName = String(summary.project_name || '').trim();
|
||||
const branch = String(summary.branch || '').trim();
|
||||
if (!projectName || !branch) return null;
|
||||
return {
|
||||
projectName,
|
||||
projectPath: String(summary.project_path || '').trim(),
|
||||
branch,
|
||||
additions: Math.max(0, Number(summary.additions || 0)),
|
||||
deletions: Math.max(0, Number(summary.deletions || 0))
|
||||
};
|
||||
});
|
||||
|
||||
const floatingStatusVisible = computed(() => {
|
||||
if (skillSlashMenuOpen.value || props.quickMenuOpen) return false;
|
||||
return (
|
||||
!!projectGitSummaryForRender.value ||
|
||||
!!props.goalRunning ||
|
||||
!!props.goalModeArmed ||
|
||||
(!!props.userQuestionMinimized && Number(props.pendingUserQuestionCount || 0) > 0)
|
||||
);
|
||||
});
|
||||
|
||||
const projectGitMenuOpen = ref<null | 'project' | 'branch'>(null);
|
||||
|
||||
const closeProjectGitMenu = () => {
|
||||
projectGitMenuOpen.value = null;
|
||||
};
|
||||
|
||||
const toggleProjectGitMenu = (menu: 'project' | 'branch') => {
|
||||
projectGitMenuOpen.value = projectGitMenuOpen.value === menu ? null : menu;
|
||||
};
|
||||
|
||||
const copyTextToClipboard = async (text: string) => {
|
||||
const value = String(text || '').trim();
|
||||
if (!value || typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(value);
|
||||
closeProjectGitMenu();
|
||||
};
|
||||
|
||||
const copyProjectPath = () => {
|
||||
copyTextToClipboard(projectGitSummaryForRender.value?.projectPath || '');
|
||||
};
|
||||
|
||||
const copyProjectBranch = () => {
|
||||
copyTextToClipboard(projectGitSummaryForRender.value?.branch || '');
|
||||
};
|
||||
|
||||
const openProjectInFileManager = async () => {
|
||||
try {
|
||||
await fetch('/api/project/open-in-file-manager', { method: 'POST' });
|
||||
} catch (error) {
|
||||
console.warn('打开项目文件管理器失败:', error);
|
||||
} finally {
|
||||
closeProjectGitMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const findSlashToken = () => {
|
||||
const instance = editor.value;
|
||||
if (!instance) return null;
|
||||
@ -781,6 +889,7 @@ const refreshSkillSlashState = () => {
|
||||
closeSlashMenu();
|
||||
return;
|
||||
}
|
||||
closeProjectGitMenu();
|
||||
skillSlashOpen.value = true;
|
||||
if (skillSlashQuery.value !== token.query) {
|
||||
skillSlashActiveIndex.value = 0;
|
||||
@ -1309,7 +1418,7 @@ const hasRuntimeLayoutExpansion = computed(() => {
|
||||
const hasQueue = runtimeQueuedMessagesForRender.value.length > 0;
|
||||
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
|
||||
const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
|
||||
return hasQueue || skillSlashOpen.value || hasImages || hasVideos || !!props.inputIsMultiline || !!props.goalModeArmed || !!props.goalRunning || goalCompleted.value;
|
||||
return hasQueue || floatingStatusVisible.value || skillSlashOpen.value || hasImages || hasVideos || !!props.inputIsMultiline || !!props.goalModeArmed || !!props.goalRunning || goalCompleted.value;
|
||||
});
|
||||
|
||||
const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done');
|
||||
@ -1353,9 +1462,14 @@ const collectComposerVisualHeight = () => {
|
||||
const shellRect = shell.getBoundingClientRect();
|
||||
let top = shellRect.top;
|
||||
let bottom = shellRect.bottom;
|
||||
const slashMenu = root.querySelector('.skill-slash-menu');
|
||||
if (slashMenu instanceof HTMLElement) {
|
||||
const rect = slashMenu.getBoundingClientRect();
|
||||
return Math.max(0, Math.max(shellRect.bottom, rect.bottom) - Math.min(shellRect.top, rect.top));
|
||||
}
|
||||
// 收起态横幅是脱离流、浮在角上的小圆点,不应计入为聊天区预留的高度,
|
||||
// 否则会把消息区可滚动范围顶高。故排除 .goal-mode-banner--collapsed。
|
||||
const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty), .skill-slash-menu, .goal-mode-banner:not(.goal-mode-banner--collapsed)');
|
||||
const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty), .floating-project-status');
|
||||
nodes.forEach((node) => {
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
@ -1365,12 +1479,27 @@ const collectComposerVisualHeight = () => {
|
||||
return Math.max(0, bottom - top);
|
||||
};
|
||||
|
||||
const collectComposerShellHeight = () => {
|
||||
const shell = compactInputShell.value;
|
||||
if (!shell) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, shell.getBoundingClientRect().height);
|
||||
};
|
||||
|
||||
const emitComposerHeight = () => {
|
||||
const visualHeight = collectComposerVisualHeight();
|
||||
if (!visualHeight) {
|
||||
return;
|
||||
}
|
||||
if (baselineComposerVisualHeight.value <= 0 || !hasRuntimeLayoutExpansion.value) {
|
||||
const shellHeight = collectComposerShellHeight();
|
||||
if (!hasRuntimeLayoutExpansion.value) {
|
||||
baselineComposerVisualHeight.value = visualHeight;
|
||||
} else if (baselineComposerVisualHeight.value <= 0 && shellHeight > 0) {
|
||||
baselineComposerVisualHeight.value = shellHeight;
|
||||
} else if (baselineComposerVisualHeight.value > 0 && shellHeight > 0) {
|
||||
baselineComposerVisualHeight.value = Math.min(baselineComposerVisualHeight.value, shellHeight);
|
||||
} else if (baselineComposerVisualHeight.value <= 0) {
|
||||
baselineComposerVisualHeight.value = visualHeight;
|
||||
}
|
||||
const baseline = baselineComposerVisualHeight.value || visualHeight;
|
||||
@ -1529,6 +1658,7 @@ defineExpose({
|
||||
compactInputShell,
|
||||
stadiumInput,
|
||||
fileUploadInput,
|
||||
emitComposerHeight,
|
||||
prepareMessageForSend,
|
||||
getComposerDraftMeta,
|
||||
restoreComposerDraftMeta
|
||||
@ -1586,6 +1716,26 @@ watch(
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.projectGitSummary,
|
||||
async () => {
|
||||
if (!projectGitSummaryForRender.value) {
|
||||
closeProjectGitMenu();
|
||||
}
|
||||
await nextTick();
|
||||
emitComposerHeight();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(floatingStatusVisible, async () => {
|
||||
await nextTick();
|
||||
emitComposerHeight();
|
||||
window.setTimeout(() => {
|
||||
emitComposerHeight();
|
||||
}, 280);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.selectedImages?.length || 0, props.selectedVideos?.length || 0],
|
||||
async () => {
|
||||
@ -1602,6 +1752,7 @@ watch(goalBannerCollapsed, async () => {
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeProjectGitMenu);
|
||||
adjustTextareaSize();
|
||||
nextTick(() => {
|
||||
emitComposerHeight();
|
||||
@ -1623,6 +1774,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', closeProjectGitMenu);
|
||||
editor.value?.destroy();
|
||||
runtimeQueueItemRefs.forEach((el) => {
|
||||
if (el) {
|
||||
@ -1653,6 +1805,207 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-project-status {
|
||||
position: absolute;
|
||||
left: calc(50% - 7px);
|
||||
bottom: calc(100% + 6px);
|
||||
z-index: 45;
|
||||
width: 90%;
|
||||
max-width: 90%;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
border: 1px solid var(--claude-border);
|
||||
border-radius: 14px;
|
||||
background: var(--theme-surface-muted);
|
||||
box-shadow: none;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.floating-project-status__left,
|
||||
.floating-project-status__right {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.floating-project-status__left {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.floating-project-status__right {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.floating-project-status__menu-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.floating-project-status__button,
|
||||
.floating-project-status__notice {
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--claude-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.floating-project-status__button {
|
||||
min-width: 0;
|
||||
padding: 0 9px;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.floating-project-status__button:hover,
|
||||
.floating-project-status__button--active,
|
||||
.floating-project-status__notice--button:hover,
|
||||
.floating-project-status__submenu button:hover {
|
||||
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
|
||||
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
|
||||
}
|
||||
|
||||
.floating-project-status__button--project {
|
||||
max-width: min(260px, 36vw);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.floating-project-status__button--branch {
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.floating-project-status__notice {
|
||||
padding: 0 8px;
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.floating-project-status__notice--button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.floating-project-status__stats {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.floating-project-status__stats:hover {
|
||||
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
|
||||
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
|
||||
}
|
||||
|
||||
.floating-project-status__add {
|
||||
color: var(--git-diff-add-text);
|
||||
}
|
||||
|
||||
.floating-project-status__del {
|
||||
color: var(--git-diff-del-text);
|
||||
}
|
||||
|
||||
.floating-project-status__submenu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
z-index: 46;
|
||||
width: max-content;
|
||||
min-width: 160px;
|
||||
max-width: min(260px, 72vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--claude-border);
|
||||
border-radius: 14px;
|
||||
background: var(--theme-surface-muted);
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.floating-project-status__submenu button {
|
||||
width: 100%;
|
||||
min-width: 148px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--claude-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark'] .floating-project-status),
|
||||
:global(body[data-theme='dark'] .floating-project-status),
|
||||
:global(html[data-theme='dark'] .floating-project-status__submenu),
|
||||
:global(body[data-theme='dark'] .floating-project-status__submenu) {
|
||||
background: #2a2a2a !important;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark'] .floating-project-status__button:hover),
|
||||
:global(body[data-theme='dark'] .floating-project-status__button:hover),
|
||||
:global(html[data-theme='dark'] .floating-project-status__notice--button:hover),
|
||||
:global(body[data-theme='dark'] .floating-project-status__notice--button:hover),
|
||||
:global(html[data-theme='dark'] .floating-project-status__stats:hover),
|
||||
:global(body[data-theme='dark'] .floating-project-status__stats:hover),
|
||||
:global(html[data-theme='dark'] .floating-project-status__submenu button:hover),
|
||||
:global(body[data-theme='dark'] .floating-project-status__submenu button:hover) {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.floating-status-motion-enter-active,
|
||||
.floating-status-motion-leave-active {
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 260ms cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
|
||||
.floating-status-motion-enter-from,
|
||||
.floating-status-motion-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(44px);
|
||||
}
|
||||
|
||||
.floating-status-motion-enter-to,
|
||||
.floating-status-motion-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
/* 目标模式横幅:与 + 按钮左对齐,置于输入框上方 */
|
||||
.goal-mode-banner {
|
||||
display: inline-flex;
|
||||
|
||||
224
static/src/components/panels/GitChangesPanel.vue
Normal file
224
static/src/components/panels/GitChangesPanel.vue
Normal file
@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<aside class="git-changes-panel" :style="{ width: width + 'px' }">
|
||||
<header class="git-changes-panel__header">
|
||||
<div class="git-changes-panel__title">
|
||||
<span class="git-changes-panel__branch">{{ branchLabel }}</span>
|
||||
<span class="git-changes-panel__arrow">→</span>
|
||||
<span class="git-changes-panel__upstream">{{ upstreamLabel }}</span>
|
||||
</div>
|
||||
<div class="git-changes-panel__summary">
|
||||
<span class="git-changes-panel__add">+{{ additions }}</span>
|
||||
<span class="git-changes-panel__del">-{{ deletions }}</span>
|
||||
<button type="button" class="git-changes-panel__close" aria-label="关闭 Git 变更面板" @click="$emit('close')">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="git-changes-panel__body">
|
||||
<div v-if="loading && !files.length" class="git-changes-panel__empty">正在加载 Git 变更…</div>
|
||||
<div v-else-if="error && !files.length" class="git-changes-panel__empty git-changes-panel__empty--error">{{ error }}</div>
|
||||
<div v-else-if="!files.length" class="git-changes-panel__empty">当前没有未提交变更</div>
|
||||
<section v-for="file in files" v-else :key="file.path" class="git-change-file">
|
||||
<div class="git-change-file__header">
|
||||
<span class="git-change-file__title">
|
||||
<span class="git-change-file__open-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="git-change-file__open-btn"
|
||||
:class="{ open: openMenuPath === file.path || appsLoadingPath === file.path }"
|
||||
:disabled="!hostMode"
|
||||
:title="hostMode ? '用应用打开文件' : 'Docker 模式不可用'"
|
||||
@click.stop.prevent="toggleOpenMenu(file.path)"
|
||||
>
|
||||
<img class="git-change-file__open-icon" :src="filePenIcon" alt="" aria-hidden="true" />
|
||||
<span class="git-change-file__open-caret" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div
|
||||
v-if="openMenuPath === file.path"
|
||||
class="git-change-file__open-menu"
|
||||
@click.stop
|
||||
>
|
||||
<div v-if="openAppsError" class="git-change-file__open-empty">{{ openAppsError }}</div>
|
||||
<button
|
||||
v-for="app in openApps"
|
||||
v-else
|
||||
:key="app.id"
|
||||
type="button"
|
||||
class="git-change-file__open-option"
|
||||
@click.stop.prevent="openFileWithApp(file.path, app.id)"
|
||||
>
|
||||
<img
|
||||
class="git-change-file__open-option-icon"
|
||||
:src="app.icon_url || filePenIcon"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ app.label }}</span>
|
||||
</button>
|
||||
<div v-if="!appsLoadingPath && !openAppsError && !openApps.length" class="git-change-file__open-empty">
|
||||
未检测到可用应用
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<span class="git-change-file__path">{{ file.path }}</span>
|
||||
<button
|
||||
v-if="canResetContext(file.path)"
|
||||
type="button"
|
||||
class="git-change-file__reset"
|
||||
@click.stop.prevent="handleResetContext(file.path)"
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
</span>
|
||||
<span class="git-change-file__stats">
|
||||
<span class="git-changes-panel__add">+{{ file.additions || 0 }}</span>
|
||||
<span class="git-changes-panel__del">-{{ file.deletions || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="git-change-file__diff">
|
||||
<div class="git-change-file__diff-inner">
|
||||
<template v-for="(hunk, hunkIndex) in file.hunks" :key="`${file.path}-${hunkIndex}`">
|
||||
<button
|
||||
v-if="hunk.before_hidden > 0"
|
||||
type="button"
|
||||
class="git-change-file__fold"
|
||||
@click.stop.prevent="handleExpandContext(file.path, hunk.before_fold_key)"
|
||||
>
|
||||
<span class="git-change-file__fold-content">
|
||||
<span class="git-change-file__fold-icon git-change-file__fold-icon--up" aria-hidden="true"></span>
|
||||
<span>{{ hunk.before_hidden }} 行未编辑的内容</span>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-for="(line, lineIndex) in hunk.lines"
|
||||
:key="`${file.path}-${hunkIndex}-${lineIndex}`"
|
||||
class="git-change-line"
|
||||
:class="{
|
||||
'git-change-line--add': line.kind === 'add',
|
||||
'git-change-line--delete': line.kind === 'delete'
|
||||
}"
|
||||
>
|
||||
<span class="git-change-line__num">{{ formatLineNumber(line) }}</span>
|
||||
<span class="git-change-line__marker">{{ lineMarker(line.kind) }}</span>
|
||||
<span class="git-change-line__text">{{ line.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
v-if="file.after_hidden > 0"
|
||||
type="button"
|
||||
class="git-change-file__fold"
|
||||
@click.stop.prevent="handleExpandContext(file.path, file.after_fold_key || 'after')"
|
||||
>
|
||||
<span class="git-change-file__fold-content">
|
||||
<span class="git-change-file__fold-icon git-change-file__fold-icon--down" aria-hidden="true"></span>
|
||||
<span>{{ file.after_hidden }} 行未编辑的内容</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineOptions({ name: 'GitChangesPanel' });
|
||||
|
||||
const props = defineProps<{
|
||||
width: number;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
hostMode?: boolean;
|
||||
foldContexts?: Record<string, Record<string, number>>;
|
||||
diff: Record<string, any> | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void;
|
||||
(event: 'expand-context', payload: { path: string; foldKey: string }): void;
|
||||
(event: 'reset-context', path: string): void;
|
||||
}>();
|
||||
|
||||
const branchLabel = computed(() => String(props.diff?.branch || 'HEAD'));
|
||||
const upstreamLabel = computed(() => String(props.diff?.upstream || '未设置云端分支'));
|
||||
const additions = computed(() => Math.max(0, Number(props.diff?.additions || 0)));
|
||||
const deletions = computed(() => Math.max(0, Number(props.diff?.deletions || 0)));
|
||||
const files = computed(() => (Array.isArray(props.diff?.files) ? props.diff.files : []));
|
||||
const canResetContext = (path: string) => {
|
||||
const fileMap = props.foldContexts?.[path];
|
||||
return !!fileMap && Object.values(fileMap).some((value) => Number(value || 0) > 0);
|
||||
};
|
||||
const filePenIcon = new URL('../../../icons/file-pen.svg', import.meta.url).href;
|
||||
const openMenuPath = ref('');
|
||||
const openApps = ref<Array<{ id: string; label: string; icon_url?: string }>>([]);
|
||||
const openAppsError = ref('');
|
||||
const appsLoadingPath = ref('');
|
||||
|
||||
const formatLineNumber = (line: Record<string, any>) => {
|
||||
const rawValue = line?.kind === 'delete' ? line.old_line : line?.new_line;
|
||||
const num = Number(rawValue);
|
||||
return Number.isFinite(num) && num > 0 ? String(num) : '';
|
||||
};
|
||||
|
||||
const lineMarker = (kind: string) => {
|
||||
if (kind === 'add') return '+';
|
||||
if (kind === 'delete') return '-';
|
||||
return ' ';
|
||||
};
|
||||
|
||||
const handleExpandContext = (path: string, foldKey: string) => {
|
||||
emit('expand-context', { path, foldKey });
|
||||
};
|
||||
|
||||
const handleResetContext = (path: string) => {
|
||||
emit('reset-context', path);
|
||||
};
|
||||
|
||||
const toggleOpenMenu = async (path: string) => {
|
||||
if (!props.hostMode) return;
|
||||
if (openMenuPath.value === path) {
|
||||
openMenuPath.value = '';
|
||||
return;
|
||||
}
|
||||
openMenuPath.value = '';
|
||||
openApps.value = [];
|
||||
openAppsError.value = '';
|
||||
appsLoadingPath.value = path;
|
||||
try {
|
||||
const resp = await fetch(`/api/project/file-open-apps?path=${encodeURIComponent(path)}`);
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !payload?.success) {
|
||||
throw new Error(payload?.error || '检测应用失败');
|
||||
}
|
||||
openApps.value = Array.isArray(payload?.data?.apps) ? payload.data.apps : [];
|
||||
openMenuPath.value = path;
|
||||
} catch (error: any) {
|
||||
openAppsError.value = error?.message || '检测应用失败';
|
||||
openMenuPath.value = path;
|
||||
} finally {
|
||||
appsLoadingPath.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const openFileWithApp = async (path: string, appId: string) => {
|
||||
try {
|
||||
const resp = await fetch('/api/project/open-file-with-app', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, app_id: appId })
|
||||
});
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !payload?.success) {
|
||||
throw new Error(payload?.error || '打开文件失败');
|
||||
}
|
||||
openMenuPath.value = '';
|
||||
} catch (error) {
|
||||
openAppsError.value = error instanceof Error ? error.message : '打开文件失败';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@ -534,6 +534,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="settings-toggle-row"
|
||||
><span class="settings-row-copy"
|
||||
><span class="settings-row-title">默认隐藏工作区</span
|
||||
><span class="settings-row-desc"
|
||||
>打开页面时默认收起左侧工作区,仍可随时手动展开</span
|
||||
></span
|
||||
><input
|
||||
type="checkbox"
|
||||
:checked="form.default_hide_workspace"
|
||||
@change="applyDefaultHideWorkspaceOption($event.target.checked)"
|
||||
/><span class="fancy-check" aria-hidden="true"
|
||||
><svg viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||||
pathLength="575.0541381835938"
|
||||
class="fancy-path"
|
||||
></path></svg></span
|
||||
></label>
|
||||
<label class="settings-toggle-row"
|
||||
><span class="settings-row-copy"
|
||||
><span class="settings-row-title">使用自定义称呼</span
|
||||
@ -578,6 +596,28 @@
|
||||
class="fancy-path"
|
||||
></path></svg></span
|
||||
></label>
|
||||
<label class="settings-toggle-row"
|
||||
><span class="settings-row-copy"
|
||||
><span class="settings-row-title">显示 Git 状态栏</span
|
||||
><span class="settings-row-desc"
|
||||
>工作区存在 Git 仓库时,在输入栏上方显示分支和变更统计</span
|
||||
></span
|
||||
><input
|
||||
type="checkbox"
|
||||
:checked="form.show_git_status_bar"
|
||||
@change="
|
||||
personalization.updateField({
|
||||
key: 'show_git_status_bar',
|
||||
value: $event.target.checked
|
||||
})
|
||||
" /><span class="fancy-check" aria-hidden="true"
|
||||
><svg viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||||
pathLength="575.0541381835938"
|
||||
class="fancy-path"
|
||||
></path></svg></span
|
||||
></label>
|
||||
<div class="settings-select-row">
|
||||
<span class="settings-row-copy"
|
||||
><span class="settings-row-title">堆叠块显示模式</span
|
||||
@ -1459,7 +1499,7 @@ import { ref, computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { usePersonalizationStore } from '@/stores/personalization';
|
||||
import { useResourceStore } from '@/stores/resource';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import { useUiStore, persistDefaultHideWorkspace } from '@/stores/ui';
|
||||
import { usePolicyStore } from '@/stores/policy';
|
||||
import { useTutorialStore } from '@/stores/tutorial';
|
||||
import { useModelStore } from '@/stores/model';
|
||||
@ -2284,6 +2324,29 @@ const applyThemeOption = async (theme: ThemeKey) => {
|
||||
console.warn('保存主题到后端失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const applyDefaultHideWorkspaceOption = async (hidden: boolean) => {
|
||||
const next = !!hidden;
|
||||
persistDefaultHideWorkspace(next);
|
||||
personalization.updateField({ key: 'default_hide_workspace', value: next });
|
||||
if (next) {
|
||||
uiStore.setWorkspaceCollapsed(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/personalization', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ default_hide_workspace: next })
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || !result.success) {
|
||||
console.warn('保存默认隐藏工作区失败:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('保存默认隐藏工作区失败:', error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -4,6 +4,7 @@ interface ResizeContext {
|
||||
isResizing?: boolean;
|
||||
resizingPanel?: PanelKey | null;
|
||||
rightCollapsed?: boolean;
|
||||
gitChangesPanelOpen?: boolean;
|
||||
rightWidth?: number;
|
||||
minPanelWidth?: number;
|
||||
maxPanelWidth?: number;
|
||||
@ -19,7 +20,7 @@ function clamp(value: number, min: number, max: number) {
|
||||
|
||||
export function startResize(ctx: ResizeContext, panel: PanelKey, event: MouseEvent) {
|
||||
// 如果右侧面板(审批面板)已折叠,禁用拖拽边缘展开
|
||||
if (panel === 'right' && ctx.rightCollapsed) {
|
||||
if (panel === 'right' && ctx.rightCollapsed && !ctx.gitChangesPanelOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useModelStore } from './model';
|
||||
import { persistDefaultHideWorkspace, useUiStore } from './ui';
|
||||
|
||||
export type BlockDisplayMode = 'traditional' | 'stacked' | 'minimal';
|
||||
export type CompactMessageDisplay = 'full' | 'brief';
|
||||
@ -20,6 +21,7 @@ interface PersonalForm {
|
||||
skill_strict_run_command_background_enabled: boolean;
|
||||
silent_tool_disable: boolean;
|
||||
enhanced_tool_display: boolean;
|
||||
show_git_status_bar: boolean;
|
||||
enhanced_tool_display_categories: string[];
|
||||
enabled_skills: string[];
|
||||
self_identify: string;
|
||||
@ -47,6 +49,7 @@ interface PersonalForm {
|
||||
deep_compress_behavior: 'continue' | 'wait';
|
||||
agents_md_auto_inject: boolean;
|
||||
allow_root_file_creation: boolean;
|
||||
default_hide_workspace: boolean;
|
||||
theme: 'classic' | 'light' | 'dark';
|
||||
goal_review_mode: 'readonly' | 'active';
|
||||
goal_end_conditions: string[];
|
||||
@ -113,6 +116,7 @@ const defaultForm = (): PersonalForm => ({
|
||||
skill_strict_run_command_background_enabled: false,
|
||||
silent_tool_disable: false,
|
||||
enhanced_tool_display: true,
|
||||
show_git_status_bar: true,
|
||||
enhanced_tool_display_categories: [],
|
||||
enabled_skills: [],
|
||||
self_identify: '',
|
||||
@ -140,6 +144,7 @@ const defaultForm = (): PersonalForm => ({
|
||||
deep_compress_behavior: 'continue',
|
||||
agents_md_auto_inject: false,
|
||||
allow_root_file_creation: false,
|
||||
default_hide_workspace: false,
|
||||
theme: loadCachedTheme(),
|
||||
goal_review_mode: 'readonly',
|
||||
goal_end_conditions: ['max_turns'],
|
||||
@ -290,6 +295,7 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
!!data.skill_strict_run_command_background_enabled,
|
||||
silent_tool_disable: !!data.silent_tool_disable,
|
||||
enhanced_tool_display: data.enhanced_tool_display !== false,
|
||||
show_git_status_bar: data.show_git_status_bar !== false,
|
||||
enhanced_tool_display_categories: Array.isArray(data.enhanced_tool_display_categories)
|
||||
? data.enhanced_tool_display_categories.filter((item: unknown) => typeof item === 'string')
|
||||
: [],
|
||||
@ -347,6 +353,7 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
deep_compress_behavior: data.deep_compress_behavior === 'wait' ? 'wait' : 'continue',
|
||||
agents_md_auto_inject: !!data.agents_md_auto_inject,
|
||||
allow_root_file_creation: !!data.allow_root_file_creation,
|
||||
default_hide_workspace: !!data.default_hide_workspace,
|
||||
theme: ['classic', 'light', 'dark'].includes(data.theme) ? data.theme : fallbackTheme,
|
||||
goal_review_mode: data.goal_review_mode === 'active' ? 'active' : 'readonly',
|
||||
goal_end_conditions: Array.isArray(data.goal_end_conditions)
|
||||
@ -370,6 +377,10 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
if (this.form.theme !== currentTheme) {
|
||||
this.applyTheme(this.form.theme);
|
||||
}
|
||||
persistDefaultHideWorkspace(this.form.default_hide_workspace);
|
||||
if (this.form.default_hide_workspace) {
|
||||
useUiStore().setWorkspaceCollapsed(true);
|
||||
}
|
||||
this.clearFeedback();
|
||||
},
|
||||
applyTheme(theme: 'classic' | 'light' | 'dark') {
|
||||
|
||||
@ -3,6 +3,21 @@ import { defineStore } from 'pinia';
|
||||
type PanelMode = 'files' | 'todo' | 'subAgents' | 'backgroundCommands';
|
||||
type ResizingPanel = 'left' | 'right' | null;
|
||||
type MobileOverlayTarget = 'conversation' | 'workspace' | 'focus' | 'approval' | null;
|
||||
const DEFAULT_HIDE_WORKSPACE_STORAGE_KEY = 'agents_default_hide_workspace';
|
||||
|
||||
export const loadDefaultHideWorkspace = (): boolean => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return false;
|
||||
}
|
||||
return window.localStorage.getItem(DEFAULT_HIDE_WORKSPACE_STORAGE_KEY) === '1';
|
||||
};
|
||||
|
||||
export const persistDefaultHideWorkspace = (hidden: boolean) => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(DEFAULT_HIDE_WORKSPACE_STORAGE_KEY, hidden ? '1' : '0');
|
||||
};
|
||||
|
||||
interface QuotaToast {
|
||||
message: string;
|
||||
@ -79,7 +94,7 @@ interface UiState {
|
||||
export const useUiStore = defineStore('ui', {
|
||||
state: (): UiState => ({
|
||||
sidebarCollapsed: true,
|
||||
workspaceCollapsed: false,
|
||||
workspaceCollapsed: loadDefaultHideWorkspace(),
|
||||
chatDisplayMode: 'chat',
|
||||
panelMode: 'files',
|
||||
panelMenuOpen: false,
|
||||
|
||||
@ -22,8 +22,11 @@
|
||||
--claude-button-hover: #c76541;
|
||||
--claude-button-active: #a95331;
|
||||
--claude-shadow: 0 14px 36px rgba(61, 57, 41, 0.12);
|
||||
--claude-success: #76b086;
|
||||
--claude-success: #16a34a;
|
||||
--claude-warning: #d99845;
|
||||
--claude-danger: #dc2626;
|
||||
--git-diff-add-text: #00e000;
|
||||
--git-diff-del-text: #ff1a1a;
|
||||
/* Theme-neutral surfaces */
|
||||
--theme-surface-card: #fffaf4;
|
||||
--theme-surface-strong: #ffffff;
|
||||
@ -65,8 +68,11 @@
|
||||
--claude-button-hover: #c76541;
|
||||
--claude-button-active: #a95331;
|
||||
--claude-shadow: 0 14px 36px rgba(61, 57, 41, 0.12);
|
||||
--claude-success: #76b086;
|
||||
--claude-success: #16a34a;
|
||||
--claude-warning: #d99845;
|
||||
--claude-danger: #dc2626;
|
||||
--git-diff-add-text: #00e000;
|
||||
--git-diff-del-text: #ff1a1a;
|
||||
--theme-surface-card: #fffaf4;
|
||||
--theme-surface-strong: #ffffff;
|
||||
--theme-surface-soft: rgba(255, 255, 255, 0.92);
|
||||
@ -110,6 +116,9 @@
|
||||
--claude-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--claude-success: #10b981;
|
||||
--claude-warning: #f59e0b;
|
||||
--claude-danger: #dc2626;
|
||||
--git-diff-add-text: #00e000;
|
||||
--git-diff-del-text: #ff1a1a;
|
||||
--theme-surface-card: #ffffff;
|
||||
--theme-surface-strong: #ffffff;
|
||||
--theme-surface-soft: #ffffff;
|
||||
@ -153,6 +162,9 @@
|
||||
--claude-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
--claude-success: #10b981;
|
||||
--claude-warning: #f59e0b;
|
||||
--claude-danger: #dc2626;
|
||||
--git-diff-add-text: #00e000;
|
||||
--git-diff-del-text: #ff1a1a;
|
||||
--theme-surface-card: #0a0a0a;
|
||||
--theme-surface-strong: #1a1a1a;
|
||||
--theme-surface-soft: #0f0f0f;
|
||||
|
||||
@ -88,7 +88,7 @@
|
||||
overflow-anchor: none;
|
||||
padding: 24px;
|
||||
padding-top: 30px;
|
||||
padding-bottom: calc(20px + var(--composer-growth-height, 0px));
|
||||
padding-bottom: 20px;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
@ -104,6 +104,14 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messages-bottom-spacer {
|
||||
width: 1px;
|
||||
height: var(--composer-growth-height, 0px);
|
||||
min-height: var(--composer-growth-height, 0px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-container .input-area {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
.runtime-queue-list {
|
||||
--runtime-queue-bg: var(--theme-surface-soft);
|
||||
--runtime-queue-divider: rgba(15, 23, 42, 0.12);
|
||||
--runtime-queue-border: rgba(15, 23, 42, 0.12);
|
||||
position: absolute;
|
||||
left: calc(50% - 7px);
|
||||
bottom: calc(100% - 2px);
|
||||
@ -38,7 +39,12 @@
|
||||
max-width: 90%;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
border: none;
|
||||
border-top: 1px solid var(--runtime-queue-border);
|
||||
border-right: 1px solid var(--runtime-queue-border);
|
||||
border-bottom: none;
|
||||
border-left: 1px solid var(--runtime-queue-border);
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 12px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
pointer-events: auto;
|
||||
@ -260,6 +266,7 @@ body[data-theme='dark'] {
|
||||
.runtime-queue-list {
|
||||
--runtime-queue-bg: #2a2a2a;
|
||||
--runtime-queue-divider: rgba(255, 255, 255, 0.12);
|
||||
--runtime-queue-border: rgba(255, 255, 255, 0.12);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
@ -62,6 +62,441 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.git-changes-panel {
|
||||
flex: 0 0 auto;
|
||||
min-width: 340px;
|
||||
max-width: min(600px, 52vw);
|
||||
height: var(--app-viewport, 100vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--claude-left-rail);
|
||||
border-left: 1px solid var(--claude-border);
|
||||
color: var(--claude-text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.git-changes-panel__header {
|
||||
flex: 0 0 auto;
|
||||
min-height: 54px;
|
||||
padding: 10px 14px 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--claude-border);
|
||||
}
|
||||
|
||||
.git-changes-panel__title {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.git-changes-panel__branch,
|
||||
.git-changes-panel__upstream {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.git-changes-panel__upstream,
|
||||
.git-changes-panel__arrow {
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.git-changes-panel__summary,
|
||||
.git-change-file__stats {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.git-changes-panel__add {
|
||||
color: var(--git-diff-add-text);
|
||||
}
|
||||
|
||||
.git-changes-panel__del {
|
||||
color: var(--git-diff-del-text);
|
||||
}
|
||||
|
||||
.git-changes-panel__close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
color: var(--claude-text-secondary);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.git-changes-panel__close:hover {
|
||||
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
|
||||
color: var(--claude-text);
|
||||
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
|
||||
}
|
||||
|
||||
.git-changes-panel__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
scrollbar-color: color-mix(in srgb, var(--claude-text-secondary, #6b7280) 55%, transparent)
|
||||
color-mix(in srgb, var(--theme-surface-muted, #d1d5db) 70%, transparent);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.git-changes-panel * {
|
||||
scrollbar-color: color-mix(in srgb, var(--claude-text-secondary, #6b7280) 55%, transparent)
|
||||
color-mix(in srgb, var(--theme-surface-muted, #d1d5db) 70%, transparent);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.git-changes-panel::-webkit-scrollbar,
|
||||
.git-changes-panel ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.git-changes-panel::-webkit-scrollbar-track,
|
||||
.git-changes-panel ::-webkit-scrollbar-track {
|
||||
background: color-mix(in srgb, var(--theme-surface-muted, #d1d5db) 70%, transparent);
|
||||
border-radius: 999px;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.git-changes-panel::-webkit-scrollbar-thumb,
|
||||
.git-changes-panel ::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--claude-text-secondary, #6b7280) 55%, transparent);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.git-changes-panel__empty {
|
||||
padding: 48px 12px;
|
||||
color: var(--claude-text-secondary);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.git-changes-panel__empty--error {
|
||||
color: var(--theme-danger, #c00);
|
||||
}
|
||||
|
||||
.git-change-file {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.git-change-file__header {
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 8px;
|
||||
color: var(--claude-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.git-change-file__title {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.git-change-file__open-wrap {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.git-change-file__open-btn {
|
||||
width: 48px;
|
||||
height: 26px;
|
||||
display: inline-grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--claude-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.git-change-file__open-btn:hover,
|
||||
.git-change-file__open-btn.open {
|
||||
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
|
||||
color: var(--claude-text);
|
||||
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
|
||||
}
|
||||
|
||||
.git-change-file__open-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.git-change-file__open-btn:disabled:hover {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.git-change-file__open-icon,
|
||||
.git-change-file__open-option-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
filter: var(--theme-icon-filter, none);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .git-change-file__open-icon,
|
||||
body[data-theme='dark'] .git-change-file__open-icon {
|
||||
filter: invert(1) opacity(0.78);
|
||||
}
|
||||
|
||||
.git-change-file__open-caret {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.git-change-file__open-caret::before {
|
||||
content: '›';
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
line-height: 14px;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.git-change-file__open-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 80;
|
||||
min-width: 190px;
|
||||
max-width: min(280px, 70vw);
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--claude-border);
|
||||
border-radius: 12px;
|
||||
background: var(--theme-surface-muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.git-change-file__open-option {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 9px;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--claude-text);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.git-change-file__open-option-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.git-change-file__open-option:hover {
|
||||
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
|
||||
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
|
||||
}
|
||||
|
||||
.git-change-file__open-empty {
|
||||
padding: 8px 10px;
|
||||
color: var(--claude-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.git-change-file__path {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.git-change-file__reset {
|
||||
flex: 0 0 auto;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--claude-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.git-change-file__reset:hover {
|
||||
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
|
||||
color: var(--claude-text);
|
||||
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
|
||||
}
|
||||
|
||||
.git-change-file__diff {
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.git-change-file__diff-inner {
|
||||
display: inline-grid;
|
||||
grid-template-columns: minmax(100%, max-content);
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.git-change-file__fold {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
margin: 4px 0;
|
||||
padding: 0 10px;
|
||||
display: block;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
background: var(--theme-surface-muted);
|
||||
color: var(--claude-text-secondary);
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.git-change-file__fold-content {
|
||||
position: sticky;
|
||||
left: 10px;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 40px);
|
||||
height: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.git-change-file__fold:hover {
|
||||
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
|
||||
color: var(--claude-text);
|
||||
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
|
||||
}
|
||||
|
||||
.git-change-file__fold-icon {
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 16px;
|
||||
color: var(--claude-text-tertiary);
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.git-change-file__fold-icon::before {
|
||||
content: '›';
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
line-height: 16px;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.git-change-file__fold-icon--up::before {
|
||||
transform: rotate(-90deg) translateX(0.5px);
|
||||
}
|
||||
|
||||
.git-change-file__fold-icon--down::before {
|
||||
transform: rotate(90deg) translateX(0.5px);
|
||||
}
|
||||
|
||||
.git-change-file__hunk-header {
|
||||
padding: 3px 8px;
|
||||
color: var(--claude-text-tertiary);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.git-change-line {
|
||||
display: grid;
|
||||
grid-template-columns: 5ch 1ch max-content;
|
||||
column-gap: 8px;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 22px;
|
||||
padding: 2px 8px;
|
||||
align-items: start;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.git-change-line__num {
|
||||
color: var(--claude-text-tertiary);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.git-change-line__marker {
|
||||
color: var(--claude-text-secondary);
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.git-change-line__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-change-line--add {
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
color: #0a0;
|
||||
}
|
||||
|
||||
.git-change-line--delete {
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.git-changes-panel-slide-enter-active,
|
||||
.git-changes-panel-slide-leave-active {
|
||||
transition:
|
||||
width 0.22s ease,
|
||||
min-width 0.22s ease,
|
||||
opacity 0.18s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.git-changes-panel-slide-enter-from,
|
||||
.git-changes-panel-slide-leave-to {
|
||||
width: 0 !important;
|
||||
min-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.no-files {
|
||||
text-align: center;
|
||||
color: var(--claude-text-secondary);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user