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/2] 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/2] 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()