From c9e2d5086969235645071d61bf0af680a56cd5c9 Mon Sep 17 00:00:00 2001 From: SmallSpider0 <568442079@qq.com> Date: Thu, 21 May 2026 14:59:32 +0800 Subject: [PATCH 1/8] feat(latex): unify workspace and add synctex jumps --- docs/en/12_GUIDED_WORKFLOW_TOUR.md | 7 + docs/zh/12_GUIDED_WORKFLOW_TOUR.md | 7 + src/deepscientist/daemon/api/handlers.py | 20 +- src/deepscientist/daemon/api/router.py | 2 + src/deepscientist/daemon/app.py | 2 +- src/deepscientist/latex_runtime.py | 1276 ++++++++++++++- src/deepscientist/quest/service.py | 30 +- src/ui/src/components/file-tree/FileTree.tsx | 17 +- .../workspace/CopilotDockOverlay.tsx | 57 +- src/ui/src/hooks/useOpenFile.ts | 63 +- src/ui/src/lib/ai/effect-dispatcher.ts | 57 +- src/ui/src/lib/api/latex.ts | 100 ++ src/ui/src/lib/i18n/messages/latex.ts | 48 + src/ui/src/lib/latex/open-queue.ts | 91 ++ src/ui/src/lib/monaco-latex.ts | 155 ++ src/ui/src/lib/plugins/latex/LatexPlugin.tsx | 1442 ++++++++++++++--- src/ui/src/lib/stores/tabs.ts | 28 + tests/test_api_contract_surface.py | 35 + tests/test_daemon_api.py | 26 +- tests/test_latex_runtime.py | 461 ++++++ tests/test_memory_and_artifact.py | 41 + 21 files changed, 3658 insertions(+), 307 deletions(-) create mode 100644 src/ui/src/lib/latex/open-queue.ts create mode 100644 src/ui/src/lib/monaco-latex.ts create mode 100644 tests/test_latex_runtime.py diff --git a/docs/en/12_GUIDED_WORKFLOW_TOUR.md b/docs/en/12_GUIDED_WORKFLOW_TOUR.md index 9ce7738e..78a7a74a 100644 --- a/docs/en/12_GUIDED_WORKFLOW_TOUR.md +++ b/docs/en/12_GUIDED_WORKFLOW_TOUR.md @@ -331,6 +331,7 @@ Common examples: - experiment summaries - result reports - paper drafts +- LaTeX source files and BibTeX references In practice, many users treat Markdown files in the quest as a private local-first notebook for: @@ -340,6 +341,12 @@ In practice, many users treat Markdown files in the quest as a private local-fir - findings - team coordination +When you open a LaTeX project folder, the browser editor treats the folder as one LaTeX workspace. Source files such as `main.tex`, chapter files under subfolders, BibTeX files, and style files switch inside the same editor, Overleaf-style, instead of spawning separate top-level editors or internal source tabs. The file picker still lists the full project source tree for quick switching. + +The editor auto-saves source edits shortly after you type. Background autosaves only persist the source; they do not start PDF compilation. Manual saves default to compile-on-save: `Ctrl/Cmd+S` or the `Save` button saves the active LaTeX file and then starts one PDF compilation when the save succeeds. `Save & Compile` remains available for an explicit compile action and still saves the current source before starting PDF compilation. + +After a successful compile, the PDF preview uses SyncTeX metadata when available. Double-click a rendered PDF word to jump back to the matching LaTeX source file and select the corresponding source token; the editor uses the PDF word box plus multiple SyncTeX samples to avoid broad line-level selections. Older builds without SyncTeX data need to be recompiled before PDF-to-source jumps are available. + ### 6.5 Canvas Canvas makes the research map visible. diff --git a/docs/zh/12_GUIDED_WORKFLOW_TOUR.md b/docs/zh/12_GUIDED_WORKFLOW_TOUR.md index 14f0ce46..f023dca6 100644 --- a/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +++ b/docs/zh/12_GUIDED_WORKFLOW_TOUR.md @@ -329,6 +329,7 @@ Explorer 是 quest 的文件视角。 - 实验总结 - 结果报告 - 论文草稿 +- LaTeX 源文件与 BibTeX 引用 很多用户会把 quest 里的 Markdown 文件当作一个本地优先、类似 Notion 的私有笔记本,用来记录: @@ -338,6 +339,12 @@ Explorer 是 quest 的文件视角。 - 发现 - 协作信息 +打开 LaTeX 项目文件夹时,浏览器编辑器会把该文件夹作为一个统一的 LaTeX 工作区处理。`main.tex`、章节子目录中的 `.tex` 文件、BibTeX 文件和样式文件会像 Overleaf 一样在同一个编辑器里直接切换,而不是为每个文件创建新的顶层编辑器或内部源码标签页。文件选择器仍会列出完整项目源码树,方便快速切换。 + +编辑器会在输入后短时间内自动保存源文件。后台自动保存只负责落盘源码,不会启动 PDF 编译。手动保存默认开启保存后自动编译:`Ctrl/Cmd+S` 或 `保存` 按钮会先保存当前 LaTeX 文件,保存成功后启动一次 PDF 编译。`保存并编译` 仍可用于显式编译,并会先保存当前源码,再启动 PDF 编译。 + +成功编译后,PDF 预览会在可用时使用 SyncTeX 元数据。双击 PDF 中渲染出的某个单词时,编辑器会结合 PDF 单词框和多点 SyncTeX 采样跳转到匹配的 LaTeX 源文件,并选中对应的源码 token,避免退化成大范围行级选中。没有 SyncTeX 数据的旧构建需要重新编译后才能使用 PDF 到源码跳转。 + ### 6.5 Canvas Canvas 会把研究地图直接展示出来。 diff --git a/src/deepscientist/daemon/api/handlers.py b/src/deepscientist/daemon/api/handlers.py index e3f6619b..1c752dc3 100644 --- a/src/deepscientist/daemon/api/handlers.py +++ b/src/deepscientist/daemon/api/handlers.py @@ -2046,7 +2046,7 @@ def document_save(self, quest_id: str, document_id: str, body: dict) -> dict: try: return self.app.quest_service.save_document( quest_id, - document_id, + unquote(document_id), body["content"], previous_revision=body.get("revision"), ) @@ -2121,6 +2121,9 @@ def latex_compile(self, project_id: str, folder_id: str, body: dict) -> dict: auto=body.get("auto"), ) + def latex_manifest(self, project_id: str, folder_id: str) -> dict: + return self.app.latex_service.manifest(project_id, folder_id) + def latex_builds(self, project_id: str, folder_id: str, path: str) -> list[dict]: query = self.parse_query(path) limit_raw = ((query.get("limit") or ["10"])[0] or "10").strip() @@ -2133,6 +2136,21 @@ def latex_builds(self, project_id: str, folder_id: str, path: str) -> list[dict] def latex_build(self, project_id: str, folder_id: str, build_id: str) -> dict: return self.app.latex_service.get_build(project_id, folder_id, build_id) + def latex_synctex_edit(self, project_id: str, folder_id: str, build_id: str, body: dict) -> dict: + return self.app.latex_service.synctex_edit( + project_id, + folder_id, + build_id, + page=body.get("page"), + x=body.get("x"), + y=body.get("y"), + pdf_word=body.get("pdf_word"), + pdf_context_words=body.get("pdf_context_words"), + pdf_context_index=body.get("pdf_context_index"), + pdf_word_bbox=body.get("pdf_word_bbox"), + pdf_word_center=body.get("pdf_word_center"), + ) + def latex_build_pdf(self, project_id: str, folder_id: str, build_id: str) -> tuple[int, dict, bytes]: payload, file_name = self.app.latex_service.get_build_pdf(project_id, folder_id, build_id) headers = { diff --git a/src/deepscientist/daemon/api/router.py b/src/deepscientist/daemon/api/router.py index f46dfa89..309b32e8 100644 --- a/src/deepscientist/daemon/api/router.py +++ b/src/deepscientist/daemon/api/router.py @@ -175,8 +175,10 @@ ("DELETE", re.compile(r"^/api/v1/annotations/(?P[^/]+)$"), "annotation_delete"), ("POST", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/init$"), "latex_init"), ("POST", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/compile$"), "latex_compile"), + ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/manifest$"), "latex_manifest"), ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/builds$"), "latex_builds"), ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/builds/(?P[^/]+)$"), "latex_build"), + ("POST", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/builds/(?P[^/]+)/synctex/edit$"), "latex_synctex_edit"), ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/builds/(?P[^/]+)/pdf$"), "latex_build_pdf"), ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/builds/(?P[^/]+)/log$"), "latex_build_log"), ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/archive$"), "latex_archive"), diff --git a/src/deepscientist/daemon/app.py b/src/deepscientist/daemon/app.py index 77922871..3cb8d826 100644 --- a/src/deepscientist/daemon/app.py +++ b/src/deepscientist/daemon/app.py @@ -8799,7 +8799,7 @@ def _dispatch(self, method: str) -> None: "repair_create", "repair_close", "hardware_update", - } or route_name in {"document_open", "document_asset_upload", "quest_file_create_folder", "quest_file_upload", "quest_file_rename", "quest_file_move", "quest_file_delete", "chat_upload_create", "chat_upload_delete", "chat", "command", "quest_control", "quest_message_read_now", "quest_message_withdraw", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "system_update_action", "weixin_login_qr_start", "weixin_login_qr_wait", "arxiv_import", "annotation_create", "auth_login", "auth_rotate"}: + } or route_name in {"document_open", "document_asset_upload", "quest_file_create_folder", "quest_file_upload", "quest_file_rename", "quest_file_move", "quest_file_delete", "chat_upload_create", "chat_upload_delete", "chat", "command", "quest_control", "quest_message_read_now", "quest_message_withdraw", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "latex_synctex_edit", "system_update_action", "weixin_login_qr_start", "weixin_login_qr_wait", "arxiv_import", "annotation_create", "auth_login", "auth_rotate"}: payload = result(**params, body=body) elif route_name == "config_validate": payload = result(body) diff --git a/src/deepscientist/latex_runtime.py b/src/deepscientist/latex_runtime.py index 0b8a5d04..b52453e2 100644 --- a/src/deepscientist/latex_runtime.py +++ b/src/deepscientist/latex_runtime.py @@ -6,7 +6,9 @@ import re import shutil import subprocess +import unicodedata import zipfile +from collections import Counter from pathlib import Path from typing import Any from urllib.parse import quote, unquote @@ -35,6 +37,28 @@ ".toc", ".vrb", } +_LATEX_EDITABLE_SUFFIXES = { + ".tex", + ".bib", + ".cls", + ".sty", + ".bst", + ".bbx", + ".cbx", +} +_LATEX_RESOURCE_SUFFIXES = { + ".pdf", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".eps", +} +_LATEX_MANIFEST_SUFFIXES = _LATEX_EDITABLE_SUFFIXES | _LATEX_RESOURCE_SUFFIXES +_LATEX_INPUT_RE = re.compile(r"\\(?:input|include)\{([^}]+)\}") +_LATEX_BIB_RE = re.compile(r"\\(?:bibliography|addbibresource)\{([^}]+)\}") def _encode_relative(value: str) -> str: @@ -139,6 +163,869 @@ def _parse_file_line_issues(log_text: str) -> tuple[list[dict[str, Any]], list[d return errors, log_items +def _parse_synctex_records(output: str) -> dict[str, str]: + records: dict[str, str] = {} + for raw_line in str(output or "").splitlines(): + line = raw_line.strip() + if not line or ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip().lower() + if key in {"output", "input", "line", "column", "offset", "context"}: + records[key] = value.strip() + return records + + +def _normalize_latex_match_text(value: str | None) -> str: + text = unicodedata.normalize("NFKC", str(value or "")) + text = ( + text.replace("fi", "fi") + .replace("fl", "fl") + .replace("ff", "ff") + .replace("ffi", "ffi") + .replace("ffl", "ffl") + ) + text = text.casefold() + return "".join(ch for ch in text if ch.isalnum() or ch in {"_", "-"}).strip("_-") + + +def _latex_token_char(char: str) -> bool: + return char.isalnum() or char in {"_", "-"} + + +_LATEX_COMMENT_BEGIN_RE = re.compile(r"\\begin\s*\{\s*comment\s*\}") +_LATEX_COMMENT_END_RE = re.compile(r"\\end\s*\{\s*comment\s*\}") +_LATEX_MACRO_DEFINITION_LINE_RE = re.compile(r"\\(?:newcommand|renewcommand|providecommand|def)\b") +_LATEX_SOURCE_SYNTAX_COMMANDS = { + "addbibresource", + "begin", + "bibliography", + "bibliographystyle", + "documentclass", + "end", + "graphicspath", + "include", + "input", + "label", + "usepackage", +} + + +def _unescaped_percent_index(value: str, start: int = 0) -> int | None: + index = max(0, int(start or 0)) + while True: + found = value.find("%", index) + if found < 0: + return None + backslashes = 0 + cursor = found - 1 + while cursor >= 0 and value[cursor] == "\\": + backslashes += 1 + cursor -= 1 + if backslashes % 2 == 0: + return found + index = found + 1 + + +def _latex_visible_segments_for_line(raw: str, in_comment_environment: bool) -> tuple[list[tuple[int, str]], bool]: + segments: list[tuple[int, str]] = [] + cursor = 0 + source = str(raw or "") + in_comment = bool(in_comment_environment) + while cursor < len(source): + if in_comment: + end_match = _LATEX_COMMENT_END_RE.search(source, cursor) + if not end_match: + return segments, True + cursor = end_match.end() + in_comment = False + continue + + percent_index = _unescaped_percent_index(source, cursor) + begin_match = _LATEX_COMMENT_BEGIN_RE.search(source, cursor) + begin_index = begin_match.start() if begin_match else None + stop_candidates = [ + candidate for candidate in [percent_index, begin_index] if candidate is not None + ] + stop = min(stop_candidates) if stop_candidates else len(source) + if stop > cursor: + segments.append((cursor, source[cursor:stop])) + if percent_index is not None and percent_index == stop: + return segments, False + if begin_match is not None and begin_match.start() == stop: + cursor = begin_match.end() + in_comment = True + continue + break + return segments, in_comment + + +def _latex_visible_line_max_column(source_text: str, line: int) -> int: + lines = str(source_text or "").splitlines() + if not lines: + return 1 + target = min(max(1, int(line or 1)), len(lines)) + in_comment = False + visible_end = 0 + for line_number, raw in enumerate(lines, start=1): + segments, in_comment = _latex_visible_segments_for_line(raw, in_comment) + if line_number == target: + visible_end = max((start + len(text) for start, text in segments), default=0) + break + return visible_end + 1 + + +def _line_col_for_offset(source_text: str, offset: int) -> tuple[int, int]: + text = str(source_text or "") + safe_offset = min(max(0, int(offset or 0)), len(text)) + line = text.count("\n", 0, safe_offset) + 1 + previous_newline = text.rfind("\n", 0, safe_offset) + column = safe_offset + 1 if previous_newline < 0 else safe_offset - previous_newline + return line, max(1, column) + + +def _latex_balanced_group_span(value: str, open_index: int) -> tuple[int, int, int] | None: + source = str(value or "") + if open_index < 0 or open_index >= len(source) or source[open_index] != "{": + return None + depth = 0 + index = open_index + while index < len(source): + char = source[index] + if char == "\\": + index += 2 + continue + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return open_index + 1, index, index + 1 + index += 1 + return None + + +def _skip_latex_spaces(value: str, index: int) -> int: + source = str(value or "") + cursor = max(0, int(index or 0)) + while cursor < len(source) and source[cursor].isspace(): + cursor += 1 + return cursor + + +def _skip_latex_optional_group(value: str, index: int) -> int: + source = str(value or "") + cursor = _skip_latex_spaces(source, index) + if cursor >= len(source) or source[cursor] != "[": + return index + depth = 0 + while cursor < len(source): + char = source[cursor] + if char == "\\": + cursor += 2 + continue + if char == "[": + depth += 1 + elif char == "]": + depth -= 1 + if depth == 0: + return cursor + 1 + cursor += 1 + return index + + +def _skip_latex_command_arguments(value: str, index: int, *, max_required: int = 2) -> int: + source = str(value or "") + cursor = _skip_latex_optional_group(source, index) + required = 0 + while required < max_required: + cursor = _skip_latex_spaces(source, cursor) + if cursor >= len(source) or source[cursor] != "{": + break + span = _latex_balanced_group_span(source, cursor) + if not span: + break + cursor = span[2] + required += 1 + return cursor + + +def _latex_approximate_text(value: str) -> str: + source = str(value or "") + + def parse(cursor: int, end_char: str | None = None) -> tuple[str, int]: + pieces: list[str] = [] + while cursor < len(source): + char = source[cursor] + if end_char and char == end_char: + return "".join(pieces), cursor + 1 + if char == "\\": + command_start = cursor + cursor += 1 + if cursor < len(source) and (source[cursor].isalpha() or source[cursor] == "@"): + name_start = cursor + cursor += 1 + while cursor < len(source) and (source[cursor].isalpha() or source[cursor] in {"@", "*"}): + cursor += 1 + name = source[name_start:cursor].rstrip("*").lower() + cursor = _skip_latex_spaces(source, cursor) + if name in {"mbox", "mathrm", "mathtt", "textrm", "text", "textbf", "textit", "emph"}: + if cursor < len(source) and source[cursor] == "{": + inner, cursor = parse(cursor + 1, "}") + pieces.append(inner) + continue + if name == "textcolor": + if cursor < len(source) and source[cursor] == "{": + _, cursor = parse(cursor + 1, "}") + cursor = _skip_latex_spaces(source, cursor) + if cursor < len(source) and source[cursor] == "{": + inner, cursor = parse(cursor + 1, "}") + pieces.append(inner) + continue + if name in {"xspace", "protect"}: + continue + # Unknown commands are often formatting macros. Keep their grouped + # argument visible by not consuming the following group. + if command_start + 1 == cursor: + pieces.append(source[command_start:cursor]) + continue + if cursor < len(source): + escaped = source[cursor] + cursor += 1 + if escaped in {"%", "$", "&", "#", "_", "{", "}", "\\", "-"}: + pieces.append("-" if escaped == "-" else escaped) + continue + continue + if char == "{": + inner, cursor = parse(cursor + 1, "}") + pieces.append(inner) + continue + if char in {"}", "$"}: + cursor += 1 + continue + if char == "~": + pieces.append(" ") + else: + pieces.append(char) + cursor += 1 + return "".join(pieces), cursor + + return re.sub(r"\s+", " ", parse(0)[0]).strip() + + +def _latex_macro_aliases(source_text: str) -> dict[str, list[str]]: + aliases: dict[str, list[str]] = {} + source = str(source_text or "") + # \newcommand{\name}{...}, \renewcommand{\name}[1]{...}, \providecommand... + command_re = re.compile(r"\\(?:re)?newcommand\s*\{\s*\\([A-Za-z@]+)\s*\}|\\providecommand\s*\{\s*\\([A-Za-z@]+)\s*\}") + for match in command_re.finditer(source): + name = match.group(1) or match.group(2) + cursor = match.end() + cursor = _skip_latex_optional_group(source, cursor) + cursor = _skip_latex_optional_group(source, cursor) + cursor = _skip_latex_spaces(source, cursor) + if cursor >= len(source) or source[cursor] != "{": + continue + span = _latex_balanced_group_span(source, cursor) + if not span: + continue + expansion = source[span[0] : span[1]] + plain = _latex_approximate_text(expansion) + normalized_values = [ + _normalize_latex_match_text(plain), + *[ + _normalize_latex_match_text(part) + for part in re.split(r"\s+", plain) + if part.strip() + ], + ] + cleaned = [value for value in normalized_values if value] + if cleaned: + aliases.setdefault(name, []) + for value in cleaned: + if value not in aliases[name]: + aliases[name].append(value) + return aliases + + +def _latex_source_tokens( + source_text: str, + *, + min_line: int = 1, + max_line: int | None = None, + macro_aliases: dict[str, list[str]] | None = None, +) -> list[dict[str, Any]]: + tokens: list[dict[str, Any]] = [] + lines = str(source_text or "").splitlines() + lower_bound = max(1, int(min_line or 1)) + upper_bound = len(lines) if max_line is None else min(len(lines), max(1, int(max_line))) + aliases = macro_aliases if macro_aliases is not None else _latex_macro_aliases(source_text) + in_comment_environment = False + for line_number in range(1, upper_bound + 1): + raw = lines[line_number - 1] if 0 <= line_number - 1 < len(lines) else "" + segments, in_comment_environment = _latex_visible_segments_for_line(raw, in_comment_environment) + if line_number < lower_bound: + continue + for segment_start, segment in segments: + if _LATEX_MACRO_DEFINITION_LINE_RE.search(segment): + continue + index = 0 + while index < len(segment): + char = segment[index] + if char == "\\": + # Do not match LaTeX command names such as \section or \textbf as PDF words. + if index + 1 < len(segment) and (segment[index + 1].isalpha() or segment[index + 1] == "@"): + command_start = index + index += 2 + while index < len(segment) and (segment[index].isalpha() or segment[index] in {"@", "*"}): + index += 1 + command_name = segment[command_start + 1 : index].rstrip("*") + command_name_lower = command_name.lower() + if command_name_lower in _LATEX_SOURCE_SYNTAX_COMMANDS: + index = _skip_latex_command_arguments(segment, index, max_required=2) + continue + command_aliases = aliases.get(command_name) or [] + if command_aliases: + tokens.append( + { + "text": "\\" + command_name, + "normalized": command_aliases[0], + "aliases": command_aliases, + "line": line_number, + "start_column": segment_start + command_start + 1, + "end_column": segment_start + index + 1, + } + ) + continue + # Escaped punctuation is layout/source syntax rather than a useful word target. + index += 2 + continue + if _latex_token_char(char): + start = index + index += 1 + while index < len(segment) and _latex_token_char(segment[index]): + index += 1 + text = segment[start:index].strip("_-") + if text: + normalized = _normalize_latex_match_text(text) + if normalized: + tokens.append( + { + "text": text, + "normalized": normalized, + "line": line_number, + "start_column": segment_start + start + 1, + "end_column": segment_start + index + 1, + } + ) + continue + index += 1 + return tokens + + +def _line_max_column(source_text: str, line: int) -> int: + lines = str(source_text or "").splitlines() + if not lines: + return 1 + line_index = min(max(1, int(line or 1)), len(lines)) - 1 + return len(lines[line_index]) + 1 + + +def _token_normalized_values(token: dict[str, Any]) -> list[str]: + values = [str(token.get("normalized") or "")] + aliases = token.get("aliases") + if isinstance(aliases, list): + values.extend(str(alias or "") for alias in aliases) + cleaned: list[str] = [] + for value in values: + if value and value not in cleaned: + cleaned.append(value) + return cleaned + + +def _line_start_selection(target_line: int, reason: str, *, confidence: float = 0.1, score: float = 30.0) -> dict[str, Any]: + return { + "start_line": target_line, + "start_column": 1, + "end_line": target_line, + "end_column": 1, + "text": "", + "precision": "line_start", + "confidence": confidence, + "score": score, + "reason": reason, + } + + +def _is_low_information_pdf_word_for_selection(value: str) -> bool: + normalized = _normalize_latex_match_text(value) + if not normalized: + return False + # Tiny rendered words carry little lexical evidence by themselves. They can + # be legitimate source tokens ("2" in prose), but they are also commonly + # produced by citations, footnotes, list labels, equation markers, and page + # artifacts. Do not reject them outright; require independent column or + # surrounding-word support before selecting a concrete source token. + return len(normalized) <= 1 or (normalized.isdigit() and len(normalized) <= 3) + + +def _latex_front_matter_render_kinds(source_text: str, target_line: int) -> set[str]: + lines = str(source_text or "").splitlines() + if not lines: + return set() + safe_line = min(max(1, int(target_line or 1)), len(lines)) + line = lines[safe_line - 1].casefold() + kinds: set[str] = set() + if re.search(r"\\maketitle\b", line): + kinds.update({"title", "author", "date"}) + if re.search(r"\\ieeedisplaynontitleabstractindextext\b", line): + kinds.update({"abstract", "keywords"}) + if re.search(r"\\ieeepeerreviewmaketitle\b", line): + kinds.update({"abstract", "keywords"}) + if re.search(r"\\ieeetitleabstractindextext\b", line): + kinds.update({"abstract", "keywords"}) + return kinds + + +def _latex_command_region(source_text: str, command: str, kind: str, priority: float) -> dict[str, Any] | None: + source = str(source_text or "") + match = re.search(rf"\\{re.escape(command)}\b", source) + if not match: + return None + cursor = _skip_latex_optional_group(source, match.end()) + cursor = _skip_latex_spaces(source, cursor) + if cursor >= len(source) or source[cursor] != "{": + return None + span = _latex_balanced_group_span(source, cursor) + if not span: + return None + start_line, start_column = _line_col_for_offset(source, span[0]) + end_line, end_column = _line_col_for_offset(source, span[1]) + return { + "kind": kind, + "priority": priority, + "start_line": start_line, + "start_column": start_column, + "end_line": end_line, + "end_column": end_column, + } + + +def _latex_environment_regions(source_text: str, environment: str, kind: str, priority: float) -> list[dict[str, Any]]: + source = str(source_text or "") + begin_re = re.compile(rf"\\begin\s*\{{\s*{re.escape(environment)}\s*\}}", re.IGNORECASE) + end_re = re.compile(rf"\\end\s*\{{\s*{re.escape(environment)}\s*\}}", re.IGNORECASE) + regions: list[dict[str, Any]] = [] + cursor = 0 + while True: + begin_match = begin_re.search(source, cursor) + if not begin_match: + break + end_match = end_re.search(source, begin_match.end()) + if not end_match: + break + start_line, start_column = _line_col_for_offset(source, begin_match.end()) + end_line, end_column = _line_col_for_offset(source, end_match.start()) + regions.append( + { + "kind": kind, + "priority": priority, + "start_line": start_line, + "start_column": start_column, + "end_line": end_line, + "end_column": end_column, + } + ) + cursor = end_match.end() + return regions + + +def _latex_front_matter_regions(source_text: str, target_line: int) -> list[dict[str, Any]]: + render_kinds = _latex_front_matter_render_kinds(source_text, target_line) + if not render_kinds: + return [] + regions: list[dict[str, Any]] = [] + if "title" in render_kinds: + for command, kind, priority in [ + ("title", "title", 220.0), + ("author", "author", 160.0), + ("date", "date", 100.0), + ]: + region = _latex_command_region(source_text, command, kind, priority) + if region: + regions.append(region) + # Some classes render title and abstract together from \maketitle. + for region in _latex_environment_regions(source_text, "abstract", "abstract", 90.0): + regions.append(region) + if "abstract" in render_kinds: + regions.extend(_latex_environment_regions(source_text, "abstract", "abstract", 240.0)) + if "keywords" in render_kinds: + regions.extend(_latex_environment_regions(source_text, "IEEEkeywords", "keywords", 160.0)) + regions.extend(_latex_environment_regions(source_text, "keywords", "keywords", 140.0)) + deduped: list[dict[str, Any]] = [] + seen: set[tuple[str, int, int, int, int]] = set() + for region in regions: + key = ( + str(region["kind"]), + int(region["start_line"]), + int(region["start_column"]), + int(region["end_line"]), + int(region["end_column"]), + ) + if key not in seen: + seen.add(key) + deduped.append(region) + return deduped + + +def _tokens_for_latex_regions(source_text: str, regions: list[dict[str, Any]]) -> list[dict[str, Any]]: + if not regions: + return [] + aliases = _latex_macro_aliases(source_text) + all_tokens = _latex_source_tokens( + source_text, + min_line=min(int(region["start_line"]) for region in regions), + max_line=max(int(region["end_line"]) for region in regions), + macro_aliases=aliases, + ) + tokens: list[dict[str, Any]] = [] + for token in all_tokens: + token_line = int(token.get("line") or 0) + token_start = int(token.get("start_column") or 0) + token_end = int(token.get("end_column") or 0) + for region in regions: + start_line = int(region["start_line"]) + end_line = int(region["end_line"]) + if token_line < start_line or token_line > end_line: + continue + if token_line == start_line and token_end <= int(region["start_column"]): + continue + if token_line == end_line and token_start > int(region["end_column"]): + continue + enriched = dict(token) + enriched["region_kind"] = region.get("kind") + enriched["region_priority"] = float(region.get("priority") or 0) + tokens.append(enriched) + break + return tokens + + +def _source_selection_for_synctex( + source_text: str, + *, + line: int | None, + column: int | None, + pdf_word: str | None = None, + pdf_context_words: list[str] | None = None, + pdf_context_index: int | None = None, +) -> dict[str, Any]: + lines = str(source_text or "").splitlines() + max_line = max(1, len(lines)) + target_line = min(max(1, int(line or 1)), max_line) + try: + raw_column = int(column) if column is not None else None + except (TypeError, ValueError): + raw_column = None + has_reliable_column = raw_column is not None and raw_column > 0 + target_column = raw_column if has_reliable_column else 1 + target_column = min( + max(1, target_column), + min(_line_max_column(source_text, target_line), _latex_visible_line_max_column(source_text, target_line)), + ) + target_word = _normalize_latex_match_text(pdf_word) + low_information_word = _is_low_information_pdf_word_for_selection(target_word) + context_words = [ + normalized + for normalized in (_normalize_latex_match_text(word) for word in (pdf_context_words or [])) + if normalized + ] + try: + context_index = int(pdf_context_index) if pdf_context_index is not None else -1 + except (TypeError, ValueError): + context_index = -1 + if context_index < 0 or context_index >= len(context_words): + context_index = -1 + + def context_score_for(token_list: list[dict[str, Any]], token_index: int) -> float: + if context_index < 0 or not context_words: + return 0.0 + score = 0.0 + for pdf_index, expected in enumerate(context_words): + source_index = token_index + (pdf_index - context_index) + if source_index < 0 or source_index >= len(token_list): + continue + actual_values = _token_normalized_values(token_list[source_index]) + if expected in actual_values: + score += 36.0 + elif any(actual and expected and (actual in expected or expected in actual) for actual in actual_values): + score += 14.0 + return min(score, 220.0) + + def token_match(token: dict[str, Any]) -> tuple[float, str] | None: + if not target_word: + return None + normalized_values = _token_normalized_values(token) + if target_word in normalized_values: + return 1000.0, "exact_word" + if len(target_word) <= 1: + return None + if any(value.startswith(target_word) or target_word.startswith(value) for value in normalized_values): + return 650.0, "nearest_token" + if any(target_word in value or value in target_word for value in normalized_values): + return 500.0, "nearest_token" + return None + + front_matter_tokens = _tokens_for_latex_regions( + source_text, + _latex_front_matter_regions(source_text, target_line), + ) + front_matter_scored: list[tuple[float, dict[str, Any], str]] = [] + if target_word: + for token_index, token in enumerate(front_matter_tokens): + match = token_match(token) + if not match: + continue + match_score, precision = match + # SyncTeX points at the rendering macro (\maketitle / IEEE display + # macro), not at the declaration. Treat front-matter regions as a + # virtual local neighborhood and let PDF text context disambiguate + # abstract vs. keywords when repeated words exist. + local_penalty = abs(int(token.get("line") or target_line) - target_line) * 2.0 + score = ( + match_score + + context_score_for(front_matter_tokens, token_index) + + float(token.get("region_priority") or 0) + - local_penalty + ) + front_matter_scored.append((score, token, precision)) + if front_matter_scored: + score, token, precision = max(front_matter_scored, key=lambda item: item[0]) + if precision == "exact_word" or score >= 700: + return { + "start_line": int(token["line"]), + "start_column": int(token["start_column"]), + "end_line": int(token["line"]), + "end_column": int(token["end_column"]), + "text": token["text"], + "precision": precision, + "confidence": max(0.0, min(1.0, score / 1000.0)), + "score": score, + "strategy": "front_matter", + "region": token.get("region_kind"), + } + + window = 8 if target_word else 2 + macro_aliases = _latex_macro_aliases(source_text) + tokens = _latex_source_tokens( + source_text, + min_line=max(1, target_line - window), + max_line=min(max_line, target_line + window), + macro_aliases=macro_aliases, + ) + + def token_distance(token: dict[str, Any]) -> float: + line_distance = abs(int(token["line"]) - target_line) + token_center = (int(token["start_column"]) + int(token["end_column"])) / 2 + column_distance = abs(token_center - target_column) if int(token["line"]) == target_line else 80 + return line_distance * 120 + column_distance + + def token_has_column_support(token: dict[str, Any]) -> bool: + if not has_reliable_column or int(token.get("line") or 0) != target_line: + return False + token_start = int(token.get("start_column") or 1) + token_end = int(token.get("end_column") or token_start) + token_width = max(1, token_end - token_start) + token_center = (token_start + token_end) / 2 + tolerance = max(3.0, min(12.0, token_width + 2.0)) + return ( + token_start - 2 <= target_column <= token_end + 2 + or abs(token_center - target_column) <= tolerance + ) + + scored: list[tuple[float, dict[str, Any], str, int, float, float]] = [] + if target_word: + for token_index, token in enumerate(tokens): + match = token_match(token) + if not match: + continue + match_score, precision = match + context_support = context_score_for(tokens, token_index) + scored.append( + ( + match_score + context_support - token_distance(token), + token, + precision, + token_index, + context_support, + match_score, + ) + ) + + if not scored and low_information_word and not has_reliable_column: + return _line_start_selection( + target_line, + "low_information_pdf_word_without_source_match", + confidence=0.08, + score=20.0, + ) + + if not scored and tokens: + if target_word and len(target_word) <= 1: + scored = [] + else: + same_line = [token for token in tokens if int(token["line"]) == target_line] + nearest_pool = same_line or tokens + for token in nearest_pool: + scored.append((250.0 - token_distance(token), token, "nearest_token", -1, 0.0, 0.0)) + + if scored: + score, token, precision, _token_index, context_support, _match_score = max(scored, key=lambda item: item[0]) + confidence = max(0.0, min(1.0, score / 1000.0)) + if low_information_word: + column_support = token_has_column_support(token) + context_support_is_strong = context_support >= 72.0 + exact_matches = [ + item + for item in scored + if item[2] == "exact_word" and target_word in _token_normalized_values(item[1]) + ] + plausible_exact_matches = [ + item + for item in exact_matches + if item[0] >= score - 80.0 + ] + has_independent_support = column_support or context_support_is_strong + if precision != "exact_word" or not has_independent_support: + return _line_start_selection( + target_line, + "low_information_pdf_word_insufficient_evidence", + confidence=0.1, + score=30.0, + ) + if len(plausible_exact_matches) > 1 and not column_support and not context_support_is_strong: + return _line_start_selection( + target_line, + "low_information_pdf_word_ambiguous_matches", + confidence=0.1, + score=30.0, + ) + if precision == "exact_word" and confidence < 0.55: + precision = "nearest_token" + return { + "start_line": int(token["line"]), + "start_column": int(token["start_column"]), + "end_line": int(token["line"]), + "end_column": int(token["end_column"]), + "text": token["text"], + "precision": precision, + "confidence": confidence, + "score": score, + } + + precision = "line_column" if column else "line_only" + return { + "start_line": target_line, + "start_column": target_column, + "end_line": target_line, + "end_column": target_column, + "text": "", + "precision": precision, + "confidence": 0.15 if column else 0.05, + "score": 40.0 if column else 10.0, + } + + +def _number_from_mapping(mapping: Any, keys: tuple[str, ...]) -> float | None: + if not isinstance(mapping, dict): + return None + for key in keys: + value = mapping.get(key) + try: + parsed = float(value) + except (TypeError, ValueError): + continue + if parsed >= 0: + return parsed + return None + + +def _coerce_pdf_word_bbox(value: Any) -> dict[str, float] | None: + if not isinstance(value, dict): + return None + left = _number_from_mapping(value, ("left", "x1", "x")) + top = _number_from_mapping(value, ("top", "y1", "y")) + right = _number_from_mapping(value, ("right", "x2")) + bottom = _number_from_mapping(value, ("bottom", "y2")) + width = _number_from_mapping(value, ("width", "w")) + height = _number_from_mapping(value, ("height", "h")) + if right is None and left is not None and width is not None: + right = left + width + if bottom is None and top is not None and height is not None: + bottom = top + height + if left is None or top is None or right is None or bottom is None: + return None + x1, x2 = sorted((left, right)) + y1, y2 = sorted((top, bottom)) + if x2 <= x1 or y2 <= y1: + return None + return {"left": x1, "top": y1, "right": x2, "bottom": y2} + + +def _coerce_pdf_word_center(value: Any) -> tuple[float, float] | None: + if not isinstance(value, dict): + return None + x = _number_from_mapping(value, ("x", "left")) + y = _number_from_mapping(value, ("y", "top")) + if x is None or y is None: + return None + return x, y + + +def _synctex_sample_points( + x: float, + y: float, + *, + pdf_word_bbox: Any = None, + pdf_word_center: Any = None, +) -> list[dict[str, Any]]: + points: list[dict[str, Any]] = [] + seen: set[tuple[int, int]] = set() + + def add(kind: str, px: float | None, py: float | None, priority: float) -> None: + if px is None or py is None or px < 0 or py < 0: + return + key = (round(px * 1000), round(py * 1000)) + if key in seen: + return + seen.add(key) + points.append({"kind": kind, "x": float(px), "y": float(py), "priority": priority}) + + bbox = _coerce_pdf_word_bbox(pdf_word_bbox) + center = _coerce_pdf_word_center(pdf_word_center) + if bbox: + bbox_center = ((bbox["left"] + bbox["right"]) / 2, (bbox["top"] + bbox["bottom"]) / 2) + center = center or bbox_center + if center: + add("word_center", center[0], center[1], 60.0) + add("click", x, y, 45.0) + if bbox: + left, top, right, bottom = bbox["left"], bbox["top"], bbox["right"], bbox["bottom"] + mid_x = (left + right) / 2 + mid_y = (top + bottom) / 2 + inset_x = max((right - left) * 0.18, 0.5) + inset_y = max((bottom - top) * 0.18, 0.5) + for kind, px, py, priority in [ + ("word_left", left + inset_x, mid_y, 38.0), + ("word_right", right - inset_x, mid_y, 38.0), + ("word_top", mid_x, top + inset_y, 32.0), + ("word_bottom", mid_x, bottom - inset_y, 32.0), + ("word_q1", left + (right - left) * 0.25, mid_y, 28.0), + ("word_q3", left + (right - left) * 0.75, mid_y, 28.0), + ]: + add(kind, px, py, priority) + return points or [{"kind": "click", "x": x, "y": y, "priority": 45.0}] + + class QuestLatexService: def __init__(self, quest_service: Any) -> None: self.quest_service = quest_service @@ -223,12 +1110,12 @@ def _resolve_main_tex(self, project_id: str, folder_path: Path, folder_relative: if folder_path not in path.resolve().parents: raise ValueError("`main_file_id` must belong to the selected LaTeX folder.") return path, relative - preferred = folder_path / "main.tex" - if preferred.exists(): - return preferred, f"{folder_relative.rstrip('/')}/main.tex" tex_candidates = sorted(folder_path.glob("*.tex")) if not tex_candidates: raise FileNotFoundError("No `.tex` file found in the LaTeX folder.") + for candidate in tex_candidates: + if candidate.name.lower() == "main.tex": + return candidate, f"{folder_relative.rstrip('/')}/{candidate.name}" chosen = tex_candidates[0] return chosen, f"{folder_relative.rstrip('/')}/{chosen.name}" @@ -276,12 +1163,159 @@ def _write_compile_report(self, project_id: str, folder_relative: str, build: di "exit_code": build.get("exit_code"), "pdf_ready": build.get("pdf_ready"), "log_ready": build.get("log_ready"), + "synctex_ready": build.get("synctex_ready"), "pdf_path": build.get("output_pdf_path"), + "synctex_path": build.get("synctex_path"), "errors": build.get("errors") or [], "log_items": build.get("log_items") or [], } write_json(report_path, payload) + @staticmethod + def _manifest_role_for(path: Path, main_tex_path: Path) -> str: + if path.resolve() == main_tex_path.resolve(): + return "main" + suffix = path.suffix.lower() + if suffix == ".tex": + return "tex" + if suffix == ".bib": + return "bib" + if suffix in {".cls", ".sty", ".bst", ".bbx", ".cbx"}: + return "style" + if suffix in _LATEX_RESOURCE_SUFFIXES: + return "resource" + return "other" + + @staticmethod + def _is_manifest_file(path: Path) -> bool: + name = path.name + if name.startswith("."): + return False + suffix = path.suffix.lower() + if suffix in _TRANSIENT_SOURCE_SUFFIXES: + return False + return suffix in _LATEX_MANIFEST_SUFFIXES + + def _latest_compiler_for_folder(self, project_id: str, folder_relative: str) -> str: + for record in self._list_build_records(project_id, folder_relative): + compiler = str(record.get("compiler") or "").strip().lower() + if compiler in _VALID_COMPILERS: + return compiler + return "pdflatex" + + def _resolve_latex_dependency( + self, + folder_path: Path, + source_path: Path, + raw_value: str, + *, + suffix: str, + ) -> str | None: + token = str(raw_value or "").strip() + if not token: + return None + # \bibliography can contain comma-separated names. + token = token.split(",", 1)[0].strip() + if not token: + return None + candidate = Path(token.replace("\\", "/")) + if candidate.suffix == "": + candidate = candidate.with_suffix(suffix) + search_roots = [source_path.parent, folder_path] + for root in search_roots: + try: + resolved = (root / candidate).resolve() + except OSError: + continue + try: + resolved.relative_to(folder_path.resolve()) + except ValueError: + continue + if resolved.exists(): + return resolved.relative_to(folder_path).as_posix() + return candidate.as_posix() + + def _manifest_dependencies(self, folder_path: Path, file_path: Path) -> list[dict[str, str]]: + if file_path.suffix.lower() != ".tex": + return [] + try: + text = file_path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return [] + deps: list[dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + for match in _LATEX_INPUT_RE.finditer(text): + target = self._resolve_latex_dependency(folder_path, file_path, match.group(1), suffix=".tex") + if not target: + continue + key = ("input", target) + if key in seen: + continue + seen.add(key) + deps.append({"kind": "input", "path": target}) + for match in _LATEX_BIB_RE.finditer(text): + target = self._resolve_latex_dependency(folder_path, file_path, match.group(1), suffix=".bib") + if not target: + continue + key = ("bibliography", target) + if key in seen: + continue + seen.add(key) + deps.append({"kind": "bibliography", "path": target}) + return deps + + def manifest(self, project_id: str, folder_id: str) -> dict[str, Any]: + folder_path, folder_relative = self._resolve_folder_path(project_id, folder_id) + main_tex_path, main_tex_relative = self._resolve_main_tex(project_id, folder_path, folder_relative, None) + workspace_root = self._workspace_root(project_id) + files: list[dict[str, Any]] = [] + + for path in sorted(folder_path.rglob("*")): + if not path.is_file(): + continue + try: + relative_to_folder = path.relative_to(folder_path).as_posix() + except ValueError: + continue + if any(part.startswith(".git") for part in Path(relative_to_folder).parts): + continue + if not self._is_manifest_file(path): + continue + try: + relative_to_workspace = path.relative_to(workspace_root).as_posix() + except ValueError: + relative_to_workspace = path.relative_to(self._quest_root(project_id)).as_posix() + role = self._manifest_role_for(path, main_tex_path) + editable = role in {"main", "tex", "bib", "style"} + files.append( + { + "id": _encode_quest_file_id(project_id, relative_to_workspace), + "name": path.name, + "path": relative_to_workspace, + "relative_path": relative_to_folder, + "role": role, + "editable": editable, + "size": path.stat().st_size, + "dependencies": self._manifest_dependencies(folder_path, path) if editable else [], + } + ) + + files.sort( + key=lambda item: ( + 0 if item.get("role") == "main" else 1, + str(item.get("relative_path") or item.get("path") or "").lower(), + ) + ) + return { + "folder_id": folder_id, + "folder_path": folder_relative, + "folder_name": folder_path.name, + "main_file_id": _encode_quest_file_id(project_id, main_tex_relative), + "main_file_path": main_tex_relative, + "compiler": self._latest_compiler_for_folder(project_id, folder_relative), + "files": files, + } + def init_project( self, project_id: str, @@ -424,6 +1458,8 @@ def compile( "log_items": [], "output_pdf_path": None, "log_path": None, + "synctex_ready": False, + "synctex_path": None, "bibtex_binary": None, "auto": bool(auto), "stop_on_first_error": bool(stop_on_first_error), @@ -460,6 +1496,7 @@ def compile( compiler_bin, "-interaction=nonstopmode", "-file-line-error", + "-synctex=1", *([] if stop_on_first_error is False else ["-halt-on-error"]), main_tex_path.name, ] @@ -508,9 +1545,16 @@ def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: errors, log_items = _parse_file_line_issues(compile_log_text) generated_pdf = folder_path / f"{main_tex_path.stem}.pdf" + generated_synctex = folder_path / f"{main_tex_path.stem}.synctex.gz" + if not generated_synctex.exists(): + generated_synctex = folder_path / f"{main_tex_path.stem}.synctex" + synctex_copy_path = build_dir / generated_synctex.name + synctex_ready = generated_synctex.exists() and generated_synctex.is_file() pdf_ready = exit_code == 0 and generated_pdf.exists() if pdf_ready: shutil.copy2(generated_pdf, pdf_copy_path) + if synctex_ready: + shutil.copy2(generated_synctex, synctex_copy_path) build.update( { @@ -524,6 +1568,8 @@ def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: "log_items": log_items, "output_pdf_path": str(pdf_copy_path) if pdf_ready else None, "log_path": str(log_path), + "synctex_ready": synctex_ready, + "synctex_path": str(synctex_copy_path) if synctex_ready else None, } ) write_json(metadata_path, build) @@ -583,3 +1629,227 @@ def create_sources_archive(self, project_id: str, folder_id: str) -> tuple[bytes continue archive.write(path, arcname=path.relative_to(folder_path).as_posix()) return buffer.getvalue(), archive_name + + def synctex_edit( + self, + project_id: str, + folder_id: str, + build_id: str, + *, + page: int | float | str | None, + x: int | float | str | None, + y: int | float | str | None, + pdf_word: str | None = None, + pdf_context_words: list[str] | None = None, + pdf_context_index: int | None = None, + pdf_word_bbox: Any = None, + pdf_word_center: Any = None, + ) -> dict[str, Any]: + folder_path, folder_relative = self._resolve_folder_path(project_id, folder_id) + build = self.get_build(project_id, folder_id, build_id) + + def _as_positive_number(value: int | float | str | None, name: str) -> float: + try: + parsed = float(value) # type: ignore[arg-type] + except (TypeError, ValueError) as exc: + raise ValueError(f"`{name}` must be a number.") from exc + if not (parsed >= 0): + raise ValueError(f"`{name}` must be non-negative.") + return parsed + + page_number = int(round(_as_positive_number(page, "page"))) + if page_number < 1: + raise ValueError("`page` must be at least 1.") + point_x = _as_positive_number(x, "x") + point_y = _as_positive_number(y, "y") + + output_pdf_path = str(build.get("output_pdf_path") or "").strip() + synctex_path = str(build.get("synctex_path") or "").strip() + if not output_pdf_path or not Path(output_pdf_path).is_file(): + return { + "ok": False, + "message": "PDF output is not available for this build.", + "reason": "missing_pdf", + } + if not bool(build.get("synctex_ready")) or not synctex_path or not Path(synctex_path).is_file(): + return { + "ok": False, + "message": "SyncTeX data is not available. Recompile the LaTeX project to enable PDF-to-source jumps.", + "reason": "missing_synctex", + } + + runtime_tools = RuntimeToolService(self.quest_service.home) + synctex_match = runtime_tools.resolve_binary("synctex", preferred_tools=("tinytex",)) + synctex_bin = synctex_match.get("path") + if not synctex_bin: + return { + "ok": False, + "message": "`synctex` is not installed on this machine.", + "reason": "missing_synctex_binary", + } + + synctex_dir = Path(synctex_path).parent + output_pdf = Path(output_pdf_path) + workspace_root = self._workspace_root(project_id).resolve() + quest_root = self._quest_root(project_id).resolve() + + def _int_or_none(value: str | None) -> int | None: + try: + parsed = int(str(value or "").strip()) + except ValueError: + return None + return parsed if parsed >= 0 else None + + def _run_synctex_sample(sample: dict[str, Any]) -> tuple[int, dict[str, str], str]: + result = subprocess.run( + [ + str(synctex_bin), + "edit", + "-o", + f"{page_number}:{float(sample['x']):.3f}:{float(sample['y']):.3f}:{output_pdf}", + "-d", + str(synctex_dir), + ], + cwd=str(folder_path), + capture_output=True, + check=False, + **utf8_text_subprocess_kwargs(), + ) + combined = (result.stdout or "") + (result.stderr or "") + return result.returncode, _parse_synctex_records(combined), combined + + def _candidate_from_records( + records: dict[str, str], + sample: dict[str, Any], + raw_output: str, + ) -> dict[str, Any] | None: + raw_input = records.get("input") + if not raw_input: + return None + input_path = Path(raw_input) + if not input_path.is_absolute(): + input_path = folder_path / input_path + try: + input_path = input_path.resolve() + except OSError: + return None + + source_root = workspace_root + try: + input_relative = input_path.relative_to(workspace_root).as_posix() + except ValueError: + try: + input_relative = input_path.relative_to(quest_root).as_posix() + source_root = quest_root + except ValueError: + return None + + try: + resolved_input = resolve_within(source_root, input_relative) + except ValueError: + resolved_input = resolve_within(quest_root, input_relative) + if not resolved_input.exists() or not resolved_input.is_file(): + return None + try: + resolved_input.resolve().relative_to(folder_path.resolve()) + except ValueError: + return None + + line = _int_or_none(records.get("line")) or 1 + column = _int_or_none(records.get("column")) + try: + source_text = resolved_input.read_text(encoding="utf-8", errors="ignore") + except OSError: + source_text = "" + selection = _source_selection_for_synctex( + source_text, + line=line, + column=column, + pdf_word=pdf_word, + pdf_context_words=pdf_context_words, + pdf_context_index=pdf_context_index, + ) + return { + "file_id": _encode_quest_file_id(project_id, input_relative), + "file_path": input_relative, + "file_name": Path(input_relative).name, + "line": max(1, line), + "column": column if column and column > 0 else None, + "selection": selection, + "sample": sample, + "raw": raw_output[-2000:], + "records": records, + } + + samples = _synctex_sample_points( + point_x, + point_y, + pdf_word_bbox=pdf_word_bbox, + pdf_word_center=pdf_word_center, + ) + candidates: list[dict[str, Any]] = [] + raw_outputs: list[str] = [] + for sample in samples: + return_code, records, raw_output = _run_synctex_sample(sample) + raw_outputs.append(raw_output) + if return_code != 0: + continue + candidate = _candidate_from_records(records, sample, raw_output) + if candidate is not None: + candidates.append(candidate) + + if not candidates: + return { + "ok": False, + "message": "No source location was found for this PDF position.", + "reason": "not_found", + "raw": "\n".join(raw_outputs)[-4000:], + } + + counts = Counter((candidate["file_path"], candidate["line"]) for candidate in candidates) + + def _candidate_score(candidate: dict[str, Any]) -> float: + selection = candidate.get("selection") or {} + sample = candidate.get("sample") or {} + score = float(selection.get("score") or 0) + score += float(sample.get("priority") or 0) + score += counts[(candidate["file_path"], candidate["line"])] * 25.0 + if selection.get("precision") == "exact_word": + score += 250.0 + return score + + best = max(candidates, key=_candidate_score) + selection = dict(best.get("selection") or {}) + selection.pop("score", None) + precision = selection.get("precision") or ("line_column" if best.get("column") else "line_only") + if precision == "exact_word": + line = int(selection.get("start_line") or best["line"]) + column = int(selection.get("start_column") or best.get("column") or 1) + else: + line = int(best["line"]) + column = int(best.get("column") or selection.get("start_column") or 1) + + return { + "ok": True, + "file_id": best["file_id"], + "file_path": best["file_path"], + "file_name": best["file_name"], + "line": max(1, line), + "column": column if column and column > 0 else None, + "selection": selection, + "precision": precision, + "confidence": selection.get("confidence"), + "pdf_word": str(pdf_word or "").strip() or None, + "pdf_context_words": pdf_context_words or None, + "pdf_context_index": pdf_context_index, + "synctex_line": best["line"], + "synctex_column": best.get("column"), + "sample_count": len(samples), + "candidate_count": len(candidates), + "matched_sample": best.get("sample"), + "page": page_number, + "x": point_x, + "y": point_y, + "folder_id": folder_id, + "folder_path": folder_relative, + } diff --git a/src/deepscientist/quest/service.py b/src/deepscientist/quest/service.py index 34261997..773ffb6c 100644 --- a/src/deepscientist/quest/service.py +++ b/src/deepscientist/quest/service.py @@ -7794,17 +7794,43 @@ def _looks_like_latex_folder(path: Path, relative: str) -> bool: normalized = str(relative or "").strip().replace("\\", "/") if not normalized: return False + if normalized == ".ds" or normalized.startswith(".ds/"): + return False try: if (path / "main.tex").is_file(): return True except OSError: return False - if normalized.startswith(".ds/"): + + # A chapter folder under an existing LaTeX project (for example + # paper/latex/sections/) may contain .tex files, but opening it as a + # separate LaTeX project fragments the editor and build context. Prefer + # the nearest/top-level ancestor that owns main.tex. + try: + parts = PurePosixPath(normalized).parts + ancestor = path.parent + for _ in range(max(0, len(parts) - 1)): + if (ancestor / "main.tex").is_file(): + return False + ancestor = ancestor.parent + except OSError: return False + try: - return any(item.is_file() and item.suffix.lower() == ".tex" for item in path.iterdir()) + tex_files = [item for item in path.iterdir() if item.is_file() and item.suffix.lower() == ".tex"] except OSError: return False + if not tex_files: + return False + for item in tex_files: + try: + if "\\documentclass" in item.read_text(encoding="utf-8", errors="ignore")[:8192]: + return True + except OSError: + continue + # Legacy fallback: a folder with direct .tex files can still be a + # standalone LaTeX folder when no ancestor owns main.tex. + return True @staticmethod def _renderer_hint_for(path: Path) -> tuple[str, str]: diff --git a/src/ui/src/components/file-tree/FileTree.tsx b/src/ui/src/components/file-tree/FileTree.tsx index 58b142b9..fcddc3b3 100644 --- a/src/ui/src/components/file-tree/FileTree.tsx +++ b/src/ui/src/components/file-tree/FileTree.tsx @@ -18,6 +18,7 @@ import { ConfirmModal } from "@/components/ui/modal"; import { useToast } from "@/components/ui/toast"; import { cn } from "@/lib/utils"; import { compileLatex } from "@/lib/api/latex"; +import { findLatexRootFolderForFile } from "@/lib/latex/open-queue"; /** * FileTree props @@ -492,16 +493,7 @@ export function FileTree({ const findLatexFolderForFile = React.useCallback( (file: FileNode): FileNode | null => { - let currentId: string | null = file.parentId; - while (currentId) { - const parent = findNode(currentId); - if (!parent) return null; - if (parent.type === "folder" && parent.folderKind === "latex") { - return parent; - } - currentId = parent.parentId; - } - return null; + return findLatexRootFolderForFile(file, findNode); }, [findNode] ); @@ -523,7 +515,10 @@ export function FileTree({ try { const build = await compileLatex(projectId, folder.id, { - main_file_id: node.id, + main_file_id: + node.name.toLowerCase() === "main.tex" || folder.latex?.mainFileId === node.id + ? node.id + : folder.latex?.mainFileId ?? null, stop_on_first_error: false, }); addToast({ diff --git a/src/ui/src/components/workspace/CopilotDockOverlay.tsx b/src/ui/src/components/workspace/CopilotDockOverlay.tsx index eb1f58f4..4ae16d89 100644 --- a/src/ui/src/components/workspace/CopilotDockOverlay.tsx +++ b/src/ui/src/components/workspace/CopilotDockOverlay.tsx @@ -13,7 +13,6 @@ import type { import OrbitLogoStatus from '@/lib/plugins/ai-manus/components/OrbitLogoStatus' import type { ChatSurface } from '@/lib/types/chat-events' import { Noise } from '@/components/react-bits' -import RotatingText from '@/components/RotatingText' import { cn } from '@/lib/utils' import { COPILOT_FILES_ENABLED } from '@/lib/feature-flags' import { @@ -31,6 +30,7 @@ import { useMaxEntitlement } from '@/lib/hooks/useMaxEntitlement' import { useI18n } from '@/lib/i18n/useI18n' import { useWorkspaceSurfaceStore, type WorkspaceTabViewState } from '@/lib/stores/workspace-surface' import { getWorkspaceContentKind, getWorkspaceContentKindBadge } from '@/lib/workspace/content-meta' +import { findLatexRootFolderForFile } from '@/lib/latex/open-queue' const CopilotDockHeaderPortalContext = React.createContext(null) @@ -680,6 +680,15 @@ export function CopilotDockOverlay({ setCopilotMeta((prev) => (isCopilotMetaEqual(prev, next) ? prev : next)) }, []) + const dockCallbacksValue = React.useMemo( + () => ({ + onActionsChange: handleActionsChange, + onMetaChange: handleMetaChange, + onHeaderExtraChange: setHeaderExtraContent, + }), + [handleActionsChange, handleMetaChange] + ) + const logHistoryToggle = React.useCallback((next: boolean) => { if (typeof window === 'undefined') return if (process.env.NODE_ENV !== 'production' || window.localStorage.getItem('ds_debug_copilot') === '1') { @@ -719,14 +728,9 @@ export function CopilotDockOverlay({ if (activeTab.context.type === 'file' && typeof activeTab.context.resourceId === 'string') { const node = findNode(activeTab.context.resourceId) if (!node) return null - let currentId = node.parentId - while (currentId) { - const parent = findNode(currentId) - if (!parent) break - if (parent.type === 'folder' && parent.folderKind === 'latex') { - return { folderId: parent.id, label: parent.name || getFileLabel(activeTab) } - } - currentId = parent.parentId + const parent = findLatexRootFolderForFile(node, findNode) + if (parent) { + return { folderId: parent.id, label: parent.name || getFileLabel(activeTab) } } } return null @@ -826,7 +830,7 @@ export function CopilotDockOverlay({ : [statusText] : [] const showStatus = statusTexts.length > 0 - const statusAnimate = statusTexts.length > 1 + const visibleStatusText = statusTexts[statusTexts.length - 1] || '' const historyOpen = historyOpenOverride const headerOrbitResetKey = `${copilotMeta?.threadId ?? projectId}:${copilotMeta?.statusKey ?? 0}:${ copilotMeta?.isResponding ? 'busy' : 'idle' @@ -953,7 +957,7 @@ export function CopilotDockOverlay({ >
- +
@@ -981,33 +985,14 @@ export function CopilotDockOverlay({ {showStatus ? ( <> · - + {visibleStatusText} ) : null}
{headerBadges.length > 0 ? ( - {headerBadges.map((badge) => ( ))} - +
) : null}
@@ -1101,11 +1086,7 @@ export function CopilotDockOverlay({
{hasCustomBody ? ( bodyContent diff --git a/src/ui/src/hooks/useOpenFile.ts b/src/ui/src/hooks/useOpenFile.ts index eb385959..f78be7c6 100644 --- a/src/ui/src/hooks/useOpenFile.ts +++ b/src/ui/src/hooks/useOpenFile.ts @@ -23,6 +23,12 @@ import { } from "@/lib/types/plugin"; import { downloadFileById } from "@/lib/api/files"; import { toFilesResourcePath } from "@/lib/utils/resource-paths"; +import { + buildLatexTabContext, + findLatexRootFolderForFile, + isLatexSourceFileName, + queueLatexOpenFile, +} from "@/lib/latex/open-queue"; /** * Options for opening a file @@ -67,26 +73,8 @@ export function useOpenFile() { const findNode = useFileTreeStore((state) => state.findNode); const storeProjectId = useFileTreeStore((state) => state.projectId); - const findLatexFolderForFile = useCallback( - (file: FileNode): FileNode | null => { - if (!file.parentId) return null; - let currentId: string | null = file.parentId; - while (currentId) { - const parent = findNode(currentId); - if (!parent) return null; - if (parent.type === "folder" && parent.folderKind === "latex") { - return parent; - } - currentId = parent.parentId; - } - return null; - }, - [findNode] - ); - const isLatexSourceFile = useCallback((fileName: string): boolean => { - const lower = fileName.toLowerCase(); - return lower.endsWith(".tex") || lower.endsWith(".bib"); + return isLatexSourceFileName(fileName); }, []); const isMarkdownFileName = useCallback((fileName: string): boolean => { @@ -174,28 +162,37 @@ export function useOpenFile() { : options.customData; if (resolvedProjectId && isLatexSourceFile(file.name)) { - const latexFolder = findLatexFolderForFile(file); + const latexFolder = findLatexRootFolderForFile(file, findNode); if (latexFolder) { preloadPlugin(BUILTIN_PLUGINS.LATEX); const readOnly = Boolean(options.customData?.readOnly) || Boolean(options.customData?.readonly); + const context = buildLatexTabContext({ + projectId: resolvedProjectId, + latexFolder, + readOnly, + }); + const existing = findTabByContext(context); + if (existing) { + setActiveTab(existing.id); + queueLatexOpenFile({ + projectId: resolvedProjectId, + latexFolderId: latexFolder.id, + fileId: file.id, + }); + return { success: true, tabId: existing.id }; + } const tabId = openTab({ pluginId: BUILTIN_PLUGINS.LATEX, - context: { - type: "custom", - resourceId: latexFolder.id, - resourceName: latexFolder.name, - customData: { - projectId: resolvedProjectId, - latexFolderId: latexFolder.id, - mainFileId: latexFolder.latex?.mainFileId ?? null, - openFileId: file.id, - readOnly, - }, - }, + context, title: latexFolder.name, }); + queueLatexOpenFile({ + projectId: resolvedProjectId, + latexFolderId: latexFolder.id, + fileId: file.id, + }); return { success: true, tabId }; } @@ -255,7 +252,7 @@ export function useOpenFile() { }, [ downloadFile, - findLatexFolderForFile, + findNode, findTabByContext, getPluginForFile, isLatexSourceFile, diff --git a/src/ui/src/lib/ai/effect-dispatcher.ts b/src/ui/src/lib/ai/effect-dispatcher.ts index 49ee8132..7a9c7c6a 100644 --- a/src/ui/src/lib/ai/effect-dispatcher.ts +++ b/src/ui/src/lib/ai/effect-dispatcher.ts @@ -18,6 +18,12 @@ import type { } from '@/lib/types/ui-effects' import { queueFileJumpEffect } from '@/lib/ai/file-jump-queue' import { queuePdfEffect } from '@/lib/ai/pdf-effect-queue' +import { + buildLatexTabContext, + findLatexRootFolderForFile, + isLatexSourceFileName, + queueLatexOpenFile, +} from '@/lib/latex/open-queue' type UIEffectContext = { surface?: 'welcome' | 'copilot' | 'lab-direct' | 'lab-group' | 'lab-friends' @@ -51,24 +57,8 @@ function requestCopilotOpen() { dispatchCustomEvent('ds:copilot:open', { source: 'ui-effect' }) } -function findLatexFolderForFile(file: FileNode): FileNode | null { - if (!file.parentId) return null - const findNode = useFileTreeStore.getState().findNode - let currentId: string | null = file.parentId - while (currentId) { - const parent = findNode(currentId) - if (!parent) return null - if (parent.type === 'folder' && parent.folderKind === 'latex') { - return parent - } - currentId = parent.parentId - } - return null -} - function isLatexSourceFile(filename: string): boolean { - const lower = filename.toLowerCase() - return lower.endsWith('.tex') || lower.endsWith('.bib') + return isLatexSourceFileName(filename) } function isMarkdownFileName(filename: string): boolean { @@ -107,23 +97,32 @@ function openFileInTab(file: FileNode, data: FileEffectData, options?: { preferE const projectId = data.projectId ?? useFileTreeStore.getState().projectId ?? undefined if (projectId && isLatexSourceFile(file.name)) { - const latexFolder = findLatexFolderForFile(file) + const latexFolder = findLatexRootFolderForFile(file, useFileTreeStore.getState().findNode) if (latexFolder) { + const context = buildLatexTabContext({ + projectId, + latexFolder, + }) + const existing = tabsStore.findTabByContext(context) + if (existing) { + tabsStore.setActiveTab(existing.id) + queueLatexOpenFile({ + projectId, + latexFolderId: latexFolder.id, + fileId: file.id, + }) + return existing.id + } const tabId = tabsStore.openTab({ pluginId: BUILTIN_PLUGINS.LATEX, - context: { - type: 'custom', - resourceId: latexFolder.id, - resourceName: latexFolder.name, - customData: { - projectId, - latexFolderId: latexFolder.id, - mainFileId: latexFolder.latex?.mainFileId ?? null, - openFileId: file.id, - }, - }, + context, title: latexFolder.name, }) + queueLatexOpenFile({ + projectId, + latexFolderId: latexFolder.id, + fileId: file.id, + }) return tabId } } diff --git a/src/ui/src/lib/api/latex.ts b/src/ui/src/lib/api/latex.ts index e552c46e..b4dbdea6 100644 --- a/src/ui/src/lib/api/latex.ts +++ b/src/ui/src/lib/api/latex.ts @@ -63,8 +63,85 @@ export interface LatexBuildResponse { error_message?: string | null; pdf_ready: boolean; log_ready: boolean; + synctex_ready?: boolean; errors: LatexBuildError[]; log_items?: LatexLogItem[]; + synctex_path?: string | null; +} + +export interface LatexManifestFile { + id: string; + name: string; + path: string; + relative_path?: string; + role: "main" | "tex" | "bib" | "style" | "resource" | "other" | string; + editable: boolean; + size?: number; + dependencies?: Array<{ kind: string; path: string }>; +} + +export interface LatexManifestResponse { + folder_id: string; + folder_path: string; + folder_name?: string; + main_file_id?: string | null; + main_file_path?: string | null; + compiler?: LatexCompiler; + files: LatexManifestFile[]; +} + +export interface LatexSyncTexEditRequest { + page: number; + x: number; + y: number; + pdf_word?: string | null; + pdf_context_words?: string[] | null; + pdf_context_index?: number | null; + pdf_word_bbox?: { + left: number; + top: number; + right: number; + bottom: number; + width?: number; + height?: number; + } | null; + pdf_word_center?: { x: number; y: number } | null; +} + +export interface LatexSyncTexSelection { + start_line: number; + start_column: number; + end_line: number; + end_column: number; + text?: string; + precision?: "exact_word" | "nearest_token" | "line_column" | "line_only" | string; + confidence?: number; +} + +export interface LatexSyncTexEditResponse { + ok: boolean; + message?: string; + reason?: string; + file_id?: string; + file_path?: string; + file_name?: string; + line?: number; + column?: number | null; + selection?: LatexSyncTexSelection | null; + precision?: string; + confidence?: number | null; + pdf_word?: string | null; + pdf_context_words?: string[] | null; + pdf_context_index?: number | null; + synctex_line?: number; + synctex_column?: number | null; + sample_count?: number; + candidate_count?: number; + page?: number; + x?: number; + y?: number; + folder_id?: string; + folder_path?: string; } export async function initLatexProject( @@ -90,6 +167,16 @@ export async function compileLatex( return res.data; } +export async function getLatexManifest( + projectId: string, + folderId: string +): Promise { + const res = await apiClient.get( + `/api/v1/projects/${projectId}/latex/${folderId}/manifest` + ); + return res.data; +} + export async function getLatexBuild( projectId: string, folderId: string, @@ -137,6 +224,19 @@ export async function getLatexBuildLogText( return String(res.data ?? ""); } +export async function syncTexEditLatexBuild( + projectId: string, + folderId: string, + buildId: string, + request: LatexSyncTexEditRequest +): Promise { + const res = await apiClient.post( + `/api/v1/projects/${projectId}/latex/${folderId}/builds/${buildId}/synctex/edit`, + request + ); + return res.data; +} + export async function getLatexSourcesArchiveBlob( projectId: string, folderId: string diff --git a/src/ui/src/lib/i18n/messages/latex.ts b/src/ui/src/lib/i18n/messages/latex.ts index 71bdf3c2..71e94a42 100644 --- a/src/ui/src/lib/i18n/messages/latex.ts +++ b/src/ui/src/lib/i18n/messages/latex.ts @@ -6,14 +6,19 @@ export const latexMessages: Partial>> load_files_failed: 'Failed to load LaTeX project files', collaboration_failed: 'Failed to initialize collaboration', compile_request_failed: 'Compile request failed', + save_request_failed: 'Save request failed', file_label: 'LaTeX file', compiler_label: 'Compiler', compiler_pdflatex: 'pdfLaTeX', compiler_xelatex: 'XeLaTeX', compiler_lualatex: 'LuaLaTeX', + auto_compile_on_save: 'Auto compile on save', + auto_compile_on_save_hint: 'Manual Save or Ctrl/Cmd+S compiles once after saving. Background autosaves do not compile.', status_read_only: 'Read only', status_unsaved: 'Unsaved', status_saving: 'Saving', + status_autosaving: 'Autosaving', + status_save_failed: 'Save failed', status_saved: 'Saved', status_compiling: 'Compiling', status_compile_failed: 'Compile failed', @@ -26,6 +31,7 @@ export const latexMessages: Partial>> compile_disabled_read_only: 'Compile is disabled in read-only mode', compile_failed_title: 'Compile failed', compile_failed_fallback: 'See log for details.', + save_failed_title: 'Save failed', error_badge: 'error', warning_badge: 'warning', warnings_title: 'Warnings', @@ -47,6 +53,12 @@ export const latexMessages: Partial>> preview_compiling: 'Compiling…', preview_no_output: 'No PDF output available.', preview_empty: 'No PDF yet. Click Compile.', + synctex_unavailable_hint: 'PDF-to-source jump needs SyncTeX data. Recompile this project to enable it.', + synctex_resolving: 'Locating source…', + synctex_not_found: 'No matching source location was found for this PDF position.', + synctex_source_not_loaded: 'The matching source file is not listed in this LaTeX project.', + synctex_switch_failed: 'Could not switch to the matching source file. Please save the current file and try again.', + synctex_failed: 'PDF-to-source jump failed.', bib_snippet_article: 'Journal article template', bib_snippet_inproceedings: 'Conference paper template', bib_snippet_misc: 'Misc reference template', @@ -71,14 +83,19 @@ export const latexMessages: Partial>> load_files_failed: '加载 LaTeX 项目文件失败', collaboration_failed: '初始化协同编辑失败', compile_request_failed: '提交编译请求失败', + save_request_failed: '提交保存请求失败', file_label: 'LaTeX 文件', compiler_label: '编译器', compiler_pdflatex: 'pdfLaTeX', compiler_xelatex: 'XeLaTeX', compiler_lualatex: 'LuaLaTeX', + auto_compile_on_save: '保存后自动编译', + auto_compile_on_save_hint: '手动保存或 Ctrl/Cmd+S 保存成功后编译一次;后台自动保存不会触发编译。', status_read_only: '只读', status_unsaved: '未保存', status_saving: '保存中', + status_autosaving: '自动保存中', + status_save_failed: '保存失败', status_saved: '已保存', status_compiling: '编译中', status_compile_failed: '编译失败', @@ -91,6 +108,7 @@ export const latexMessages: Partial>> compile_disabled_read_only: '只读模式下不可编译', compile_failed_title: '编译失败', compile_failed_fallback: '请查看日志了解详情。', + save_failed_title: '保存失败', error_badge: '错误', warning_badge: '警告', warnings_title: '警告', @@ -112,6 +130,12 @@ export const latexMessages: Partial>> preview_compiling: '正在编译…', preview_no_output: '暂无可用 PDF 输出。', preview_empty: '还没有 PDF,点击编译即可生成。', + synctex_unavailable_hint: 'PDF 到源码跳转需要 SyncTeX 数据;请重新编译该项目以启用。', + synctex_resolving: '正在定位源码…', + synctex_not_found: '未找到该 PDF 位置对应的源码位置。', + synctex_source_not_loaded: '对应的源码文件未出现在当前 LaTeX 项目列表中。', + synctex_switch_failed: '无法切换到对应源码文件,请先保存当前文件后重试。', + synctex_failed: 'PDF 到源码跳转失败。', bib_snippet_article: '期刊论文模板', bib_snippet_inproceedings: '会议论文模板', bib_snippet_misc: '其他条目模板', @@ -136,14 +160,19 @@ export const latexMessages: Partial>> load_files_failed: 'Échec du chargement des fichiers du projet LaTeX', collaboration_failed: 'Échec de l’initialisation de la collaboration', compile_request_failed: 'La requête de compilation a échoué', + save_request_failed: 'La requête d’enregistrement a échoué', file_label: 'Fichier LaTeX', compiler_label: 'Compilateur', compiler_pdflatex: 'pdfLaTeX', compiler_xelatex: 'XeLaTeX', compiler_lualatex: 'LuaLaTeX', + auto_compile_on_save: 'Compiler après enregistrement', + auto_compile_on_save_hint: 'Enregistrer manuellement ou Ctrl/Cmd+S compile une fois après l’enregistrement. Les enregistrements automatiques ne compilent pas.', status_read_only: 'Lecture seule', status_unsaved: 'Non enregistré', status_saving: 'Enregistrement', + status_autosaving: 'Enregistrement automatique', + status_save_failed: 'Échec de l’enregistrement', status_saved: 'Enregistré', status_compiling: 'Compilation', status_compile_failed: 'Échec de compilation', @@ -156,6 +185,7 @@ export const latexMessages: Partial>> compile_disabled_read_only: 'La compilation est désactivée en mode lecture seule', compile_failed_title: 'Échec de compilation', compile_failed_fallback: 'Consultez le journal pour plus de détails.', + save_failed_title: 'Échec d’enregistrement', error_badge: 'erreur', warning_badge: 'avertissement', warnings_title: 'Avertissements', @@ -197,14 +227,19 @@ export const latexMessages: Partial>> load_files_failed: 'LaTeX プロジェクトファイルの読み込みに失敗しました', collaboration_failed: '共同編集の初期化に失敗しました', compile_request_failed: 'コンパイル要求に失敗しました', + save_request_failed: '保存要求に失敗しました', file_label: 'LaTeX ファイル', compiler_label: 'コンパイラ', compiler_pdflatex: 'pdfLaTeX', compiler_xelatex: 'XeLaTeX', compiler_lualatex: 'LuaLaTeX', + auto_compile_on_save: '保存後に自動コンパイル', + auto_compile_on_save_hint: '手動保存または Ctrl/Cmd+S の保存後に一度だけコンパイルします。バックグラウンドの自動保存ではコンパイルしません。', status_read_only: '読み取り専用', status_unsaved: '未保存', status_saving: '保存中', + status_autosaving: '自動保存中', + status_save_failed: '保存に失敗しました', status_saved: '保存済み', status_compiling: 'コンパイル中', status_compile_failed: 'コンパイル失敗', @@ -217,6 +252,7 @@ export const latexMessages: Partial>> compile_disabled_read_only: '読み取り専用モードではコンパイルできません', compile_failed_title: 'コンパイル失敗', compile_failed_fallback: '詳細はログを確認してください。', + save_failed_title: '保存失敗', error_badge: 'エラー', warning_badge: '警告', warnings_title: '警告', @@ -258,14 +294,19 @@ export const latexMessages: Partial>> load_files_failed: 'LaTeX 프로젝트 파일을 불러오지 못했습니다', collaboration_failed: '협업 초기화에 실패했습니다', compile_request_failed: '컴파일 요청에 실패했습니다', + save_request_failed: '저장 요청에 실패했습니다', file_label: 'LaTeX 파일', compiler_label: '컴파일러', compiler_pdflatex: 'pdfLaTeX', compiler_xelatex: 'XeLaTeX', compiler_lualatex: 'LuaLaTeX', + auto_compile_on_save: '저장 후 자동 컴파일', + auto_compile_on_save_hint: '수동 저장 또는 Ctrl/Cmd+S 저장 후 한 번 컴파일합니다. 백그라운드 자동 저장은 컴파일하지 않습니다.', status_read_only: '읽기 전용', status_unsaved: '저장되지 않음', status_saving: '저장 중', + status_autosaving: '자동 저장 중', + status_save_failed: '저장 실패', status_saved: '저장됨', status_compiling: '컴파일 중', status_compile_failed: '컴파일 실패', @@ -278,6 +319,7 @@ export const latexMessages: Partial>> compile_disabled_read_only: '읽기 전용 모드에서는 컴파일할 수 없습니다', compile_failed_title: '컴파일 실패', compile_failed_fallback: '자세한 내용은 로그를 확인하세요.', + save_failed_title: '저장 실패', error_badge: '오류', warning_badge: '경고', warnings_title: '경고', @@ -319,14 +361,19 @@ export const latexMessages: Partial>> load_files_failed: 'Не удалось загрузить файлы проекта LaTeX', collaboration_failed: 'Не удалось инициализировать совместную работу', compile_request_failed: 'Ошибка запроса на компиляцию', + save_request_failed: 'Ошибка запроса на сохранение', file_label: 'Файл LaTeX', compiler_label: 'Компилятор', compiler_pdflatex: 'pdfLaTeX', compiler_xelatex: 'XeLaTeX', compiler_lualatex: 'LuaLaTeX', + auto_compile_on_save: 'Компилировать после сохранения', + auto_compile_on_save_hint: 'Ручное сохранение или Ctrl/Cmd+S запускает одну компиляцию после сохранения. Фоновое автосохранение не компилирует.', status_read_only: 'Только чтение', status_unsaved: 'Не сохранено', status_saving: 'Сохранение', + status_autosaving: 'Автосохранение', + status_save_failed: 'Не удалось сохранить', status_saved: 'Сохранено', status_compiling: 'Компиляция', status_compile_failed: 'Сбой компиляции', @@ -339,6 +386,7 @@ export const latexMessages: Partial>> compile_disabled_read_only: 'В режиме только для чтения компиляция отключена', compile_failed_title: 'Сбой компиляции', compile_failed_fallback: 'Подробности смотрите в журнале.', + save_failed_title: 'Сбой сохранения', error_badge: 'ошибка', warning_badge: 'предупреждение', warnings_title: 'Предупреждения', diff --git a/src/ui/src/lib/latex/open-queue.ts b/src/ui/src/lib/latex/open-queue.ts new file mode 100644 index 00000000..923a7355 --- /dev/null +++ b/src/ui/src/lib/latex/open-queue.ts @@ -0,0 +1,91 @@ +import type { FileNode } from '@/lib/types/file' +import type { TabContext } from '@/lib/types/tab' + +export const LATEX_OPEN_FILE_EVENT = 'ds:latex:open-file' + +export type LatexOpenFileRequest = { + projectId?: string + latexFolderId?: string + fileId?: string | null + filePath?: string | null + line?: number | null + column?: number | null + word?: string | null +} + +const pendingRequests: LatexOpenFileRequest[] = [] + +function sameLatexProject(request: LatexOpenFileRequest, projectId?: string, latexFolderId?: string) { + if (!request.latexFolderId || !latexFolderId || request.latexFolderId !== latexFolderId) return false + if (request.projectId && projectId && request.projectId !== projectId) return false + return true +} + +export function queueLatexOpenFile(request: LatexOpenFileRequest) { + pendingRequests.push(request) + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(LATEX_OPEN_FILE_EVENT, { detail: request })) + } +} + +export function consumeLatexOpenFileRequests(projectId?: string, latexFolderId?: string) { + if (!latexFolderId) return [] + const matched: LatexOpenFileRequest[] = [] + for (let index = pendingRequests.length - 1; index >= 0; index -= 1) { + const request = pendingRequests[index] + if (!sameLatexProject(request, projectId, latexFolderId)) continue + pendingRequests.splice(index, 1) + matched.unshift(request) + } + return matched +} + +export function buildLatexTabContext(args: { + projectId: string + latexFolder: FileNode + readOnly?: boolean +}): TabContext { + const { projectId, latexFolder, readOnly } = args + return { + type: 'custom', + resourceId: latexFolder.id, + resourceName: latexFolder.name, + resourcePath: latexFolder.path ? `/FILES/${latexFolder.path.replace(/^\/+/, '')}` : undefined, + customData: { + kind: 'latex-workspace', + projectId, + latexFolderId: latexFolder.id, + readOnly: Boolean(readOnly), + }, + } +} + +export function findLatexRootFolderForFile( + file: FileNode, + findNode: (nodeId: string) => FileNode | null | undefined, +): FileNode | null { + let currentId: string | null = file.parentId + let found: FileNode | null = null + while (currentId) { + const parent = findNode(currentId) + if (!parent) break + if (parent.type === 'folder' && parent.folderKind === 'latex') { + found = parent + } + currentId = parent.parentId + } + return found +} + +export function isLatexSourceFileName(fileName: string): boolean { + const lower = fileName.toLowerCase() + return ( + lower.endsWith('.tex') || + lower.endsWith('.bib') || + lower.endsWith('.cls') || + lower.endsWith('.sty') || + lower.endsWith('.bst') || + lower.endsWith('.bbx') || + lower.endsWith('.cbx') + ) +} diff --git a/src/ui/src/lib/monaco-latex.ts b/src/ui/src/lib/monaco-latex.ts new file mode 100644 index 00000000..ef86051d --- /dev/null +++ b/src/ui/src/lib/monaco-latex.ts @@ -0,0 +1,155 @@ +"use client"; + +export const LATEX_LANGUAGE_ID = "latex-ds"; +export const BIBTEX_LANGUAGE_ID = "bibtex-ds"; + +let configured = false; + +function languageExists(monaco: any, id: string): boolean { + const languages = monaco.languages.getLanguages?.() || []; + return languages.some((item: { id: string }) => item.id === id); +} + +export function ensureMonacoLatexLanguages(monaco: any) { + if (!monaco?.languages || !monaco?.editor) return; + if (!languageExists(monaco, LATEX_LANGUAGE_ID)) { + monaco.languages.register({ + id: LATEX_LANGUAGE_ID, + aliases: ["LaTeX", "latex", "tex"], + extensions: [".tex", ".latex", ".sty", ".cls"], + mimetypes: ["text/x-tex", "text/x-latex"], + }); + } + if (!languageExists(monaco, BIBTEX_LANGUAGE_ID)) { + monaco.languages.register({ + id: BIBTEX_LANGUAGE_ID, + aliases: ["BibTeX", "bibtex"], + extensions: [".bib"], + mimetypes: ["text/x-bibtex"], + }); + } + if (configured) return; + + monaco.languages.setLanguageConfiguration(LATEX_LANGUAGE_ID, { + comments: { + lineComment: "%", + blockComment: ["\\begin{comment}", "\\end{comment}"], + }, + brackets: [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ], + autoClosingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: "$", close: "$", notIn: ["comment"] }, + ], + surroundingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: "$", close: "$" }, + ], + }); + + monaco.languages.setMonarchTokensProvider(LATEX_LANGUAGE_ID, { + defaultToken: "", + tokenPostfix: ".tex", + tokenizer: { + root: [ + [/\\begin\s*\{\s*comment\s*\}/, { token: "comment.block", next: "@commentBlock" }], + [/%.*$/, "comment"], + [/\\(?:begin|end|documentclass|usepackage|include|input|bibliography|bibliographystyle)\b/, "keyword"], + [ + /\\(?:part|chapter|section|subsection|subsubsection|paragraph|subparagraph|caption|label)\b/, + "keyword", + ], + [/\\(?:cite|citet|citep|autocite|parencite|ref|eqref|url|href)\b/, "type.identifier"], + [/\\(?:textbf|textit|emph|underline|mathrm|mathbf|mathit|mathcal)\b/, "type.identifier"], + [/\\[a-zA-Z@]+/, "identifier"], + [/\\./, "identifier"], + [/\\\[/, { token: "string.math", next: "@displayMathBracket" }], + [/\\\(/, { token: "string.math", next: "@inlineMathParen" }], + [/\$\$/, { token: "string.math", next: "@displayMathDollar" }], + [/\$/, { token: "string.math", next: "@inlineMathDollar" }], + [/[{}()[\]]/, "@brackets"], + [/[&_#^~]/, "operator"], + ], + commentBlock: [ + [/\\end\s*\{\s*comment\s*\}/, { token: "comment.block", next: "@pop" }], + [/.*$/, "comment.block"], + ], + inlineMathDollar: [ + [/%.*$/, "comment"], + [/\$/, { token: "string.math", next: "@pop" }], + [/\\[a-zA-Z@]+/, "identifier"], + [/./, "string.math"], + ], + displayMathDollar: [ + [/%.*$/, "comment"], + [/\$\$/, { token: "string.math", next: "@pop" }], + [/\\[a-zA-Z@]+/, "identifier"], + [/./, "string.math"], + ], + inlineMathParen: [ + [/%.*$/, "comment"], + [/\\\)/, { token: "string.math", next: "@pop" }], + [/\\[a-zA-Z@]+/, "identifier"], + [/./, "string.math"], + ], + displayMathBracket: [ + [/%.*$/, "comment"], + [/\\\]/, { token: "string.math", next: "@pop" }], + [/\\[a-zA-Z@]+/, "identifier"], + [/./, "string.math"], + ], + }, + }); + + monaco.languages.setLanguageConfiguration(BIBTEX_LANGUAGE_ID, { + comments: { + lineComment: "%", + }, + brackets: [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ], + autoClosingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: '"', close: '"', notIn: ["comment", "string"] }, + ], + surroundingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: '"', close: '"' }, + ], + }); + + monaco.languages.setMonarchTokensProvider(BIBTEX_LANGUAGE_ID, { + defaultToken: "", + tokenPostfix: ".bib", + tokenizer: { + root: [ + [/%.*$/, "comment"], + [/@(?:article|book|booklet|conference|inbook|incollection|inproceedings|manual|mastersthesis|misc|phdthesis|proceedings|techreport|unpublished|string|preamble|comment)\b/i, "keyword"], + [/[a-zA-Z][\w-]*(?=\s*=)/, "attribute.name"], + [/"([^"\\]|\\.)*$/, "string.invalid"], + [/"/, { token: "string.quote", next: "@string" }], + [/[{}()[\],=]/, "@brackets"], + ], + string: [ + [/[^\\"]+/, "string"], + [/\\./, "string.escape"], + [/"/, { token: "string.quote", next: "@pop" }], + ], + }, + }); + + configured = true; +} diff --git a/src/ui/src/lib/plugins/latex/LatexPlugin.tsx b/src/ui/src/lib/plugins/latex/LatexPlugin.tsx index 7cc6121e..232a07ed 100644 --- a/src/ui/src/lib/plugins/latex/LatexPlugin.tsx +++ b/src/ui/src/lib/plugins/latex/LatexPlugin.tsx @@ -35,19 +35,32 @@ import { PAGE_DIMENSIONS, ZOOM_LEVELS } from "@/lib/plugins/pdf-viewer/types"; import { PDF_CMAP_URL, PDF_WORKER_SRC } from "@/lib/plugins/pdf-viewer/lib/pdf-utils"; import { compileLatex, + getLatexManifest, getLatexBuild, getLatexBuildLogText, getLatexBuildPdfBlob, listLatexBuilds, + syncTexEditLatexBuild, type LatexCompiler, type LatexBuildStatus, type LatexBuildError, type LatexLogItem, + type LatexSyncTexSelection, } from "@/lib/api/latex"; import { useI18n } from "@/lib/i18n/useI18n"; import { useWorkspaceSurfaceStore } from "@/lib/stores/workspace-surface"; import { toFilesResourcePath } from "@/lib/utils/resource-paths"; import { supportsSocketIo } from "@/lib/runtime/quest-runtime"; +import { + BIBTEX_LANGUAGE_ID, + LATEX_LANGUAGE_ID, + ensureMonacoLatexLanguages, +} from "@/lib/monaco-latex"; +import { + LATEX_OPEN_FILE_EVENT, + consumeLatexOpenFileRequests, + type LatexOpenFileRequest, +} from "@/lib/latex/open-queue"; const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false }); configureMonacoLoader(); @@ -83,12 +96,46 @@ type PdfSurfaceProps = { zoomFactor: number; highlights: IHighlight[]; onPageWidth: (width: number) => void; + onPointDoubleClick?: (point: PdfSourcePoint) => void; +}; + +type PdfWordBox = { + left: number; + top: number; + right: number; + bottom: number; + width?: number; + height?: number; +}; + +type PdfSourcePoint = { + page: number; + x: number; + y: number; + word?: string | null; + contextWords?: string[] | null; + contextIndex?: number | null; + wordBBox?: PdfWordBox | null; + wordCenter?: { x: number; y: number } | null; }; type LatexFileMeta = { id: string; name: string; path?: string; + relativePath?: string; + role?: string; + editable?: boolean; +}; + +type LatexSaveState = "idle" | "saving" | "error"; +type LatexSaveTrigger = "manual" | "auto" | "lifecycle" | "compile"; +type PendingJumpLocation = { + fileId: string | null; + line: number; + column?: number | null; + word?: string | null; + selection?: LatexSyncTexSelection | null; }; function getLatexIssueIdentity(issue: { @@ -124,6 +171,14 @@ type BibSnippet = { snippet: string; }; +type LatexCompletionSnippet = { + label: string; + insertText: string; + detail: string; + documentation?: string; + filterText?: string; +}; + const normalizeBuildErrors = ( errors?: LatexBuildError[] | null, logItems?: LatexLogItem[] | null @@ -140,6 +195,8 @@ const normalizeBuildErrors = ( }; const LATEX_COMPILER_OPTIONS: LatexCompiler[] = ["pdflatex", "xelatex", "lualatex"]; +const LATEX_AUTOSAVE_DELAY_MS = 1000; +const LATEX_AUTO_COMPILE_ON_SAVE_STORAGE_PREFIX = "ds:latex:auto-compile-on-save"; const BIB_SNIPPETS: BibSnippet[] = [ { id: "article", @@ -161,11 +218,113 @@ const BIB_SNIPPETS: BibSnippet[] = [ }, ]; +const LATEX_ENVIRONMENT_SNIPPETS: LatexCompletionSnippet[] = [ + { + label: "begin{comment}", + filterText: "\\begin{comment}", + detail: "comment environment", + insertText: "\\begin{comment}\n\t$0\n\\end{comment}", + documentation: "Insert a complete comment environment.", + }, + { + label: "begin{figure}", + filterText: "\\begin{figure}", + detail: "figure environment", + insertText: "\\begin{figure}[${1:htbp}]\n\t\\centering\n\t$0\n\t\\caption{${2:Caption}}\n\t\\label{fig:${3:label}}\n\\end{figure}", + }, + { + label: "begin{table}", + filterText: "\\begin{table}", + detail: "table environment", + insertText: "\\begin{table}[${1:htbp}]\n\t\\centering\n\t$0\n\t\\caption{${2:Caption}}\n\t\\label{tab:${3:label}}\n\\end{table}", + }, + { + label: "begin{equation}", + filterText: "\\begin{equation}", + detail: "equation environment", + insertText: "\\begin{equation}\n\t$0\n\\end{equation}", + }, + { + label: "begin{align}", + filterText: "\\begin{align}", + detail: "align environment", + insertText: "\\begin{align}\n\t$0\n\\end{align}", + }, + { + label: "begin{itemize}", + filterText: "\\begin{itemize}", + detail: "itemize environment", + insertText: "\\begin{itemize}\n\t\\item $0\n\\end{itemize}", + }, + { + label: "begin{enumerate}", + filterText: "\\begin{enumerate}", + detail: "enumerate environment", + insertText: "\\begin{enumerate}\n\t\\item $0\n\\end{enumerate}", + }, +]; + +const LATEX_COMMAND_SNIPPETS: LatexCompletionSnippet[] = [ + { + label: "\\begin", + detail: "LaTeX environment", + insertText: "\\begin{${1:environment}}\n\t$0\n\\end{${1:environment}}", + }, + { + label: "\\section", + detail: "section heading", + insertText: "\\section{${1:Title}}", + }, + { + label: "\\subsection", + detail: "subsection heading", + insertText: "\\subsection{${1:Title}}", + }, + { + label: "\\label", + detail: "label", + insertText: "\\label{${1:key}}", + }, + { + label: "\\ref", + detail: "reference", + insertText: "\\ref{${1:key}}", + }, + { + label: "\\eqref", + detail: "equation reference", + insertText: "\\eqref{${1:key}}", + }, + { + label: "\\cite", + detail: "citation", + insertText: "\\cite{${1:key}}", + }, + { + label: "\\textbf", + detail: "bold text", + insertText: "\\textbf{${1:text}}", + }, + { + label: "\\emph", + detail: "emphasized text", + insertText: "\\emph{${1:text}}", + }, +]; + function normalizeCompiler(value?: string | null): LatexCompiler { if (value === "xelatex" || value === "lualatex") return value; return "pdflatex"; } +function latexAutoCompileOnSaveStorageKey(projectId?: string, folderId?: string) { + return [ + LATEX_AUTO_COMPILE_ON_SAVE_STORAGE_PREFIX, + projectId || "unknown-project", + folderId || "unknown-folder", + ].join(":"); +} + function normalizeLatexPath(value?: string | null) { return String(value || "") .trim() @@ -212,20 +371,181 @@ function resolveLatexFileId(files: LatexFileMeta[], rawPath?: string | null) { const normalized = normalizeLatexPath(rawPath); if (!normalized) return null; - const exact = files.find((file) => normalizeLatexPath(file.name) === normalized); + const exact = files.find( + (file) => + normalizeLatexPath(file.path) === normalized || + normalizeLatexPath(file.relativePath) === normalized || + normalizeLatexPath(file.name) === normalized + ); if (exact) return exact.id; const basename = normalized.split("/").filter(Boolean).pop(); if (!basename) return null; - const byBasename = files.find((file) => normalizeLatexPath(file.name).endsWith(`/${basename}`)); + const byBasename = files.find((file) => + [file.path, file.relativePath, file.name].some((value) => + normalizeLatexPath(value).endsWith(`/${basename}`) + ) + ); if (byBasename) return byBasename.id; const simpleName = files.find((file) => file.name.toLowerCase() === basename); return simpleName?.id ?? null; } -function PdfSurface({ pdfDocument, zoomFactor, highlights, onPageWidth }: PdfSurfaceProps) { +function latexFileDisplayPath(file?: LatexFileMeta | null) { + return file?.relativePath || file?.path || file?.name || ""; +} + +function isEditableLatexManifestFile(file: LatexFileMeta) { + if (file.editable === true) return true; + const lower = (file.name || file.path || "").toLowerCase(); + return ( + lower.endsWith(".tex") || + lower.endsWith(".bib") || + lower.endsWith(".cls") || + lower.endsWith(".sty") || + lower.endsWith(".bst") || + lower.endsWith(".bbx") || + lower.endsWith(".cbx") + ); +} + +function resolveLatexOpenRequestFileId(files: LatexFileMeta[], request: LatexOpenFileRequest) { + if (request.fileId && files.some((file) => file.id === request.fileId)) { + return request.fileId; + } + return resolveLatexFileId(files, request.filePath); +} + +function latexWordCharacter(char: string) { + return /[\p{L}\p{N}_:-]/u.test(char); +} + +type PdfWordHit = { + word: string; + clientBox: PdfWordBox; + contextWords?: string[]; + contextIndex?: number; +}; + +function rectDistanceToPoint(rect: DOMRect | PdfWordBox, x: number, y: number) { + const dx = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0; + const dy = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0; + return Math.hypot(dx, dy); +} + +function rectContainsPoint(rect: DOMRect | PdfWordBox, x: number, y: number, tolerance = 0) { + return ( + x >= rect.left - tolerance && + x <= rect.right + tolerance && + y >= rect.top - tolerance && + y <= rect.bottom + tolerance + ); +} + +function rangeBoundingBox(range: Range): PdfWordBox | null { + const rects = Array.from(range.getClientRects()).filter((rect) => rect.width > 0 && rect.height > 0); + if (rects.length === 0) return null; + const left = Math.min(...rects.map((rect) => rect.left)); + const top = Math.min(...rects.map((rect) => rect.top)); + const right = Math.max(...rects.map((rect) => rect.right)); + const bottom = Math.max(...rects.map((rect) => rect.bottom)); + return { + left, + top, + right, + bottom, + width: right - left, + height: bottom - top, + }; +} + +function firstTextNode(element: Element): Text | null { + const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + if (node.nodeType === Node.TEXT_NODE && (node.textContent || "").trim()) { + return node as Text; + } + node = walker.nextNode(); + } + return null; +} + +function textLayerWordEntries(doc: Document, textLayer: Element) { + const entries: Array<{ word: string; clientBox: PdfWordBox }> = []; + for (const element of Array.from(textLayer.querySelectorAll("span, div"))) { + const textNode = firstTextNode(element); + const source = textNode?.textContent || ""; + if (!textNode || !source.trim()) continue; + let index = 0; + while (index < source.length) { + if (!latexWordCharacter(source[index])) { + index += 1; + continue; + } + const start = index; + index += 1; + while (index < source.length && latexWordCharacter(source[index])) index += 1; + const word = source.slice(start, index).trim(); + if (!word) continue; + const range = doc.createRange(); + try { + range.setStart(textNode, start); + range.setEnd(textNode, index); + const clientBox = rangeBoundingBox(range); + if (clientBox) entries.push({ word, clientBox }); + } finally { + range.detach?.(); + } + } + } + return entries; +} + +function hitTestPdfTextLayerWord( + event: React.MouseEvent, + pageElement: HTMLElement +): PdfWordHit | null { + const doc = event.currentTarget.ownerDocument; + const textLayer = pageElement.querySelector(".textLayer"); + if (!textLayer) return null; + + const clientX = event.clientX; + const clientY = event.clientY; + const entries = textLayerWordEntries(doc, textLayer) + .map((entry) => ({ + ...entry, + score: + (rectContainsPoint(entry.clientBox, clientX, clientY, 2) ? 100000 : 0) - + rectDistanceToPoint(entry.clientBox, clientX, clientY), + })) + .sort((a, b) => b.score - a.score); + + const best = entries[0]; + if (!best || best.score < -12) return null; + const centerY = (best.clientBox.top + best.clientBox.bottom) / 2; + const tolerance = Math.max(Number(best.clientBox.height || 0) * 0.9, 6); + const lineEntries = entries + .filter((entry) => { + const entryCenterY = (entry.clientBox.top + entry.clientBox.bottom) / 2; + return Math.abs(entryCenterY - centerY) <= tolerance; + }) + .sort((a, b) => a.clientBox.left - b.clientBox.left); + const lineIndex = lineEntries.findIndex((entry) => entry === best); + const contextStart = Math.max(0, lineIndex - 5); + const contextEnd = Math.min(lineEntries.length, lineIndex + 6); + const contextSlice = lineEntries.slice(contextStart, contextEnd); + return { + word: best.word, + clientBox: best.clientBox, + contextWords: contextSlice.map((entry) => entry.word), + contextIndex: Math.max(0, lineIndex - contextStart), + }; +} + +function PdfSurface({ pdfDocument, zoomFactor, highlights, onPageWidth, onPointDoubleClick }: PdfSurfaceProps) { React.useEffect(() => { let cancelled = false; pdfDocument @@ -250,21 +570,89 @@ function PdfSurface({ pdfDocument, zoomFactor, highlights, onPageWidth }: PdfSur Math.abs(safeZoomFactor - 1) < 0.001 ? "page-width" : `page-width:${safeZoomFactor}`; return ( - - pdfDocument={pdfDocument} - pdfScaleValue={pdfScaleValue} - highlights={highlights} - highlightTransform={() => <>} - onScrollChange={() => {}} - scrollRef={() => {}} - onSelectionFinished={( - _position: ScaledPosition, - _content: Content, - _hideTipAndSelection: () => void, - _transformSelection: () => void - ) => null} - enableAreaSelection={() => false} - /> +
{ + if (!onPointDoubleClick) return; + const target = event.target; + if (!(target instanceof Element)) return; + const pageElement = target.closest(".page[data-page-number]") as HTMLElement | null; + if (!pageElement) return; + const pageNumber = Number(pageElement.dataset.pageNumber || ""); + if (!Number.isFinite(pageNumber) || pageNumber < 1) return; + const pageRect = pageElement.getBoundingClientRect(); + if (!pageRect.width || !pageRect.height) return; + const localX = event.clientX - pageRect.left; + const localY = event.clientY - pageRect.top; + if (localX < 0 || localY < 0 || localX > pageRect.width || localY > pageRect.height) return; + event.preventDefault(); + event.stopPropagation(); + const wordHit = hitTestPdfTextLayerWord(event, pageElement); + void pdfDocument + .getPage(pageNumber) + .then((page) => { + const viewport = page.getViewport({ scale: 1 }); + const scaleX = viewport.width / pageRect.width; + const scaleY = viewport.height / pageRect.height; + const toPdfX = (clientX: number) => (clientX - pageRect.left) * scaleX; + const toPdfY = (clientY: number) => (clientY - pageRect.top) * scaleY; + const wordBBox = wordHit?.clientBox + ? { + left: toPdfX(wordHit.clientBox.left), + top: toPdfY(wordHit.clientBox.top), + right: toPdfX(wordHit.clientBox.right), + bottom: toPdfY(wordHit.clientBox.bottom), + width: wordHit.clientBox.width ? wordHit.clientBox.width * scaleX : undefined, + height: wordHit.clientBox.height ? wordHit.clientBox.height * scaleY : undefined, + } + : null; + const wordCenter = wordBBox + ? { + x: (wordBBox.left + wordBBox.right) / 2, + y: (wordBBox.top + wordBBox.bottom) / 2, + } + : null; + const x = wordCenter?.x ?? localX * scaleX; + const y = wordCenter?.y ?? localY * scaleY; + onPointDoubleClick({ + page: pageNumber, + x, + y, + word: wordHit?.word ?? null, + contextWords: wordHit?.contextWords ?? null, + contextIndex: wordHit?.contextIndex ?? null, + wordBBox, + wordCenter, + }); + }) + .catch(() => { + onPointDoubleClick({ + page: pageNumber, + x: localX, + y: localY, + word: wordHit?.word ?? null, + contextWords: wordHit?.contextWords ?? null, + contextIndex: wordHit?.contextIndex ?? null, + }); + }); + }} + > + + pdfDocument={pdfDocument} + pdfScaleValue={pdfScaleValue} + highlights={highlights} + highlightTransform={() => <>} + onScrollChange={() => {}} + scrollRef={() => {}} + onSelectionFinished={( + _position: ScaledPosition, + _content: Content, + _hideTipAndSelection: () => void, + _transformSelection: () => void + ) => null} + enableAreaSelection={() => false} + /> +
); } @@ -287,16 +675,33 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const initialFileId = custom.openFileId ?? custom.mainFileId ?? null; const [activeFileId, setActiveFileId] = React.useState(initialFileId); const [activeFileName, setActiveFileName] = React.useState("main.tex"); + const [manifestMainFileId, setManifestMainFileId] = React.useState( + custom.mainFileId ?? null + ); + const compileMainFileId = React.useMemo(() => { + if (custom.mainFileId) return custom.mainFileId; + if (manifestMainFileId) return manifestMainFileId; + return files.find((file) => file.role === "main")?.id ?? + files.find((file) => file.name.toLowerCase() === "main.tex")?.id ?? + null; + }, [custom.mainFileId, files, manifestMainFileId]); const [initialText, setInitialText] = React.useState(""); const [syncState, setSyncState] = React.useState<"idle" | "loading" | "ready" | "error">("idle"); - const [saveState, setSaveState] = React.useState<"idle" | "saving" | "error">("idle"); + const [saveState, setSaveState] = React.useState("idle"); + const [saveTrigger, setSaveTrigger] = React.useState("manual"); + const [saveError, setSaveError] = React.useState(null); const [error, setError] = React.useState(null); const [isDirty, setIsDirty] = React.useState(false); + const [dirtyVersion, setDirtyVersion] = React.useState(0); const [buildId, setBuildId] = React.useState(null); const [buildStatus, setBuildStatus] = React.useState("idle"); const [buildError, setBuildError] = React.useState(null); const [buildErrors, setBuildErrors] = React.useState([]); + const [synctexReady, setSynctexReady] = React.useState(false); + const [synctexBusy, setSynctexBusy] = React.useState(false); + const [synctexError, setSynctexError] = React.useState(null); const [compiler, setCompiler] = React.useState("pdflatex"); + const [autoCompileOnSave, setAutoCompileOnSave] = React.useState(true); const [currentBranch, setCurrentBranch] = React.useState(null); const [pdfObjectUrl, setPdfObjectUrl] = React.useState(null); const [logText, setLogText] = React.useState(null); @@ -314,6 +719,13 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const emptyHighlights = React.useMemo(() => [] as IHighlight[], []); const lastSavedRef = React.useRef(""); + const isDirtyRef = React.useRef(false); + const saveStateRef = React.useRef("idle"); + const buildStatusRef = React.useRef("idle"); + const activeFileIdRef = React.useRef(activeFileId); + const saveInFlightRef = React.useRef<{ fileId: string; promise: Promise } | null>(null); + const failedSaveTextRef = React.useRef(null); + const lastSaveTriggerRef = React.useRef("manual"); const yDocRef = React.useRef(null); const yTextRef = React.useRef(null); const syncRef = React.useRef(null); @@ -330,7 +742,8 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const splitContainerRef = React.useRef(null); const pdfPaneRef = React.useRef(null); const editorRef = React.useRef(null); - const pendingJumpRef = React.useRef<{ fileId: string | null; line: number } | null>(null); + const boundEditorFileIdRef = React.useRef(null); + const pendingJumpRef = React.useRef(null); const citationIndexRef = React.useRef([]); const labelIndexRef = React.useRef([]); const latexCompletionDisposablesRef = React.useRef void }>>([]); @@ -340,6 +753,68 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const canUseRealtimeSync = supportsSocketIo(); const isBibFile = activeFileName.toLowerCase().endsWith(".bib"); + React.useEffect(() => { + activeFileIdRef.current = activeFileId; + }, [activeFileId]); + + React.useEffect(() => { + isDirtyRef.current = isDirty; + }, [isDirty]); + + React.useEffect(() => { + saveStateRef.current = saveState; + }, [saveState]); + + React.useEffect(() => { + buildStatusRef.current = buildStatus; + }, [buildStatus]); + + React.useEffect(() => { + if (typeof window === "undefined") return; + const stored = window.localStorage.getItem( + latexAutoCompileOnSaveStorageKey(projectId, latexFolderId) + ); + // Default-on: only an explicit "0" disables manual-save auto compile. + setAutoCompileOnSave(stored !== "0"); + }, [latexFolderId, projectId]); + + const updateAutoCompileOnSave = React.useCallback( + (enabled: boolean) => { + setAutoCompileOnSave(enabled); + if (typeof window === "undefined") return; + window.localStorage.setItem( + latexAutoCompileOnSaveStorageKey(projectId, latexFolderId), + enabled ? "1" : "0" + ); + }, + [latexFolderId, projectId] + ); + + const setEditorDirty = React.useCallback( + (nextDirty: boolean) => { + isDirtyRef.current = nextDirty; + setIsDirty(nextDirty); + setDirty(nextDirty); + }, + [setDirty] + ); + + const markDirty = React.useCallback(() => { + failedSaveTextRef.current = null; + setSaveError(null); + setEditorDirty(true); + setDirtyVersion((version) => version + 1); + if (saveStateRef.current === "error") { + saveStateRef.current = "idle"; + setSaveState("idle"); + } + }, [setEditorDirty]); + + const getCurrentText = React.useCallback(() => { + const ytext = yTextRef.current; + return ytext ? String(ytext.toString?.() ?? "") : ""; + }, []); + React.useEffect(() => { const activeFileMeta = files.find((file) => file.id === activeFileId) ?? @@ -356,7 +831,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug ? "compiling" : saveState === "saving" ? "saving" - : buildStatus === "error" + : saveState === "error" || saveError || buildStatus === "error" ? "error" : "idle", diagnostics: { @@ -372,6 +847,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug context.resourceName, effectiveReadOnly, files, + saveError, saveState, tabId, updateWorkspaceTabState, @@ -435,44 +911,111 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug }; }, [projectId]); - // Load folder file list. + // Load the LaTeX project manifest. The manifest is recursive so multi-file + // papers (sections, shared .bib/.sty files, etc.) are managed in one editor. React.useEffect(() => { if (!projectId || !latexFolderId) return; let cancelled = false; + + const resolveInitialFile = (candidates: LatexFileMeta[], mainFileId?: string | null) => { + const queuedRequests = consumeLatexOpenFileRequests(projectId, latexFolderId); + for (const request of queuedRequests.slice().reverse()) { + const requested = resolveLatexOpenRequestFileId(candidates, request); + if (requested) { + if (request.line) { + pendingJumpRef.current = { + fileId: requested, + line: Math.max(1, Number(request.line || 1)), + column: request.column ?? null, + word: request.word ?? null, + }; + } + return requested; + } + } + const active = activeFileIdRef.current; + if (active && candidates.some((file) => file.id === active)) return active; + if (initialFileId && candidates.some((file) => file.id === initialFileId)) return initialFileId; + if (mainFileId && candidates.some((file) => file.id === mainFileId)) return mainFileId; + return ( + candidates.find((file) => file.role === "main")?.id ?? + candidates.find((file) => file.name.toLowerCase() === "main.tex")?.id ?? + candidates.find((file) => file.name.toLowerCase().endsWith(".tex"))?.id ?? + candidates[0]?.id ?? + null + ); + }; + (async () => { try { - const items = await listFiles(projectId, latexFolderId); + const manifest = await getLatexManifest(projectId, latexFolderId); if (cancelled) return; - const candidates = items - .filter((x) => x.type === "file") - .map((x) => ({ id: x.id, name: x.name, path: x.path || undefined })) - .sort((a, b) => a.name.localeCompare(b.name)); + const candidates = manifest.files + .map((file) => ({ + id: file.id, + name: file.name, + path: file.path || undefined, + relativePath: file.relative_path || undefined, + role: file.role, + editable: file.editable, + })) + .filter(isEditableLatexManifestFile) + .sort((a, b) => { + if (a.role === "main" && b.role !== "main") return -1; + if (a.role !== "main" && b.role === "main") return 1; + return latexFileDisplayPath(a).localeCompare(latexFileDisplayPath(b)); + }); setFiles(candidates); - - // Resolve a main file if needed. - if (!activeFileId) { - const main = - candidates.find((f) => f.name.toLowerCase() === "main.tex") ?? - candidates.find((f) => f.name.toLowerCase().endsWith(".tex")) ?? - candidates[0]; - if (main) { - setActiveFileId(main.id); - setActiveFileName(main.name); + setManifestMainFileId(manifest.main_file_id ?? null); + setCompiler(normalizeCompiler(manifest.compiler)); + + const nextActiveId = resolveInitialFile(candidates, manifest.main_file_id ?? null); + if (nextActiveId) { + const meta = candidates.find((file) => file.id === nextActiveId); + setActiveFileId(nextActiveId); + setActiveFileName(meta?.name || "main.tex"); + } + } catch (manifestError) { + try { + const items = await listFiles(projectId, latexFolderId); + if (cancelled) return; + const candidates = items + .filter((x) => x.type === "file") + .map((x) => ({ id: x.id, name: x.name, path: x.path || undefined })) + .filter(isEditableLatexManifestFile) + .sort((a, b) => a.name.localeCompare(b.name)); + setFiles(candidates); + setManifestMainFileId(custom.mainFileId ?? null); + + const nextActiveId = resolveInitialFile(candidates, custom.mainFileId ?? null); + if (nextActiveId) { + const meta = candidates.find((file) => file.id === nextActiveId); + setActiveFileId(nextActiveId); + setActiveFileName(meta?.name || "main.tex"); } - } else { - const meta = candidates.find((f) => f.id === activeFileId); - if (meta) setActiveFileName(meta.name); + } catch (fallbackError) { + console.error("[LatexPlugin] Failed to load files:", fallbackError); + setError( + fallbackError instanceof Error + ? fallbackError.message + : manifestError instanceof Error + ? manifestError.message + : t("load_files_failed") + ); } - } catch (e) { - console.error("[LatexPlugin] Failed to list files:", e); - setError(e instanceof Error ? e.message : t("load_files_failed")); } })(); return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, latexFolderId]); + }, [custom.mainFileId, initialFileId, latexFolderId, projectId, t]); + + React.useEffect(() => { + if (!activeFileId) return; + const meta = files.find((file) => file.id === activeFileId); + if (!meta) return; + setActiveFileName(meta.name); + }, [activeFileId, files]); React.useEffect(() => { citationIndexRef.current = citationIndex; @@ -523,12 +1066,12 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const nextCitationIndex = loaded .filter((item) => item.file.name.toLowerCase().endsWith(".bib")) - .flatMap((item) => parseBibEntries(item.content, item.file.name)) + .flatMap((item) => parseBibEntries(item.content, latexFileDisplayPath(item.file) || item.file.name)) .sort((a, b) => a.key.localeCompare(b.key)); const nextLabelIndex = loaded .filter((item) => item.file.name.toLowerCase().endsWith(".tex")) - .flatMap((item) => parseLatexLabels(item.content, item.file.name)) + .flatMap((item) => parseLatexLabels(item.content, latexFileDisplayPath(item.file) || item.file.name)) .sort((a, b) => a.key.localeCompare(b.key)); setCitationIndex(nextCitationIndex); @@ -553,6 +1096,9 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug bindingCleanupRef.current?.(); } finally { bindingCleanupRef.current = null; + if (boundEditorFileIdRef.current === activeFileId) { + boundEditorFileIdRef.current = null; + } } }; }, [activeFileId]); @@ -565,6 +1111,12 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug let cleanup: null | (() => void) = null; setSyncState("loading"); + saveStateRef.current = "idle"; + setSaveState("idle"); + lastSaveTriggerRef.current = "manual"; + setSaveTrigger("manual"); + failedSaveTextRef.current = null; + setSaveError(null); setError(null); (async () => { @@ -587,8 +1139,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const textNow = ytext.toString(); setInitialText(textNow); lastSavedRef.current = textNow; - setIsDirty(false); - setDirty(false); + setEditorDirty(false); setSyncState("ready"); cleanup = () => { @@ -732,8 +1283,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const textNow = ytext.toString(); setInitialText(textNow); lastSavedRef.current = textNow; - setIsDirty(false); - setDirty(false); + setEditorDirty(false); setSyncState("ready"); cleanup = () => { @@ -794,36 +1344,173 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug cancelled = true; cleanup?.(); }; - }, [activeFileId, canUseRealtimeSync, effectiveReadOnly, projectId, resetNonce, setDirty, socketAuthMode, t, user?.id, user?.username]); + }, [activeFileId, canUseRealtimeSync, effectiveReadOnly, projectId, resetNonce, setEditorDirty, socketAuthMode, t, user?.id, user?.username]); + + const revealEditorRange = React.useCallback( + ( + editor: any, + range: { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + } + ) => { + const reveal = () => { + try { + editor.revealRangeInCenter?.(range, 0); + return; + } catch { + // Fall back to position-based reveal below. + } + try { + editor.revealPositionInCenter?.({ + lineNumber: range.startLineNumber, + column: range.startColumn, + }); + } catch { + // ignore + } + }; - const jumpEditorToLine = React.useCallback((line: number) => { + reveal(); + if (typeof window !== "undefined") { + window.requestAnimationFrame(() => { + reveal(); + window.requestAnimationFrame(reveal); + }); + window.setTimeout(reveal, 80); + } + }, + [] + ); + + const jumpEditorToLocation = React.useCallback((location: PendingJumpLocation) => { const editor = editorRef.current; if (!editor) return false; const model = editor.getModel?.(); if (!model) return false; const maxLine = Math.max(1, Number(model.getLineCount?.() ?? 1)); - const safeLine = Math.min(Math.max(1, Math.round(line || 1)), maxLine); - editor.revealLineInCenter?.(safeLine); - editor.setPosition?.({ lineNumber: safeLine, column: 1 }); - editor.setSelection?.({ - startLineNumber: safeLine, - startColumn: 1, - endLineNumber: safeLine, - endColumn: Number(model.getLineMaxColumn?.(safeLine) ?? 1), - }); - editor.focus?.(); + const preciseSelection = location.selection; + if ( + preciseSelection && + typeof preciseSelection.start_line === "number" && + typeof preciseSelection.start_column === "number" && + typeof preciseSelection.end_line === "number" && + typeof preciseSelection.end_column === "number" + ) { + const startLine = Math.min(Math.max(1, Math.round(preciseSelection.start_line)), maxLine); + const endLine = Math.min(Math.max(startLine, Math.round(preciseSelection.end_line)), maxLine); + const startMaxColumn = Math.max(1, Number(model.getLineMaxColumn?.(startLine) ?? 1)); + const endMaxColumn = Math.max(1, Number(model.getLineMaxColumn?.(endLine) ?? 1)); + const startColumn = Math.min(Math.max(1, Math.round(preciseSelection.start_column)), startMaxColumn); + const endColumn = Math.min(Math.max(1, Math.round(preciseSelection.end_column)), endMaxColumn); + const selectionEndColumn = startLine === endLine ? Math.max(startColumn, endColumn) : endColumn; + const editorSelection = { + startLineNumber: startLine, + startColumn, + endLineNumber: endLine, + endColumn: selectionEndColumn, + }; + editor.setPosition?.({ lineNumber: startLine, column: startColumn }); + editor.setSelection?.(editorSelection); + editor.focus?.(); + revealEditorRange(editor, editorSelection); + return true; + } + + const safeLine = Math.min(Math.max(1, Math.round(location.line || 1)), maxLine); + const maxColumn = Math.max(1, Number(model.getLineMaxColumn?.(safeLine) ?? 1)); + const requestedColumn = + typeof location.column === "number" && Number.isFinite(location.column) + ? Math.round(location.column) + : 1; + const safeColumn = Math.min(Math.max(1, requestedColumn), maxColumn); + + let selection: { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + } | null = null; + + const rawWord = String(location.word || "").trim(); + const lineContent = String(model.getLineContent?.(safeLine) ?? ""); + if (rawWord && rawWord.length <= 120 && lineContent) { + const lowerLine = lineContent.toLocaleLowerCase(); + const lowerWord = rawWord.toLocaleLowerCase(); + const matches: number[] = []; + let index = lowerLine.indexOf(lowerWord); + while (index >= 0) { + matches.push(index); + index = lowerLine.indexOf(lowerWord, index + Math.max(1, lowerWord.length)); + } + if (matches.length > 0) { + const nearest = matches.reduce((best, next) => { + const bestDistance = Math.abs(best + 1 - safeColumn); + const nextDistance = Math.abs(next + 1 - safeColumn); + return nextDistance < bestDistance ? next : best; + }, matches[0]); + selection = { + startLineNumber: safeLine, + startColumn: nearest + 1, + endLineNumber: safeLine, + endColumn: Math.min(maxColumn, nearest + rawWord.length + 1), + }; + } + } + + if (!selection) { + const wordAtPosition = model.getWordAtPosition?.({ + lineNumber: safeLine, + column: safeColumn, + }); + if ( + wordAtPosition && + typeof wordAtPosition.startColumn === "number" && + typeof wordAtPosition.endColumn === "number" && + wordAtPosition.endColumn > wordAtPosition.startColumn + ) { + selection = { + startLineNumber: safeLine, + startColumn: wordAtPosition.startColumn, + endLineNumber: safeLine, + endColumn: wordAtPosition.endColumn, + }; + } + } + + const targetColumn = selection?.startColumn ?? safeColumn; + if (selection) { + editor.setPosition?.({ lineNumber: selection.startLineNumber, column: selection.startColumn }); + editor.setSelection?.(selection); + editor.focus?.(); + revealEditorRange(editor, selection); + } else { + const cursorSelection = { + startLineNumber: safeLine, + startColumn: safeColumn, + endLineNumber: safeLine, + endColumn: safeColumn, + }; + editor.setSelection?.(cursorSelection); + editor.setPosition?.({ lineNumber: safeLine, column: targetColumn }); + editor.focus?.(); + revealEditorRange(editor, cursorSelection); + } return true; - }, []); + }, [revealEditorRange]); const flushPendingJump = React.useCallback(() => { const pending = pendingJumpRef.current; if (!pending) return; if (pending.fileId && pending.fileId !== activeFileId) return; - if (jumpEditorToLine(pending.line)) { + if (!activeFileId || boundEditorFileIdRef.current !== activeFileId) return; + if (jumpEditorToLocation(pending)) { pendingJumpRef.current = null; } - }, [activeFileId, jumpEditorToLine]); + }, [activeFileId, jumpEditorToLocation]); const insertAtCursor = React.useCallback((text: string) => { const editor = editorRef.current; @@ -838,9 +1525,8 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug }, ]); editor.focus?.(); - setIsDirty(true); - setDirty(true); - }, [effectiveReadOnly, setDirty]); + markDirty(); + }, [effectiveReadOnly, markDirty]); const insertCitation = React.useCallback( (entry: CitationEntry, command = "\\cite") => { @@ -934,15 +1620,8 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const model = editor.getModel?.(); if (!model) return; - const ensureLanguage = (id: string) => { - const languages = monaco.languages.getLanguages?.() || []; - if (!languages.some((item: { id: string }) => item.id === id)) { - monaco.languages.register({ id }); - } - }; - ensureLanguage("latex-ds"); - ensureLanguage("bibtex-ds"); - monaco.editor.setModelLanguage(model, isBibFile ? "bibtex-ds" : "latex-ds"); + ensureMonacoLatexLanguages(monaco); + monaco.editor.setModelLanguage(model, isBibFile ? BIBTEX_LANGUAGE_ID : LATEX_LANGUAGE_ID); latexCompletionDisposablesRef.current.forEach((disposable) => { try { @@ -952,8 +1631,8 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug } }); latexCompletionDisposablesRef.current = [ - monaco.languages.registerCompletionItemProvider("latex-ds", { - triggerCharacters: ["\\", "{"], + monaco.languages.registerCompletionItemProvider(LATEX_LANGUAGE_ID, { + triggerCharacters: ["\\", "{", "}"], provideCompletionItems: (targetModel: any, position: any) => { const linePrefix = targetModel.getValueInRange({ startLineNumber: position.lineNumber, @@ -968,6 +1647,30 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug position.lineNumber, word.endColumn ); + const beginMatch = linePrefix.match(/\\begin\{([A-Za-z*]*)\}?$/); + if (beginMatch) { + const replaceRange = new monaco.Range( + position.lineNumber, + position.column - beginMatch[0].length, + position.lineNumber, + position.column + ); + const envPrefix = beginMatch[1].toLowerCase(); + return { + suggestions: LATEX_ENVIRONMENT_SNIPPETS.filter((item) => + item.label.toLowerCase().startsWith(`begin{${envPrefix}`) + ).map((item) => ({ + label: item.label, + kind: monaco.languages.CompletionItemKind.Snippet, + insertText: item.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + detail: item.detail, + documentation: item.documentation, + filterText: item.filterText, + range: replaceRange, + })), + }; + } if (/\\(?:cite|citet|citep|autocite|parencite)\{[^}]*$/i.test(linePrefix)) { return { @@ -994,10 +1697,31 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug }; } - return { suggestions: [] }; + const commandMatch = linePrefix.match(/\\[A-Za-z]*$/); + const commandRange = commandMatch + ? new monaco.Range( + position.lineNumber, + position.column - commandMatch[0].length, + position.lineNumber, + position.column + ) + : range; + + return { + suggestions: LATEX_COMMAND_SNIPPETS.map((item) => ({ + label: item.label, + kind: monaco.languages.CompletionItemKind.Snippet, + insertText: item.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + detail: item.detail, + documentation: item.documentation, + filterText: item.filterText, + range: commandRange, + })), + }; }, }), - monaco.languages.registerCompletionItemProvider("bibtex-ds", { + monaco.languages.registerCompletionItemProvider(BIBTEX_LANGUAGE_ID, { triggerCharacters: ["@"], provideCompletionItems: (_targetModel: any, position: any) => { const range = new monaco.Range( @@ -1030,6 +1754,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug } finally { applyingRemoteRef.current = false; } + boundEditorFileIdRef.current = activeFileId; // Remote delta -> Monaco edits const applyDelta = (delta: any[]) => { @@ -1076,7 +1801,9 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const origin = event?.transaction?.origin; if (origin !== remoteOrigin) return; applyDelta(event.delta ?? []); - setIsDirty(true); + if (!effectiveReadOnly) { + markDirty(); + } }; ytext.observe(yObserver); @@ -1097,7 +1824,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug if (text) ytext.insert(offset, text); } }, "ds-monaco"); - setIsDirty(true); + markDirty(); }); bindingCleanupRef.current = () => { @@ -1117,7 +1844,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug flushPendingJump(); }); }, - [effectiveReadOnly, flushPendingJump, isBibFile, setDirty, t] + [activeFileId, effectiveReadOnly, flushPendingJump, isBibFile, markDirty, t] ); React.useEffect(() => { @@ -1125,66 +1852,290 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug flushPendingJump(); }, [activeFileId, flushPendingJump, resetNonce, syncState]); - const save = React.useCallback(async () => { + const save = React.useCallback(async (trigger: LatexSaveTrigger = "manual") => { if (!activeFileId) return false; if (effectiveReadOnly) return false; const ytext = yTextRef.current; if (!ytext) return false; - try { - setSaveState("saving"); - const text = String(ytext.toString?.() ?? ""); - const res = await updateFileContent(activeFileId, text); - lastSavedRef.current = text; - setSaveState("idle"); - setIsDirty(false); - setDirty(false); - if (res?.updated_at) { - updateFileMeta(activeFileId, { - updatedAt: res.updated_at, - size: typeof res.size === "number" ? res.size : undefined, - mimeType: res.mime_type, - }); + + const activeInFlight = saveInFlightRef.current; + if (activeInFlight && activeInFlight.fileId === activeFileId) { + lastSaveTriggerRef.current = trigger; + setSaveTrigger(trigger); + return activeInFlight.promise; + } + + const fileId = activeFileId; + const textToSave = String(ytext.toString?.() ?? ""); + lastSaveTriggerRef.current = trigger; + setSaveTrigger(trigger); + + let promise: Promise; + promise = (async () => { + try { + saveStateRef.current = "saving"; + failedSaveTextRef.current = null; + setSaveError(null); + setSaveState("saving"); + const res = await updateFileContent(fileId, textToSave); + + if (res?.updated_at) { + updateFileMeta(fileId, { + updatedAt: res.updated_at, + size: typeof res.size === "number" ? res.size : undefined, + mimeType: res.mime_type, + }); + } + + if (activeFileIdRef.current !== fileId) { + return true; + } + + const currentYText = yTextRef.current; + const currentText = currentYText ? String(currentYText.toString?.() ?? "") : ""; + lastSavedRef.current = textToSave; + saveStateRef.current = "idle"; + setSaveState("idle"); + + if (currentText === textToSave) { + setEditorDirty(false); + return true; + } + + setEditorDirty(true); + return false; + } catch (e) { + console.error("[LatexPlugin] Save failed:", e); + if (activeFileIdRef.current === fileId) { + failedSaveTextRef.current = textToSave; + saveStateRef.current = "error"; + setSaveError(e instanceof Error ? e.message : t("save_request_failed")); + setSaveState("error"); + setEditorDirty(true); + } + return false; + } finally { + if (saveInFlightRef.current?.promise === promise) { + saveInFlightRef.current = null; + } + } + })(); + + saveInFlightRef.current = { fileId, promise }; + return promise; + }, [activeFileId, effectiveReadOnly, setEditorDirty, t, updateFileMeta]); + + const switchToLatexFile = React.useCallback( + async ( + fileId: string | null | undefined, + opts?: { + line?: number | null; + column?: number | null; + word?: string | null; + selection?: LatexSyncTexSelection | null; + } + ) => { + if (!fileId) return false; + const targetMeta = files.find((file) => file.id === fileId); + if (!targetMeta) return false; + + if (!effectiveReadOnly && (isDirtyRef.current || saveStateRef.current === "saving")) { + const saved = await save("lifecycle"); + if (!saved && isDirtyRef.current) return false; } + + setActiveFileName(targetMeta.name); + setReferencePanelOpen(false); + setBibPanelOpen(false); + setAssistQuery(""); + + if (opts?.line) { + pendingJumpRef.current = { + fileId, + line: Math.max(1, Number(opts.line || 1)), + column: opts.column ?? null, + word: opts.word ?? null, + selection: opts.selection ?? null, + }; + } + + if (fileId !== activeFileIdRef.current) { + boundEditorFileIdRef.current = null; + setSyncState("loading"); + setInitialText(""); + setActiveFileId(fileId); + return true; + } + + flushPendingJump(); + return true; + }, + [effectiveReadOnly, files, flushPendingJump, save] + ); + + const handleLatexOpenRequest = React.useCallback( + (request: LatexOpenFileRequest) => { + const targetFileId = resolveLatexOpenRequestFileId(files, request); + if (!targetFileId) return false; + void switchToLatexFile(targetFileId, { + line: request.line ?? null, + column: request.column ?? null, + word: request.word ?? null, + }); return true; - } catch (e) { - console.error("[LatexPlugin] Save failed:", e); - setSaveState("error"); - window.setTimeout(() => setSaveState("idle"), 1400); - return false; + }, + [files, switchToLatexFile] + ); + + React.useEffect(() => { + if (!projectId || !latexFolderId || files.length === 0) return; + + for (const request of consumeLatexOpenFileRequests(projectId, latexFolderId)) { + handleLatexOpenRequest(request); } - }, [activeFileId, effectiveReadOnly, setDirty, updateFileMeta]); + + const listener = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail) return; + if (detail.latexFolderId !== latexFolderId) return; + if (detail.projectId && detail.projectId !== projectId) return; + handleLatexOpenRequest(detail); + }; + + window.addEventListener(LATEX_OPEN_FILE_EVENT, listener as EventListener); + return () => { + window.removeEventListener(LATEX_OPEN_FILE_EVENT, listener as EventListener); + }; + }, [files.length, handleLatexOpenRequest, latexFolderId, projectId]); + + React.useEffect(() => { + if (!activeFileId) return; + if (effectiveReadOnly) return; + if (syncState !== "ready") return; + if (!isDirty) return; + if (saveState === "saving") return; + + const currentText = getCurrentText(); + if (saveState === "error" && failedSaveTextRef.current === currentText) { + return; + } + + const timer = window.setTimeout(() => { + if (!activeFileIdRef.current) return; + if (!isDirtyRef.current) return; + if (saveStateRef.current === "saving") return; + const latestText = getCurrentText(); + if (saveStateRef.current === "error" && failedSaveTextRef.current === latestText) { + return; + } + void save("auto"); + }, LATEX_AUTOSAVE_DELAY_MS); + + return () => { + window.clearTimeout(timer); + }; + }, [activeFileId, dirtyVersion, effectiveReadOnly, getCurrentText, isDirty, save, saveState, syncState]); + + React.useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!isDirtyRef.current) return; + event.preventDefault(); + event.returnValue = ""; + return ""; + }; + + const handleVisibilityChange = () => { + if (document.visibilityState !== "hidden") return; + if (!isDirtyRef.current) return; + void save("lifecycle").then((saved) => { + if (saved) return; + if (!isDirtyRef.current) return; + if (saveStateRef.current === "saving") return; + void save("lifecycle"); + }); + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [save]); const compile = React.useCallback( async (opts?: { auto?: boolean }) => { if (!projectId || !latexFolderId) return; if (viewReadOnly) return; - if (isDirty && !effectiveReadOnly) { - const saved = await save(); + if (buildStatusRef.current === "queued" || buildStatusRef.current === "running") return; + if (!effectiveReadOnly && (isDirtyRef.current || saveStateRef.current === "saving")) { + const saved = await save("compile"); if (!saved) return; } try { setBuildError(null); setBuildErrors([]); + setSynctexError(null); + setSynctexReady(false); setLogText(null); + buildStatusRef.current = "queued"; setBuildStatus("queued"); const res = await compileLatex(projectId, latexFolderId, { compiler, + main_file_id: compileMainFileId, auto: Boolean(opts?.auto), stop_on_first_error: false, }); setBuildId(res.build_id); setCompiler(normalizeCompiler(res.compiler)); + setSynctexReady(Boolean(res.synctex_ready)); + buildStatusRef.current = res.status ?? "queued"; setBuildStatus(res.status ?? "queued"); } catch (e) { console.error("[LatexPlugin] Compile failed:", e); setBuildError(e instanceof Error ? e.message : t("compile_request_failed")); + setSynctexReady(false); + buildStatusRef.current = "error"; setBuildStatus("error"); } }, - [compiler, effectiveReadOnly, isDirty, latexFolderId, projectId, save, t, viewReadOnly] + [compiler, compileMainFileId, effectiveReadOnly, latexFolderId, projectId, save, t, viewReadOnly] ); + const triggerManualSave = React.useCallback(async () => { + const targetFileId = activeFileIdRef.current; + if (!targetFileId || effectiveReadOnly) return; + + let saved = await save("manual"); + // A manual save may have joined an in-flight autosave that captured older text. + // Retry once so Ctrl/Cmd+S means "save the text I see now", then compile. + if (!saved && isDirtyRef.current && activeFileIdRef.current === targetFileId) { + saved = await save("manual"); + } + + if (!saved) return; + if (isDirtyRef.current) return; + if (activeFileIdRef.current !== targetFileId) return; + if (!autoCompileOnSave) return; + if (viewReadOnly) return; + if (buildStatusRef.current === "queued" || buildStatusRef.current === "running") return; + + void compile({ auto: true }); + }, [autoCompileOnSave, compile, effectiveReadOnly, save, viewReadOnly]); + + React.useEffect(() => { + if (!activeFileId || effectiveReadOnly) return; + const handler = (event: KeyboardEvent) => { + const isSave = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s"; + if (!isSave) return; + event.preventDefault(); + void triggerManualSave(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [activeFileId, effectiveReadOnly, triggerManualSave]); + React.useEffect(() => { if (!projectId || !latexFolderId) return; const handler = (event: Event) => { @@ -1203,6 +2154,8 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug setBuildStatus(detail.status ?? "queued"); setBuildError(detail.errorMessage ?? null); setBuildErrors([]); + setSynctexReady(false); + setSynctexError(null); setLogText(null); }; @@ -1228,6 +2181,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug setBuildStatus(latest.status ?? "idle"); setBuildError(latest.error_message ?? null); setBuildErrors(normalizeBuildErrors(latest.errors, latest.log_items)); + setSynctexReady(Boolean(latest.synctex_ready)); } } catch { // ignore @@ -1252,6 +2206,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug setBuildStatus(res.status); setBuildError(res.error_message ?? null); setBuildErrors(normalizeBuildErrors(res.errors, res.log_items)); + setSynctexReady(Boolean(res.synctex_ready)); if (res.status === "success" && res.pdf_ready) { if (lastLoadedPdfBuildIdRef.current !== buildId) { @@ -1382,12 +2337,20 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug }; } if (saveState === "saving") { + const autosaveLike = saveTrigger === "auto" || saveTrigger === "lifecycle"; return { - label: t("status_saving"), + label: autosaveLike ? t("status_autosaving") : t("status_saving"), className: "border-[#A6B0B6]/30 bg-[#A6B0B6]/12 text-[#5c666b] dark:bg-[#A6B0B6]/12 dark:text-[#d8dde0]", }; } + if (saveError || saveState === "error") { + return { + label: t("status_save_failed"), + className: + "border-red-400/30 bg-red-50/80 text-red-600 dark:bg-red-500/10 dark:text-red-200", + }; + } if (isDirty) { return { label: t("status_unsaved"), @@ -1407,7 +2370,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug className: "border-[#9AA79A]/30 bg-[#9AA79A]/12 text-[#5f6b5f] dark:bg-[#9AA79A]/12 dark:text-[#dbe4db]", }; - }, [buildStatus, effectiveReadOnly, isDirty, saveState, t]); + }, [buildStatus, effectiveReadOnly, isDirty, saveError, saveState, saveTrigger, t]); const buildFocusedIssue = React.useCallback( (issue: LatexBuildError) => { @@ -1505,24 +2468,11 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug null; if (!targetFileId) return; - const targetMeta = files.find((file) => file.id === targetFileId); - pendingJumpRef.current = { - fileId: targetFileId, + void switchToLatexFile(targetFileId, { line: Math.max(1, Number(issue.line || 1)), - }; - - if (targetMeta?.name && targetMeta.name !== activeFileName) { - setActiveFileName(targetMeta.name); - } - - if (targetFileId !== activeFileId) { - setActiveFileId(targetFileId); - return; - } - - flushPendingJump(); + }); }, - [activeFileId, activeFileName, files, flushPendingJump, focusBuildIssue] + [activeFileId, files, focusBuildIssue, switchToLatexFile] ); const handleAskDeepScientistForIssue = React.useCallback( @@ -1661,6 +2611,58 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug setPdfPageWidth(width || PAGE_DIMENSIONS.A4_WIDTH); }, []); + const handlePdfPointDoubleClick = React.useCallback( + async (point: PdfSourcePoint) => { + if (!projectId || !latexFolderId || !buildId) return; + setSynctexError(null); + + if (!synctexReady) { + setSynctexError(t("synctex_unavailable_hint")); + return; + } + + setSynctexBusy(true); + try { + const result = await syncTexEditLatexBuild(projectId, latexFolderId, buildId, { + page: point.page, + x: point.x, + y: point.y, + pdf_word: point.word ?? null, + pdf_context_words: point.contextWords ?? null, + pdf_context_index: point.contextIndex ?? null, + pdf_word_bbox: point.wordBBox ?? null, + pdf_word_center: point.wordCenter ?? null, + }); + if (!result.ok) { + setSynctexError(result.message || t("synctex_not_found")); + return; + } + const targetFileId = + resolveLatexFileId(files, result.file_path) ?? + (result.file_id && files.some((file) => file.id === result.file_id) ? result.file_id : null); + if (!targetFileId) { + setSynctexError(t("synctex_source_not_loaded")); + return; + } + const switched = await switchToLatexFile(targetFileId, { + line: result.line ?? 1, + column: result.column ?? null, + word: result.pdf_word ?? point.word ?? null, + selection: result.selection ?? null, + }); + if (!switched) { + setSynctexError(t("synctex_switch_failed")); + } + } catch (e) { + console.error("[LatexPlugin] SyncTeX reverse sync failed:", e); + setSynctexError(e instanceof Error ? e.message : t("synctex_failed")); + } finally { + setSynctexBusy(false); + } + }, + [buildId, files, latexFolderId, projectId, switchToLatexFile, synctexReady, t] + ); + const handleResizeStart = React.useCallback( (event: React.PointerEvent) => { if (!isWideLayout || !splitContainerRef.current) return; @@ -1727,42 +2729,39 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug : undefined } > -
-
- +
+
+ -
- -
- {t("compiler_label")} + value={activeFileId ?? ""} + onChange={(e) => { + void switchToLatexFile(e.target.value || null); + }} + disabled={files.length === 0} + aria-label={t("file_label")} + title={latexFileDisplayPath(files.find((file) => file.id === activeFileId)) || activeFileName} + > + {files.map((f) => ( + + ))} + +
+ +
+ {t("compiler_label")} updateAutoCompileOnSave(event.target.checked)} + /> + {t("auto_compile_on_save")} + + {!isBibFile ? ( ) : null} @@ -1816,7 +2834,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug }); }} className={cn( - "h-8 px-3 rounded-lg text-sm border inline-flex items-center gap-2", + "h-7 shrink-0 whitespace-nowrap px-2 rounded-md text-xs border inline-flex items-center gap-1", "bg-white/70 border-black/10 hover:bg-white/90", "dark:bg-white/[0.04] dark:border-white/10 dark:hover:bg-white/[0.08]", bibPanelOpen && "border-[#A99EBE]/28 bg-[#A99EBE]/12 text-[#564f6a]" @@ -1824,15 +2842,15 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug aria-label={t("assist_bibtex")} title={t("assist_bibtex")} > - - {t("assist_bibtex")} + + {t("assist_bibtex")} ) : null} -
+
@@ -1841,10 +2859,10 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug -
-
+ +
+
{showAssistPanel ? (
@@ -2040,8 +3058,15 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug { + try { + ensureMonacoLatexLanguages(monaco); + } catch (e) { + console.error("[LatexPlugin] Failed to configure LaTeX language:", e); + } + }} onMount={(editor, monaco) => { try { bindEditor(editor, monaco); @@ -2051,15 +3076,39 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug }} options={{ readOnly: effectiveReadOnly, + automaticLayout: true, minimap: { enabled: false }, wordWrap: "on", fontSize: 13, scrollBeyondLastLine: false, + tabCompletion: "on", + quickSuggestions: { other: true, comments: false, strings: false }, + suggestOnTriggerCharacters: true, + acceptSuggestionOnCommitCharacter: true, + renderWhitespace: "selection", + selectionHighlight: false, + occurrencesHighlight: "off", + tabSize: 2, + insertSpaces: true, }} /> )}
+ {saveError ? ( +
+
+ +
+
{t("save_failed_title")}
+
+ {saveError} +
+
+
+
+ ) : null} + {buildStatus === "error" ? (
@@ -2192,11 +3241,27 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug title={t("download_pdf")} > - -
+ +
+ + {pdfObjectUrl && (synctexBusy || synctexError) ? ( +
+
+ {synctexBusy ? : null} + {synctexError ?? t("synctex_resolving")} +
+
+ ) : null} - {pdfObjectUrl ? ( - ( - )} + zoomFactor={zoomScale} + highlights={emptyHighlights} + onPageWidth={handlePageWidth} + onPointDoubleClick={handlePdfPointDoubleClick} + /> + )} ) : buildStatus === "queued" || buildStatus === "running" ? (
diff --git a/src/ui/src/lib/stores/tabs.ts b/src/ui/src/lib/stores/tabs.ts index 67e9a6df..aece8054 100644 --- a/src/ui/src/lib/stores/tabs.ts +++ b/src/ui/src/lib/stores/tabs.ts @@ -56,6 +56,12 @@ export interface TabsState { function contextEquals(a: TabContext, b: TabContext): boolean { if (a.type !== b.type) return false; + const latexA = latexContextIdentity(a); + const latexB = latexContextIdentity(b); + if (latexA && latexB) { + return latexA === latexB; + } + // For file and notebook types, compare resourceId if (a.type === "file" || a.type === "notebook") { return a.resourceId === b.resourceId; @@ -69,6 +75,28 @@ function contextEquals(a: TabContext, b: TabContext): boolean { return false; } +function customStringValue(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function latexContextIdentity(context: TabContext): string | null { + if (context.type !== "custom") return null; + const customData = context.customData; + if (!customData || typeof customData !== "object" || Array.isArray(customData)) return null; + const record = customData as Record; + const hasLatexMarker = record.kind === "latex-workspace" || customStringValue(record.latexFolderId); + if (!hasLatexMarker) return null; + const projectId = customStringValue(record.projectId); + const latexFolderId = customStringValue(record.latexFolderId) ?? customStringValue(context.resourceId); + if (!projectId || !latexFolderId) return null; + return [ + "latex-workspace", + projectId, + latexFolderId, + Boolean(record.readOnly || record.readonly) ? "readonly" : "writable", + ].join("::"); +} + /** * Generate default title for a tab based on context */ diff --git a/tests/test_api_contract_surface.py b/tests/test_api_contract_surface.py index 7e8a6aa2..c3336888 100644 --- a/tests/test_api_contract_surface.py +++ b/tests/test_api_contract_surface.py @@ -145,11 +145,13 @@ def test_backend_routes_cover_shared_web_and_tui_surface() -> None: ("PATCH", "/api/v1/annotations/ann-001", "annotation_update"), ("DELETE", "/api/v1/annotations/ann-001", "annotation_delete"), ("POST", "/api/v1/projects/q-001/latex/init", "latex_init"), + ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/manifest", "latex_manifest"), ("POST", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/compile", "latex_compile"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/builds", "latex_builds"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/builds/latex-001", "latex_build"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/builds/latex-001/pdf", "latex_build_pdf"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/builds/latex-001/log", "latex_build_log"), + ("POST", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/builds/latex-001/synctex/edit", "latex_synctex_edit"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/archive", "latex_archive"), ("GET", "/api/config/files", "config_files"), ("GET", "/api/config/core", "config_show"), @@ -268,11 +270,13 @@ def test_web_client_uses_acp_and_git_surface_expected_by_backend() -> None: latex_fragments = [ "/api/v1/projects/${projectId}/latex/init", + "/api/v1/projects/${projectId}/latex/${folderId}/manifest", "/api/v1/projects/${projectId}/latex/${folderId}/compile", "/api/v1/projects/${projectId}/latex/${folderId}/builds", "/api/v1/projects/${projectId}/latex/${folderId}/builds/${buildId}", "/api/v1/projects/${projectId}/latex/${folderId}/builds/${buildId}/pdf", "/api/v1/projects/${projectId}/latex/${folderId}/builds/${buildId}/log", + "/api/v1/projects/${projectId}/latex/${folderId}/builds/${buildId}/synctex/edit", "/api/v1/projects/${projectId}/latex/${folderId}/archive", ] @@ -326,6 +330,10 @@ def test_settings_control_center_client_prefers_system_alias_surface() -> None: def test_local_workspace_does_not_route_markdown_or_commands_through_dead_notebook_and_auth_paths() -> None: workspace_source = _read("src/ui/src/components/workspace/WorkspaceLayout.tsx") open_file_source = _read("src/ui/src/hooks/useOpenFile.ts") + open_queue_source = _read("src/ui/src/lib/latex/open-queue.ts") + latex_source = _read("src/ui/src/lib/api/latex.ts") + latex_plugin_source = _read("src/ui/src/lib/plugins/latex/LatexPlugin.tsx") + tabs_source = _read("src/ui/src/lib/stores/tabs.ts") plugin_types_source = _read("src/ui/src/lib/types/plugin.ts") plugin_init_source = _read("src/ui/src/lib/plugin/init.ts") @@ -336,6 +344,33 @@ def test_local_workspace_does_not_route_markdown_or_commands_through_dead_notebo assert "BUILTIN_PLUGINS.NOTEBOOK,\n BUILTIN_PLUGINS.LATEX" not in workspace_source assert "updateTabPlugin(tab.id, BUILTIN_PLUGINS.NOTEBOOK" in workspace_source assert 'return BUILTIN_PLUGINS.NOTEBOOK;' in open_file_source + assert "queueLatexOpenFile" in open_file_source + assert "openFileId: file.id" not in open_file_source + assert "latexContextIdentity" in tabs_source + assert "mainFileId:" not in open_queue_source + assert "quest_stage_selection" not in open_queue_source + assert "LATEX_OPEN_FILE_EVENT" in latex_plugin_source + assert "syncTexEditLatexBuild" in latex_plugin_source + assert "jumpEditorToLocation" in latex_plugin_source + assert "hitTestPdfTextLayerWord" in latex_plugin_source + assert "pdf_word_bbox" in latex_source + assert "pdf_context_words" in latex_source + assert "contextWords: wordHit?.contextWords" in latex_plugin_source + assert "selection?: LatexSyncTexSelection" in latex_source + assert "result.selection" in latex_plugin_source + assert "revealEditorRange" in latex_plugin_source + assert "revealRangeInCenter" in latex_plugin_source + assert "selectionHighlight: false" in latex_plugin_source + assert 'occurrencesHighlight: "off"' in latex_plugin_source + assert "boundEditorFileIdRef" in latex_plugin_source + assert "boundEditorFileIdRef.current !== activeFileId" in latex_plugin_source + assert "select?: boolean" not in latex_plugin_source + assert "ds-latex-jump-inline" not in _read("src/ui/src/index.css") + assert 'setSyncState("loading")' in latex_plugin_source + assert "getWordAtPosition" in latex_plugin_source + assert "openFileTabs" not in latex_plugin_source + assert "handleCloseFileTab" not in latex_plugin_source + assert "synctex_hint" not in latex_plugin_source assert '"text/markdown": BUILTIN_PLUGINS.NOTEBOOK' in plugin_types_source assert '".md": BUILTIN_PLUGINS.NOTEBOOK' in plugin_types_source assert 'extensions: [".md", ".markdown"],\n mimeTypes: ["text/markdown", "text/x-markdown"],\n priority: 95,' in plugin_init_source diff --git a/tests/test_daemon_api.py b/tests/test_daemon_api.py index b25fdd55..0c44bc63 100644 --- a/tests/test_daemon_api.py +++ b/tests/test_daemon_api.py @@ -1319,8 +1319,13 @@ def test_daemon_serves_health_and_ui(temp_home: Path, project_root: Path, python (quest_root / "docs").mkdir(parents=True, exist_ok=True) (quest_root / "figures").mkdir(parents=True, exist_ok=True) (quest_root / "paper" / "latex").mkdir(parents=True, exist_ok=True) + (quest_root / "paper" / "latex" / "sections").mkdir(parents=True, exist_ok=True) (quest_root / "docs" / "appendix.pdf").write_bytes(b"%PDF-1.4\n%quest-pdf\n") (quest_root / "figures" / "plot.png").write_bytes(b"\x89PNG\r\n\x1a\nquest-plot") + (quest_root / "paper" / "latex" / "sections" / "intro.tex").write_text( + "Hello from daemon API test.\n", + encoding="utf-8", + ) (quest_root / "paper" / "latex" / "main.tex").write_text( "\n".join( [ @@ -1330,7 +1335,7 @@ def test_daemon_serves_health_and_ui(temp_home: Path, project_root: Path, python r"\date{}", r"\begin{document}", r"\maketitle", - "Hello from daemon API test.", + r"\input{sections/intro}", r"\end{document}", "", ] @@ -1450,6 +1455,13 @@ def test_daemon_serves_health_and_ui(temp_home: Path, project_root: Path, python assert response.read().startswith(b"%PDF-1.4") if shutil.which("pdflatex"): folder_id = f"quest-dir::{quest_id}::paper%2Flatex" + manifest = _get_json( + f"http://127.0.0.1:20901/api/v1/projects/{quest_id}/latex/{folder_id}/manifest" + ) + assert manifest["main_file_path"] == "paper/latex/main.tex" + manifest_paths = {item["relative_path"] for item in manifest["files"]} + assert {"main.tex", "sections/intro.tex"}.issubset(manifest_paths) + assert manifest["main_file_id"] compile_request = Request( f"http://127.0.0.1:20901/api/v1/projects/{quest_id}/latex/{folder_id}/compile", data=json.dumps({"compiler": "pdflatex"}).encode("utf-8"), @@ -1462,6 +1474,7 @@ def test_daemon_serves_health_and_ui(temp_home: Path, project_root: Path, python assert compile_payload["folder_id"] == folder_id assert compile_payload["status"] == "success" assert compile_payload["pdf_ready"] is True + assert compile_payload["synctex_ready"] is True build_id = compile_payload["build_id"] builds = _get_json( f"http://127.0.0.1:20901/api/v1/projects/{quest_id}/latex/{folder_id}/builds?limit=5" @@ -1476,11 +1489,22 @@ def test_daemon_serves_health_and_ui(temp_home: Path, project_root: Path, python ) as response: log_text = response.read().decode("utf-8") assert "pdflatex" in log_text + assert "-synctex=1" in log_text with urlopen( # noqa: S310 f"http://127.0.0.1:20901/api/v1/projects/{quest_id}/latex/{folder_id}/builds/{build_id}/pdf" ) as response: assert response.headers["Content-Type"] == "application/pdf" assert response.read().startswith(b"%PDF-") + if shutil.which("synctex") and build.get("synctex_ready"): + synctex_request = Request( + f"http://127.0.0.1:20901/api/v1/projects/{quest_id}/latex/{folder_id}/builds/{build_id}/synctex/edit", + data=json.dumps({"page": 1, "x": 100, "y": 100}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urlopen(synctex_request) as response: # noqa: S310 + synctex_payload = json.loads(response.read().decode("utf-8")) + assert "ok" in synctex_payload with urlopen( # noqa: S310 f"http://127.0.0.1:20901/api/v1/projects/{quest_id}/latex/{folder_id}/archive" ) as response: diff --git a/tests/test_latex_runtime.py b/tests/test_latex_runtime.py new file mode 100644 index 00000000..1929da20 --- /dev/null +++ b/tests/test_latex_runtime.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from deepscientist.config import ConfigManager +from deepscientist.home import ensure_home_layout, repo_root +import deepscientist.latex_runtime as latex_runtime +from deepscientist.latex_runtime import ( + QuestLatexService, + _latex_source_tokens, + _parse_synctex_records, + _source_selection_for_synctex, + _synctex_sample_points, +) +from deepscientist.quest import QuestService +from deepscientist.skills import SkillInstaller + + +def test_parse_synctex_edit_records() -> None: + payload = _parse_synctex_records( + "\n".join( + [ + "SyncTeX result begin", + "Output:/tmp/main.pdf", + "Input:/tmp/project/sections/intro.tex", + "Line:42", + "Column:7", + "Offset:0", + "Context:ignored", + "SyncTeX result end", + ] + ) + ) + + assert payload["input"] == "/tmp/project/sections/intro.tex" + assert payload["line"] == "42" + assert payload["column"] == "7" + + +def test_source_selection_prefers_exact_pdf_word_near_synctex_column() -> None: + source = "\n".join( + [ + r"\section{Method}", + r"The method baseline is different from the improved method used here.", + "", + ] + ) + + selection = _source_selection_for_synctex( + source, + line=2, + column=55, + pdf_word="method", + ) + + assert selection["precision"] == "exact_word" + assert selection["start_line"] == 2 + assert selection["text"] == "method" + assert selection["start_column"] > source.splitlines()[1].index("improved") + assert selection["end_column"] > selection["start_column"] + + +def test_source_selection_uses_pdf_line_context_for_repeated_words() -> None: + line = "The method baseline is different from the improved method used here." + source = "\\section{Method}\n" + line + "\n" + + selection = _source_selection_for_synctex( + source, + line=2, + column=1, + pdf_word="method", + pdf_context_words=["different", "from", "the", "improved", "method", "used", "here"], + pdf_context_index=4, + ) + + assert selection["precision"] == "exact_word" + assert selection["text"] == "method" + assert selection["start_column"] == line.rindex("method") + 1 + + +def test_source_selection_never_selects_entire_line_when_word_is_missing() -> None: + source = "This line has several source tokens but not the PDF token.\n" + + selection = _source_selection_for_synctex( + source, + line=1, + column=19, + pdf_word="unmatched", + ) + + assert selection["precision"] == "nearest_token" + assert selection["start_line"] == 1 + assert selection["end_line"] == 1 + assert selection["end_column"] - selection["start_column"] < len(source) + + +def test_source_selection_ignores_trailing_latex_comments() -> None: + source = "\n".join( + [ + r"\begin{document}", + r"% method only appears in this comment", + r"Visible method appears here. % another method comment", + "", + ] + ) + + tokens = _latex_source_tokens(source, min_line=1, max_line=4) + assert not any(token["line"] == 2 and token["text"] == "method" for token in tokens) + assert not any(token["text"] == "another" for token in tokens) + + selection = _source_selection_for_synctex( + source, + line=2, + column=4, + pdf_word="method", + ) + + assert selection["precision"] == "exact_word" + assert selection["start_line"] == 3 + assert selection["text"] == "method" + + +def test_source_selection_ignores_latex_comment_environment() -> None: + source = "\n".join( + [ + r"\begin{comment}", + "method inside comment environment", + r"\end{comment}", + "Visible method outside comment.", + "", + ] + ) + + selection = _source_selection_for_synctex( + source, + line=2, + column=3, + pdf_word="method", + ) + + assert selection["precision"] == "exact_word" + assert selection["start_line"] == 4 + assert selection["text"] == "method" + + +def test_single_letter_pdf_word_does_not_match_substrings() -> None: + source = "alpha beta gamma\n" + + selection = _source_selection_for_synctex( + source, + line=1, + column=3, + pdf_word="a", + ) + + assert selection["precision"] == "line_column" + assert selection["start_column"] == 3 + assert selection["end_column"] == 3 + assert selection["text"] == "" + + +def test_single_letter_pdf_word_can_select_exact_article_only() -> None: + source = "alpha beta a gamma\n" + + selection = _source_selection_for_synctex( + source, + line=1, + column=12, + pdf_word="a", + ) + + assert selection["precision"] == "exact_word" + assert selection["text"] == "a" + assert selection["start_column"] == source.index(" a ") + 2 + assert selection["end_column"] == selection["start_column"] + 1 + + +def test_low_information_pdf_word_without_evidence_moves_to_line_start() -> None: + source = r"Prior work \cite{smith2020} reports 2 different results in section 2." + "\n" + + selection = _source_selection_for_synctex( + source, + line=1, + column=None, + pdf_word="2", + pdf_context_words=["2"], + pdf_context_index=0, + ) + + assert selection["precision"] == "line_start" + assert selection["reason"] == "low_information_pdf_word_insufficient_evidence" + assert selection["start_line"] == 1 + assert selection["start_column"] == 1 + assert selection["end_column"] == 1 + assert selection["text"] == "" + + +def test_low_information_pdf_word_with_column_support_can_select_source_token() -> None: + source = "The model achieved 2 improvements and section 2 confirms it.\n" + target_column = source.index("2") + 1 + + selection = _source_selection_for_synctex( + source, + line=1, + column=target_column, + pdf_word="2", + ) + + assert selection["precision"] == "exact_word" + assert selection["text"] == "2" + assert selection["start_line"] == 1 + assert selection["start_column"] == target_column + assert selection["end_column"] == target_column + 1 + + +def test_low_information_pdf_word_with_context_support_can_select_source_token() -> None: + source = "The model achieved 2 improvements.\n" + + selection = _source_selection_for_synctex( + source, + line=1, + column=None, + pdf_word="2", + pdf_context_words=["model", "achieved", "2", "improvements"], + pdf_context_index=2, + ) + + assert selection["precision"] == "exact_word" + assert selection["text"] == "2" + assert selection["start_line"] == 1 + assert selection["start_column"] == source.index("2") + 1 + + +def test_source_selection_maps_maketitle_back_to_title_declaration() -> None: + source = "\n".join( + [ + r"\documentclass{article}", + r"\title{\red{Precise Risk-to-Proof Joint Inspection}}", + r"\author{Anonymous Authors}", + r"\begin{document}", + r"\maketitle", + r"\section{Introduction}", + "", + ] + ) + + selection = _source_selection_for_synctex( + source, + line=5, + column=None, + pdf_word="Risk-to-Proof", + pdf_context_words=["Precise", "Risk-to-Proof", "Joint", "Inspection"], + pdf_context_index=1, + ) + + assert selection["precision"] == "exact_word" + assert selection["start_line"] == 2 + assert selection["text"] == "Risk-to-Proof" + assert selection["strategy"] == "front_matter" + assert selection["region"] == "title" + + +def test_source_selection_maps_ieee_abstract_display_back_to_abstract_not_keywords() -> None: + source = "\n".join( + [ + r"\documentclass[journal]{IEEEtran}", + r"\begin{document}", + r"\IEEEtitleabstractindextext{", + r"\begin{abstract}", + r"Outsourced training is used for industrial fault diagnosis models.", + r"\end{abstract}", + r"\begin{IEEEkeywords}", + r"Outsourced training, industrial fault diagnosis.", + r"\end{IEEEkeywords}}", + r"\maketitle", + r"\IEEEdisplaynontitleabstractindextext", + "", + ] + ) + + selection = _source_selection_for_synctex( + source, + line=11, + column=None, + pdf_word="Outsourced", + pdf_context_words=["Outsourced", "training", "is", "used", "for"], + pdf_context_index=0, + ) + + assert selection["precision"] == "exact_word" + assert selection["start_line"] == 5 + assert selection["text"] == "Outsourced" + assert selection["strategy"] == "front_matter" + assert selection["region"] == "abstract" + + +def test_source_selection_maps_front_matter_macro_invocation_to_title_use() -> None: + source = "\n".join( + [ + r"\documentclass{article}", + r"\newcommand{\name}{$\mathtt{PoL\mbox{-}JI}$\xspace}", + r"\begin{document}", + r"\title{\name: Risk-aware inspection}", + r"\maketitle", + "", + ] + ) + + selection = _source_selection_for_synctex( + source, + line=5, + column=None, + pdf_word="PoL-JI", + pdf_context_words=["PoL-JI", "Risk-aware", "inspection"], + pdf_context_index=0, + ) + + assert selection["precision"] == "exact_word" + assert selection["start_line"] == 4 + assert selection["text"] == r"\name" + assert selection["strategy"] == "front_matter" + assert selection["region"] == "title" + + +def test_synctex_sample_points_prioritize_pdf_word_center_and_bbox() -> None: + samples = _synctex_sample_points( + 10, + 20, + pdf_word_center={"x": 30, "y": 40}, + pdf_word_bbox={"left": 20, "top": 35, "right": 44, "bottom": 47}, + ) + + assert samples[0]["kind"] == "word_center" + assert samples[0]["x"] == 30 + assert samples[0]["y"] == 40 + assert any(sample["kind"] == "click" for sample in samples) + assert len(samples) >= 5 + + +def test_synctex_edit_returns_backend_source_selection_range(temp_home: Path, monkeypatch) -> None: + ensure_home_layout(temp_home) + ConfigManager(temp_home).ensure_files() + quest_service = QuestService(temp_home, skill_installer=SkillInstaller(repo_root(), temp_home)) + quest = quest_service.create("synctex precise selection quest") + quest_root = Path(quest["quest_root"]) + project_id = quest["quest_id"] + + latex_root = quest_root / "paper" / "latex" + latex_root.mkdir(parents=True, exist_ok=True) + source_line = "The method baseline is different from the improved method used here." + source_path = latex_root / "main.tex" + source_path.write_text("\\documentclass{article}\n" + source_line + "\n", encoding="utf-8") + pdf_path = latex_root / "main.pdf" + synctex_path = latex_root / "main.synctex.gz" + pdf_path.write_bytes(b"%PDF-1.4\n") + synctex_path.write_bytes(b"fake synctex") + + service = QuestLatexService(quest_service) + folder_id = f"quest-dir::{project_id}::paper%2Flatex" + build_id = "latex-test-selection" + build_path = service._build_record_path(project_id, "paper/latex", build_id) + build_path.parent.mkdir(parents=True, exist_ok=True) + build_path.write_text( + __import__("json").dumps( + { + "build_id": build_id, + "project_id": project_id, + "folder_id": folder_id, + "folder_path": "paper/latex", + "output_pdf_path": str(pdf_path), + "synctex_ready": True, + "synctex_path": str(synctex_path), + } + ), + encoding="utf-8", + ) + + monkeypatch.setattr( + latex_runtime.RuntimeToolService, + "resolve_binary", + lambda self, binary, preferred_tools=(): {"path": "/usr/bin/synctex", "source": "test"}, + ) + + def _fake_run(*args, **kwargs): + return SimpleNamespace( + returncode=0, + stdout="\n".join( + [ + "SyncTeX result begin", + f"Input:{source_path}", + "Line:2", + "Column:1", + "Offset:0", + "SyncTeX result end", + ] + ), + stderr="", + ) + + monkeypatch.setattr(latex_runtime.subprocess, "run", _fake_run) + + result = service.synctex_edit( + project_id, + folder_id, + build_id, + page=1, + x=10, + y=20, + pdf_word="method", + pdf_context_words=["different", "from", "the", "improved", "method", "used", "here"], + pdf_context_index=4, + pdf_word_bbox={"left": 8, "top": 18, "right": 28, "bottom": 26}, + pdf_word_center={"x": 18, "y": 22}, + ) + + assert result["ok"] is True + assert result["precision"] == "exact_word" + assert result["sample_count"] >= 5 + assert result["selection"]["text"] == "method" + assert result["selection"]["start_line"] == 2 + assert result["selection"]["start_column"] == source_line.rindex("method") + 1 + + +def test_latex_manifest_lists_nested_editable_files(temp_home: Path) -> None: + ensure_home_layout(temp_home) + ConfigManager(temp_home).ensure_files() + quest_service = QuestService(temp_home, skill_installer=SkillInstaller(repo_root(), temp_home)) + quest = quest_service.create("latex manifest nested files quest") + quest_root = Path(quest["quest_root"]) + project_id = quest["quest_id"] + + latex_root = quest_root / "paper" / "latex" + sections_root = latex_root / "sections" + sections_root.mkdir(parents=True, exist_ok=True) + (latex_root / "main.tex").write_text( + "\n".join( + [ + r"\documentclass{article}", + r"\begin{document}", + r"\input{sections/intro}", + r"\bibliography{refs}", + r"\end{document}", + "", + ] + ), + encoding="utf-8", + ) + (sections_root / "intro.tex").write_text(r"\section{Intro}" + "\n", encoding="utf-8") + (latex_root / "refs.bib").write_text("@article{x,title={X}}\n", encoding="utf-8") + + folder_id = f"quest-dir::{project_id}::paper%2Flatex" + manifest = QuestLatexService(quest_service).manifest(project_id, folder_id) + + by_relative = {item["relative_path"]: item for item in manifest["files"]} + assert manifest["main_file_path"] == "paper/latex/main.tex" + assert by_relative["main.tex"]["role"] == "main" + assert by_relative["sections/intro.tex"]["role"] == "tex" + assert by_relative["refs.bib"]["role"] == "bib" + assert {"kind": "input", "path": "sections/intro.tex"} in by_relative["main.tex"]["dependencies"] + assert {"kind": "bibliography", "path": "refs.bib"} in by_relative["main.tex"]["dependencies"] diff --git a/tests/test_memory_and_artifact.py b/tests/test_memory_and_artifact.py index 45629104..90871f26 100644 --- a/tests/test_memory_and_artifact.py +++ b/tests/test_memory_and_artifact.py @@ -4530,6 +4530,47 @@ def flatten(nodes: list[dict]) -> list[dict]: assert latex_node["folder_kind"] == "latex" +def test_explorer_keeps_nested_latex_chapter_folders_under_root_project(temp_home: Path) -> None: + ensure_home_layout(temp_home) + ConfigManager(temp_home).ensure_files() + quest_service = QuestService(temp_home, skill_installer=SkillInstaller(repo_root(), temp_home)) + quest = quest_service.create("nested paper latex explorer quest") + quest_root = Path(quest["quest_root"]) + + latex_root = quest_root / "paper" / "latex" + sections_root = latex_root / "sections" + sections_root.mkdir(parents=True, exist_ok=True) + (latex_root / "main.tex").write_text( + "\n".join( + [ + r"\documentclass{article}", + r"\begin{document}", + r"\input{sections/intro}", + r"\end{document}", + "", + ] + ), + encoding="utf-8", + ) + (sections_root / "intro.tex").write_text(r"\section{Introduction}" + "\n", encoding="utf-8") + + explorer = quest_service.explorer(quest["quest_id"]) + research = next(section for section in explorer["sections"] if section["id"] == "research") + + def flatten(nodes: list[dict]) -> list[dict]: + items: list[dict] = [] + for node in nodes: + items.append(node) + items.extend(flatten(node.get("children") or [])) + return items + + research_nodes = flatten(research["nodes"]) + latex_node = next(node for node in research_nodes if node.get("path") == "paper/latex") + sections_node = next(node for node in research_nodes if node.get("path") == "paper/latex/sections") + assert latex_node["folder_kind"] == "latex" + assert sections_node.get("folder_kind") is None + + def test_explorer_marks_paper_latex_folder_for_snapshot_opening(temp_home: Path) -> None: ensure_home_layout(temp_home) ConfigManager(temp_home).ensure_files() From 1af9fdd3ba652ffcefd90fde60eec95fc6f04202 Mon Sep 17 00:00:00 2001 From: SmallSpider0 <568442079@qq.com> Date: Thu, 21 May 2026 17:15:24 +0800 Subject: [PATCH 2/8] fix(latex): protect external source edits --- docs/en/12_GUIDED_WORKFLOW_TOUR.md | 2 + docs/zh/12_GUIDED_WORKFLOW_TOUR.md | 2 + src/ui/src/lib/api.ts | 1 + src/ui/src/lib/api/files.ts | 68 +++- src/ui/src/lib/api/quest-files.ts | 68 +++- src/ui/src/lib/i18n/messages/latex.ts | 14 + src/ui/src/lib/plugins/latex/LatexPlugin.tsx | 310 ++++++++++++++++++- tests/test_api_contract_surface.py | 11 + tests/test_memory_and_artifact.py | 28 ++ 9 files changed, 485 insertions(+), 19 deletions(-) diff --git a/docs/en/12_GUIDED_WORKFLOW_TOUR.md b/docs/en/12_GUIDED_WORKFLOW_TOUR.md index 78a7a74a..25920328 100644 --- a/docs/en/12_GUIDED_WORKFLOW_TOUR.md +++ b/docs/en/12_GUIDED_WORKFLOW_TOUR.md @@ -345,6 +345,8 @@ When you open a LaTeX project folder, the browser editor treats the folder as on The editor auto-saves source edits shortly after you type. Background autosaves only persist the source; they do not start PDF compilation. Manual saves default to compile-on-save: `Ctrl/Cmd+S` or the `Save` button saves the active LaTeX file and then starts one PDF compilation when the save succeeds. `Save & Compile` remains available for an explicit compile action and still saves the current source before starting PDF compilation. +If another process changes the active LaTeX source file while it is open, such as an AI edit or terminal command, the editor refreshes automatically when the local buffer has no unsaved edits. If the local buffer is dirty, autosave pauses and the editor asks you to either reload the external version or explicitly overwrite it, so an ordinary save cannot silently replace external changes. + After a successful compile, the PDF preview uses SyncTeX metadata when available. Double-click a rendered PDF word to jump back to the matching LaTeX source file and select the corresponding source token; the editor uses the PDF word box plus multiple SyncTeX samples to avoid broad line-level selections. Older builds without SyncTeX data need to be recompiled before PDF-to-source jumps are available. ### 6.5 Canvas diff --git a/docs/zh/12_GUIDED_WORKFLOW_TOUR.md b/docs/zh/12_GUIDED_WORKFLOW_TOUR.md index f023dca6..1a3b8226 100644 --- a/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +++ b/docs/zh/12_GUIDED_WORKFLOW_TOUR.md @@ -343,6 +343,8 @@ Explorer 是 quest 的文件视角。 编辑器会在输入后短时间内自动保存源文件。后台自动保存只负责落盘源码,不会启动 PDF 编译。手动保存默认开启保存后自动编译:`Ctrl/Cmd+S` 或 `保存` 按钮会先保存当前 LaTeX 文件,保存成功后启动一次 PDF 编译。`保存并编译` 仍可用于显式编译,并会先保存当前源码,再启动 PDF 编译。 +如果其它进程在当前 LaTeX 源文件打开期间修改了它,例如 AI 编辑或终端命令,且本地缓冲区没有未保存内容,编辑器会自动刷新到外部版本。若本地缓冲区已被修改,自动保存会暂停,并提示你选择重新载入外部版本或明确覆盖外部版本,避免普通保存静默覆盖外部修改。 + 成功编译后,PDF 预览会在可用时使用 SyncTeX 元数据。双击 PDF 中渲染出的某个单词时,编辑器会结合 PDF 单词框和多点 SyncTeX 采样跳转到匹配的 LaTeX 源文件,并选中对应的源码 token,避免退化成大范围行级选中。没有 SyncTeX 数据的旧构建需要重新编译后才能使用 PDF 到源码跳转。 ### 6.5 Canvas diff --git a/src/ui/src/lib/api.ts b/src/ui/src/lib/api.ts index bd6bc285..ad342617 100644 --- a/src/ui/src/lib/api.ts +++ b/src/ui/src/lib/api.ts @@ -582,6 +582,7 @@ export const client = { conflict?: boolean message?: string revision?: string + current_revision?: string updated_payload?: OpenDocumentPayload }>(`/api/quests/${questId}/documents/${documentId}`, { method: 'PUT', diff --git a/src/ui/src/lib/api/files.ts b/src/ui/src/lib/api/files.ts index 18e354ba..0ce2d830 100644 --- a/src/ui/src/lib/api/files.ts +++ b/src/ui/src/lib/api/files.ts @@ -22,6 +22,7 @@ import { getQuestFile, getQuestFileBlob, getQuestFileContent, + getQuestFileContentSnapshot, getQuestFileTextPreview, getQuestFileTree, getQuestNodeAssetUrl, @@ -63,6 +64,21 @@ export interface FileTextPreviewResponse { encoding: string; } +export interface FileContentSnapshot { + file_id: string; + content: string; + revision?: string | null; + updated_at?: string | null; + size?: number; + mime_type?: string | null; + project_id?: string; +} + +export interface FileSaveOptions { + revision?: string | null; + force?: boolean; +} + /** * List files in a directory */ @@ -356,6 +372,51 @@ export async function getFileContent(fileId: string): Promise { return response.data as string; } +/** + * Get file content plus the backend revision used for optimistic save checks. + */ +export async function getFileContentSnapshot(fileId: string): Promise { + if (isDemoFileId(fileId)) { + const demoContent = getDemoFileContent(fileId); + if (demoContent == null) { + throw new Error(`Unknown demo file content for \`${fileId}\`.`); + } + return { + file_id: fileId, + content: demoContent, + revision: null, + updated_at: null, + size: demoContent.length, + mime_type: "text/plain", + }; + } + const cliRef = parseCliFileId(fileId); + if (cliRef) { + const response = await readCliFile(cliRef.projectId, cliRef.serverId, cliRef.path); + return { + file_id: fileId, + content: response.content, + revision: response.modified_at ? `modified:${response.modified_at}` : null, + updated_at: response.modified_at ?? null, + size: typeof response.size === "number" ? response.size : response.content.length, + mime_type: "text/plain", + project_id: cliRef.projectId, + }; + } + if (isQuestNodeId(fileId)) { + return await getQuestFileContentSnapshot(fileId); + } + const content = await getFileContent(fileId); + return { + file_id: fileId, + content, + revision: null, + updated_at: null, + size: content.length, + mime_type: "text/plain", + }; +} + /** * Get a truncated text preview for a file. */ @@ -383,8 +444,9 @@ export async function getFileTextPreview( */ export async function updateFileContent( fileId: string, - content: string -): Promise { + content: string, + options: FileSaveOptions = {} +): Promise { const cliRef = parseCliFileId(fileId); if (cliRef) { await writeCliFile(cliRef.projectId, cliRef.serverId, { @@ -418,7 +480,7 @@ export async function updateFileContent( }; } if (isQuestNodeId(fileId)) { - return await updateQuestFileContent(fileId, content); + return await updateQuestFileContent(fileId, content, options); } const response = await apiClient.put( `${FILES_BASE}/${fileId}/content`, diff --git a/src/ui/src/lib/api/quest-files.ts b/src/ui/src/lib/api/quest-files.ts index 0352a200..08446106 100644 --- a/src/ui/src/lib/api/quest-files.ts +++ b/src/ui/src/lib/api/quest-files.ts @@ -26,6 +26,21 @@ type CachedQuestFile = FileAPIResponse & { document_id?: string } +export type QuestFileContentSnapshot = { + file_id: string + content: string + revision?: string | null + updated_at?: string | null + size?: number + mime_type?: string | null + project_id?: string +} + +export type QuestFileSaveOptions = { + revision?: string | null + force?: boolean +} + type QuestMutationItem = { name: string path: string @@ -538,6 +553,24 @@ export async function getQuestFileContent(fileId: string): Promise { return document.content || '' } +export async function getQuestFileContentSnapshot(fileId: string): Promise { + const ref = parseQuestNodeId(fileId) + if (!ref || ref.type !== 'file') { + throw new Error('Only quest files can be opened as text.') + } + const document = await questClient.openDocument(ref.projectId, ref.documentId) + upsertFileFromDocument(fileId, ref, document) + return { + file_id: fileId, + content: document.content || '', + revision: typeof document.revision === 'string' ? document.revision : null, + updated_at: typeof document.updated_at === 'string' ? document.updated_at : null, + size: typeof document.size_bytes === 'number' ? document.size_bytes : undefined, + mime_type: document.mime_type ?? null, + project_id: ref.projectId, + } +} + export async function getQuestFileTextPreview( fileId: string, maxChars = 4000 @@ -556,19 +589,44 @@ export async function getQuestFileTextPreview( } } -export async function updateQuestFileContent(fileId: string, content: string): Promise { +export async function updateQuestFileContent( + fileId: string, + content: string, + options: QuestFileSaveOptions = {} +): Promise { const ref = parseQuestNodeId(fileId) if (!ref || ref.type !== 'file') { throw new Error('Only quest files can be saved.') } - const existing = await questClient.openDocument(ref.projectId, ref.documentId) - const saved = await questClient.saveDocument(ref.projectId, ref.documentId, content, existing.revision) + const expectedRevision = + options.force === true + ? undefined + : options.revision === null + ? undefined + : options.revision + const saved = await questClient.saveDocument(ref.projectId, ref.documentId, content, expectedRevision) const updated = saved.updated_payload if (!saved.ok || !updated) { - throw new Error(saved.message || 'Failed to save quest file.') + const error = new Error(saved.message || 'Failed to save quest file.') as Error & { + conflict?: boolean + currentRevision?: string | null + updatedPayload?: OpenDocumentPayload + } + error.conflict = Boolean(saved.conflict) + error.currentRevision = typeof saved.current_revision === 'string' ? saved.current_revision : null + error.updatedPayload = updated + throw error } treeCache.delete(ref.projectId) - return upsertFileFromDocument(fileId, ref, updated) + return { + ...upsertFileFromDocument(fileId, ref, updated), + revision: typeof saved.revision === 'string' + ? saved.revision + : typeof updated.revision === 'string' + ? updated.revision + : null, + conflict: false, + } } export async function getQuestFileBlob(fileId: string): Promise { diff --git a/src/ui/src/lib/i18n/messages/latex.ts b/src/ui/src/lib/i18n/messages/latex.ts index 71e94a42..b948e8eb 100644 --- a/src/ui/src/lib/i18n/messages/latex.ts +++ b/src/ui/src/lib/i18n/messages/latex.ts @@ -20,6 +20,7 @@ export const latexMessages: Partial>> status_autosaving: 'Autosaving', status_save_failed: 'Save failed', status_saved: 'Saved', + status_external_changed: 'External change', status_compiling: 'Compiling', status_compile_failed: 'Compile failed', button_save: 'Save', @@ -32,6 +33,12 @@ export const latexMessages: Partial>> compile_failed_title: 'Compile failed', compile_failed_fallback: 'See log for details.', save_failed_title: 'Save failed', + external_change_title: 'File changed outside this editor', + external_change_dirty_message: + 'The source file was modified by another process, such as an AI edit or terminal command. Autosave is paused to avoid overwriting those changes.', + external_change_reload: 'Reload external version', + external_change_overwrite: 'Overwrite external version', + external_change_save_blocked: 'This file changed outside the editor. Reload or explicitly overwrite the external version before saving.', error_badge: 'error', warning_badge: 'warning', warnings_title: 'Warnings', @@ -97,6 +104,7 @@ export const latexMessages: Partial>> status_autosaving: '自动保存中', status_save_failed: '保存失败', status_saved: '已保存', + status_external_changed: '外部修改', status_compiling: '编译中', status_compile_failed: '编译失败', button_save: '保存', @@ -109,6 +117,12 @@ export const latexMessages: Partial>> compile_failed_title: '编译失败', compile_failed_fallback: '请查看日志了解详情。', save_failed_title: '保存失败', + external_change_title: '文件已在编辑器外被修改', + external_change_dirty_message: + '该源码文件已被其它进程修改,例如 AI 编辑或终端命令。为避免覆盖外部修改,自动保存已暂停。', + external_change_reload: '重新载入外部版本', + external_change_overwrite: '覆盖外部版本', + external_change_save_blocked: '该文件已在编辑器外被修改。请先重新载入,或明确选择覆盖外部版本后再保存。', error_badge: '错误', warning_badge: '警告', warnings_title: '警告', diff --git a/src/ui/src/lib/plugins/latex/LatexPlugin.tsx b/src/ui/src/lib/plugins/latex/LatexPlugin.tsx index 232a07ed..2a24b428 100644 --- a/src/ui/src/lib/plugins/latex/LatexPlugin.tsx +++ b/src/ui/src/lib/plugins/latex/LatexPlugin.tsx @@ -17,7 +17,13 @@ import { import type { PluginComponentProps } from "@/lib/types/plugin"; import { cn } from "@/lib/utils"; import { client as questClient } from "@/lib/api"; -import { listFiles, getFileContent, updateFileContent } from "@/lib/api/files"; +import { + listFiles, + getFileContent, + getFileContentSnapshot, + updateFileContent, + type FileContentSnapshot, +} from "@/lib/api/files"; import { useFileTreeStore } from "@/lib/stores/file-tree"; import { ProjectSyncClient } from "@/lib/plugins/notebook/lib/project-sync"; import { useAuthStore } from "@/lib/stores/auth"; @@ -165,6 +171,14 @@ type LabelEntry = { sourceFile: string; }; +type LatexExternalConflict = { + fileId: string; + remoteContent: string; + remoteRevision?: string | null; + remoteUpdatedAt?: string | null; + reason: "poll" | "focus" | "visibility" | "diff" | "save_conflict"; +}; + type BibSnippet = { id: string; labelKey: string; @@ -196,6 +210,7 @@ const normalizeBuildErrors = ( const LATEX_COMPILER_OPTIONS: LatexCompiler[] = ["pdflatex", "xelatex", "lualatex"]; const LATEX_AUTOSAVE_DELAY_MS = 1000; +const LATEX_EXTERNAL_CHECK_INTERVAL_MS = 4000; const LATEX_AUTO_COMPILE_ON_SAVE_STORAGE_PREFIX = "ds:latex:auto-compile-on-save"; const BIB_SNIPPETS: BibSnippet[] = [ { @@ -690,6 +705,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const [saveState, setSaveState] = React.useState("idle"); const [saveTrigger, setSaveTrigger] = React.useState("manual"); const [saveError, setSaveError] = React.useState(null); + const [externalConflict, setExternalConflict] = React.useState(null); const [error, setError] = React.useState(null); const [isDirty, setIsDirty] = React.useState(false); const [dirtyVersion, setDirtyVersion] = React.useState(0); @@ -726,6 +742,10 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const saveInFlightRef = React.useRef<{ fileId: string; promise: Promise } | null>(null); const failedSaveTextRef = React.useRef(null); const lastSaveTriggerRef = React.useRef("manual"); + const savedRevisionRef = React.useRef(null); + const loadedRevisionRef = React.useRef(null); + const externalConflictRef = React.useRef(null); + const externalCheckInFlightRef = React.useRef(false); const yDocRef = React.useRef(null); const yTextRef = React.useRef(null); const syncRef = React.useRef(null); @@ -815,6 +835,102 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug return ytext ? String(ytext.toString?.() ?? "") : ""; }, []); + const setExternalConflictState = React.useCallback((conflict: LatexExternalConflict | null) => { + externalConflictRef.current = conflict; + setExternalConflict(conflict); + }, []); + + const applyFileSnapshotToEditor = React.useCallback( + (fileId: string, snapshot: Pick) => { + const content = String(snapshot.content ?? ""); + const revision = snapshot.revision ?? null; + const ydoc = yDocRef.current; + const ytext = yTextRef.current; + let origin = remoteOriginRef.current; + if (!origin) { + origin = `ds-external:${projectId || "project"}:${fileId}:${Date.now()}`; + remoteOriginRef.current = origin; + } + + applyingRemoteRef.current = true; + try { + if (ydoc && ytext) { + ydoc.transact(() => { + const length = Number(ytext.length || 0); + if (length) ytext.delete(0, length); + if (content) ytext.insert(0, content); + }, origin); + } + + const editor = editorRef.current; + const model = editor?.getModel?.(); + if (model && typeof model.getValue === "function" && model.getValue() !== content) { + model.setValue(content); + } + } finally { + applyingRemoteRef.current = false; + } + + setInitialText(content); + lastSavedRef.current = content; + loadedRevisionRef.current = revision; + savedRevisionRef.current = revision; + failedSaveTextRef.current = null; + setSaveError(null); + saveStateRef.current = "idle"; + setSaveState("idle"); + setEditorDirty(false); + setExternalConflictState(null); + if (fileId) { + updateFileMeta(fileId, { + updatedAt: snapshot.updated_at ?? undefined, + size: typeof snapshot.size === "number" ? snapshot.size : undefined, + mimeType: snapshot.mime_type ?? undefined, + }); + } + }, + [projectId, setEditorDirty, setExternalConflictState, updateFileMeta] + ); + + const checkExternalSnapshot = React.useCallback( + async (reason: LatexExternalConflict["reason"] = "poll") => { + const fileId = activeFileIdRef.current; + if (!fileId || syncState !== "ready") return; + if (saveStateRef.current === "saving") return; + if (externalCheckInFlightRef.current) return; + + externalCheckInFlightRef.current = true; + try { + const snapshot = await getFileContentSnapshot(fileId); + if (activeFileIdRef.current !== fileId) return; + const remoteRevision = snapshot.revision ?? null; + const knownRevision = savedRevisionRef.current; + const changed = remoteRevision && knownRevision + ? remoteRevision !== knownRevision + : String(snapshot.content ?? "") !== lastSavedRef.current; + if (!changed) return; + + if (isDirtyRef.current) { + setExternalConflictState({ + fileId, + remoteContent: String(snapshot.content ?? ""), + remoteRevision, + remoteUpdatedAt: snapshot.updated_at ?? null, + reason, + }); + return; + } + + applyFileSnapshotToEditor(fileId, snapshot); + } catch (e) { + console.warn("[LatexPlugin] External LaTeX refresh check failed:", e); + } finally { + externalCheckInFlightRef.current = false; + } + }, + [applyFileSnapshotToEditor, setExternalConflictState, syncState] + ); + React.useEffect(() => { const activeFileMeta = files.find((file) => file.id === activeFileId) ?? @@ -1116,6 +1232,9 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug lastSaveTriggerRef.current = "manual"; setSaveTrigger("manual"); failedSaveTextRef.current = null; + savedRevisionRef.current = null; + loadedRevisionRef.current = null; + setExternalConflictState(null); setSaveError(null); setError(null); @@ -1129,16 +1248,21 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug yTextRef.current = ytext; if (!canUseRealtimeSync) { - const seed = await getFileContent(activeFileId); + const remoteOrigin = `ds-external:${projectId || "project"}:${activeFileId}:${Date.now()}`; + remoteOriginRef.current = remoteOrigin; + const seedSnapshot = await getFileContentSnapshot(activeFileId); + const seed = seedSnapshot.content; ydoc.transact(() => { const length = ytext.length || 0; if (length) ytext.delete(0, length); if (seed) ytext.insert(0, seed); - }, "ds-local-seed"); + }, remoteOrigin); const textNow = ytext.toString(); setInitialText(textNow); lastSavedRef.current = textNow; + loadedRevisionRef.current = seedSnapshot.revision ?? null; + savedRevisionRef.current = seedSnapshot.revision ?? null; setEditorDirty(false); setSyncState("ready"); @@ -1170,12 +1294,15 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug } if (forceSeedRef.current) { - const seed = await getFileContent(activeFileId); + const seedSnapshot = await getFileContentSnapshot(activeFileId); + const seed = seedSnapshot.content; ydoc.transact(() => { const length = ytext.length || 0; if (length) ytext.delete(0, length); if (seed) ytext.insert(0, seed); }, "ds-reset"); + loadedRevisionRef.current = seedSnapshot.revision ?? null; + savedRevisionRef.current = seedSnapshot.revision ?? null; if (!effectiveReadOnly) { const resetUpdate = encodeStateAsUpdate(ydoc); await sync.pushDocUpdate(activeFileId, resetUpdate); @@ -1184,14 +1311,25 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug } if (!diff) { - const seed = await getFileContent(activeFileId); + const seedSnapshot = await getFileContentSnapshot(activeFileId); + const seed = seedSnapshot.content; ydoc.transact(() => { ytext.insert(0, seed); }, "ds-seed"); + loadedRevisionRef.current = seedSnapshot.revision ?? null; + savedRevisionRef.current = seedSnapshot.revision ?? null; if (!effectiveReadOnly) { const initUpdate = encodeStateAsUpdate(ydoc); await sync.pushDocUpdate(activeFileId, initUpdate); } + } else { + try { + const baselineSnapshot = await getFileContentSnapshot(activeFileId); + loadedRevisionRef.current = baselineSnapshot.revision ?? null; + savedRevisionRef.current = baselineSnapshot.revision ?? null; + } catch { + // Keep editing usable even when revision metadata cannot be refreshed. + } } const unsubscribeRemote = sync.onDocUpdate((msg) => { @@ -1344,7 +1482,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug cancelled = true; cleanup?.(); }; - }, [activeFileId, canUseRealtimeSync, effectiveReadOnly, projectId, resetNonce, setEditorDirty, socketAuthMode, t, user?.id, user?.username]); + }, [activeFileId, canUseRealtimeSync, effectiveReadOnly, projectId, resetNonce, setEditorDirty, setExternalConflictState, socketAuthMode, t, user?.id, user?.username]); const revealEditorRange = React.useCallback( ( @@ -1852,11 +1990,24 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug flushPendingJump(); }, [activeFileId, flushPendingJump, resetNonce, syncState]); - const save = React.useCallback(async (trigger: LatexSaveTrigger = "manual") => { + const save = React.useCallback(async ( + trigger: LatexSaveTrigger = "manual", + opts: { overwriteExternal?: boolean } = {} + ) => { if (!activeFileId) return false; if (effectiveReadOnly) return false; const ytext = yTextRef.current; if (!ytext) return false; + const externalConflictForSave = externalConflictRef.current; + const overwriteExternal = opts.overwriteExternal === true; + if (externalConflictForSave && !overwriteExternal) { + failedSaveTextRef.current = String(ytext.toString?.() ?? ""); + saveStateRef.current = "error"; + setSaveError(t("external_change_save_blocked")); + setSaveState("error"); + setEditorDirty(true); + return false; + } const activeInFlight = saveInFlightRef.current; if (activeInFlight && activeInFlight.fileId === activeFileId) { @@ -1877,7 +2028,13 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug failedSaveTextRef.current = null; setSaveError(null); setSaveState("saving"); - const res = await updateFileContent(fileId, textToSave); + const expectedRevision = overwriteExternal + ? externalConflictForSave?.remoteRevision ?? savedRevisionRef.current + : savedRevisionRef.current; + const res = await updateFileContent(fileId, textToSave, { + revision: expectedRevision, + force: overwriteExternal && !expectedRevision, + }); if (res?.updated_at) { updateFileMeta(fileId, { @@ -1893,7 +2050,11 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const currentYText = yTextRef.current; const currentText = currentYText ? String(currentYText.toString?.() ?? "") : ""; + const nextRevision = typeof res?.revision === "string" ? res.revision : null; + savedRevisionRef.current = nextRevision; + loadedRevisionRef.current = nextRevision; lastSavedRef.current = textToSave; + setExternalConflictState(null); saveStateRef.current = "idle"; setSaveState("idle"); @@ -1907,9 +2068,33 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug } catch (e) { console.error("[LatexPlugin] Save failed:", e); if (activeFileIdRef.current === fileId) { + const maybeConflict = e as Error & { + conflict?: boolean; + currentRevision?: string | null; + updatedPayload?: { + content?: string; + revision?: string; + updated_at?: string; + }; + }; + if (maybeConflict.conflict && maybeConflict.updatedPayload) { + setExternalConflictState({ + fileId, + remoteContent: String(maybeConflict.updatedPayload.content ?? ""), + remoteRevision: maybeConflict.updatedPayload.revision ?? maybeConflict.currentRevision ?? null, + remoteUpdatedAt: maybeConflict.updatedPayload.updated_at ?? null, + reason: "save_conflict", + }); + } failedSaveTextRef.current = textToSave; saveStateRef.current = "error"; - setSaveError(e instanceof Error ? e.message : t("save_request_failed")); + setSaveError( + maybeConflict.conflict + ? t("external_change_save_blocked") + : e instanceof Error + ? e.message + : t("save_request_failed") + ); setSaveState("error"); setEditorDirty(true); } @@ -1923,7 +2108,21 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug saveInFlightRef.current = { fileId, promise }; return promise; - }, [activeFileId, effectiveReadOnly, setEditorDirty, t, updateFileMeta]); + }, [activeFileId, effectiveReadOnly, setEditorDirty, setExternalConflictState, t, updateFileMeta]); + + const reloadExternalVersion = React.useCallback(() => { + const conflict = externalConflictRef.current; + if (!conflict) return; + applyFileSnapshotToEditor(conflict.fileId, { + content: conflict.remoteContent, + revision: conflict.remoteRevision ?? null, + updated_at: conflict.remoteUpdatedAt ?? null, + }); + }, [applyFileSnapshotToEditor]); + + const overwriteExternalVersion = React.useCallback(() => { + void save("manual", { overwriteExternal: true }); + }, [save]); const switchToLatexFile = React.useCallback( async ( @@ -2013,6 +2212,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug if (effectiveReadOnly) return; if (syncState !== "ready") return; if (!isDirty) return; + if (externalConflictRef.current) return; if (saveState === "saving") return; const currentText = getCurrentText(); @@ -2023,6 +2223,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const timer = window.setTimeout(() => { if (!activeFileIdRef.current) return; if (!isDirtyRef.current) return; + if (externalConflictRef.current) return; if (saveStateRef.current === "saving") return; const latestText = getCurrentText(); if (saveStateRef.current === "error" && failedSaveTextRef.current === latestText) { @@ -2036,6 +2237,54 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug }; }, [activeFileId, dirtyVersion, effectiveReadOnly, getCurrentText, isDirty, save, saveState, syncState]); + React.useEffect(() => { + if (!activeFileId) return; + if (syncState !== "ready") return; + const interval = window.setInterval(() => { + void checkExternalSnapshot("poll"); + }, LATEX_EXTERNAL_CHECK_INTERVAL_MS); + + const handleFocus = () => { + void checkExternalSnapshot("focus"); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + void checkExternalSnapshot("visibility"); + } + }; + + window.addEventListener("focus", handleFocus); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.clearInterval(interval); + window.removeEventListener("focus", handleFocus); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [activeFileId, checkExternalSnapshot, syncState]); + + React.useEffect(() => { + if (!activeFileId) return; + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ + fileId?: string; + filePath?: string; + projectId?: string; + }>).detail; + if (!detail) return; + if (detail.projectId && projectId && detail.projectId !== projectId) return; + const activeMeta = files.find((file) => file.id === activeFileId) ?? null; + const filePath = String(detail.filePath || "").replace(/^\/+/, ""); + const matches = + detail.fileId === activeFileId || + (filePath && resolveLatexFileId(files, filePath) === activeFileId) || + (filePath && activeMeta && [activeMeta.path, activeMeta.relativePath].filter(Boolean).includes(filePath)); + if (!matches) return; + void checkExternalSnapshot("diff"); + }; + window.addEventListener("ds:file:diff", handler as EventListener); + return () => window.removeEventListener("ds:file:diff", handler as EventListener); + }, [activeFileId, checkExternalSnapshot, files, projectId]); + React.useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { if (!isDirtyRef.current) return; @@ -2336,6 +2585,13 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug "border-[#8FA3B8]/30 bg-[#8FA3B8]/10 text-[#52667a] dark:bg-[#8FA3B8]/12 dark:text-[#c8d4df]", }; } + if (externalConflict) { + return { + label: t("status_external_changed"), + className: + "border-amber-400/40 bg-amber-50/90 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200", + }; + } if (saveState === "saving") { const autosaveLike = saveTrigger === "auto" || saveTrigger === "lifecycle"; return { @@ -2370,7 +2626,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug className: "border-[#9AA79A]/30 bg-[#9AA79A]/12 text-[#5f6b5f] dark:bg-[#9AA79A]/12 dark:text-[#dbe4db]", }; - }, [buildStatus, effectiveReadOnly, isDirty, saveError, saveState, saveTrigger, t]); + }, [buildStatus, effectiveReadOnly, externalConflict, isDirty, saveError, saveState, saveTrigger, t]); const buildFocusedIssue = React.useCallback( (issue: LatexBuildError) => { @@ -3095,6 +3351,38 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug )}
+ {externalConflict ? ( +
+
+
+ +
+
{t("external_change_title")}
+
+ {t("external_change_dirty_message")} +
+
+
+
+ + +
+
+
+ ) : null} + {saveError ? (
diff --git a/tests/test_api_contract_surface.py b/tests/test_api_contract_surface.py index c3336888..d893956f 100644 --- a/tests/test_api_contract_surface.py +++ b/tests/test_api_contract_surface.py @@ -331,6 +331,8 @@ def test_local_workspace_does_not_route_markdown_or_commands_through_dead_notebo workspace_source = _read("src/ui/src/components/workspace/WorkspaceLayout.tsx") open_file_source = _read("src/ui/src/hooks/useOpenFile.ts") open_queue_source = _read("src/ui/src/lib/latex/open-queue.ts") + files_api_source = _read("src/ui/src/lib/api/files.ts") + quest_files_source = _read("src/ui/src/lib/api/quest-files.ts") latex_source = _read("src/ui/src/lib/api/latex.ts") latex_plugin_source = _read("src/ui/src/lib/plugins/latex/LatexPlugin.tsx") tabs_source = _read("src/ui/src/lib/stores/tabs.ts") @@ -371,6 +373,15 @@ def test_local_workspace_does_not_route_markdown_or_commands_through_dead_notebo assert "openFileTabs" not in latex_plugin_source assert "handleCloseFileTab" not in latex_plugin_source assert "synctex_hint" not in latex_plugin_source + assert "getFileContentSnapshot" in files_api_source + assert "getQuestFileContentSnapshot" in quest_files_source + assert "updateQuestFileContent(fileId, content, options)" in files_api_source + assert "const existing = await questClient.openDocument" not in quest_files_source + assert "savedRevisionRef.current" in latex_plugin_source + assert "externalConflictRef.current" in latex_plugin_source + assert "external_change_save_blocked" in latex_plugin_source + assert "LATEX_EXTERNAL_CHECK_INTERVAL_MS" in latex_plugin_source + assert 'window.addEventListener("ds:file:diff"' in latex_plugin_source assert '"text/markdown": BUILTIN_PLUGINS.NOTEBOOK' in plugin_types_source assert '".md": BUILTIN_PLUGINS.NOTEBOOK' in plugin_types_source assert 'extensions: [".md", ".markdown"],\n mimeTypes: ["text/markdown", "text/x-markdown"],\n priority: 95,' in plugin_init_source diff --git a/tests/test_memory_and_artifact.py b/tests/test_memory_and_artifact.py index 90871f26..6630d2a1 100644 --- a/tests/test_memory_and_artifact.py +++ b/tests/test_memory_and_artifact.py @@ -4419,6 +4419,34 @@ def flatten(nodes: list[dict]) -> list[dict]: assert "Updated from explorer." in reopened["content"] +def test_save_document_rejects_stale_revision_after_external_change(temp_home: Path) -> None: + ensure_home_layout(temp_home) + ConfigManager(temp_home).ensure_files() + quest_service = QuestService(temp_home, skill_installer=SkillInstaller(repo_root(), temp_home)) + quest = quest_service.create("document revision conflict quest") + quest_root = Path(quest["quest_root"]) + + note_path = quest_root / "paper" / "latex" / "main.tex" + note_path.parent.mkdir(parents=True, exist_ok=True) + note_path.write_text("original text\n", encoding="utf-8") + + opened = quest_service.open_document(quest["quest_id"], "path::paper/latex/main.tex") + note_path.write_text("external ai edit\n", encoding="utf-8") + + stale_save = quest_service.save_document( + quest["quest_id"], + "path::paper/latex/main.tex", + "stale editor buffer\n", + previous_revision=opened["revision"], + ) + + assert stale_save["ok"] is False + assert stale_save["conflict"] is True + assert stale_save["current_revision"] != opened["revision"] + assert "external ai edit" in stale_save["updated_payload"]["content"] + assert note_path.read_text(encoding="utf-8") == "external ai edit\n" + + def test_explorer_search_finds_paths_and_normalizes_legacy_glob_wrappers(temp_home: Path) -> None: ensure_home_layout(temp_home) ConfigManager(temp_home).ensure_files() From e184386b8af7d6e9f15d15e06e550ec947764be2 Mon Sep 17 00:00:00 2001 From: SmallSpider0 <568442079@qq.com> Date: Thu, 21 May 2026 17:46:46 +0800 Subject: [PATCH 3/8] feat(latex): add git-backed version history --- docs/en/12_GUIDED_WORKFLOW_TOUR.md | 2 + docs/zh/12_GUIDED_WORKFLOW_TOUR.md | 2 + src/deepscientist/daemon/api/handlers.py | 53 ++ src/deepscientist/daemon/api/router.py | 7 + src/deepscientist/latex_runtime.py | 661 ++++++++++++++++++- src/deepscientist/quest/layout.py | 2 + src/ui/src/lib/api/latex.ts | 186 ++++++ src/ui/src/lib/i18n/messages/latex.ts | 68 ++ src/ui/src/lib/plugins/latex/LatexPlugin.tsx | 444 ++++++++++++- tests/test_api_contract_surface.py | 14 + tests/test_latex_runtime.py | 83 +++ 11 files changed, 1513 insertions(+), 9 deletions(-) diff --git a/docs/en/12_GUIDED_WORKFLOW_TOUR.md b/docs/en/12_GUIDED_WORKFLOW_TOUR.md index 25920328..3179dba5 100644 --- a/docs/en/12_GUIDED_WORKFLOW_TOUR.md +++ b/docs/en/12_GUIDED_WORKFLOW_TOUR.md @@ -347,6 +347,8 @@ The editor auto-saves source edits shortly after you type. Background autosaves If another process changes the active LaTeX source file while it is open, such as an AI edit or terminal command, the editor refreshes automatically when the local buffer has no unsaved edits. If the local buffer is dirty, autosave pauses and the editor asks you to either reload the external version or explicitly overwrite it, so an ordinary save cannot silently replace external changes. +The `History` button in the LaTeX toolbar opens a Git-backed version panel scoped to the current LaTeX folder. You can create named versions, inspect changed source files, compare a version with the current workspace, and restore either the active file or the whole LaTeX project. Compile actions also record the source commit used for the build, and AI/file-diff edits are captured as automatic LaTeX versions when possible. + After a successful compile, the PDF preview uses SyncTeX metadata when available. Double-click a rendered PDF word to jump back to the matching LaTeX source file and select the corresponding source token; the editor uses the PDF word box plus multiple SyncTeX samples to avoid broad line-level selections. Older builds without SyncTeX data need to be recompiled before PDF-to-source jumps are available. ### 6.5 Canvas diff --git a/docs/zh/12_GUIDED_WORKFLOW_TOUR.md b/docs/zh/12_GUIDED_WORKFLOW_TOUR.md index 1a3b8226..f51bb071 100644 --- a/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +++ b/docs/zh/12_GUIDED_WORKFLOW_TOUR.md @@ -345,6 +345,8 @@ Explorer 是 quest 的文件视角。 如果其它进程在当前 LaTeX 源文件打开期间修改了它,例如 AI 编辑或终端命令,且本地缓冲区没有未保存内容,编辑器会自动刷新到外部版本。若本地缓冲区已被修改,自动保存会暂停,并提示你选择重新载入外部版本或明确覆盖外部版本,避免普通保存静默覆盖外部修改。 +LaTeX 工具栏中的 `历史版本` 按钮会打开限定在当前 LaTeX 文件夹内的 Git 版本面板。你可以创建命名版本、查看变更源码文件、将某个版本与当前工作区比较,并恢复当前文件或整个 LaTeX 项目。编译操作也会记录该次构建使用的源码 commit;AI / 文件 diff 修改在可行时会自动捕获为 LaTeX 版本。 + 成功编译后,PDF 预览会在可用时使用 SyncTeX 元数据。双击 PDF 中渲染出的某个单词时,编辑器会结合 PDF 单词框和多点 SyncTeX 采样跳转到匹配的 LaTeX 源文件,并选中对应的源码 token,避免退化成大范围行级选中。没有 SyncTeX 数据的旧构建需要重新编译后才能使用 PDF 到源码跳转。 ### 6.5 Canvas diff --git a/src/deepscientist/daemon/api/handlers.py b/src/deepscientist/daemon/api/handlers.py index 1c752dc3..b88a4acf 100644 --- a/src/deepscientist/daemon/api/handlers.py +++ b/src/deepscientist/daemon/api/handlers.py @@ -2124,6 +2124,59 @@ def latex_compile(self, project_id: str, folder_id: str, body: dict) -> dict: def latex_manifest(self, project_id: str, folder_id: str) -> dict: return self.app.latex_service.manifest(project_id, folder_id) + def latex_versions(self, project_id: str, folder_id: str, path: str) -> dict: + query = self.parse_query(path) + limit_raw = ((query.get("limit") or ["30"])[0] or "30").strip() + try: + limit = int(limit_raw) + except ValueError: + limit = 30 + return self.app.latex_service.list_versions(project_id, folder_id, limit=limit) + + def latex_version_create(self, project_id: str, folder_id: str, body: dict) -> dict: + return self.app.latex_service.create_version( + project_id, + folder_id, + label=body.get("label"), + description=body.get("description"), + source=body.get("source"), + author=body.get("author"), + build_id=body.get("build_id"), + allow_empty=body.get("allow_empty", True) is not False, + ) + + def latex_versions_compare(self, project_id: str, folder_id: str, path: str) -> dict: + query = self.parse_query(path) + base = ((query.get("base") or [""])[0] or "").strip() + head = ((query.get("head") or [""])[0] or "").strip() + if not base or not head: + return {"ok": False, "message": "`base` and `head` are required."} + return self.app.latex_service.compare_versions(project_id, folder_id, base=base, head=head) + + def latex_version(self, project_id: str, folder_id: str, version_id: str) -> dict: + return self.app.latex_service.get_version(project_id, folder_id, version_id) + + def latex_version_files(self, project_id: str, folder_id: str, version_id: str) -> dict: + return self.app.latex_service.version_files(project_id, folder_id, version_id) + + def latex_version_file(self, project_id: str, folder_id: str, version_id: str, path: str) -> dict: + query = self.parse_query(path) + file_path = ((query.get("path") or [""])[0] or "").strip() + if not file_path: + return {"ok": False, "message": "`path` is required."} + return self.app.latex_service.version_file(project_id, folder_id, version_id, file_path) + + def latex_version_restore(self, project_id: str, folder_id: str, version_id: str, body: dict) -> dict: + return self.app.latex_service.restore_version( + project_id, + folder_id, + version_id, + mode=body.get("mode"), + path=body.get("path"), + expected_head=body.get("expected_head"), + conflict_policy=body.get("conflict_policy"), + ) + def latex_builds(self, project_id: str, folder_id: str, path: str) -> list[dict]: query = self.parse_query(path) limit_raw = ((query.get("limit") or ["10"])[0] or "10").strip() diff --git a/src/deepscientist/daemon/api/router.py b/src/deepscientist/daemon/api/router.py index 309b32e8..64fe6e68 100644 --- a/src/deepscientist/daemon/api/router.py +++ b/src/deepscientist/daemon/api/router.py @@ -176,6 +176,13 @@ ("POST", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/init$"), "latex_init"), ("POST", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/compile$"), "latex_compile"), ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/manifest$"), "latex_manifest"), + ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions$"), "latex_versions"), + ("POST", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions$"), "latex_version_create"), + ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions/compare$"), "latex_versions_compare"), + ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions/(?P[^/]+)$"), "latex_version"), + ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions/(?P[^/]+)/files$"), "latex_version_files"), + ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions/(?P[^/]+)/file$"), "latex_version_file"), + ("POST", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions/(?P[^/]+)/restore$"), "latex_version_restore"), ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/builds$"), "latex_builds"), ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/builds/(?P[^/]+)$"), "latex_build"), ("POST", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/builds/(?P[^/]+)/synctex/edit$"), "latex_synctex_edit"), diff --git a/src/deepscientist/latex_runtime.py b/src/deepscientist/latex_runtime.py index b52453e2..78d767f2 100644 --- a/src/deepscientist/latex_runtime.py +++ b/src/deepscientist/latex_runtime.py @@ -13,8 +13,18 @@ from typing import Any from urllib.parse import quote, unquote +from .gitops import compare_refs, head_commit from .runtime_tools import RuntimeToolService -from .shared import ensure_dir, generate_id, resolve_within, utc_now, utf8_text_subprocess_kwargs, write_json +from .shared import ( + ensure_dir, + generate_id, + resolve_within, + run_command, + run_command_bytes, + utc_now, + utf8_text_subprocess_kwargs, + write_json, +) _QUEST_DIR_PREFIX = "quest-dir::" _QUEST_FILE_PREFIX = "quest-file::" @@ -59,6 +69,17 @@ _LATEX_MANIFEST_SUFFIXES = _LATEX_EDITABLE_SUFFIXES | _LATEX_RESOURCE_SUFFIXES _LATEX_INPUT_RE = re.compile(r"\\(?:input|include)\{([^}]+)\}") _LATEX_BIB_RE = re.compile(r"\\(?:bibliography|addbibresource)\{([^}]+)\}") +_LATEX_VERSION_TRAILER_PREFIX = "DeepScientist-Latex-" +_LATEX_VERSION_TRAILERS = { + "Version", + "Folder", + "Main", + "Source", + "Author", + "Build", + "Label", + "Description", +} def _encode_relative(value: str) -> str: @@ -1123,9 +1144,205 @@ def _folder_build_root(self, project_id: str, folder_relative: str) -> Path: quest_root = self._quest_root(project_id) return ensure_dir(quest_root / ".ds" / "latex_builds" / _sanitize_folder_key(folder_relative)) + def _folder_version_root(self, project_id: str, folder_relative: str) -> Path: + quest_root = self._quest_root(project_id) + return ensure_dir(quest_root / ".ds" / "latex_versions" / _sanitize_folder_key(folder_relative)) + + def _folder_version_index_path(self, project_id: str, folder_relative: str) -> Path: + return self._folder_version_root(project_id, folder_relative) / "index.json" + def _build_record_path(self, project_id: str, folder_relative: str, build_id: str) -> Path: return self._folder_build_root(project_id, folder_relative) / "builds" / build_id / "build.json" + @staticmethod + def _git_stdout(repo: Path, args: list[str]) -> str: + result = run_command(["git", *args], cwd=repo, check=False) + if result.returncode != 0: + raise RuntimeError((result.stderr or result.stdout or "Git command failed.").strip()) + return result.stdout + + @staticmethod + def _git_bytes(repo: Path, args: list[str]) -> bytes: + result = run_command_bytes(["git", *args], cwd=repo, check=False) + if result.returncode != 0: + message = "" + try: + message = (result.stderr or result.stdout or b"").decode("utf-8", errors="replace").strip() + except Exception: + message = "Git command failed." + raise RuntimeError(message or "Git command failed.") + return result.stdout + + @staticmethod + def _clean_version_text(value: Any, *, fallback: str = "", max_length: int = 500) -> str: + text = str(value or "").strip() + if not text: + text = fallback + text = re.sub(r"[\r\n]+", " ", text).strip() + if len(text) > max_length: + text = text[: max_length - 1].rstrip() + "…" + return text + + @staticmethod + def _parse_latex_version_trailers(message: str) -> dict[str, str]: + trailers: dict[str, str] = {} + for raw_line in str(message or "").splitlines(): + line = raw_line.strip() + if not line.startswith(_LATEX_VERSION_TRAILER_PREFIX): + continue + key, _, value = line.partition(":") + trailer_key = key[len(_LATEX_VERSION_TRAILER_PREFIX) :].strip() + if trailer_key in _LATEX_VERSION_TRAILERS: + trailers[trailer_key.lower()] = value.strip() + return trailers + + def _latex_version_commit_message( + self, + *, + version_id: str, + folder_relative: str, + main_file_relative: str | None, + label: str, + description: str | None, + source: str, + author: str | None, + build_id: str | None, + ) -> str: + title = self._clean_version_text(label, fallback="LaTeX version", max_length=120) + body_lines = [f"latex: {title}", ""] + if description: + body_lines.extend([self._clean_version_text(description, max_length=500), ""]) + trailers = { + "Version": version_id, + "Folder": folder_relative, + "Main": main_file_relative or "", + "Source": source, + "Author": author or "user", + "Build": build_id or "", + "Label": title, + "Description": self._clean_version_text(description or "", max_length=500), + } + for key, value in trailers.items(): + body_lines.append(f"{_LATEX_VERSION_TRAILER_PREFIX}{key}: {value}") + return "\n".join(body_lines).rstrip() + "\n" + + def _folder_has_git_changes(self, repo: Path, folder_relative: str) -> bool: + result = run_command(["git", "status", "--porcelain", "--", folder_relative], cwd=repo, check=False) + return bool((result.stdout or "").strip()) + + def _create_empty_latex_version_commit(self, repo: Path, message: str) -> tuple[bool, str | None, str | None]: + head = head_commit(repo) + if not head: + return False, None, "Cannot create an empty LaTeX version before the quest repository has an initial commit." + tree = self._git_stdout(repo, ["rev-parse", f"{head}^{{tree}}"]).strip() + result = run_command(["git", "commit-tree", tree, "-p", head, "-m", message], cwd=repo, check=False) + if result.returncode != 0: + return False, head, (result.stderr or result.stdout or "Failed to create LaTeX version.").strip() + commit = (result.stdout or "").strip() + if not commit: + return False, head, "Git did not return a commit id for the LaTeX version." + update = run_command(["git", "update-ref", "HEAD", commit], cwd=repo, check=False) + if update.returncode != 0: + return False, head, (update.stderr or update.stdout or "Failed to update HEAD.").strip() + return True, commit, None + + def _latex_commit_stats(self, repo: Path, commit: str, folder_relative: str) -> dict[str, Any]: + try: + raw = self._git_stdout( + repo, + ["show", "--find-renames", "--numstat", "--format=", commit, "--", folder_relative], + ) + except Exception: + raw = "" + changed_paths: list[str] = [] + added_total = 0 + removed_total = 0 + for line in raw.splitlines(): + parts = line.split("\t") + if len(parts) < 3: + continue + added_raw, removed_raw, file_path = parts[0], parts[1], parts[-1] + file_path = file_path.strip() + if not file_path: + continue + changed_paths.append(file_path) + if added_raw.isdigit(): + added_total += int(added_raw) + if removed_raw.isdigit(): + removed_total += int(removed_raw) + return { + "changed_paths": changed_paths, + "file_count": len(changed_paths), + "added": added_total, + "removed": removed_total, + } + + def _version_summary_from_commit( + self, + repo: Path, + *, + project_id: str, + folder_id: str, + folder_relative: str, + commit: str, + ) -> dict[str, Any] | None: + try: + raw = self._git_stdout(repo, ["show", "-s", "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1f%B", commit]) + except Exception: + return None + parts = raw.split("\x1f", 5) + if len(parts) < 6: + return None + sha, short_sha, author_name, authored_at, subject, message = [part.strip() for part in parts] + trailers = self._parse_latex_version_trailers(message) + version_folder = trailers.get("folder") + if version_folder != folder_relative: + return None + version_id = trailers.get("version") or sha + label = trailers.get("label") or subject.removeprefix("latex:").strip() or short_sha + description = trailers.get("description") or None + stats = self._latex_commit_stats(repo, sha, folder_relative) + parent_raw = run_command(["git", "rev-list", "--parents", "-n", "1", sha], cwd=repo, check=False) + parent_parts = (parent_raw.stdout or "").strip().split() + parents = parent_parts[1:] if len(parent_parts) > 1 else [] + return { + "version_id": version_id, + "commit": sha, + "short_commit": short_sha or sha[:7], + "parents": parents, + "compare_base": parents[0] if parents else None, + "folder_id": folder_id, + "folder_path": folder_relative, + "main_file_path": trailers.get("main") or None, + "label": label, + "description": description, + "source": trailers.get("source") or "manual", + "author": trailers.get("author") or author_name or None, + "created_at": authored_at or utc_now(), + "build_id": trailers.get("build") or None, + **stats, + } + + def _remember_latex_version(self, project_id: str, folder_relative: str, summary: dict[str, Any]) -> None: + path = self._folder_version_index_path(project_id, folder_relative) + try: + existing = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + except (OSError, json.JSONDecodeError): + existing = {} + versions = existing.get("versions") if isinstance(existing.get("versions"), list) else [] + version_id = str(summary.get("version_id") or "") + versions = [item for item in versions if not (isinstance(item, dict) and item.get("version_id") == version_id)] + versions.insert(0, summary) + write_json( + path, + { + "schema_version": 1, + "folder_path": folder_relative, + "updated_at": utc_now(), + "versions": versions[:200], + }, + ) + def _list_build_records(self, project_id: str, folder_relative: str) -> list[dict[str, Any]]: builds_root = self._folder_build_root(project_id, folder_relative) / "builds" if not builds_root.exists(): @@ -1316,6 +1533,421 @@ def manifest(self, project_id: str, folder_id: str) -> dict[str, Any]: "files": files, } + def create_version( + self, + project_id: str, + folder_id: str, + *, + label: str | None = None, + description: str | None = None, + source: str | None = None, + author: str | None = None, + build_id: str | None = None, + allow_empty: bool = True, + ) -> dict[str, Any]: + folder_path, folder_relative = self._resolve_folder_path(project_id, folder_id) + _main_tex_path, main_tex_relative = self._resolve_main_tex(project_id, folder_path, folder_relative, None) + repo = self._workspace_root(project_id) + version_id = generate_id("latex-version") + resolved_source = self._clean_version_text(source, fallback="manual", max_length=40).lower().replace(" ", "_") + if resolved_source not in {"manual", "auto", "compile", "ai", "restore"}: + resolved_source = "manual" + default_label = { + "manual": "Manual version", + "auto": "Auto version", + "compile": "Compile source", + "ai": "AI edit", + "restore": "Restore version", + }.get(resolved_source, "LaTeX version") + resolved_label = self._clean_version_text(label, fallback=default_label, max_length=120) + resolved_description = self._clean_version_text(description, max_length=500) if description else None + message = self._latex_version_commit_message( + version_id=version_id, + folder_relative=folder_relative, + main_file_relative=main_tex_relative, + label=resolved_label, + description=resolved_description, + source=resolved_source, + author=author, + build_id=build_id, + ) + + has_changes = self._folder_has_git_changes(repo, folder_relative) + previous_head = head_commit(repo) + if not has_changes and not allow_empty: + return { + "ok": False, + "created": False, + "message": "No LaTeX source changes to version.", + "version_id": None, + "commit": previous_head, + "head": previous_head, + "folder_id": folder_id, + "folder_path": folder_relative, + } + + if has_changes: + run_command(["git", "add", "-A", "--", folder_relative], cwd=repo, check=False) + command = ["git", "commit"] + if allow_empty: + command.append("--allow-empty") + command.extend(["-m", message, "--", folder_relative]) + result = run_command(command, cwd=repo, check=False) + if result.returncode != 0: + return { + "ok": False, + "created": False, + "message": (result.stderr or result.stdout or "Failed to create LaTeX version.").strip(), + "version_id": version_id, + "commit": previous_head, + "head": previous_head, + "folder_id": folder_id, + "folder_path": folder_relative, + } + commit = head_commit(repo) + else: + ok, commit, error_message = self._create_empty_latex_version_commit(repo, message) + if not ok or not commit: + return { + "ok": False, + "created": False, + "message": error_message or "Failed to create LaTeX version.", + "version_id": version_id, + "commit": previous_head, + "head": previous_head, + "folder_id": folder_id, + "folder_path": folder_relative, + } + + summary = self._version_summary_from_commit( + repo, + project_id=project_id, + folder_id=folder_id, + folder_relative=folder_relative, + commit=commit or "", + ) + if not summary: + summary = { + "version_id": version_id, + "commit": commit, + "short_commit": str(commit or "")[:7], + "folder_id": folder_id, + "folder_path": folder_relative, + "main_file_path": main_tex_relative, + "label": resolved_label, + "description": resolved_description, + "source": resolved_source, + "author": author or "user", + "created_at": utc_now(), + "build_id": build_id, + "changed_paths": [], + "file_count": 0, + "added": 0, + "removed": 0, + } + self._remember_latex_version(project_id, folder_relative, summary) + return { + "ok": True, + "created": True, + "message": "LaTeX version created.", + "head": commit, + "previous_head": previous_head, + "version": summary, + **summary, + } + + def list_versions(self, project_id: str, folder_id: str, limit: int = 30) -> dict[str, Any]: + _folder_path, folder_relative = self._resolve_folder_path(project_id, folder_id) + repo = self._workspace_root(project_id) + resolved_limit = max(1, min(int(limit or 30), 100)) + scan_limit = max(100, resolved_limit * 8) + try: + raw = self._git_stdout( + repo, + ["log", f"-n{scan_limit}", "--format=%H%x1e"], + ) + except Exception as exc: + return { + "ok": False, + "message": str(exc), + "folder_id": folder_id, + "folder_path": folder_relative, + "head": head_commit(repo), + "versions": [], + } + + versions: list[dict[str, Any]] = [] + seen: set[str] = set() + for record in raw.split("\x1e"): + commit = record.strip() + if not commit or commit in seen: + continue + seen.add(commit) + summary = self._version_summary_from_commit( + repo, + project_id=project_id, + folder_id=folder_id, + folder_relative=folder_relative, + commit=commit, + ) + if not summary: + continue + versions.append(summary) + if len(versions) >= resolved_limit: + break + + return { + "ok": True, + "folder_id": folder_id, + "folder_path": folder_relative, + "head": head_commit(repo), + "versions": versions, + "limit": resolved_limit, + } + + def _resolve_version_commit(self, project_id: str, folder_id: str, version_id: str) -> tuple[str, dict[str, Any] | None, str]: + _folder_path, folder_relative = self._resolve_folder_path(project_id, folder_id) + repo = self._workspace_root(project_id) + raw = str(version_id or "").strip() + if not raw: + raise ValueError("`version_id` is required.") + versions = self.list_versions(project_id, folder_id, limit=100).get("versions") or [] + for item in versions: + if not isinstance(item, dict): + continue + if raw in {str(item.get("version_id") or ""), str(item.get("commit") or ""), str(item.get("short_commit") or "")}: + return str(item.get("commit") or ""), item, folder_relative + result = run_command(["git", "rev-parse", "--verify", raw], cwd=repo, check=False) + if result.returncode == 0: + commit = (result.stdout or "").strip() + summary = self._version_summary_from_commit( + repo, + project_id=project_id, + folder_id=folder_id, + folder_relative=folder_relative, + commit=commit, + ) + return commit, summary, folder_relative + raise FileNotFoundError(f"Unknown LaTeX version `{version_id}`.") + + def get_version(self, project_id: str, folder_id: str, version_id: str) -> dict[str, Any]: + commit, summary, folder_relative = self._resolve_version_commit(project_id, folder_id, version_id) + return { + "ok": True, + "folder_id": folder_id, + "folder_path": folder_relative, + "version": summary, + **(summary or {"commit": commit}), + } + + def version_files(self, project_id: str, folder_id: str, version_id: str) -> dict[str, Any]: + commit, summary, folder_relative = self._resolve_version_commit(project_id, folder_id, version_id) + repo = self._workspace_root(project_id) + raw = self._git_stdout(repo, ["ls-tree", "-r", "--name-only", commit, "--", folder_relative]) + files: list[dict[str, Any]] = [] + folder_path, _folder_relative = self._resolve_folder_path(project_id, folder_id) + main_relative = str((summary or {}).get("main_file_path") or "") + for relative in [line.strip() for line in raw.splitlines() if line.strip()]: + path = Path(relative) + if path.name.startswith("."): + continue + if path.suffix.lower() not in _LATEX_MANIFEST_SUFFIXES: + continue + suffix = path.suffix.lower() + if relative == main_relative: + role = "main" + elif suffix == ".tex": + role = "tex" + elif suffix == ".bib": + role = "bib" + elif suffix in {".cls", ".sty", ".bst", ".bbx", ".cbx"}: + role = "style" + elif suffix in _LATEX_RESOURCE_SUFFIXES: + role = "resource" + else: + role = "other" + try: + rel_to_folder = path.relative_to(Path(folder_relative)).as_posix() + except Exception: + rel_to_folder = path.name + files.append( + { + "id": _encode_quest_file_id(project_id, relative), + "name": path.name, + "path": relative, + "relative_path": rel_to_folder, + "role": role, + "editable": role in {"main", "tex", "bib", "style"}, + "document_id": f"git::{commit}::{relative}", + } + ) + return { + "ok": True, + "folder_id": folder_id, + "folder_path": folder_relative, + "version": summary, + "commit": commit, + "files": files, + "folder_exists": folder_path.exists(), + } + + def version_file(self, project_id: str, folder_id: str, version_id: str, path: str) -> dict[str, Any]: + commit, summary, folder_relative = self._resolve_version_commit(project_id, folder_id, version_id) + relative = str(path or "").strip().lstrip("/") + if not relative: + raise ValueError("`path` is required.") + if not (relative == folder_relative or relative.startswith(f"{folder_relative.rstrip('/')}/")): + raise ValueError("`path` must belong to the LaTeX folder.") + content = self._git_bytes(self._workspace_root(project_id), ["show", f"{commit}:{relative}"]) + try: + text = content.decode("utf-8") + encoding = "utf-8" + except UnicodeDecodeError: + text = "" + encoding = None + return { + "ok": True, + "folder_id": folder_id, + "folder_path": folder_relative, + "version": summary, + "commit": commit, + "path": relative, + "content": text, + "encoding": encoding, + "size_bytes": len(content), + } + + def compare_versions(self, project_id: str, folder_id: str, *, base: str, head: str) -> dict[str, Any]: + base_commit, base_summary, folder_relative = self._resolve_version_commit(project_id, folder_id, base) + head_commit_value, head_summary, _folder_relative = self._resolve_version_commit(project_id, folder_id, head) + repo = self._workspace_root(project_id) + payload = compare_refs(repo, base=base_commit, head=head_commit_value) + files = [ + item + for item in payload.get("files", []) + if str(item.get("path") or "") == folder_relative + or str(item.get("path") or "").startswith(f"{folder_relative.rstrip('/')}/") + ] + return { + **payload, + "folder_id": folder_id, + "folder_path": folder_relative, + "base": base_commit, + "head": head_commit_value, + "base_version": base_summary, + "head_version": head_summary, + "files": files, + "file_count": len(files), + } + + def _current_manifest_paths(self, folder_path: Path) -> set[str]: + paths: set[str] = set() + for path in sorted(folder_path.rglob("*")): + if not path.is_file(): + continue + try: + relative = path.relative_to(folder_path).as_posix() + except ValueError: + continue + if any(part.startswith(".git") for part in Path(relative).parts): + continue + if self._is_manifest_file(path): + paths.add(relative) + return paths + + def restore_version( + self, + project_id: str, + folder_id: str, + version_id: str, + *, + mode: str | None = None, + path: str | None = None, + expected_head: str | None = None, + conflict_policy: str | None = None, + ) -> dict[str, Any]: + commit, summary, folder_relative = self._resolve_version_commit(project_id, folder_id, version_id) + repo = self._workspace_root(project_id) + current_head = head_commit(repo) + if expected_head and current_head and str(expected_head).strip() != current_head: + return { + "ok": False, + "conflict": True, + "message": "The quest HEAD changed. Refresh the LaTeX history before restoring.", + "expected_head": expected_head, + "current_head": current_head, + } + force = str(conflict_policy or "").strip().lower() == "force" + if self._folder_has_git_changes(repo, folder_relative) and not force: + return { + "ok": False, + "conflict": True, + "message": "The LaTeX folder has unversioned changes. Create a version or force restore before restoring.", + "current_head": current_head, + } + + folder_path, _folder_relative = self._resolve_folder_path(project_id, folder_id) + restore_mode = str(mode or "folder").strip().lower() + restored_paths: list[str] = [] + + if restore_mode == "file": + relative = str(path or "").strip().lstrip("/") + if not relative: + raise ValueError("`path` is required when restoring one file.") + if not (relative == folder_relative or relative.startswith(f"{folder_relative.rstrip('/')}/")): + raise ValueError("`path` must belong to the LaTeX folder.") + target_path = resolve_within(repo, relative) + blob = self._git_bytes(repo, ["show", f"{commit}:{relative}"]) + ensure_dir(target_path.parent) + target_path.write_bytes(blob) + restored_paths.append(relative) + else: + raw_paths = self._git_stdout(repo, ["ls-tree", "-r", "--name-only", commit, "--", folder_relative]) + version_paths = [ + line.strip() + for line in raw_paths.splitlines() + if line.strip() and Path(line.strip()).suffix.lower() in _LATEX_MANIFEST_SUFFIXES + ] + version_rel_to_folder = { + Path(relative).relative_to(Path(folder_relative)).as_posix() + for relative in version_paths + if relative == folder_relative or relative.startswith(f"{folder_relative.rstrip('/')}/") + } + for current_relative in self._current_manifest_paths(folder_path): + if current_relative not in version_rel_to_folder: + try: + (folder_path / current_relative).unlink() + except FileNotFoundError: + pass + for relative in version_paths: + if not (relative == folder_relative or relative.startswith(f"{folder_relative.rstrip('/')}/")): + continue + target_path = resolve_within(repo, relative) + blob = self._git_bytes(repo, ["show", f"{commit}:{relative}"]) + ensure_dir(target_path.parent) + target_path.write_bytes(blob) + restored_paths.append(relative) + + label = f"Restore {str((summary or {}).get('label') or version_id)}" + created = self.create_version( + project_id, + folder_id, + label=label, + description=f"Restored from LaTeX version {version_id} ({commit[:12]}).", + source="restore", + author="user", + allow_empty=False, + ) + return { + "ok": bool(created.get("ok")), + "message": created.get("message") or "LaTeX version restored.", + "restored_from": summary or {"commit": commit, "version_id": version_id}, + "restored_paths": restored_paths, + "restore_version": created.get("version"), + "head": created.get("head") or head_commit(repo), + "conflict": False, + } + def init_project( self, project_id: str, @@ -1463,7 +2095,34 @@ def compile( "bibtex_binary": None, "auto": bool(auto), "stop_on_first_error": bool(stop_on_first_error), + "source_commit": None, + "source_version_id": None, + "source_version": None, + "source_version_error": None, } + + try: + source_version = self.create_version( + project_id, + folder_id, + label="Compile source" if not auto else "Auto compile source", + description="Source snapshot captured before LaTeX compilation.", + source="compile", + author="system", + build_id=build_id, + allow_empty=not bool(auto), + ) + if source_version.get("ok") and isinstance(source_version.get("version"), dict): + version_payload = dict(source_version["version"]) + build["source_commit"] = version_payload.get("commit") + build["source_version_id"] = version_payload.get("version_id") + build["source_version"] = version_payload + else: + build["source_commit"] = source_version.get("commit") or head_commit(self._workspace_root(project_id)) + build["source_version_error"] = source_version.get("message") + except Exception as exc: + build["source_commit"] = head_commit(self._workspace_root(project_id)) + build["source_version_error"] = str(exc) write_json(metadata_path, build) runtime_tools = RuntimeToolService(self.quest_service.home) diff --git a/src/deepscientist/quest/layout.py b/src/deepscientist/quest/layout.py index 851967a7..643f9ab5 100644 --- a/src/deepscientist/quest/layout.py +++ b/src/deepscientist/quest/layout.py @@ -138,6 +138,8 @@ def gitignore() -> str: ".ds/*.pid", ".ds/*.sock", ".ds/*.tmp", + ".ds/latex_builds/", + ".ds/latex_versions/", ".ds/worktrees/", "tmp/", "__pycache__/", diff --git a/src/ui/src/lib/api/latex.ts b/src/ui/src/lib/api/latex.ts index b4dbdea6..5ad0dbf5 100644 --- a/src/ui/src/lib/api/latex.ts +++ b/src/ui/src/lib/api/latex.ts @@ -67,6 +67,10 @@ export interface LatexBuildResponse { errors: LatexBuildError[]; log_items?: LatexLogItem[]; synctex_path?: string | null; + source_commit?: string | null; + source_version_id?: string | null; + source_version?: LatexVersionSummary | null; + source_version_error?: string | null; } export interface LatexManifestFile { @@ -90,6 +94,116 @@ export interface LatexManifestResponse { files: LatexManifestFile[]; } +export type LatexVersionSource = "manual" | "auto" | "compile" | "ai" | "restore" | string; + +export interface LatexVersionSummary { + version_id: string; + commit: string; + short_commit?: string; + parents?: string[]; + compare_base?: string | null; + folder_id?: string; + folder_path: string; + main_file_path?: string | null; + label: string; + description?: string | null; + source: LatexVersionSource; + author?: string | null; + created_at: string; + build_id?: string | null; + changed_paths?: string[]; + file_count?: number; + added?: number; + removed?: number; +} + +export interface LatexVersionListResponse { + ok: boolean; + message?: string; + folder_id: string; + folder_path: string; + head?: string | null; + versions: LatexVersionSummary[]; + limit?: number; +} + +export interface LatexVersionCreateRequest { + label?: string | null; + description?: string | null; + source?: LatexVersionSource; + author?: string | null; + build_id?: string | null; + allow_empty?: boolean; +} + +export interface LatexVersionCreateResponse extends LatexVersionSummary { + ok: boolean; + created?: boolean; + message?: string; + head?: string | null; + previous_head?: string | null; + version?: LatexVersionSummary; +} + +export interface LatexVersionFileEntry { + id: string; + name: string; + path: string; + relative_path?: string; + role?: string; + editable?: boolean; + document_id?: string; +} + +export interface LatexVersionFilesResponse { + ok: boolean; + folder_id: string; + folder_path: string; + version?: LatexVersionSummary | null; + commit: string; + files: LatexVersionFileEntry[]; +} + +export interface LatexVersionRestoreRequest { + mode?: "file" | "folder"; + path?: string | null; + expected_head?: string | null; + conflict_policy?: "fail" | "force"; +} + +export interface LatexVersionRestoreResponse { + ok: boolean; + conflict?: boolean; + message?: string; + restored_from?: LatexVersionSummary | null; + restored_paths?: string[]; + restore_version?: LatexVersionSummary | null; + head?: string | null; + current_head?: string | null; +} + +export interface LatexVersionCompareResponse { + ok: boolean; + base: string; + head: string; + folder_id: string; + folder_path: string; + base_version?: LatexVersionSummary | null; + head_version?: LatexVersionSummary | null; + files: Array<{ + path: string; + old_path?: string | null; + status?: string; + added?: number; + removed?: number; + binary?: boolean; + }>; + file_count?: number; + commit_count?: number; + ahead?: number; + behind?: number; +} + export interface LatexSyncTexEditRequest { page: number; x: number; @@ -177,6 +291,78 @@ export async function getLatexManifest( return res.data; } +export async function listLatexVersions( + projectId: string, + folderId: string, + limit = 30 +): Promise { + const res = await apiClient.get( + `/api/v1/projects/${projectId}/latex/${folderId}/versions`, + { params: { limit } } + ); + return res.data; +} + +export async function createLatexVersion( + projectId: string, + folderId: string, + request: LatexVersionCreateRequest +): Promise { + const res = await apiClient.post( + `/api/v1/projects/${projectId}/latex/${folderId}/versions`, + request + ); + return res.data; +} + +export async function getLatexVersion( + projectId: string, + folderId: string, + versionId: string +): Promise { + const res = await apiClient.get( + `/api/v1/projects/${projectId}/latex/${folderId}/versions/${versionId}` + ); + return res.data; +} + +export async function listLatexVersionFiles( + projectId: string, + folderId: string, + versionId: string +): Promise { + const res = await apiClient.get( + `/api/v1/projects/${projectId}/latex/${folderId}/versions/${versionId}/files` + ); + return res.data; +} + +export async function compareLatexVersions( + projectId: string, + folderId: string, + base: string, + head: string +): Promise { + const res = await apiClient.get( + `/api/v1/projects/${projectId}/latex/${folderId}/versions/compare`, + { params: { base, head } } + ); + return res.data; +} + +export async function restoreLatexVersion( + projectId: string, + folderId: string, + versionId: string, + request: LatexVersionRestoreRequest +): Promise { + const res = await apiClient.post( + `/api/v1/projects/${projectId}/latex/${folderId}/versions/${versionId}/restore`, + request + ); + return res.data; +} + export async function getLatexBuild( projectId: string, folderId: string, diff --git a/src/ui/src/lib/i18n/messages/latex.ts b/src/ui/src/lib/i18n/messages/latex.ts index b948e8eb..cb221b3d 100644 --- a/src/ui/src/lib/i18n/messages/latex.ts +++ b/src/ui/src/lib/i18n/messages/latex.ts @@ -39,6 +39,40 @@ export const latexMessages: Partial>> external_change_reload: 'Reload external version', external_change_overwrite: 'Overwrite external version', external_change_save_blocked: 'This file changed outside the editor. Reload or explicitly overwrite the external version before saving.', + version_history: 'History', + version_history_title: 'LaTeX versions', + version_history_hint: 'Create, compare, and restore Git-backed source versions.', + version_current_head: 'Current HEAD', + version_refresh: 'Refresh', + version_label_placeholder: 'Version name', + version_description_placeholder: 'Optional description', + version_create: 'Create version', + version_loading: 'Loading versions…', + version_empty: 'No LaTeX versions yet.', + version_select_hint: 'Select a version to inspect it.', + version_files_changed: 'files', + version_build: 'build', + version_compare_current: 'Compare current', + version_compare_summary: 'Changes since this version', + version_compare_empty: 'No LaTeX source differences.', + version_changed_files: 'Changed files', + version_restore_file: 'Restore file', + version_restore_project: 'Restore project', + version_restore_file_confirm: 'Restore the active file from this version? This will create a new restore version.', + version_restore_folder_confirm: 'Restore the whole LaTeX project from this version? This will create a new restore version.', + version_history_load_failed: 'Failed to load LaTeX versions.', + version_create_failed: 'Failed to create LaTeX version.', + version_compare_failed: 'Failed to compare LaTeX versions.', + version_restore_failed: 'Failed to restore LaTeX version.', + version_restore_dirty_blocked: 'Save, version, or resolve current changes before restoring.', + version_restore_file_missing: 'The active file cannot be mapped to a LaTeX source path.', + version_resolve_external_change: 'Resolve the external file change before creating a version.', + version_save_before_create: 'Save the current editor changes before creating a version.', + version_manual_label: 'Manual version', + version_auto_label: 'Auto version', + version_auto_description: 'Automatic LaTeX editing checkpoint.', + version_ai_label: 'AI edit', + version_ai_description: 'Automatic version captured after an AI file edit.', error_badge: 'error', warning_badge: 'warning', warnings_title: 'Warnings', @@ -123,6 +157,40 @@ export const latexMessages: Partial>> external_change_reload: '重新载入外部版本', external_change_overwrite: '覆盖外部版本', external_change_save_blocked: '该文件已在编辑器外被修改。请先重新载入,或明确选择覆盖外部版本后再保存。', + version_history: '历史版本', + version_history_title: 'LaTeX 历史版本', + version_history_hint: '创建、比较并恢复 Git 支撑的源码版本。', + version_current_head: '当前 HEAD', + version_refresh: '刷新', + version_label_placeholder: '版本名称', + version_description_placeholder: '可选说明', + version_create: '创建版本', + version_loading: '正在加载版本…', + version_empty: '暂无 LaTeX 历史版本。', + version_select_hint: '选择一个版本查看详情。', + version_files_changed: '个文件', + version_build: '构建', + version_compare_current: '与当前比较', + version_compare_summary: '该版本至当前的变化', + version_compare_empty: '没有 LaTeX 源码差异。', + version_changed_files: '变更文件', + version_restore_file: '恢复当前文件', + version_restore_project: '恢复整个项目', + version_restore_file_confirm: '要从该版本恢复当前文件吗?该操作会创建一个新的恢复版本。', + version_restore_folder_confirm: '要从该版本恢复整个 LaTeX 项目吗?该操作会创建一个新的恢复版本。', + version_history_load_failed: '加载 LaTeX 历史版本失败。', + version_create_failed: '创建 LaTeX 版本失败。', + version_compare_failed: '比较 LaTeX 版本失败。', + version_restore_failed: '恢复 LaTeX 版本失败。', + version_restore_dirty_blocked: '恢复前请先保存、创建版本或解决当前修改。', + version_restore_file_missing: '无法将当前文件映射到 LaTeX 源码路径。', + version_resolve_external_change: '创建版本前请先解决外部文件修改。', + version_save_before_create: '创建版本前请先保存当前编辑器修改。', + version_manual_label: '手动版本', + version_auto_label: '自动版本', + version_auto_description: '自动 LaTeX 编辑检查点。', + version_ai_label: 'AI 修改', + version_ai_description: 'AI 文件编辑后自动捕获的版本。', error_badge: '错误', warning_badge: '警告', warnings_title: '警告', diff --git a/src/ui/src/lib/plugins/latex/LatexPlugin.tsx b/src/ui/src/lib/plugins/latex/LatexPlugin.tsx index 2a24b428..94ec073e 100644 --- a/src/ui/src/lib/plugins/latex/LatexPlugin.tsx +++ b/src/ui/src/lib/plugins/latex/LatexPlugin.tsx @@ -13,6 +13,9 @@ import { ZoomOut, Link2, AtSign, + History, + RotateCcw, + GitCompare, } from "lucide-react"; import type { PluginComponentProps } from "@/lib/types/plugin"; import { cn } from "@/lib/utils"; @@ -41,17 +44,23 @@ import { PAGE_DIMENSIONS, ZOOM_LEVELS } from "@/lib/plugins/pdf-viewer/types"; import { PDF_CMAP_URL, PDF_WORKER_SRC } from "@/lib/plugins/pdf-viewer/lib/pdf-utils"; import { compileLatex, + compareLatexVersions, + createLatexVersion, getLatexManifest, getLatexBuild, getLatexBuildLogText, getLatexBuildPdfBlob, listLatexBuilds, + listLatexVersions, + restoreLatexVersion, syncTexEditLatexBuild, type LatexCompiler, type LatexBuildStatus, type LatexBuildError, type LatexLogItem, type LatexSyncTexSelection, + type LatexVersionCompareResponse, + type LatexVersionSummary, } from "@/lib/api/latex"; import { useI18n } from "@/lib/i18n/useI18n"; import { useWorkspaceSurfaceStore } from "@/lib/stores/workspace-surface"; @@ -211,6 +220,7 @@ const normalizeBuildErrors = ( const LATEX_COMPILER_OPTIONS: LatexCompiler[] = ["pdflatex", "xelatex", "lualatex"]; const LATEX_AUTOSAVE_DELAY_MS = 1000; const LATEX_EXTERNAL_CHECK_INTERVAL_MS = 4000; +const LATEX_AUTO_VERSION_INTERVAL_MS = 5 * 60 * 1000; const LATEX_AUTO_COMPILE_ON_SAVE_STORAGE_PREFIX = "ds:latex:auto-compile-on-save"; const BIB_SNIPPETS: BibSnippet[] = [ { @@ -687,6 +697,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const updateFileMeta = useFileTreeStore((s) => s.updateFileMeta); const [files, setFiles] = React.useState([]); + const [manifestRefreshNonce, setManifestRefreshNonce] = React.useState(0); const initialFileId = custom.openFileId ?? custom.mainFileId ?? null; const [activeFileId, setActiveFileId] = React.useState(initialFileId); const [activeFileName, setActiveFileName] = React.useState("main.tex"); @@ -719,6 +730,16 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const [compiler, setCompiler] = React.useState("pdflatex"); const [autoCompileOnSave, setAutoCompileOnSave] = React.useState(true); const [currentBranch, setCurrentBranch] = React.useState(null); + const [historyOpen, setHistoryOpen] = React.useState(false); + const [latexVersions, setLatexVersions] = React.useState([]); + const [latexVersionsHead, setLatexVersionsHead] = React.useState(null); + const [selectedVersionId, setSelectedVersionId] = React.useState(null); + const [historyLoading, setHistoryLoading] = React.useState(false); + const [historyActionBusy, setHistoryActionBusy] = React.useState(false); + const [historyError, setHistoryError] = React.useState(null); + const [historyCompare, setHistoryCompare] = React.useState(null); + const [historyLabel, setHistoryLabel] = React.useState(""); + const [historyDescription, setHistoryDescription] = React.useState(""); const [pdfObjectUrl, setPdfObjectUrl] = React.useState(null); const [logText, setLogText] = React.useState(null); const [zoomScale, setZoomScale] = React.useState(1); @@ -746,6 +767,8 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const loadedRevisionRef = React.useRef(null); const externalConflictRef = React.useRef(null); const externalCheckInFlightRef = React.useRef(false); + const lastAutoVersionAtRef = React.useRef(0); + const aiVersionTimerRef = React.useRef(null); const yDocRef = React.useRef(null); const yTextRef = React.useRef(null); const syncRef = React.useRef(null); @@ -931,6 +954,179 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug [applyFileSnapshotToEditor, setExternalConflictState, syncState] ); + const selectedLatexVersion = React.useMemo( + () => latexVersions.find((version) => version.version_id === selectedVersionId || version.commit === selectedVersionId) ?? latexVersions[0] ?? null, + [latexVersions, selectedVersionId] + ); + + const loadLatexVersionHistory = React.useCallback(async () => { + if (!projectId || !latexFolderId) return; + setHistoryLoading(true); + setHistoryError(null); + try { + const payload = await listLatexVersions(projectId, latexFolderId, 50); + const versions = Array.isArray(payload.versions) ? payload.versions : []; + setLatexVersions(versions); + setLatexVersionsHead(payload.head ?? null); + setSelectedVersionId((current) => { + if (current && versions.some((version) => version.version_id === current || version.commit === current)) { + return current; + } + return versions[0]?.version_id ?? null; + }); + } catch (e) { + setHistoryError(e instanceof Error ? e.message : t("version_history_load_failed")); + } finally { + setHistoryLoading(false); + } + }, [latexFolderId, projectId, t]); + + const createAutoLatexVersion = React.useCallback( + async (source: "auto" | "ai", label: string, description: string) => { + if (!projectId || !latexFolderId || viewReadOnly) return; + try { + const result = await createLatexVersion(projectId, latexFolderId, { + label, + description, + source, + author: source === "ai" ? "ai" : "system", + allow_empty: false, + }); + if (result.ok && historyOpen) { + void loadLatexVersionHistory(); + } + } catch (e) { + console.warn("[LatexPlugin] Failed to create automatic LaTeX version:", e); + } + }, + [historyOpen, latexFolderId, loadLatexVersionHistory, projectId, viewReadOnly] + ); + + const maybeCreateTimedAutoVersion = React.useCallback(() => { + const now = Date.now(); + if (now - lastAutoVersionAtRef.current < LATEX_AUTO_VERSION_INTERVAL_MS) return; + lastAutoVersionAtRef.current = now; + void createAutoLatexVersion( + "auto", + t("version_auto_label"), + t("version_auto_description") + ); + }, [createAutoLatexVersion, t]); + + const createManualLatexVersion = React.useCallback(async () => { + if (!projectId || !latexFolderId || viewReadOnly) return; + if (externalConflictRef.current) { + setHistoryError(t("version_resolve_external_change")); + return; + } + if (isDirtyRef.current) { + const inFlightSave = saveInFlightRef.current?.promise; + const saved = inFlightSave ? await inFlightSave : false; + if (!saved && isDirtyRef.current) { + setHistoryError(t("version_save_before_create")); + return; + } + } + setHistoryActionBusy(true); + setHistoryError(null); + try { + const result = await createLatexVersion(projectId, latexFolderId, { + label: historyLabel.trim() || t("version_manual_label"), + description: historyDescription.trim() || null, + source: "manual", + author: "user", + allow_empty: true, + }); + if (!result.ok) { + setHistoryError(result.message || t("version_create_failed")); + return; + } + setHistoryLabel(""); + setHistoryDescription(""); + await loadLatexVersionHistory(); + } catch (e) { + setHistoryError(e instanceof Error ? e.message : t("version_create_failed")); + } finally { + setHistoryActionBusy(false); + } + }, [historyDescription, historyLabel, latexFolderId, loadLatexVersionHistory, projectId, t, viewReadOnly]); + + const compareSelectedLatexVersion = React.useCallback(async () => { + if (!projectId || !latexFolderId || !selectedLatexVersion) return; + setHistoryActionBusy(true); + setHistoryError(null); + try { + const result = await compareLatexVersions( + projectId, + latexFolderId, + selectedLatexVersion.version_id || selectedLatexVersion.commit, + latexVersionsHead || "HEAD" + ); + setHistoryCompare(result); + } catch (e) { + setHistoryError(e instanceof Error ? e.message : t("version_compare_failed")); + } finally { + setHistoryActionBusy(false); + } + }, [latexFolderId, latexVersionsHead, projectId, selectedLatexVersion, t]); + + const restoreSelectedLatexVersion = React.useCallback( + async (mode: "file" | "folder") => { + if (!projectId || !latexFolderId || !selectedLatexVersion) return; + if (viewReadOnly) return; + if (externalConflictRef.current || isDirtyRef.current) { + setHistoryError(t("version_restore_dirty_blocked")); + return; + } + const activeMeta = files.find((file) => file.id === activeFileIdRef.current) ?? null; + const restorePath = mode === "file" ? activeMeta?.path : null; + if (mode === "file" && !restorePath) { + setHistoryError(t("version_restore_file_missing")); + return; + } + const confirmed = window.confirm( + mode === "file" + ? t("version_restore_file_confirm") + : t("version_restore_folder_confirm") + ); + if (!confirmed) return; + setHistoryActionBusy(true); + setHistoryError(null); + try { + const result = await restoreLatexVersion(projectId, latexFolderId, selectedLatexVersion.version_id, { + mode, + path: restorePath, + expected_head: latexVersionsHead ?? undefined, + conflict_policy: "fail", + }); + if (!result.ok) { + setHistoryError(result.message || t("version_restore_failed")); + return; + } + setManifestRefreshNonce((value) => value + 1); + await loadLatexVersionHistory(); + if (activeFileIdRef.current) { + try { + const snapshot = await getFileContentSnapshot(activeFileIdRef.current); + applyFileSnapshotToEditor(activeFileIdRef.current, snapshot); + } catch { + // The restored folder may have removed the active file. Manifest refresh will choose another file. + } + } + } catch (e) { + setHistoryError(e instanceof Error ? e.message : t("version_restore_failed")); + } finally { + setHistoryActionBusy(false); + } + }, + [applyFileSnapshotToEditor, files, latexFolderId, latexVersionsHead, loadLatexVersionHistory, projectId, selectedLatexVersion, t, viewReadOnly] + ); + + React.useEffect(() => { + if (!historyOpen) return; + void loadLatexVersionHistory(); + }, [historyOpen, loadLatexVersionHistory]); + React.useEffect(() => { const activeFileMeta = files.find((file) => file.id === activeFileId) ?? @@ -1124,7 +1320,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug return () => { cancelled = true; }; - }, [custom.mainFileId, initialFileId, latexFolderId, projectId, t]); + }, [custom.mainFileId, initialFileId, latexFolderId, manifestRefreshNonce, projectId, t]); React.useEffect(() => { if (!activeFileId) return; @@ -2060,6 +2256,9 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug if (currentText === textToSave) { setEditorDirty(false); + if (trigger === "auto") { + maybeCreateTimedAutoVersion(); + } return true; } @@ -2108,7 +2307,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug saveInFlightRef.current = { fileId, promise }; return promise; - }, [activeFileId, effectiveReadOnly, setEditorDirty, setExternalConflictState, t, updateFileMeta]); + }, [activeFileId, effectiveReadOnly, maybeCreateTimedAutoVersion, setEditorDirty, setExternalConflictState, t, updateFileMeta]); const reloadExternalVersion = React.useCallback(() => { const conflict = externalConflictRef.current; @@ -2274,16 +2473,39 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug if (detail.projectId && projectId && detail.projectId !== projectId) return; const activeMeta = files.find((file) => file.id === activeFileId) ?? null; const filePath = String(detail.filePath || "").replace(/^\/+/, ""); + const matchedFileId = + (detail.fileId && files.some((file) => file.id === detail.fileId) ? detail.fileId : null) || + (filePath ? resolveLatexFileId(files, filePath) : null); const matches = detail.fileId === activeFileId || - (filePath && resolveLatexFileId(files, filePath) === activeFileId) || + matchedFileId === activeFileId || (filePath && activeMeta && [activeMeta.path, activeMeta.relativePath].filter(Boolean).includes(filePath)); - if (!matches) return; - void checkExternalSnapshot("diff"); + if (matches) { + void checkExternalSnapshot("diff"); + } + if (matchedFileId && !viewReadOnly) { + if (aiVersionTimerRef.current != null) { + window.clearTimeout(aiVersionTimerRef.current); + } + aiVersionTimerRef.current = window.setTimeout(() => { + aiVersionTimerRef.current = null; + void createAutoLatexVersion( + "ai", + t("version_ai_label"), + t("version_ai_description") + ); + }, 1200); + } }; window.addEventListener("ds:file:diff", handler as EventListener); - return () => window.removeEventListener("ds:file:diff", handler as EventListener); - }, [activeFileId, checkExternalSnapshot, files, projectId]); + return () => { + window.removeEventListener("ds:file:diff", handler as EventListener); + if (aiVersionTimerRef.current != null) { + window.clearTimeout(aiVersionTimerRef.current); + aiVersionTimerRef.current = null; + } + }; + }, [activeFileId, checkExternalSnapshot, createAutoLatexVersion, files, projectId, t, viewReadOnly]); React.useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { @@ -2341,6 +2563,9 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug setSynctexReady(Boolean(res.synctex_ready)); buildStatusRef.current = res.status ?? "queued"; setBuildStatus(res.status ?? "queued"); + if (historyOpen) { + void loadLatexVersionHistory(); + } } catch (e) { console.error("[LatexPlugin] Compile failed:", e); setBuildError(e instanceof Error ? e.message : t("compile_request_failed")); @@ -2349,7 +2574,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug setBuildStatus("error"); } }, - [compiler, compileMainFileId, effectiveReadOnly, latexFolderId, projectId, save, t, viewReadOnly] + [compiler, compileMainFileId, effectiveReadOnly, historyOpen, latexFolderId, loadLatexVersionHistory, projectId, save, t, viewReadOnly] ); const triggerManualSave = React.useCallback(async () => { @@ -3104,6 +3329,21 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug ) : null}
+ +
+ {historyOpen ? ( +
+
+
+
+
+
{t("version_history_title")}
+
+ {latexVersionsHead ? `${t("version_current_head")}: ${latexVersionsHead.slice(0, 8)}` : t("version_history_hint")} +
+
+ +
+ +
+ setHistoryLabel(event.target.value)} + placeholder={t("version_label_placeholder")} + className="h-8 w-full rounded-lg border border-black/10 bg-white/80 px-3 text-xs dark:border-white/10 dark:bg-white/[0.05]" + /> + setHistoryDescription(event.target.value)} + placeholder={t("version_description_placeholder")} + className="h-8 w-full rounded-lg border border-black/10 bg-white/80 px-3 text-xs dark:border-white/10 dark:bg-white/[0.05]" + /> + +
+ +
+ {latexVersions.length > 0 ? ( + latexVersions.map((version) => { + const selected = selectedLatexVersion?.version_id === version.version_id; + return ( + + ); + }) + ) : ( +
+ {historyLoading ? t("version_loading") : t("version_empty")} +
+ )} +
+
+ +
+ {selectedLatexVersion ? ( +
+
+
+
{selectedLatexVersion.label}
+
+ {selectedLatexVersion.short_commit || selectedLatexVersion.commit.slice(0, 8)} + {selectedLatexVersion.source} + {selectedLatexVersion.author ? {selectedLatexVersion.author} : null} + {selectedLatexVersion.build_id ? {t("version_build")}: {selectedLatexVersion.build_id} : null} +
+ {selectedLatexVersion.description ? ( +
{selectedLatexVersion.description}
+ ) : null} +
+
+ + + +
+
+ + {historyCompare ? ( +
+
+ {t("version_compare_summary")}: {historyCompare.file_count ?? historyCompare.files?.length ?? 0} {t("version_files_changed")} +
+
+ {(historyCompare.files || []).length > 0 ? ( + historyCompare.files.map((file) => ( +
+ {file.path} + + {file.status || "M"} · +{file.added ?? 0} / -{file.removed ?? 0} + +
+ )) + ) : ( +
{t("version_compare_empty")}
+ )} +
+
+ ) : null} + + {selectedLatexVersion.changed_paths && selectedLatexVersion.changed_paths.length > 0 ? ( +
+
{t("version_changed_files")}
+
+ {selectedLatexVersion.changed_paths.map((path) => ( +
+ {path} +
+ ))} +
+
+ ) : null} +
+ ) : ( +
+ {t("version_select_hint")} +
+ )} +
+
+ + {historyError ? ( +
+ + {historyError} +
+ ) : null} +
+ ) : null} + {showAssistPanel ? (
diff --git a/tests/test_api_contract_surface.py b/tests/test_api_contract_surface.py index d893956f..dddf94b0 100644 --- a/tests/test_api_contract_surface.py +++ b/tests/test_api_contract_surface.py @@ -147,6 +147,13 @@ def test_backend_routes_cover_shared_web_and_tui_surface() -> None: ("POST", "/api/v1/projects/q-001/latex/init", "latex_init"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/manifest", "latex_manifest"), ("POST", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/compile", "latex_compile"), + ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions", "latex_versions"), + ("POST", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions", "latex_version_create"), + ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions/compare", "latex_versions_compare"), + ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions/latex-version-001", "latex_version"), + ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions/latex-version-001/files", "latex_version_files"), + ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions/latex-version-001/file", "latex_version_file"), + ("POST", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions/latex-version-001/restore", "latex_version_restore"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/builds", "latex_builds"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/builds/latex-001", "latex_build"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/builds/latex-001/pdf", "latex_build_pdf"), @@ -382,6 +389,13 @@ def test_local_workspace_does_not_route_markdown_or_commands_through_dead_notebo assert "external_change_save_blocked" in latex_plugin_source assert "LATEX_EXTERNAL_CHECK_INTERVAL_MS" in latex_plugin_source assert 'window.addEventListener("ds:file:diff"' in latex_plugin_source + assert "listLatexVersions" in latex_source + assert "createLatexVersion" in latex_source + assert "restoreLatexVersion" in latex_source + assert "compareLatexVersions" in latex_source + assert "historyOpen" in latex_plugin_source + assert "version_history" in latex_plugin_source + assert "createAutoLatexVersion" in latex_plugin_source assert '"text/markdown": BUILTIN_PLUGINS.NOTEBOOK' in plugin_types_source assert '".md": BUILTIN_PLUGINS.NOTEBOOK' in plugin_types_source assert 'extensions: [".md", ".markdown"],\n mimeTypes: ["text/markdown", "text/x-markdown"],\n priority: 95,' in plugin_init_source diff --git a/tests/test_latex_runtime.py b/tests/test_latex_runtime.py index 1929da20..84c5ff08 100644 --- a/tests/test_latex_runtime.py +++ b/tests/test_latex_runtime.py @@ -459,3 +459,86 @@ def test_latex_manifest_lists_nested_editable_files(temp_home: Path) -> None: assert by_relative["refs.bib"]["role"] == "bib" assert {"kind": "input", "path": "sections/intro.tex"} in by_relative["main.tex"]["dependencies"] assert {"kind": "bibliography", "path": "refs.bib"} in by_relative["main.tex"]["dependencies"] + + +def test_latex_versions_create_compare_and_restore_file(temp_home: Path) -> None: + ensure_home_layout(temp_home) + ConfigManager(temp_home).ensure_files() + quest_service = QuestService(temp_home, skill_installer=SkillInstaller(repo_root(), temp_home)) + quest = quest_service.create("latex versions quest") + quest_root = Path(quest["quest_root"]) + project_id = quest["quest_id"] + + latex_root = quest_root / "paper" / "latex" + latex_root.mkdir(parents=True, exist_ok=True) + main_tex = latex_root / "main.tex" + main_tex.write_text("original source\n", encoding="utf-8") + + service = QuestLatexService(quest_service) + folder_id = f"quest-dir::{project_id}::paper%2Flatex" + + first = service.create_version(project_id, folder_id, label="Before rewrite", description="baseline") + assert first["ok"] is True + assert first["version"]["source"] == "manual" + assert first["version"]["folder_path"] == "paper/latex" + assert "paper/latex/main.tex" in first["version"]["changed_paths"] + + main_tex.write_text("rewritten source\n", encoding="utf-8") + second = service.create_version(project_id, folder_id, label="After rewrite", allow_empty=False) + assert second["ok"] is True + + versions = service.list_versions(project_id, folder_id) + assert versions["ok"] is True + assert [item["label"] for item in versions["versions"][:2]] == ["After rewrite", "Before rewrite"] + + compare = service.compare_versions( + project_id, + folder_id, + base=first["version_id"], + head=second["version_id"], + ) + assert compare["ok"] is True + assert compare["file_count"] == 1 + assert compare["files"][0]["path"] == "paper/latex/main.tex" + + restored = service.restore_version( + project_id, + folder_id, + first["version_id"], + mode="file", + path="paper/latex/main.tex", + expected_head=second["head"], + ) + assert restored["ok"] is True + assert restored["restore_version"]["source"] == "restore" + assert main_tex.read_text(encoding="utf-8") == "original source\n" + + +def test_latex_compile_records_source_version_even_without_latex_runtime(temp_home: Path, monkeypatch) -> None: + ensure_home_layout(temp_home) + ConfigManager(temp_home).ensure_files() + quest_service = QuestService(temp_home, skill_installer=SkillInstaller(repo_root(), temp_home)) + quest = quest_service.create("latex compile version quest") + quest_root = Path(quest["quest_root"]) + project_id = quest["quest_id"] + + latex_root = quest_root / "paper" / "latex" + latex_root.mkdir(parents=True, exist_ok=True) + (latex_root / "main.tex").write_text(r"\documentclass{article}" + "\n", encoding="utf-8") + + monkeypatch.setattr( + latex_runtime.RuntimeToolService, + "resolve_binary", + lambda self, binary, preferred_tools=(): {"path": None, "source": None}, + ) + + service = QuestLatexService(quest_service) + folder_id = f"quest-dir::{project_id}::paper%2Flatex" + build = service.compile(project_id, folder_id, compiler="pdflatex", auto=False) + + assert build["status"] == "error" + assert build["source_version_id"] + assert build["source_commit"] + versions = service.list_versions(project_id, folder_id) + assert versions["versions"][0]["source"] == "compile" + assert versions["versions"][0]["build_id"] == build["build_id"] From ffae54b4e1fd11bd8edc27c7e5156b2f4734bb61 Mon Sep 17 00:00:00 2001 From: SmallSpider0 <568442079@qq.com> Date: Thu, 21 May 2026 17:58:31 +0800 Subject: [PATCH 4/8] fix(latex): handle version history query path --- src/deepscientist/daemon/api/handlers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/deepscientist/daemon/api/handlers.py b/src/deepscientist/daemon/api/handlers.py index b88a4acf..d8564c65 100644 --- a/src/deepscientist/daemon/api/handlers.py +++ b/src/deepscientist/daemon/api/handlers.py @@ -2124,7 +2124,7 @@ def latex_compile(self, project_id: str, folder_id: str, body: dict) -> dict: def latex_manifest(self, project_id: str, folder_id: str) -> dict: return self.app.latex_service.manifest(project_id, folder_id) - def latex_versions(self, project_id: str, folder_id: str, path: str) -> dict: + def latex_versions(self, project_id: str, folder_id: str, path: str = "") -> dict: query = self.parse_query(path) limit_raw = ((query.get("limit") or ["30"])[0] or "30").strip() try: @@ -2145,7 +2145,7 @@ def latex_version_create(self, project_id: str, folder_id: str, body: dict) -> d allow_empty=body.get("allow_empty", True) is not False, ) - def latex_versions_compare(self, project_id: str, folder_id: str, path: str) -> dict: + def latex_versions_compare(self, project_id: str, folder_id: str, path: str = "") -> dict: query = self.parse_query(path) base = ((query.get("base") or [""])[0] or "").strip() head = ((query.get("head") or [""])[0] or "").strip() @@ -2159,7 +2159,7 @@ def latex_version(self, project_id: str, folder_id: str, version_id: str) -> dic def latex_version_files(self, project_id: str, folder_id: str, version_id: str) -> dict: return self.app.latex_service.version_files(project_id, folder_id, version_id) - def latex_version_file(self, project_id: str, folder_id: str, version_id: str, path: str) -> dict: + def latex_version_file(self, project_id: str, folder_id: str, version_id: str, path: str = "") -> dict: query = self.parse_query(path) file_path = ((query.get("path") or [""])[0] or "").strip() if not file_path: From ac292f38516447423314ffbf14ee41c5852f16eb Mon Sep 17 00:00:00 2001 From: SmallSpider0 <568442079@qq.com> Date: Thu, 21 May 2026 18:35:45 +0800 Subject: [PATCH 5/8] feat(latex): add policy-driven auto versions --- docs/en/12_GUIDED_WORKFLOW_TOUR.md | 2 +- docs/zh/12_GUIDED_WORKFLOW_TOUR.md | 2 +- src/deepscientist/daemon/api/handlers.py | 11 +- src/deepscientist/daemon/api/router.py | 1 + src/deepscientist/daemon/app.py | 5 +- src/deepscientist/latex_runtime.py | 537 ++++++++++++++++++- src/ui/src/lib/api/latex.ts | 48 +- src/ui/src/lib/i18n/messages/latex.ts | 4 + src/ui/src/lib/plugins/latex/LatexPlugin.tsx | 108 ++-- tests/test_api_contract_surface.py | 4 + tests/test_latex_runtime.py | 55 +- 11 files changed, 713 insertions(+), 64 deletions(-) diff --git a/docs/en/12_GUIDED_WORKFLOW_TOUR.md b/docs/en/12_GUIDED_WORKFLOW_TOUR.md index 3179dba5..4e3058a0 100644 --- a/docs/en/12_GUIDED_WORKFLOW_TOUR.md +++ b/docs/en/12_GUIDED_WORKFLOW_TOUR.md @@ -347,7 +347,7 @@ The editor auto-saves source edits shortly after you type. Background autosaves If another process changes the active LaTeX source file while it is open, such as an AI edit or terminal command, the editor refreshes automatically when the local buffer has no unsaved edits. If the local buffer is dirty, autosave pauses and the editor asks you to either reload the external version or explicitly overwrite it, so an ordinary save cannot silently replace external changes. -The `History` button in the LaTeX toolbar opens a Git-backed version panel scoped to the current LaTeX folder. You can create named versions, inspect changed source files, compare a version with the current workspace, and restore either the active file or the whole LaTeX project. Compile actions also record the source commit used for the build, and AI/file-diff edits are captured as automatic LaTeX versions when possible. +The `History` button in the LaTeX toolbar opens a Git-backed version panel scoped to the current LaTeX folder. You can create named versions, inspect changed source files, compare a version with the current workspace, and restore either the active file or the whole LaTeX project. In addition to manual versions, the editor asks the backend to create Overleaf-style automatic checkpoints after idle saves, AI/file-diff edits, visibility changes, or compile actions. The backend applies a shared policy so checkpoints are not too frequent: it skips very recent checkpoints, creates a new visible version after significant source changes, and also creates one after a long enough editing interval even for smaller edits. Compile actions keep hidden build snapshots for reproducibility; the History panel hides those by default and can show them with the build-snapshot toggle. After a successful compile, the PDF preview uses SyncTeX metadata when available. Double-click a rendered PDF word to jump back to the matching LaTeX source file and select the corresponding source token; the editor uses the PDF word box plus multiple SyncTeX samples to avoid broad line-level selections. Older builds without SyncTeX data need to be recompiled before PDF-to-source jumps are available. diff --git a/docs/zh/12_GUIDED_WORKFLOW_TOUR.md b/docs/zh/12_GUIDED_WORKFLOW_TOUR.md index f51bb071..42ecf426 100644 --- a/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +++ b/docs/zh/12_GUIDED_WORKFLOW_TOUR.md @@ -345,7 +345,7 @@ Explorer 是 quest 的文件视角。 如果其它进程在当前 LaTeX 源文件打开期间修改了它,例如 AI 编辑或终端命令,且本地缓冲区没有未保存内容,编辑器会自动刷新到外部版本。若本地缓冲区已被修改,自动保存会暂停,并提示你选择重新载入外部版本或明确覆盖外部版本,避免普通保存静默覆盖外部修改。 -LaTeX 工具栏中的 `历史版本` 按钮会打开限定在当前 LaTeX 文件夹内的 Git 版本面板。你可以创建命名版本、查看变更源码文件、将某个版本与当前工作区比较,并恢复当前文件或整个 LaTeX 项目。编译操作也会记录该次构建使用的源码 commit;AI / 文件 diff 修改在可行时会自动捕获为 LaTeX 版本。 +LaTeX 工具栏中的 `历史版本` 按钮会打开限定在当前 LaTeX 文件夹内的 Git 版本面板。你可以创建命名版本、查看变更源码文件、将某个版本与当前工作区比较,并恢复当前文件或整个 LaTeX 项目。除手动版本外,编辑器会在空闲自动保存、AI / 文件 diff 修改、页面隐藏或编译动作后请求后端创建类似 Overleaf 的自动检查点。后端统一执行“不要太频繁”的策略:距离上次检查点太近会跳过;源码改动达到一定量时创建新的可见版本;即使改动较小,只要编辑间隔足够长也会创建版本。编译动作仍会为可复现性保留隐藏的构建快照;历史版本面板默认隐藏这些构建快照,可通过“显示构建快照”开关查看。 成功编译后,PDF 预览会在可用时使用 SyncTeX 元数据。双击 PDF 中渲染出的某个单词时,编辑器会结合 PDF 单词框和多点 SyncTeX 采样跳转到匹配的 LaTeX 源文件,并选中对应的源码 token,避免退化成大范围行级选中。没有 SyncTeX 数据的旧构建需要重新编译后才能使用 PDF 到源码跳转。 diff --git a/src/deepscientist/daemon/api/handlers.py b/src/deepscientist/daemon/api/handlers.py index d8564c65..abbc5dc7 100644 --- a/src/deepscientist/daemon/api/handlers.py +++ b/src/deepscientist/daemon/api/handlers.py @@ -2127,11 +2127,12 @@ def latex_manifest(self, project_id: str, folder_id: str) -> dict: def latex_versions(self, project_id: str, folder_id: str, path: str = "") -> dict: query = self.parse_query(path) limit_raw = ((query.get("limit") or ["30"])[0] or "30").strip() + include_hidden = ((query.get("include_hidden") or ["false"])[0] or "").strip().lower() in {"1", "true", "yes", "on"} try: limit = int(limit_raw) except ValueError: limit = 30 - return self.app.latex_service.list_versions(project_id, folder_id, limit=limit) + return self.app.latex_service.list_versions(project_id, folder_id, limit=limit, include_hidden=include_hidden) def latex_version_create(self, project_id: str, folder_id: str, body: dict) -> dict: return self.app.latex_service.create_version( @@ -2145,6 +2146,14 @@ def latex_version_create(self, project_id: str, folder_id: str, body: dict) -> d allow_empty=body.get("allow_empty", True) is not False, ) + def latex_version_auto(self, project_id: str, folder_id: str, body: dict) -> dict: + return self.app.latex_service.maybe_create_auto_version( + project_id, + folder_id, + reason=body.get("reason"), + active_file=body.get("active_file"), + ) + def latex_versions_compare(self, project_id: str, folder_id: str, path: str = "") -> dict: query = self.parse_query(path) base = ((query.get("base") or [""])[0] or "").strip() diff --git a/src/deepscientist/daemon/api/router.py b/src/deepscientist/daemon/api/router.py index 64fe6e68..a830b548 100644 --- a/src/deepscientist/daemon/api/router.py +++ b/src/deepscientist/daemon/api/router.py @@ -178,6 +178,7 @@ ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/manifest$"), "latex_manifest"), ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions$"), "latex_versions"), ("POST", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions$"), "latex_version_create"), + ("POST", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions/auto$"), "latex_version_auto"), ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions/compare$"), "latex_versions_compare"), ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions/(?P[^/]+)$"), "latex_version"), ("GET", re.compile(r"^/api/v1/projects/(?P[^/]+)/latex/(?P[^/]+)/versions/(?P[^/]+)/files$"), "latex_version_files"), diff --git a/src/deepscientist/daemon/app.py b/src/deepscientist/daemon/app.py index 3cb8d826..a6858275 100644 --- a/src/deepscientist/daemon/app.py +++ b/src/deepscientist/daemon/app.py @@ -8772,6 +8772,9 @@ def _dispatch(self, method: str) -> None: "document_asset", "terminal_restore", "terminal_history", + "latex_versions", + "latex_versions_compare", + "latex_version_file", "latex_builds", "arxiv_list", "annotations_file", @@ -8799,7 +8802,7 @@ def _dispatch(self, method: str) -> None: "repair_create", "repair_close", "hardware_update", - } or route_name in {"document_open", "document_asset_upload", "quest_file_create_folder", "quest_file_upload", "quest_file_rename", "quest_file_move", "quest_file_delete", "chat_upload_create", "chat_upload_delete", "chat", "command", "quest_control", "quest_message_read_now", "quest_message_withdraw", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "latex_synctex_edit", "system_update_action", "weixin_login_qr_start", "weixin_login_qr_wait", "arxiv_import", "annotation_create", "auth_login", "auth_rotate"}: + } or route_name in {"document_open", "document_asset_upload", "quest_file_create_folder", "quest_file_upload", "quest_file_rename", "quest_file_move", "quest_file_delete", "chat_upload_create", "chat_upload_delete", "chat", "command", "quest_control", "quest_message_read_now", "quest_message_withdraw", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "latex_version_auto", "latex_synctex_edit", "system_update_action", "weixin_login_qr_start", "weixin_login_qr_wait", "arxiv_import", "annotation_create", "auth_login", "auth_rotate"}: payload = result(**params, body=body) elif route_name == "config_validate": payload = result(body) diff --git a/src/deepscientist/latex_runtime.py b/src/deepscientist/latex_runtime.py index 78d767f2..db704404 100644 --- a/src/deepscientist/latex_runtime.py +++ b/src/deepscientist/latex_runtime.py @@ -9,15 +9,18 @@ import unicodedata import zipfile from collections import Counter +from datetime import UTC, datetime from pathlib import Path from typing import Any from urllib.parse import quote, unquote +from .file_lock import advisory_file_lock from .gitops import compare_refs, head_commit from .runtime_tools import RuntimeToolService from .shared import ( ensure_dir, generate_id, + read_json, resolve_within, run_command, run_command_bytes, @@ -79,7 +82,16 @@ "Build", "Label", "Description", + "Base", + "Reason", + "Hidden", } +_LATEX_AUTO_VERSION_POLICY_VERSION = "auto-v1" +_LATEX_AUTO_VERSION_MIN_INTERVAL_SECONDS = 5 * 60 +_LATEX_AUTO_VERSION_TIME_INTERVAL_SECONDS = 15 * 60 +_LATEX_AUTO_VERSION_SIGNIFICANT_LINES = 30 +_LATEX_AUTO_VERSION_SIGNIFICANT_CHARS = 1500 +_LATEX_AUTO_VERSION_SIGNIFICANT_FILES = 2 def _encode_relative(value: str) -> str: @@ -1151,6 +1163,12 @@ def _folder_version_root(self, project_id: str, folder_relative: str) -> Path: def _folder_version_index_path(self, project_id: str, folder_relative: str) -> Path: return self._folder_version_root(project_id, folder_relative) / "index.json" + def _folder_auto_version_state_path(self, project_id: str, folder_relative: str) -> Path: + return self._folder_version_root(project_id, folder_relative) / "auto_state.json" + + def _folder_version_lock_path(self, project_id: str, folder_relative: str) -> Path: + return self._folder_version_root(project_id, folder_relative) / "version.lock" + def _build_record_path(self, project_id: str, folder_relative: str, build_id: str) -> Path: return self._folder_build_root(project_id, folder_relative) / "builds" / build_id / "build.json" @@ -1173,6 +1191,14 @@ def _git_bytes(repo: Path, args: list[str]) -> bytes: raise RuntimeError(message or "Git command failed.") return result.stdout + @staticmethod + def _commit_exists(repo: Path, commit: str | None) -> bool: + raw = str(commit or "").strip() + if not raw: + return False + result = run_command(["git", "cat-file", "-e", f"{raw}^{{commit}}"], cwd=repo, check=False) + return result.returncode == 0 + @staticmethod def _clean_version_text(value: Any, *, fallback: str = "", max_length: int = 500) -> str: text = str(value or "").strip() @@ -1196,6 +1222,23 @@ def _parse_latex_version_trailers(message: str) -> dict[str, str]: trailers[trailer_key.lower()] = value.strip() return trailers + @staticmethod + def _is_truthy_trailer(value: Any) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + @staticmethod + def _parse_utc_timestamp(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + def _latex_version_commit_message( self, *, @@ -1207,6 +1250,9 @@ def _latex_version_commit_message( source: str, author: str | None, build_id: str | None, + base_commit: str | None = None, + reason: str | None = None, + hidden: bool = False, ) -> str: title = self._clean_version_text(label, fallback="LaTeX version", max_length=120) body_lines = [f"latex: {title}", ""] @@ -1221,6 +1267,9 @@ def _latex_version_commit_message( "Build": build_id or "", "Label": title, "Description": self._clean_version_text(description or "", max_length=500), + "Base": self._clean_version_text(base_commit or "", max_length=80), + "Reason": self._clean_version_text(reason or "", max_length=80), + "Hidden": "true" if hidden else "false", } for key, value in trailers.items(): body_lines.append(f"{_LATEX_VERSION_TRAILER_PREFIX}{key}: {value}") @@ -1230,6 +1279,67 @@ def _folder_has_git_changes(self, repo: Path, folder_relative: str) -> bool: result = run_command(["git", "status", "--porcelain", "--", folder_relative], cwd=repo, check=False) return bool((result.stdout or "").strip()) + @staticmethod + def _is_transient_latex_artifact_path(relative: str) -> bool: + path = Path(str(relative or "")) + name = path.name + lower_name = name.lower() + suffix = path.suffix.lower() + return suffix in _TRANSIENT_SOURCE_SUFFIXES or any( + lower_name.endswith(transient_suffix) for transient_suffix in _TRANSIENT_SOURCE_SUFFIXES + ) + + @classmethod + def _is_latex_version_file(cls, relative: str, main_file_relative: str | None = None) -> bool: + normalized = str(relative or "").strip().lstrip("/") + if not normalized: + return False + path = Path(normalized) + if any(part.startswith(".git") for part in path.parts): + return False + if path.name.startswith("."): + return False + if cls._is_transient_latex_artifact_path(normalized): + return False + if main_file_relative: + main_pdf = Path(main_file_relative).with_suffix(".pdf").as_posix() + if normalized == main_pdf: + return False + return path.suffix.lower() in _LATEX_MANIFEST_SUFFIXES + + def _latex_version_paths(self, repo: Path, folder_path: Path, folder_relative: str, main_file_relative: str | None) -> list[str]: + paths: set[str] = set() + for path in sorted(folder_path.rglob("*")): + if not path.is_file(): + continue + try: + relative = path.relative_to(repo).as_posix() + except ValueError: + continue + if self._is_latex_version_file(relative, main_file_relative): + paths.add(relative) + + status = run_command(["git", "status", "--porcelain", "--", folder_relative], cwd=repo, check=False) + if status.returncode == 0: + for raw_line in status.stdout.splitlines(): + if len(raw_line) < 4: + continue + raw_path = raw_line[3:].strip() + candidates = [raw_path] + if " -> " in raw_path: + candidates = [part.strip() for part in raw_path.split(" -> ", 1)] + for candidate in candidates: + if self._is_latex_version_file(candidate, main_file_relative): + paths.add(candidate) + return sorted(paths) + + @staticmethod + def _paths_have_git_changes(repo: Path, paths: list[str]) -> bool: + if not paths: + return False + result = run_command(["git", "status", "--porcelain", "--", *paths], cwd=repo, check=False) + return bool((result.stdout or "").strip()) + def _create_empty_latex_version_commit(self, repo: Path, message: str) -> tuple[bool, str | None, str | None]: head = head_commit(repo) if not head: @@ -1246,7 +1356,110 @@ def _create_empty_latex_version_commit(self, repo: Path, message: str) -> tuple[ return False, head, (update.stderr or update.stdout or "Failed to update HEAD.").strip() return True, commit, None - def _latex_commit_stats(self, repo: Path, commit: str, folder_relative: str) -> dict[str, Any]: + def _latex_range_stats( + self, + repo: Path, + folder_relative: str, + base_ref: str | None, + head_ref: str | None = None, + main_file_relative: str | None = None, + ) -> dict[str, Any]: + args = ["diff", "--find-renames", "--numstat"] + if base_ref and head_ref: + args.extend([base_ref, head_ref]) + elif base_ref: + args.append(base_ref) + else: + args.append("HEAD") + args.extend(["--", folder_relative]) + result = run_command(["git", *args], cwd=repo, check=False) + raw = result.stdout if result.returncode == 0 else "" + + changed_paths: list[str] = [] + seen_paths: set[str] = set() + added_total = 0 + removed_total = 0 + binary_files = 0 + for line in raw.splitlines(): + parts = line.split("\t") + if len(parts) < 3: + continue + added_raw, removed_raw, file_path = parts[0], parts[1], parts[-1] + file_path = file_path.strip() + if not file_path: + continue + if not self._is_latex_version_file(file_path, main_file_relative): + continue + if file_path not in seen_paths: + seen_paths.add(file_path) + changed_paths.append(file_path) + if added_raw.isdigit(): + added_total += int(added_raw) + elif added_raw == "-": + binary_files += 1 + if removed_raw.isdigit(): + removed_total += int(removed_raw) + + untracked_count = 0 + untracked_bytes = 0 + if head_ref is None: + untracked = run_command( + ["git", "ls-files", "--others", "--exclude-standard", "--", folder_relative], + cwd=repo, + check=False, + ) + if untracked.returncode == 0: + for raw_path in untracked.stdout.splitlines(): + file_path = raw_path.strip() + if not file_path or file_path in seen_paths: + continue + if not self._is_latex_version_file(file_path, main_file_relative): + continue + path = repo / file_path + if not path.exists() or not path.is_file(): + continue + seen_paths.add(file_path) + changed_paths.append(file_path) + untracked_count += 1 + try: + size = path.stat().st_size + except OSError: + size = 0 + untracked_bytes += max(0, size) + if path.suffix.lower() in _LATEX_EDITABLE_SUFFIXES: + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + text = "" + added_total += len(text.splitlines()) or (1 if text else 0) + + changed_lines = added_total + removed_total + changed_chars = changed_lines * 80 + untracked_bytes + score = changed_lines + min(changed_chars // 80, 100) + len(changed_paths) * 10 + binary_files * 10 + significant = ( + changed_lines >= _LATEX_AUTO_VERSION_SIGNIFICANT_LINES + or changed_chars >= _LATEX_AUTO_VERSION_SIGNIFICANT_CHARS + or len(changed_paths) >= _LATEX_AUTO_VERSION_SIGNIFICANT_FILES + or score >= 40 + ) + return { + "changed_paths": changed_paths, + "file_count": len(changed_paths), + "changed_files": len(changed_paths), + "added": added_total, + "removed": removed_total, + "changed_lines": changed_lines, + "changed_chars": changed_chars, + "binary_files": binary_files, + "untracked_files": untracked_count, + "score": score, + "significant": significant, + "has_changes": bool(changed_paths or changed_lines or changed_chars), + "base": base_ref, + "head": head_ref, + } + + def _latex_commit_stats(self, repo: Path, commit: str, folder_relative: str, main_file_relative: str | None = None) -> dict[str, Any]: try: raw = self._git_stdout( repo, @@ -1265,6 +1478,8 @@ def _latex_commit_stats(self, repo: Path, commit: str, folder_relative: str) -> file_path = file_path.strip() if not file_path: continue + if not self._is_latex_version_file(file_path, main_file_relative): + continue changed_paths.append(file_path) if added_raw.isdigit(): added_total += int(added_raw) @@ -1301,25 +1516,39 @@ def _version_summary_from_commit( version_id = trailers.get("version") or sha label = trailers.get("label") or subject.removeprefix("latex:").strip() or short_sha description = trailers.get("description") or None - stats = self._latex_commit_stats(repo, sha, folder_relative) + main_file_relative = trailers.get("main") or None + source = trailers.get("source") or "manual" + hidden = self._is_truthy_trailer(trailers.get("hidden")) or ( + source == "compile" and "hidden" not in trailers + ) parent_raw = run_command(["git", "rev-list", "--parents", "-n", "1", sha], cwd=repo, check=False) parent_parts = (parent_raw.stdout or "").strip().split() parents = parent_parts[1:] if len(parent_parts) > 1 else [] + trailer_base = trailers.get("base") or None + if trailer_base and not self._commit_exists(repo, trailer_base): + trailer_base = None + compare_base = trailer_base or (parents[0] if parents else None) + if compare_base: + stats = self._latex_range_stats(repo, folder_relative, compare_base, sha, main_file_relative=main_file_relative) + else: + stats = self._latex_commit_stats(repo, sha, folder_relative, main_file_relative=main_file_relative) return { "version_id": version_id, "commit": sha, "short_commit": short_sha or sha[:7], "parents": parents, - "compare_base": parents[0] if parents else None, + "compare_base": compare_base, "folder_id": folder_id, "folder_path": folder_relative, - "main_file_path": trailers.get("main") or None, + "main_file_path": main_file_relative, "label": label, "description": description, - "source": trailers.get("source") or "manual", + "source": source, "author": trailers.get("author") or author_name or None, "created_at": authored_at or utc_now(), "build_id": trailers.get("build") or None, + "reason": trailers.get("reason") or None, + "hidden": hidden, **stats, } @@ -1343,6 +1572,47 @@ def _remember_latex_version(self, project_id: str, folder_relative: str, summary }, ) + def _read_latex_auto_version_state(self, project_id: str, folder_relative: str) -> dict[str, Any]: + payload = read_json(self._folder_auto_version_state_path(project_id, folder_relative), default={}) + return payload if isinstance(payload, dict) else {} + + def _write_latex_auto_version_state(self, project_id: str, folder_relative: str, state: dict[str, Any]) -> None: + payload = { + "schema_version": 1, + "policy_version": _LATEX_AUTO_VERSION_POLICY_VERSION, + "folder_path": folder_relative, + "updated_at": utc_now(), + **state, + } + write_json(self._folder_auto_version_state_path(project_id, folder_relative), payload) + + def _remember_latex_visible_version(self, project_id: str, folder_relative: str, summary: dict[str, Any]) -> None: + if summary.get("hidden"): + return + commit = str(summary.get("commit") or "").strip() + if not commit: + return + state = self._read_latex_auto_version_state(project_id, folder_relative) + created_at = str(summary.get("created_at") or utc_now()) + state.update( + { + "last_visible_version_commit": commit, + "last_visible_version_id": summary.get("version_id"), + "last_visible_version_at": created_at, + "last_visible_source": summary.get("source"), + } + ) + if str(summary.get("source") or "").lower() in {"auto", "ai"}: + state.update( + { + "last_auto_version_commit": commit, + "last_auto_version_id": summary.get("version_id"), + "last_auto_version_at": created_at, + "last_auto_reason": summary.get("reason"), + } + ) + self._write_latex_auto_version_state(project_id, folder_relative, state) + def _list_build_records(self, project_id: str, folder_relative: str) -> list[dict[str, Any]]: builds_root = self._folder_build_root(project_id, folder_relative) / "builds" if not builds_root.exists(): @@ -1544,6 +1814,9 @@ def create_version( author: str | None = None, build_id: str | None = None, allow_empty: bool = True, + hidden: bool = False, + reason: str | None = None, + base_commit: str | None = None, ) -> dict[str, Any]: folder_path, folder_relative = self._resolve_folder_path(project_id, folder_id) _main_tex_path, main_tex_relative = self._resolve_main_tex(project_id, folder_path, folder_relative, None) @@ -1570,9 +1843,13 @@ def create_version( source=resolved_source, author=author, build_id=build_id, + base_commit=base_commit, + reason=reason, + hidden=hidden, ) - has_changes = self._folder_has_git_changes(repo, folder_relative) + version_paths = self._latex_version_paths(repo, folder_path, folder_relative, main_tex_relative) + has_changes = self._paths_have_git_changes(repo, version_paths) previous_head = head_commit(repo) if not has_changes and not allow_empty: return { @@ -1584,14 +1861,14 @@ def create_version( "head": previous_head, "folder_id": folder_id, "folder_path": folder_relative, - } + } if has_changes: - run_command(["git", "add", "-A", "--", folder_relative], cwd=repo, check=False) + run_command(["git", "add", "-A", "--", *version_paths], cwd=repo, check=False) command = ["git", "commit"] if allow_empty: command.append("--allow-empty") - command.extend(["-m", message, "--", folder_relative]) + command.extend(["-m", message, "--", *version_paths]) result = run_command(command, cwd=repo, check=False) if result.returncode != 0: return { @@ -1640,12 +1917,16 @@ def create_version( "author": author or "user", "created_at": utc_now(), "build_id": build_id, + "reason": reason, + "hidden": bool(hidden), + "compare_base": base_commit, "changed_paths": [], "file_count": 0, "added": 0, "removed": 0, } self._remember_latex_version(project_id, folder_relative, summary) + self._remember_latex_visible_version(project_id, folder_relative, summary) return { "ok": True, "created": True, @@ -1656,11 +1937,11 @@ def create_version( **summary, } - def list_versions(self, project_id: str, folder_id: str, limit: int = 30) -> dict[str, Any]: + def list_versions(self, project_id: str, folder_id: str, limit: int = 30, *, include_hidden: bool = False) -> dict[str, Any]: _folder_path, folder_relative = self._resolve_folder_path(project_id, folder_id) repo = self._workspace_root(project_id) resolved_limit = max(1, min(int(limit or 30), 100)) - scan_limit = max(100, resolved_limit * 8) + scan_limit = max(500, resolved_limit * 50) try: raw = self._git_stdout( repo, @@ -1692,6 +1973,8 @@ def list_versions(self, project_id: str, folder_id: str, limit: int = 30) -> dic ) if not summary: continue + if summary.get("hidden") and not include_hidden: + continue versions.append(summary) if len(versions) >= resolved_limit: break @@ -1703,15 +1986,196 @@ def list_versions(self, project_id: str, folder_id: str, limit: int = 30) -> dic "head": head_commit(repo), "versions": versions, "limit": resolved_limit, + "include_hidden": include_hidden, } + def maybe_create_auto_version( + self, + project_id: str, + folder_id: str, + *, + reason: str | None = None, + active_file: str | None = None, + ) -> dict[str, Any]: + folder_path, folder_relative = self._resolve_folder_path(project_id, folder_id) + _main_tex_path, main_tex_relative = self._resolve_main_tex(project_id, folder_path, folder_relative, None) + repo = self._workspace_root(project_id) + reason_key = self._clean_version_text(reason or "idle_save", fallback="idle_save", max_length=80) + reason_key = re.sub(r"[^a-zA-Z0-9_-]+", "_", reason_key.lower()).strip("_") or "idle_save" + + with advisory_file_lock(self._folder_version_lock_path(project_id, folder_relative)): + state = self._read_latex_auto_version_state(project_id, folder_relative) + visible_versions = self.list_versions(project_id, folder_id, limit=1, include_hidden=False).get("versions") or [] + latest_visible = visible_versions[0] if visible_versions and isinstance(visible_versions[0], dict) else None + if latest_visible: + state_commit = str(state.get("last_visible_version_commit") or "").strip() + latest_commit = str(latest_visible.get("commit") or "").strip() + if latest_commit and state_commit != latest_commit: + state.update( + { + "last_visible_version_commit": latest_commit, + "last_visible_version_id": latest_visible.get("version_id"), + "last_visible_version_at": latest_visible.get("created_at") or utc_now(), + "last_visible_source": latest_visible.get("source"), + } + ) + + base_commit = str(state.get("last_visible_version_commit") or "").strip() + if base_commit and not self._commit_exists(repo, base_commit): + base_commit = "" + if not base_commit and latest_visible: + base_commit = str(latest_visible.get("commit") or "").strip() + if base_commit and not self._commit_exists(repo, base_commit): + base_commit = "" + if not base_commit: + base_commit = head_commit(repo) or "" + + metrics = self._latex_range_stats( + repo, + folder_relative, + base_commit or None, + None, + main_file_relative=main_tex_relative, + ) + if not metrics.get("has_changes"): + self._write_latex_auto_version_state(project_id, folder_relative, state) + return { + "ok": True, + "created": False, + "skipped_reason": "no_changes", + "message": "No LaTeX source changes to checkpoint.", + "folder_id": folder_id, + "folder_path": folder_relative, + "head": head_commit(repo), + "base": base_commit or None, + "metrics": metrics, + "policy": { + "version": _LATEX_AUTO_VERSION_POLICY_VERSION, + "min_interval_seconds": _LATEX_AUTO_VERSION_MIN_INTERVAL_SECONDS, + "time_interval_seconds": _LATEX_AUTO_VERSION_TIME_INTERVAL_SECONDS, + }, + } + + now = datetime.now(UTC) + last_checkpoint_at = self._parse_utc_timestamp(state.get("last_visible_version_at")) + if last_checkpoint_at is None: + last_checkpoint_at = self._parse_utc_timestamp(state.get("last_auto_version_at")) + elapsed_seconds = None + if last_checkpoint_at is not None: + elapsed_seconds = max(0, int((now - last_checkpoint_at).total_seconds())) + if elapsed_seconds < _LATEX_AUTO_VERSION_MIN_INTERVAL_SECONDS: + next_eligible = last_checkpoint_at.timestamp() + _LATEX_AUTO_VERSION_MIN_INTERVAL_SECONDS + return { + "ok": True, + "created": False, + "skipped_reason": "too_soon", + "message": "The last LaTeX checkpoint is too recent.", + "folder_id": folder_id, + "folder_path": folder_relative, + "head": head_commit(repo), + "base": base_commit or None, + "metrics": metrics, + "elapsed_seconds": elapsed_seconds, + "next_eligible_at": datetime.fromtimestamp(next_eligible, UTC).replace(microsecond=0).isoformat(), + "policy": { + "version": _LATEX_AUTO_VERSION_POLICY_VERSION, + "min_interval_seconds": _LATEX_AUTO_VERSION_MIN_INTERVAL_SECONDS, + "time_interval_seconds": _LATEX_AUTO_VERSION_TIME_INTERVAL_SECONDS, + }, + } + + first_visible_checkpoint = latest_visible is None and not state.get("last_visible_version_commit") + significant = bool(metrics.get("significant")) + timed = elapsed_seconds is not None and elapsed_seconds >= _LATEX_AUTO_VERSION_TIME_INTERVAL_SECONDS + if not (first_visible_checkpoint or significant or timed): + self._write_latex_auto_version_state(project_id, folder_relative, state) + return { + "ok": True, + "created": False, + "skipped_reason": "change_below_threshold", + "message": "LaTeX changes are below the automatic checkpoint threshold.", + "folder_id": folder_id, + "folder_path": folder_relative, + "head": head_commit(repo), + "base": base_commit or None, + "metrics": metrics, + "elapsed_seconds": elapsed_seconds, + "policy": { + "version": _LATEX_AUTO_VERSION_POLICY_VERSION, + "min_interval_seconds": _LATEX_AUTO_VERSION_MIN_INTERVAL_SECONDS, + "time_interval_seconds": _LATEX_AUTO_VERSION_TIME_INTERVAL_SECONDS, + "significant_lines": _LATEX_AUTO_VERSION_SIGNIFICANT_LINES, + "significant_chars": _LATEX_AUTO_VERSION_SIGNIFICANT_CHARS, + "significant_files": _LATEX_AUTO_VERSION_SIGNIFICANT_FILES, + }, + } + + source = "ai" if reason_key == "ai_edit" else "auto" + label = "AI edit" if source == "ai" else "Auto version" + if reason_key == "visibility_hidden": + description = "Automatic checkpoint captured when the editor was hidden." + elif reason_key == "compile": + description = "Automatic checkpoint captured before LaTeX compilation." + elif source == "ai": + description = "Automatic checkpoint captured after an AI file edit." + elif timed: + description = "Automatic checkpoint captured after an editing interval." + else: + description = "Automatic checkpoint captured after significant LaTeX edits." + + created = self.create_version( + project_id, + folder_id, + label=label, + description=description, + source=source, + author="ai" if source == "ai" else "system", + allow_empty=True, + hidden=False, + reason=reason_key, + base_commit=base_commit or None, + ) + if not created.get("ok"): + return { + **created, + "created": False, + "skipped_reason": "create_failed", + "metrics": metrics, + "base": base_commit or None, + } + state = self._read_latex_auto_version_state(project_id, folder_relative) + state.update( + { + "last_auto_metrics": metrics, + "last_auto_active_file": self._clean_version_text(active_file or "", max_length=240), + "last_auto_reason": reason_key, + } + ) + self._write_latex_auto_version_state(project_id, folder_relative, state) + return { + **created, + "created": True, + "skipped_reason": None, + "reason": reason_key, + "base": base_commit or None, + "metrics": metrics, + "policy": { + "version": _LATEX_AUTO_VERSION_POLICY_VERSION, + "min_interval_seconds": _LATEX_AUTO_VERSION_MIN_INTERVAL_SECONDS, + "time_interval_seconds": _LATEX_AUTO_VERSION_TIME_INTERVAL_SECONDS, + "significant_lines": _LATEX_AUTO_VERSION_SIGNIFICANT_LINES, + "significant_chars": _LATEX_AUTO_VERSION_SIGNIFICANT_CHARS, + "significant_files": _LATEX_AUTO_VERSION_SIGNIFICANT_FILES, + }, + } + def _resolve_version_commit(self, project_id: str, folder_id: str, version_id: str) -> tuple[str, dict[str, Any] | None, str]: _folder_path, folder_relative = self._resolve_folder_path(project_id, folder_id) repo = self._workspace_root(project_id) raw = str(version_id or "").strip() if not raw: raise ValueError("`version_id` is required.") - versions = self.list_versions(project_id, folder_id, limit=100).get("versions") or [] + versions = self.list_versions(project_id, folder_id, limit=100, include_hidden=True).get("versions") or [] for item in versions: if not isinstance(item, dict): continue @@ -1822,11 +2286,19 @@ def compare_versions(self, project_id: str, folder_id: str, *, base: str, head: head_commit_value, head_summary, _folder_relative = self._resolve_version_commit(project_id, folder_id, head) repo = self._workspace_root(project_id) payload = compare_refs(repo, base=base_commit, head=head_commit_value) + main_file_relative = str( + (head_summary or {}).get("main_file_path") + or (base_summary or {}).get("main_file_path") + or "" + ) files = [ item for item in payload.get("files", []) if str(item.get("path") or "") == folder_relative - or str(item.get("path") or "").startswith(f"{folder_relative.rstrip('/')}/") + or ( + str(item.get("path") or "").startswith(f"{folder_relative.rstrip('/')}/") + and self._is_latex_version_file(str(item.get("path") or ""), main_file_relative or None) + ) ] return { **payload, @@ -1840,7 +2312,12 @@ def compare_versions(self, project_id: str, folder_id: str, *, base: str, head: "file_count": len(files), } - def _current_manifest_paths(self, folder_path: Path) -> set[str]: + def _current_manifest_paths( + self, + folder_path: Path, + folder_relative: str = "", + main_file_relative: str | None = None, + ) -> set[str]: paths: set[str] = set() for path in sorted(folder_path.rglob("*")): if not path.is_file(): @@ -1851,7 +2328,8 @@ def _current_manifest_paths(self, folder_path: Path) -> set[str]: continue if any(part.startswith(".git") for part in Path(relative).parts): continue - if self._is_manifest_file(path): + workspace_relative = f"{folder_relative.rstrip('/')}/{relative}" if folder_relative else relative + if self._is_latex_version_file(workspace_relative, main_file_relative): paths.add(relative) return paths @@ -1878,7 +2356,15 @@ def restore_version( "current_head": current_head, } force = str(conflict_policy or "").strip().lower() == "force" - if self._folder_has_git_changes(repo, folder_relative) and not force: + folder_path, _folder_relative = self._resolve_folder_path(project_id, folder_id) + main_file_relative = str((summary or {}).get("main_file_path") or "") + if not main_file_relative: + try: + _main_tex_path, main_file_relative = self._resolve_main_tex(project_id, folder_path, folder_relative, None) + except Exception: + main_file_relative = "" + current_version_paths = self._latex_version_paths(repo, folder_path, folder_relative, main_file_relative or None) + if self._paths_have_git_changes(repo, current_version_paths) and not force: return { "ok": False, "conflict": True, @@ -1886,7 +2372,6 @@ def restore_version( "current_head": current_head, } - folder_path, _folder_relative = self._resolve_folder_path(project_id, folder_id) restore_mode = str(mode or "folder").strip().lower() restored_paths: list[str] = [] @@ -1906,14 +2391,14 @@ def restore_version( version_paths = [ line.strip() for line in raw_paths.splitlines() - if line.strip() and Path(line.strip()).suffix.lower() in _LATEX_MANIFEST_SUFFIXES + if line.strip() and self._is_latex_version_file(line.strip(), main_file_relative or None) ] version_rel_to_folder = { Path(relative).relative_to(Path(folder_relative)).as_posix() for relative in version_paths if relative == folder_relative or relative.startswith(f"{folder_relative.rstrip('/')}/") } - for current_relative in self._current_manifest_paths(folder_path): + for current_relative in self._current_manifest_paths(folder_path, folder_relative, main_file_relative or None): if current_relative not in version_rel_to_folder: try: (folder_path / current_relative).unlink() @@ -2099,8 +2584,20 @@ def compile( "source_version_id": None, "source_version": None, "source_version_error": None, + "auto_version_id": None, + "auto_version": None, + "auto_version_skipped_reason": None, } + try: + auto_version = self.maybe_create_auto_version(project_id, folder_id, reason="compile") + build["auto_version_skipped_reason"] = auto_version.get("skipped_reason") + if auto_version.get("created") and isinstance(auto_version.get("version"), dict): + build["auto_version_id"] = auto_version["version"].get("version_id") + build["auto_version"] = auto_version["version"] + except Exception: + pass + try: source_version = self.create_version( project_id, @@ -2110,6 +2607,8 @@ def compile( source="compile", author="system", build_id=build_id, + hidden=True, + reason="compile", allow_empty=not bool(auto), ) if source_version.get("ok") and isinstance(source_version.get("version"), dict): diff --git a/src/ui/src/lib/api/latex.ts b/src/ui/src/lib/api/latex.ts index 5ad0dbf5..e6b41eeb 100644 --- a/src/ui/src/lib/api/latex.ts +++ b/src/ui/src/lib/api/latex.ts @@ -111,8 +111,13 @@ export interface LatexVersionSummary { author?: string | null; created_at: string; build_id?: string | null; + reason?: string | null; + hidden?: boolean; changed_paths?: string[]; file_count?: number; + changed_files?: number; + changed_lines?: number; + changed_chars?: number; added?: number; removed?: number; } @@ -125,6 +130,7 @@ export interface LatexVersionListResponse { head?: string | null; versions: LatexVersionSummary[]; limit?: number; + include_hidden?: boolean; } export interface LatexVersionCreateRequest { @@ -145,6 +151,31 @@ export interface LatexVersionCreateResponse extends LatexVersionSummary { version?: LatexVersionSummary; } +export interface LatexAutoVersionRequest { + reason?: "idle_save" | "manual_save" | "ai_edit" | "compile" | "visibility_hidden" | string; + active_file?: string | null; +} + +export interface LatexAutoVersionResponse extends Partial { + ok: boolean; + created: boolean; + skipped_reason?: string | null; + metrics?: { + changed_paths?: string[]; + file_count?: number; + changed_files?: number; + added?: number; + removed?: number; + changed_lines?: number; + changed_chars?: number; + score?: number; + significant?: boolean; + has_changes?: boolean; + }; + next_eligible_at?: string | null; + elapsed_seconds?: number | null; +} + export interface LatexVersionFileEntry { id: string; name: string; @@ -294,11 +325,12 @@ export async function getLatexManifest( export async function listLatexVersions( projectId: string, folderId: string, - limit = 30 + limit = 30, + includeHidden = false ): Promise { const res = await apiClient.get( `/api/v1/projects/${projectId}/latex/${folderId}/versions`, - { params: { limit } } + { params: { limit, include_hidden: includeHidden ? "true" : undefined } } ); return res.data; } @@ -315,6 +347,18 @@ export async function createLatexVersion( return res.data; } +export async function maybeCreateLatexAutoVersion( + projectId: string, + folderId: string, + request: LatexAutoVersionRequest +): Promise { + const res = await apiClient.post( + `/api/v1/projects/${projectId}/latex/${folderId}/versions/auto`, + request + ); + return res.data; +} + export async function getLatexVersion( projectId: string, folderId: string, diff --git a/src/ui/src/lib/i18n/messages/latex.ts b/src/ui/src/lib/i18n/messages/latex.ts index cb221b3d..aefbbdc7 100644 --- a/src/ui/src/lib/i18n/messages/latex.ts +++ b/src/ui/src/lib/i18n/messages/latex.ts @@ -56,6 +56,8 @@ export const latexMessages: Partial>> version_compare_summary: 'Changes since this version', version_compare_empty: 'No LaTeX source differences.', version_changed_files: 'Changed files', + version_show_build_snapshots: 'Show build snapshots', + version_hidden_snapshot: 'build snapshot', version_restore_file: 'Restore file', version_restore_project: 'Restore project', version_restore_file_confirm: 'Restore the active file from this version? This will create a new restore version.', @@ -174,6 +176,8 @@ export const latexMessages: Partial>> version_compare_summary: '该版本至当前的变化', version_compare_empty: '没有 LaTeX 源码差异。', version_changed_files: '变更文件', + version_show_build_snapshots: '显示构建快照', + version_hidden_snapshot: '构建快照', version_restore_file: '恢复当前文件', version_restore_project: '恢复整个项目', version_restore_file_confirm: '要从该版本恢复当前文件吗?该操作会创建一个新的恢复版本。', diff --git a/src/ui/src/lib/plugins/latex/LatexPlugin.tsx b/src/ui/src/lib/plugins/latex/LatexPlugin.tsx index 94ec073e..dd7feefc 100644 --- a/src/ui/src/lib/plugins/latex/LatexPlugin.tsx +++ b/src/ui/src/lib/plugins/latex/LatexPlugin.tsx @@ -52,6 +52,7 @@ import { getLatexBuildPdfBlob, listLatexBuilds, listLatexVersions, + maybeCreateLatexAutoVersion, restoreLatexVersion, syncTexEditLatexBuild, type LatexCompiler, @@ -220,7 +221,8 @@ const normalizeBuildErrors = ( const LATEX_COMPILER_OPTIONS: LatexCompiler[] = ["pdflatex", "xelatex", "lualatex"]; const LATEX_AUTOSAVE_DELAY_MS = 1000; const LATEX_EXTERNAL_CHECK_INTERVAL_MS = 4000; -const LATEX_AUTO_VERSION_INTERVAL_MS = 5 * 60 * 1000; +const LATEX_AUTO_VERSION_IDLE_DELAY_MS = 25 * 1000; +const LATEX_AUTO_VERSION_MANUAL_DELAY_MS = 5 * 1000; const LATEX_AUTO_COMPILE_ON_SAVE_STORAGE_PREFIX = "ds:latex:auto-compile-on-save"; const BIB_SNIPPETS: BibSnippet[] = [ { @@ -740,6 +742,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const [historyCompare, setHistoryCompare] = React.useState(null); const [historyLabel, setHistoryLabel] = React.useState(""); const [historyDescription, setHistoryDescription] = React.useState(""); + const [historyShowBuildSnapshots, setHistoryShowBuildSnapshots] = React.useState(false); const [pdfObjectUrl, setPdfObjectUrl] = React.useState(null); const [logText, setLogText] = React.useState(null); const [zoomScale, setZoomScale] = React.useState(1); @@ -767,7 +770,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const loadedRevisionRef = React.useRef(null); const externalConflictRef = React.useRef(null); const externalCheckInFlightRef = React.useRef(false); - const lastAutoVersionAtRef = React.useRef(0); + const autoVersionTimerRef = React.useRef(null); const aiVersionTimerRef = React.useRef(null); const yDocRef = React.useRef(null); const yTextRef = React.useRef(null); @@ -964,7 +967,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug setHistoryLoading(true); setHistoryError(null); try { - const payload = await listLatexVersions(projectId, latexFolderId, 50); + const payload = await listLatexVersions(projectId, latexFolderId, 50, historyShowBuildSnapshots); const versions = Array.isArray(payload.versions) ? payload.versions : []; setLatexVersions(versions); setLatexVersionsHead(payload.head ?? null); @@ -979,39 +982,55 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug } finally { setHistoryLoading(false); } - }, [latexFolderId, projectId, t]); + }, [historyShowBuildSnapshots, latexFolderId, projectId, t]); const createAutoLatexVersion = React.useCallback( - async (source: "auto" | "ai", label: string, description: string) => { + async (reason: "idle_save" | "manual_save" | "ai_edit" | "compile" | "visibility_hidden") => { if (!projectId || !latexFolderId || viewReadOnly) return; + const activeMeta = activeFileIdRef.current + ? files.find((file) => file.id === activeFileIdRef.current) ?? null + : null; try { - const result = await createLatexVersion(projectId, latexFolderId, { - label, - description, - source, - author: source === "ai" ? "ai" : "system", - allow_empty: false, + const result = await maybeCreateLatexAutoVersion(projectId, latexFolderId, { + reason, + active_file: activeMeta?.path ?? activeMeta?.relativePath ?? null, }); - if (result.ok && historyOpen) { + if (result.ok && result.created && historyOpen) { void loadLatexVersionHistory(); } } catch (e) { console.warn("[LatexPlugin] Failed to create automatic LaTeX version:", e); } }, - [historyOpen, latexFolderId, loadLatexVersionHistory, projectId, viewReadOnly] + [files, historyOpen, latexFolderId, loadLatexVersionHistory, projectId, viewReadOnly] ); - const maybeCreateTimedAutoVersion = React.useCallback(() => { - const now = Date.now(); - if (now - lastAutoVersionAtRef.current < LATEX_AUTO_VERSION_INTERVAL_MS) return; - lastAutoVersionAtRef.current = now; - void createAutoLatexVersion( - "auto", - t("version_auto_label"), - t("version_auto_description") - ); - }, [createAutoLatexVersion, t]); + const queueLatexAutoVersionCheck = React.useCallback( + ( + reason: "idle_save" | "manual_save" | "ai_edit" | "compile" | "visibility_hidden", + delayMs = LATEX_AUTO_VERSION_IDLE_DELAY_MS + ) => { + if (!projectId || !latexFolderId || viewReadOnly) return; + if (typeof window === "undefined") return; + if (autoVersionTimerRef.current != null) { + window.clearTimeout(autoVersionTimerRef.current); + } + autoVersionTimerRef.current = window.setTimeout(() => { + autoVersionTimerRef.current = null; + void createAutoLatexVersion(reason); + }, delayMs); + }, + [createAutoLatexVersion, latexFolderId, projectId, viewReadOnly] + ); + + React.useEffect(() => { + return () => { + if (autoVersionTimerRef.current != null) { + window.clearTimeout(autoVersionTimerRef.current); + autoVersionTimerRef.current = null; + } + }; + }, []); const createManualLatexVersion = React.useCallback(async () => { if (!projectId || !latexFolderId || viewReadOnly) return; @@ -2257,7 +2276,11 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug if (currentText === textToSave) { setEditorDirty(false); if (trigger === "auto") { - maybeCreateTimedAutoVersion(); + queueLatexAutoVersionCheck("idle_save"); + } else if (trigger === "manual") { + queueLatexAutoVersionCheck("manual_save", LATEX_AUTO_VERSION_MANUAL_DELAY_MS); + } else if (trigger === "lifecycle") { + void createAutoLatexVersion("visibility_hidden"); } return true; } @@ -2307,7 +2330,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug saveInFlightRef.current = { fileId, promise }; return promise; - }, [activeFileId, effectiveReadOnly, maybeCreateTimedAutoVersion, setEditorDirty, setExternalConflictState, t, updateFileMeta]); + }, [activeFileId, createAutoLatexVersion, effectiveReadOnly, queueLatexAutoVersionCheck, setEditorDirty, setExternalConflictState, t, updateFileMeta]); const reloadExternalVersion = React.useCallback(() => { const conflict = externalConflictRef.current; @@ -2489,11 +2512,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug } aiVersionTimerRef.current = window.setTimeout(() => { aiVersionTimerRef.current = null; - void createAutoLatexVersion( - "ai", - t("version_ai_label"), - t("version_ai_description") - ); + queueLatexAutoVersionCheck("ai_edit"); }, 1200); } }; @@ -2505,7 +2524,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug aiVersionTimerRef.current = null; } }; - }, [activeFileId, checkExternalSnapshot, createAutoLatexVersion, files, projectId, t, viewReadOnly]); + }, [activeFileId, checkExternalSnapshot, files, projectId, queueLatexAutoVersionCheck, viewReadOnly]); React.useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { @@ -3460,6 +3479,17 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug {historyActionBusy ? : } {t("version_create")} +
@@ -3488,9 +3518,11 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug
- {version.short_commit || version.commit?.slice(0, 7)} - {version.created_at ? new Date(version.created_at).toLocaleString(language) : ""} - {typeof version.file_count === "number" ? {version.file_count} {t("version_files_changed")} : null} + {version.short_commit || version.commit?.slice(0, 7)} + {version.created_at ? new Date(version.created_at).toLocaleString(language) : ""} + {version.reason ? {version.reason} : null} + {version.hidden ? {t("version_hidden_snapshot")} : null} + {typeof version.file_count === "number" ? {version.file_count} {t("version_files_changed")} : null} {typeof version.added === "number" ? +{version.added} : null} {typeof version.removed === "number" ? -{version.removed} : null}
@@ -3512,9 +3544,11 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug
{selectedLatexVersion.label}
- {selectedLatexVersion.short_commit || selectedLatexVersion.commit.slice(0, 8)} - {selectedLatexVersion.source} - {selectedLatexVersion.author ? {selectedLatexVersion.author} : null} + {selectedLatexVersion.short_commit || selectedLatexVersion.commit.slice(0, 8)} + {selectedLatexVersion.source} + {selectedLatexVersion.reason ? {selectedLatexVersion.reason} : null} + {selectedLatexVersion.hidden ? {t("version_hidden_snapshot")} : null} + {selectedLatexVersion.author ? {selectedLatexVersion.author} : null} {selectedLatexVersion.build_id ? {t("version_build")}: {selectedLatexVersion.build_id} : null}
{selectedLatexVersion.description ? ( diff --git a/tests/test_api_contract_surface.py b/tests/test_api_contract_surface.py index dddf94b0..dbd4fbaf 100644 --- a/tests/test_api_contract_surface.py +++ b/tests/test_api_contract_surface.py @@ -149,6 +149,7 @@ def test_backend_routes_cover_shared_web_and_tui_surface() -> None: ("POST", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/compile", "latex_compile"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions", "latex_versions"), ("POST", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions", "latex_version_create"), + ("POST", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions/auto", "latex_version_auto"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions/compare", "latex_versions_compare"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions/latex-version-001", "latex_version"), ("GET", "/api/v1/projects/q-001/latex/quest-dir::q-001::paper%2Flatex/versions/latex-version-001/files", "latex_version_files"), @@ -391,11 +392,14 @@ def test_local_workspace_does_not_route_markdown_or_commands_through_dead_notebo assert 'window.addEventListener("ds:file:diff"' in latex_plugin_source assert "listLatexVersions" in latex_source assert "createLatexVersion" in latex_source + assert "maybeCreateLatexAutoVersion" in latex_source assert "restoreLatexVersion" in latex_source assert "compareLatexVersions" in latex_source assert "historyOpen" in latex_plugin_source assert "version_history" in latex_plugin_source assert "createAutoLatexVersion" in latex_plugin_source + assert "queueLatexAutoVersionCheck" in latex_plugin_source + assert "version_show_build_snapshots" in latex_plugin_source assert '"text/markdown": BUILTIN_PLUGINS.NOTEBOOK' in plugin_types_source assert '".md": BUILTIN_PLUGINS.NOTEBOOK' in plugin_types_source assert 'extensions: [".md", ".markdown"],\n mimeTypes: ["text/markdown", "text/x-markdown"],\n priority: 95,' in plugin_init_source diff --git a/tests/test_latex_runtime.py b/tests/test_latex_runtime.py index 84c5ff08..f51eb8a9 100644 --- a/tests/test_latex_runtime.py +++ b/tests/test_latex_runtime.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path from types import SimpleNamespace @@ -490,6 +491,7 @@ def test_latex_versions_create_compare_and_restore_file(temp_home: Path) -> None versions = service.list_versions(project_id, folder_id) assert versions["ok"] is True assert [item["label"] for item in versions["versions"][:2]] == ["After rewrite", "Before rewrite"] + assert all(not item.get("hidden") for item in versions["versions"]) compare = service.compare_versions( project_id, @@ -514,6 +516,50 @@ def test_latex_versions_create_compare_and_restore_file(temp_home: Path) -> None assert main_tex.read_text(encoding="utf-8") == "original source\n" +def test_latex_auto_versions_use_change_and_time_policy(temp_home: Path) -> None: + ensure_home_layout(temp_home) + ConfigManager(temp_home).ensure_files() + quest_service = QuestService(temp_home, skill_installer=SkillInstaller(repo_root(), temp_home)) + quest = quest_service.create("latex auto version quest") + quest_root = Path(quest["quest_root"]) + project_id = quest["quest_id"] + + latex_root = quest_root / "paper" / "latex" + latex_root.mkdir(parents=True, exist_ok=True) + main_tex = latex_root / "main.tex" + main_tex.write_text("baseline\n", encoding="utf-8") + + service = QuestLatexService(quest_service) + folder_id = f"quest-dir::{project_id}::paper%2Flatex" + + baseline = service.create_version(project_id, folder_id, label="Baseline") + assert baseline["ok"] is True + + main_tex.write_text("baseline\nsmall edit\n", encoding="utf-8") + too_soon = service.maybe_create_auto_version(project_id, folder_id, reason="idle_save") + assert too_soon["ok"] is True + assert too_soon["created"] is False + assert too_soon["skipped_reason"] == "too_soon" + + state_path = service._folder_auto_version_state_path(project_id, "paper/latex") + state = json.loads(state_path.read_text(encoding="utf-8")) + state["last_visible_version_at"] = "2000-01-01T00:00:00+00:00" + state_path.write_text(json.dumps(state), encoding="utf-8") + + main_tex.write_text("".join(f"line {idx}\n" for idx in range(45)), encoding="utf-8") + auto = service.maybe_create_auto_version(project_id, folder_id, reason="idle_save") + assert auto["ok"] is True + assert auto["created"] is True + assert auto["version"]["source"] == "auto" + assert auto["version"]["reason"] == "idle_save" + assert auto["metrics"]["changed_lines"] >= 30 + + versions = service.list_versions(project_id, folder_id) + assert versions["versions"][0]["source"] == "auto" + assert versions["versions"][0]["compare_base"] == baseline["commit"] + assert "paper/latex/main.tex" in versions["versions"][0]["changed_paths"] + + def test_latex_compile_records_source_version_even_without_latex_runtime(temp_home: Path, monkeypatch) -> None: ensure_home_layout(temp_home) ConfigManager(temp_home).ensure_files() @@ -540,5 +586,10 @@ def test_latex_compile_records_source_version_even_without_latex_runtime(temp_ho assert build["source_version_id"] assert build["source_commit"] versions = service.list_versions(project_id, folder_id) - assert versions["versions"][0]["source"] == "compile" - assert versions["versions"][0]["build_id"] == build["build_id"] + assert versions["versions"][0]["source"] == "auto" + assert not versions["versions"][0].get("hidden") + all_versions = service.list_versions(project_id, folder_id, include_hidden=True) + hidden_compile = [version for version in all_versions["versions"] if version["source"] == "compile"] + assert hidden_compile + assert hidden_compile[0]["hidden"] is True + assert hidden_compile[0]["build_id"] == build["build_id"] From f3993d4c6b1223628184eb354271ebe362aabcdf Mon Sep 17 00:00:00 2001 From: SmallSpider0 <568442079@qq.com> Date: Thu, 21 May 2026 18:44:48 +0800 Subject: [PATCH 6/8] fix(latex): pass body to version actions --- src/deepscientist/daemon/app.py | 2 +- tests/test_api_contract_surface.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/deepscientist/daemon/app.py b/src/deepscientist/daemon/app.py index a6858275..2a8ff3e3 100644 --- a/src/deepscientist/daemon/app.py +++ b/src/deepscientist/daemon/app.py @@ -8802,7 +8802,7 @@ def _dispatch(self, method: str) -> None: "repair_create", "repair_close", "hardware_update", - } or route_name in {"document_open", "document_asset_upload", "quest_file_create_folder", "quest_file_upload", "quest_file_rename", "quest_file_move", "quest_file_delete", "chat_upload_create", "chat_upload_delete", "chat", "command", "quest_control", "quest_message_read_now", "quest_message_withdraw", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "latex_version_auto", "latex_synctex_edit", "system_update_action", "weixin_login_qr_start", "weixin_login_qr_wait", "arxiv_import", "annotation_create", "auth_login", "auth_rotate"}: + } or route_name in {"document_open", "document_asset_upload", "quest_file_create_folder", "quest_file_upload", "quest_file_rename", "quest_file_move", "quest_file_delete", "chat_upload_create", "chat_upload_delete", "chat", "command", "quest_control", "quest_message_read_now", "quest_message_withdraw", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "latex_version_create", "latex_version_auto", "latex_version_restore", "latex_synctex_edit", "system_update_action", "weixin_login_qr_start", "weixin_login_qr_wait", "arxiv_import", "annotation_create", "auth_login", "auth_rotate"}: payload = result(**params, body=body) elif route_name == "config_validate": payload = result(body) diff --git a/tests/test_api_contract_surface.py b/tests/test_api_contract_surface.py index dbd4fbaf..e194c881 100644 --- a/tests/test_api_contract_surface.py +++ b/tests/test_api_contract_surface.py @@ -346,6 +346,7 @@ def test_local_workspace_does_not_route_markdown_or_commands_through_dead_notebo tabs_source = _read("src/ui/src/lib/stores/tabs.ts") plugin_types_source = _read("src/ui/src/lib/types/plugin.ts") plugin_init_source = _read("src/ui/src/lib/plugin/init.ts") + daemon_app_source = _read("src/deepscientist/daemon/app.py") assert "getMyToken(" not in workspace_source assert "rotateMyToken(" not in workspace_source @@ -395,6 +396,8 @@ def test_local_workspace_does_not_route_markdown_or_commands_through_dead_notebo assert "maybeCreateLatexAutoVersion" in latex_source assert "restoreLatexVersion" in latex_source assert "compareLatexVersions" in latex_source + assert '"latex_version_create"' in daemon_app_source + assert '"latex_version_restore"' in daemon_app_source assert "historyOpen" in latex_plugin_source assert "version_history" in latex_plugin_source assert "createAutoLatexVersion" in latex_plugin_source From 6b35b69e6bd3fb4945e768a8da7c2bc8bc1a2830 Mon Sep 17 00:00:00 2001 From: SmallSpider0 <568442079@qq.com> Date: Thu, 21 May 2026 19:03:24 +0800 Subject: [PATCH 7/8] feat(latex): show version diff previews --- docs/en/12_GUIDED_WORKFLOW_TOUR.md | 2 +- docs/zh/12_GUIDED_WORKFLOW_TOUR.md | 2 +- src/ui/src/lib/i18n/messages/latex.ts | 12 ++ src/ui/src/lib/plugins/latex/LatexPlugin.tsx | 189 +++++++++++++++---- tests/test_api_contract_surface.py | 7 + 5 files changed, 170 insertions(+), 42 deletions(-) diff --git a/docs/en/12_GUIDED_WORKFLOW_TOUR.md b/docs/en/12_GUIDED_WORKFLOW_TOUR.md index 4e3058a0..7a3421f4 100644 --- a/docs/en/12_GUIDED_WORKFLOW_TOUR.md +++ b/docs/en/12_GUIDED_WORKFLOW_TOUR.md @@ -347,7 +347,7 @@ The editor auto-saves source edits shortly after you type. Background autosaves If another process changes the active LaTeX source file while it is open, such as an AI edit or terminal command, the editor refreshes automatically when the local buffer has no unsaved edits. If the local buffer is dirty, autosave pauses and the editor asks you to either reload the external version or explicitly overwrite it, so an ordinary save cannot silently replace external changes. -The `History` button in the LaTeX toolbar opens a Git-backed version panel scoped to the current LaTeX folder. You can create named versions, inspect changed source files, compare a version with the current workspace, and restore either the active file or the whole LaTeX project. In addition to manual versions, the editor asks the backend to create Overleaf-style automatic checkpoints after idle saves, AI/file-diff edits, visibility changes, or compile actions. The backend applies a shared policy so checkpoints are not too frequent: it skips very recent checkpoints, creates a new visible version after significant source changes, and also creates one after a long enough editing interval even for smaller edits. Compile actions keep hidden build snapshots for reproducibility; the History panel hides those by default and can show them with the build-snapshot toggle. +The `History` button in the LaTeX toolbar opens a Git-backed version panel scoped to the current LaTeX folder. You can create named versions, inspect changed source files, view the changes saved by a version, compare a version with the current workspace, click any changed file to view a red/green unified diff, and restore either the active file or the whole LaTeX project. In addition to manual versions, the editor asks the backend to create Overleaf-style automatic checkpoints after idle saves, AI/file-diff edits, visibility changes, or compile actions. The backend applies a shared policy so checkpoints are not too frequent: it skips very recent checkpoints, creates a new visible version after significant source changes, and also creates one after a long enough editing interval even for smaller edits. Compile actions keep hidden build snapshots for reproducibility; the History panel hides those by default and can show them with the build-snapshot toggle. After a successful compile, the PDF preview uses SyncTeX metadata when available. Double-click a rendered PDF word to jump back to the matching LaTeX source file and select the corresponding source token; the editor uses the PDF word box plus multiple SyncTeX samples to avoid broad line-level selections. Older builds without SyncTeX data need to be recompiled before PDF-to-source jumps are available. diff --git a/docs/zh/12_GUIDED_WORKFLOW_TOUR.md b/docs/zh/12_GUIDED_WORKFLOW_TOUR.md index 42ecf426..fa94fa74 100644 --- a/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +++ b/docs/zh/12_GUIDED_WORKFLOW_TOUR.md @@ -345,7 +345,7 @@ Explorer 是 quest 的文件视角。 如果其它进程在当前 LaTeX 源文件打开期间修改了它,例如 AI 编辑或终端命令,且本地缓冲区没有未保存内容,编辑器会自动刷新到外部版本。若本地缓冲区已被修改,自动保存会暂停,并提示你选择重新载入外部版本或明确覆盖外部版本,避免普通保存静默覆盖外部修改。 -LaTeX 工具栏中的 `历史版本` 按钮会打开限定在当前 LaTeX 文件夹内的 Git 版本面板。你可以创建命名版本、查看变更源码文件、将某个版本与当前工作区比较,并恢复当前文件或整个 LaTeX 项目。除手动版本外,编辑器会在空闲自动保存、AI / 文件 diff 修改、页面隐藏或编译动作后请求后端创建类似 Overleaf 的自动检查点。后端统一执行“不要太频繁”的策略:距离上次检查点太近会跳过;源码改动达到一定量时创建新的可见版本;即使改动较小,只要编辑间隔足够长也会创建版本。编译动作仍会为可复现性保留隐藏的构建快照;历史版本面板默认隐藏这些构建快照,可通过“显示构建快照”开关查看。 +LaTeX 工具栏中的 `历史版本` 按钮会打开限定在当前 LaTeX 文件夹内的 Git 版本面板。你可以创建命名版本、查看变更源码文件、查看某个版本保存时的变化、将某个版本与当前工作区比较,点击任意变更文件查看红/绿 unified diff,并恢复当前文件或整个 LaTeX 项目。除手动版本外,编辑器会在空闲自动保存、AI / 文件 diff 修改、页面隐藏或编译动作后请求后端创建类似 Overleaf 的自动检查点。后端统一执行“不要太频繁”的策略:距离上次检查点太近会跳过;源码改动达到一定量时创建新的可见版本;即使改动较小,只要编辑间隔足够长也会创建版本。编译动作仍会为可复现性保留隐藏的构建快照;历史版本面板默认隐藏这些构建快照,可通过“显示构建快照”开关查看。 成功编译后,PDF 预览会在可用时使用 SyncTeX 元数据。双击 PDF 中渲染出的某个单词时,编辑器会结合 PDF 单词框和多点 SyncTeX 采样跳转到匹配的 LaTeX 源文件,并选中对应的源码 token,避免退化成大范围行级选中。没有 SyncTeX 数据的旧构建需要重新编译后才能使用 PDF 到源码跳转。 diff --git a/src/ui/src/lib/i18n/messages/latex.ts b/src/ui/src/lib/i18n/messages/latex.ts index aefbbdc7..b689c1d2 100644 --- a/src/ui/src/lib/i18n/messages/latex.ts +++ b/src/ui/src/lib/i18n/messages/latex.ts @@ -52,10 +52,16 @@ export const latexMessages: Partial>> version_select_hint: 'Select a version to inspect it.', version_files_changed: 'files', version_build: 'build', + version_view_changes: 'View changes', version_compare_current: 'Compare current', version_compare_summary: 'Changes since this version', + version_saved_changes_hint: 'Changes saved by this version.', + version_compare_current_hint: 'Changes from this version to the current workspace.', version_compare_empty: 'No LaTeX source differences.', version_changed_files: 'Changed files', + version_diff_loading: 'Loading diff…', + version_diff_failed: 'Failed to load diff.', + version_diff_select_file: 'Select a changed file to view the diff.', version_show_build_snapshots: 'Show build snapshots', version_hidden_snapshot: 'build snapshot', version_restore_file: 'Restore file', @@ -172,10 +178,16 @@ export const latexMessages: Partial>> version_select_hint: '选择一个版本查看详情。', version_files_changed: '个文件', version_build: '构建', + version_view_changes: '查看变更', version_compare_current: '与当前比较', version_compare_summary: '该版本至当前的变化', + version_saved_changes_hint: '显示该版本保存时的变化。', + version_compare_current_hint: '显示该版本到当前工作区之间的变化。', version_compare_empty: '没有 LaTeX 源码差异。', version_changed_files: '变更文件', + version_diff_loading: '正在加载差异…', + version_diff_failed: '加载差异失败。', + version_diff_select_file: '选择一个变更文件查看差异。', version_show_build_snapshots: '显示构建快照', version_hidden_snapshot: '构建快照', version_restore_file: '恢复当前文件', diff --git a/src/ui/src/lib/plugins/latex/LatexPlugin.tsx b/src/ui/src/lib/plugins/latex/LatexPlugin.tsx index dd7feefc..395ba7c3 100644 --- a/src/ui/src/lib/plugins/latex/LatexPlugin.tsx +++ b/src/ui/src/lib/plugins/latex/LatexPlugin.tsx @@ -20,6 +20,8 @@ import { import type { PluginComponentProps } from "@/lib/types/plugin"; import { cn } from "@/lib/utils"; import { client as questClient } from "@/lib/api"; +import { GitDiffViewer } from "@/components/workspace/GitDiffViewer"; +import type { GitDiffPayload } from "@/types"; import { listFiles, getFileContent, @@ -740,6 +742,11 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const [historyActionBusy, setHistoryActionBusy] = React.useState(false); const [historyError, setHistoryError] = React.useState(null); const [historyCompare, setHistoryCompare] = React.useState(null); + const [historyCompareMode, setHistoryCompareMode] = React.useState<"saved" | "current" | null>(null); + const [historyDiffPath, setHistoryDiffPath] = React.useState(null); + const [historyDiffPayload, setHistoryDiffPayload] = React.useState(null); + const [historyDiffLoading, setHistoryDiffLoading] = React.useState(false); + const [historyDiffError, setHistoryDiffError] = React.useState(null); const [historyLabel, setHistoryLabel] = React.useState(""); const [historyDescription, setHistoryDescription] = React.useState(""); const [historyShowBuildSnapshots, setHistoryShowBuildSnapshots] = React.useState(false); @@ -962,6 +969,14 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug [latexVersions, selectedVersionId] ); + const clearHistoryDiff = React.useCallback(() => { + setHistoryCompareMode(null); + setHistoryDiffPath(null); + setHistoryDiffPayload(null); + setHistoryDiffLoading(false); + setHistoryDiffError(null); + }, []); + const loadLatexVersionHistory = React.useCallback(async () => { if (!projectId || !latexFolderId) return; setHistoryLoading(true); @@ -1070,24 +1085,59 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug } }, [historyDescription, historyLabel, latexFolderId, loadLatexVersionHistory, projectId, t, viewReadOnly]); - const compareSelectedLatexVersion = React.useCallback(async () => { + const loadHistoryDiffFile = React.useCallback( + async (comparePayload: LatexVersionCompareResponse, filePath: string) => { + if (!projectId || !filePath) return; + setHistoryDiffLoading(true); + setHistoryDiffError(null); + try { + const diff = await questClient.gitDiffFile(projectId, comparePayload.base, comparePayload.head, filePath); + setHistoryDiffPath(filePath); + setHistoryDiffPayload(diff); + } catch (e) { + setHistoryDiffPayload(null); + setHistoryDiffError(e instanceof Error ? e.message : t("version_diff_failed")); + } finally { + setHistoryDiffLoading(false); + } + }, + [projectId, t] + ); + + const compareSelectedLatexVersion = React.useCallback(async (mode: "saved" | "current" = "current") => { if (!projectId || !latexFolderId || !selectedLatexVersion) return; + const selectedHead = selectedLatexVersion.commit || selectedLatexVersion.version_id; + const base = + mode === "saved" + ? selectedLatexVersion.compare_base || selectedLatexVersion.parents?.[0] || "" + : selectedLatexVersion.version_id || selectedLatexVersion.commit; + const head = mode === "saved" ? selectedHead : latexVersionsHead || "HEAD"; + if (!base || !head) { + setHistoryError(t("version_compare_failed")); + return; + } setHistoryActionBusy(true); setHistoryError(null); + clearHistoryDiff(); try { const result = await compareLatexVersions( projectId, latexFolderId, - selectedLatexVersion.version_id || selectedLatexVersion.commit, - latexVersionsHead || "HEAD" + base, + head ); setHistoryCompare(result); + setHistoryCompareMode(mode); + const firstFile = (result.files || []).find((file) => !file.binary) ?? (result.files || [])[0] ?? null; + if (firstFile?.path) { + await loadHistoryDiffFile(result, firstFile.path); + } } catch (e) { setHistoryError(e instanceof Error ? e.message : t("version_compare_failed")); } finally { setHistoryActionBusy(false); } - }, [latexFolderId, latexVersionsHead, projectId, selectedLatexVersion, t]); + }, [clearHistoryDiff, latexFolderId, latexVersionsHead, loadHistoryDiffFile, projectId, selectedLatexVersion, t]); const restoreSelectedLatexVersion = React.useCallback( async (mode: "file" | "folder") => { @@ -3486,6 +3536,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug onChange={(event) => { setHistoryShowBuildSnapshots(event.target.checked); setHistoryCompare(null); + clearHistoryDiff(); }} /> {t("version_show_build_snapshots")} @@ -3500,10 +3551,11 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug
-
- +
+
+ +
- {historyCompare ? ( -
-
- {t("version_compare_summary")}: {historyCompare.file_count ?? historyCompare.files?.length ?? 0} {t("version_files_changed")} -
-
- {(historyCompare.files || []).length > 0 ? ( - historyCompare.files.map((file) => ( -
- {file.path} - - {file.status || "M"} · +{file.added ?? 0} / -{file.removed ?? 0} - -
- )) - ) : ( -
{t("version_compare_empty")}
- )} -
-
- ) : null} + {historyCompare ? ( +
+
+
+ {t("version_compare_summary")}: {historyCompare.file_count ?? historyCompare.files?.length ?? 0} {t("version_files_changed")} +
+
+ {historyCompareMode === "saved" ? t("version_saved_changes_hint") : t("version_compare_current_hint")} +
+
+ {(historyCompare.files || []).length > 0 ? ( +
+
+ {(historyCompare.files || []).map((file) => { + const selected = historyDiffPath === file.path; + return ( + + ); + })} +
+
+ {historyDiffLoading ? ( +
+ + {t("version_diff_loading")} +
+ ) : historyDiffError ? ( +
+ + {historyDiffError} +
+ ) : historyDiffPayload ? ( +
+ +
+ ) : ( +
+ {t("version_diff_select_file")} +
+ )} +
+
+ ) : ( +
{t("version_compare_empty")}
+ )} +
+ ) : null} {selectedLatexVersion.changed_paths && selectedLatexVersion.changed_paths.length > 0 ? (
diff --git a/tests/test_api_contract_surface.py b/tests/test_api_contract_surface.py index e194c881..8c3dfb6b 100644 --- a/tests/test_api_contract_surface.py +++ b/tests/test_api_contract_surface.py @@ -403,6 +403,13 @@ def test_local_workspace_does_not_route_markdown_or_commands_through_dead_notebo assert "createAutoLatexVersion" in latex_plugin_source assert "queueLatexAutoVersionCheck" in latex_plugin_source assert "version_show_build_snapshots" in latex_plugin_source + assert "GitDiffViewer" in latex_plugin_source + assert "questClient.gitDiffFile" in latex_plugin_source + assert "historyDiffPath" in latex_plugin_source + assert "version_view_changes" in latex_plugin_source + assert "version_diff_select_file" in latex_plugin_source + assert "historyCompareMode" in latex_plugin_source + assert "version_saved_changes_hint" in latex_plugin_source assert '"text/markdown": BUILTIN_PLUGINS.NOTEBOOK' in plugin_types_source assert '".md": BUILTIN_PLUGINS.NOTEBOOK' in plugin_types_source assert 'extensions: [".md", ".markdown"],\n mimeTypes: ["text/markdown", "text/x-markdown"],\n priority: 95,' in plugin_init_source From ee0ae9e0e6a738af04ca39e7b4619b2515702666 Mon Sep 17 00:00:00 2001 From: SmallSpider0 <568442079@qq.com> Date: Thu, 21 May 2026 19:22:45 +0800 Subject: [PATCH 8/8] feat(latex): expand history diff view --- docs/en/12_GUIDED_WORKFLOW_TOUR.md | 2 +- docs/zh/12_GUIDED_WORKFLOW_TOUR.md | 2 +- src/ui/src/lib/i18n/messages/latex.ts | 10 +- src/ui/src/lib/plugins/latex/LatexPlugin.tsx | 161 ++++++++++--------- tests/test_api_contract_surface.py | 11 +- 5 files changed, 101 insertions(+), 85 deletions(-) diff --git a/docs/en/12_GUIDED_WORKFLOW_TOUR.md b/docs/en/12_GUIDED_WORKFLOW_TOUR.md index 7a3421f4..4cde438f 100644 --- a/docs/en/12_GUIDED_WORKFLOW_TOUR.md +++ b/docs/en/12_GUIDED_WORKFLOW_TOUR.md @@ -347,7 +347,7 @@ The editor auto-saves source edits shortly after you type. Background autosaves If another process changes the active LaTeX source file while it is open, such as an AI edit or terminal command, the editor refreshes automatically when the local buffer has no unsaved edits. If the local buffer is dirty, autosave pauses and the editor asks you to either reload the external version or explicitly overwrite it, so an ordinary save cannot silently replace external changes. -The `History` button in the LaTeX toolbar opens a Git-backed version panel scoped to the current LaTeX folder. You can create named versions, inspect changed source files, view the changes saved by a version, compare a version with the current workspace, click any changed file to view a red/green unified diff, and restore either the active file or the whole LaTeX project. In addition to manual versions, the editor asks the backend to create Overleaf-style automatic checkpoints after idle saves, AI/file-diff edits, visibility changes, or compile actions. The backend applies a shared policy so checkpoints are not too frequent: it skips very recent checkpoints, creates a new visible version after significant source changes, and also creates one after a long enough editing interval even for smaller edits. Compile actions keep hidden build snapshots for reproducibility; the History panel hides those by default and can show them with the build-snapshot toggle. +The `History` button in the LaTeX toolbar opens a Git-backed version view scoped to the current LaTeX folder. While History is open, it takes over the LaTeX content area instead of showing the source editor and PDF preview side by side. You can create named versions, inspect changed source files, select any history item to automatically compare it with the latest archive point, click any changed file to view a red/green unified diff, and restore either the active file or the whole LaTeX project. In addition to manual versions, the editor asks the backend to create Overleaf-style automatic checkpoints after idle saves, AI/file-diff edits, visibility changes, or compile actions. The backend applies a shared policy so checkpoints are not too frequent: it skips very recent checkpoints, creates a new visible version after significant source changes, and also creates one after a long enough editing interval even for smaller edits. Compile actions keep hidden build snapshots for reproducibility; the History panel hides those by default and can show them with the build-snapshot toggle. After a successful compile, the PDF preview uses SyncTeX metadata when available. Double-click a rendered PDF word to jump back to the matching LaTeX source file and select the corresponding source token; the editor uses the PDF word box plus multiple SyncTeX samples to avoid broad line-level selections. Older builds without SyncTeX data need to be recompiled before PDF-to-source jumps are available. diff --git a/docs/zh/12_GUIDED_WORKFLOW_TOUR.md b/docs/zh/12_GUIDED_WORKFLOW_TOUR.md index fa94fa74..959a0f5b 100644 --- a/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +++ b/docs/zh/12_GUIDED_WORKFLOW_TOUR.md @@ -345,7 +345,7 @@ Explorer 是 quest 的文件视角。 如果其它进程在当前 LaTeX 源文件打开期间修改了它,例如 AI 编辑或终端命令,且本地缓冲区没有未保存内容,编辑器会自动刷新到外部版本。若本地缓冲区已被修改,自动保存会暂停,并提示你选择重新载入外部版本或明确覆盖外部版本,避免普通保存静默覆盖外部修改。 -LaTeX 工具栏中的 `历史版本` 按钮会打开限定在当前 LaTeX 文件夹内的 Git 版本面板。你可以创建命名版本、查看变更源码文件、查看某个版本保存时的变化、将某个版本与当前工作区比较,点击任意变更文件查看红/绿 unified diff,并恢复当前文件或整个 LaTeX 项目。除手动版本外,编辑器会在空闲自动保存、AI / 文件 diff 修改、页面隐藏或编译动作后请求后端创建类似 Overleaf 的自动检查点。后端统一执行“不要太频繁”的策略:距离上次检查点太近会跳过;源码改动达到一定量时创建新的可见版本;即使改动较小,只要编辑间隔足够长也会创建版本。编译动作仍会为可复现性保留隐藏的构建快照;历史版本面板默认隐藏这些构建快照,可通过“显示构建快照”开关查看。 +LaTeX 工具栏中的 `历史版本` 按钮会打开限定在当前 LaTeX 文件夹内的 Git 版本视图。历史版本打开时会占满 LaTeX 内容区,不再同时显示源码编辑器和 PDF 预览。你可以创建命名版本、查看变更源码文件、点击任意历史版本后自动与最新的存档点比较,点击任意变更文件查看红/绿 unified diff,并恢复当前文件或整个 LaTeX 项目。除手动版本外,编辑器会在空闲自动保存、AI / 文件 diff 修改、页面隐藏或编译动作后请求后端创建类似 Overleaf 的自动检查点。后端统一执行“不要太频繁”的策略:距离上次检查点太近会跳过;源码改动达到一定量时创建新的可见版本;即使改动较小,只要编辑间隔足够长也会创建版本。编译动作仍会为可复现性保留隐藏的构建快照;历史版本面板默认隐藏这些构建快照,可通过“显示构建快照”开关查看。 成功编译后,PDF 预览会在可用时使用 SyncTeX 元数据。双击 PDF 中渲染出的某个单词时,编辑器会结合 PDF 单词框和多点 SyncTeX 采样跳转到匹配的 LaTeX 源文件,并选中对应的源码 token,避免退化成大范围行级选中。没有 SyncTeX 数据的旧构建需要重新编译后才能使用 PDF 到源码跳转。 diff --git a/src/ui/src/lib/i18n/messages/latex.ts b/src/ui/src/lib/i18n/messages/latex.ts index b689c1d2..8c106b24 100644 --- a/src/ui/src/lib/i18n/messages/latex.ts +++ b/src/ui/src/lib/i18n/messages/latex.ts @@ -41,7 +41,7 @@ export const latexMessages: Partial>> external_change_save_blocked: 'This file changed outside the editor. Reload or explicitly overwrite the external version before saving.', version_history: 'History', version_history_title: 'LaTeX versions', - version_history_hint: 'Create, compare, and restore Git-backed source versions.', + version_history_hint: 'Create, inspect, and restore Git-backed source versions.', version_current_head: 'Current HEAD', version_refresh: 'Refresh', version_label_placeholder: 'Version name', @@ -54,7 +54,8 @@ export const latexMessages: Partial>> version_build: 'build', version_view_changes: 'View changes', version_compare_current: 'Compare current', - version_compare_summary: 'Changes since this version', + version_compare_summary: 'Changes versus latest archive', + version_compare_latest_hint: 'The selected version is compared with the latest archive point automatically.', version_saved_changes_hint: 'Changes saved by this version.', version_compare_current_hint: 'Changes from this version to the current workspace.', version_compare_empty: 'No LaTeX source differences.', @@ -167,7 +168,7 @@ export const latexMessages: Partial>> external_change_save_blocked: '该文件已在编辑器外被修改。请先重新载入,或明确选择覆盖外部版本后再保存。', version_history: '历史版本', version_history_title: 'LaTeX 历史版本', - version_history_hint: '创建、比较并恢复 Git 支撑的源码版本。', + version_history_hint: '创建、查看并恢复 Git 支撑的源码版本。', version_current_head: '当前 HEAD', version_refresh: '刷新', version_label_placeholder: '版本名称', @@ -180,7 +181,8 @@ export const latexMessages: Partial>> version_build: '构建', version_view_changes: '查看变更', version_compare_current: '与当前比较', - version_compare_summary: '该版本至当前的变化', + version_compare_summary: '与最新存档点的变化', + version_compare_latest_hint: '所选版本会自动与最新的存档点比较。', version_saved_changes_hint: '显示该版本保存时的变化。', version_compare_current_hint: '显示该版本到当前工作区之间的变化。', version_compare_empty: '没有 LaTeX 源码差异。', diff --git a/src/ui/src/lib/plugins/latex/LatexPlugin.tsx b/src/ui/src/lib/plugins/latex/LatexPlugin.tsx index 395ba7c3..8e56f3c9 100644 --- a/src/ui/src/lib/plugins/latex/LatexPlugin.tsx +++ b/src/ui/src/lib/plugins/latex/LatexPlugin.tsx @@ -15,7 +15,6 @@ import { AtSign, History, RotateCcw, - GitCompare, } from "lucide-react"; import type { PluginComponentProps } from "@/lib/types/plugin"; import { cn } from "@/lib/utils"; @@ -742,7 +741,6 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const [historyActionBusy, setHistoryActionBusy] = React.useState(false); const [historyError, setHistoryError] = React.useState(null); const [historyCompare, setHistoryCompare] = React.useState(null); - const [historyCompareMode, setHistoryCompareMode] = React.useState<"saved" | "current" | null>(null); const [historyDiffPath, setHistoryDiffPath] = React.useState(null); const [historyDiffPayload, setHistoryDiffPayload] = React.useState(null); const [historyDiffLoading, setHistoryDiffLoading] = React.useState(false); @@ -778,6 +776,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug const externalConflictRef = React.useRef(null); const externalCheckInFlightRef = React.useRef(false); const autoVersionTimerRef = React.useRef(null); + const historyAutoCompareKeyRef = React.useRef(null); const aiVersionTimerRef = React.useRef(null); const yDocRef = React.useRef(null); const yTextRef = React.useRef(null); @@ -969,8 +968,12 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug [latexVersions, selectedVersionId] ); + const latestLatexArchiveVersion = React.useMemo( + () => latexVersions.find((version) => !version.hidden) ?? latexVersions[0] ?? null, + [latexVersions] + ); + const clearHistoryDiff = React.useCallback(() => { - setHistoryCompareMode(null); setHistoryDiffPath(null); setHistoryDiffPayload(null); setHistoryDiffLoading(false); @@ -981,6 +984,9 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug if (!projectId || !latexFolderId) return; setHistoryLoading(true); setHistoryError(null); + historyAutoCompareKeyRef.current = null; + setHistoryCompare(null); + clearHistoryDiff(); try { const payload = await listLatexVersions(projectId, latexFolderId, 50, historyShowBuildSnapshots); const versions = Array.isArray(payload.versions) ? payload.versions : []; @@ -990,14 +996,18 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug if (current && versions.some((version) => version.version_id === current || version.commit === current)) { return current; } - return versions[0]?.version_id ?? null; + const firstComparable = versions.find((version) => !version.hidden) ?? versions[0] ?? null; + const firstDifferentFromLatest = + versions.find((version) => (version.version_id || version.commit) !== (firstComparable?.version_id || firstComparable?.commit)) ?? + firstComparable; + return firstDifferentFromLatest?.version_id ?? firstDifferentFromLatest?.commit ?? null; }); } catch (e) { setHistoryError(e instanceof Error ? e.message : t("version_history_load_failed")); } finally { setHistoryLoading(false); } - }, [historyShowBuildSnapshots, latexFolderId, projectId, t]); + }, [clearHistoryDiff, historyShowBuildSnapshots, latexFolderId, projectId, t]); const createAutoLatexVersion = React.useCallback( async (reason: "idle_save" | "manual_save" | "ai_edit" | "compile" | "visibility_hidden") => { @@ -1104,14 +1114,10 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug [projectId, t] ); - const compareSelectedLatexVersion = React.useCallback(async (mode: "saved" | "current" = "current") => { - if (!projectId || !latexFolderId || !selectedLatexVersion) return; - const selectedHead = selectedLatexVersion.commit || selectedLatexVersion.version_id; - const base = - mode === "saved" - ? selectedLatexVersion.compare_base || selectedLatexVersion.parents?.[0] || "" - : selectedLatexVersion.version_id || selectedLatexVersion.commit; - const head = mode === "saved" ? selectedHead : latexVersionsHead || "HEAD"; + const compareLatexVersionWithLatest = React.useCallback(async (version: LatexVersionSummary | null = selectedLatexVersion) => { + if (!projectId || !latexFolderId || !version || !latestLatexArchiveVersion) return; + const base = version.version_id || version.commit; + const head = latestLatexArchiveVersion.version_id || latestLatexArchiveVersion.commit; if (!base || !head) { setHistoryError(t("version_compare_failed")); return; @@ -1127,7 +1133,6 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug head ); setHistoryCompare(result); - setHistoryCompareMode(mode); const firstFile = (result.files || []).find((file) => !file.binary) ?? (result.files || [])[0] ?? null; if (firstFile?.path) { await loadHistoryDiffFile(result, firstFile.path); @@ -1137,7 +1142,7 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug } finally { setHistoryActionBusy(false); } - }, [clearHistoryDiff, latexFolderId, latexVersionsHead, loadHistoryDiffFile, projectId, selectedLatexVersion, t]); + }, [clearHistoryDiff, latestLatexArchiveVersion, latexFolderId, loadHistoryDiffFile, projectId, selectedLatexVersion, t]); const restoreSelectedLatexVersion = React.useCallback( async (mode: "file" | "folder") => { @@ -1196,6 +1201,23 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug void loadLatexVersionHistory(); }, [historyOpen, loadLatexVersionHistory]); + React.useEffect(() => { + if (!historyOpen || historyLoading || !selectedLatexVersion || !latestLatexArchiveVersion) return; + const selectedRef = selectedLatexVersion.version_id || selectedLatexVersion.commit; + const latestRef = latestLatexArchiveVersion.version_id || latestLatexArchiveVersion.commit; + if (!selectedRef || !latestRef) return; + const compareKey = `${selectedRef}->${latestRef}`; + if (historyAutoCompareKeyRef.current === compareKey) return; + historyAutoCompareKeyRef.current = compareKey; + void compareLatexVersionWithLatest(selectedLatexVersion); + }, [ + compareLatexVersionWithLatest, + historyLoading, + historyOpen, + latestLatexArchiveVersion, + selectedLatexVersion, + ]); + React.useEffect(() => { const activeFileMeta = files.find((file) => file.id === activeFileId) ?? @@ -3266,11 +3288,12 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug
{historyOpen ? ( -
-
-
+
+
+
{t("version_history_title")}
@@ -3532,18 +3555,19 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug
-
+
{latexVersions.length > 0 ? ( latexVersions.map((version) => { const selected = selectedLatexVersion?.version_id === version.version_id; @@ -3551,11 +3575,12 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug
-
+
{selectedLatexVersion ? ( -
+
{selectedLatexVersion.label}
@@ -3606,31 +3631,11 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug {selectedLatexVersion.description ? (
{selectedLatexVersion.description}
) : null} -
-
+
+
- -
- {historyCompare ? ( -
-
-
- {t("version_compare_summary")}: {historyCompare.file_count ?? historyCompare.files?.length ?? 0} {t("version_files_changed")} -
-
- {historyCompareMode === "saved" ? t("version_saved_changes_hint") : t("version_compare_current_hint")} -
-
- {(historyCompare.files || []).length > 0 ? ( -
-
+ {historyCompare ? ( +
+
+
+ {t("version_compare_summary")}: {historyCompare.file_count ?? historyCompare.files?.length ?? 0} {t("version_files_changed")} +
+
+ {t("version_compare_latest_hint")} +
+
+ {(historyCompare.files || []).length > 0 ? ( +
+
{(historyCompare.files || []).map((file) => { const selected = historyDiffPath === file.path; return ( @@ -3689,9 +3694,9 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug ); })}
-
- {historyDiffLoading ? ( -
+
+ {historyDiffLoading ? ( +
{t("version_diff_loading")}
@@ -3700,8 +3705,8 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug {historyDiffError}
- ) : historyDiffPayload ? ( -
+ ) : historyDiffPayload ? ( +
) : ( @@ -3747,6 +3752,8 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug
) : null} + {!historyOpen ? ( + <> {showAssistPanel ? (
@@ -4020,9 +4027,11 @@ export default function LatexPlugin({ context, tabId, setDirty, setTitle }: Plug
) : null} + + ) : null}
- {isWideLayout ? ( + {isWideLayout && !historyOpen ? (
) : null} -
+