diff --git a/.agents/skills/intelli-git-debug/SKILL.md b/.agents/skills/intelli-git-debug/SKILL.md index 155bae4..0f7f05a 100644 --- a/.agents/skills/intelli-git-debug/SKILL.md +++ b/.agents/skills/intelli-git-debug/SKILL.md @@ -1,6 +1,6 @@ --- name: intelli-git-debug -description: Launch, control, inspect, reload, and stop a real Intelli Git VS Code Extension Development Host entirely from commands, with an isolated fixture, Extension Host Node inspector, renderer/webview CDP, owned process state, and direct log access. Use when Codex must reproduce or verify extension-host or webview behavior without clicking the source VS Code Run Extension UI or relying on Debug Console, especially for focus, layout, CSP, rendering, lifecycle, and interaction bugs. +description: Launch, control, inspect, reload, and stop a real Intelli Git VS Code Extension Development Host entirely from commands, with an isolated fixture, Extension Host Node inspector, renderer/webview CDP, owned process state, and direct log access. Use when Codex must reproduce or verify extension-host or webview behavior without clicking the source VS Code Run Extension UI or relying on Debug Console, especially for Monaco, focus, layout, CSP, rendering, lifecycle, and interaction bugs. --- # Intelli Git Debug @@ -102,7 +102,7 @@ node "$CONTROL" workbench-eval --state "$STATE" \ node "$CONTROL" webview-targets --state "$STATE" ``` -Open a conflicted file from `Conflicting Changes` deterministically: +Open the standard merge-conflict resolver deterministically: ```bash node "$CONTROL" webview-eval --state "$STATE" --contains 'Conflicting Changes' \ @@ -115,14 +115,14 @@ node "$CONTROL" webview-eval --state "$STATE" --contains 'Conflicting Changes' \ '(() => { const item = [...document.querySelectorAll(`[role="treeitem"]`)].find((element) => element.textContent?.trim() === "conflict.txt"); item?.click(); return { found: Boolean(item), selected: item?.getAttribute("aria-selected") }; })()' ``` -The file opens in the standard VS Code text editor, not in a webview. +Then enumerate targets again and select the context whose URL contains `#/conflict-resolver` or whose title is `Merge: conflict.txt`. Select a context by visible text or route and evaluate real DOM state: ```bash node "$CONTROL" webview-eval \ --state "$STATE" \ - --contains 'Conflicting Changes' \ + --contains 'Conflict Resolver' \ '(() => { const element = document.querySelector(""); const style = getComputedStyle(element); @@ -141,6 +141,8 @@ node "$CONTROL" webview-eval \ VS Code often exposes an outer webview frame plus a rendered inner frame. The controller selects rendered contexts by actual visible text; do not assume the first iframe is interactive. +For Monaco issues, inspect both the target node and its positioned parent before interaction, after focus, and after blur. Check CSP errors and CSSOM state before changing Monaco layout code. + ## Reload Without UI Automation After rebuilding extension code, reload the complete Development Host: diff --git a/.agents/skills/intelli-git-test/SKILL.md b/.agents/skills/intelli-git-test/SKILL.md index 0d6adb6..b8c73fb 100644 --- a/.agents/skills/intelli-git-test/SKILL.md +++ b/.agents/skills/intelli-git-test/SKILL.md @@ -1,6 +1,6 @@ --- name: intelli-git-test -description: Create and open reproducible Intelli Git VS Code extension test repositories. Use when testing Intelli Git checkout, branch switch, rebase, merge conflicts, stash-pop conflict, dirty worktree protection, index.lock race, or when the user asks to build a conflicted fixture repo and open it with the current debug extension. +description: Create and open reproducible Intelli Git VS Code extension test repositories. Use when testing Intelli Git checkout, branch switch, rebase, merge conflict resolver, stash-pop conflict, dirty worktree protection, index.lock race, or when the user asks to build a conflicted fixture repo and open it with the current debug extension. --- # Intelli Git Test diff --git a/AGENTS.md b/AGENTS.md index 340cf38..455cce8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,11 +267,12 @@ Improve AI provider setup flow - File items 不得显示 hunk child nodes。 - 如果已支持,changelist roots 之间的 drag and drop 文件重分配行为应继续可用。 -### Conflict File Behavior -- Intelli Git 不提供自己的 merge editor。点击或双击 conflict file 时,在 VS Code 标准 editor 中打开该文件,由 VS Code 自带的 conflict 能力处理内容编辑。 -- Commit view 的 `Accept Current Change` / `Accept Incoming Change` 必须保留 `repoPath` 并路由到对应 repository 的 Git service。 -- `Mark as Resolved` 只对 `resolvedCandidate` 文件执行 `git add` 并刷新 commit view。 -- 二进制 conflict files 可以显示在 `Conflicting Changes` root 下,通过 whole-side accept 解决。 +### Conflict Resolver Behavior +- 点击或双击 conflict file 时,必须在 VS Code editor 区域打开 Intelli Git 自己的 conflict resolver webview,不应走普通 open file 路径,也不得把 merge editor 嵌在 commit view 侧边栏里。 +- Conflict resolver 通过 shared RPC 从 Git index stages 读取 base/current/incoming,通过 worktree 读取 result;webview 不得直接推断 Git stages。 +- `Accept Current Change` / `Accept Incoming Change` 必须保留 `repoPath` 并路由到对应 repository 的 Git service。 +- `Mark as Resolved` 必须写入 result content、执行 `git add`、刷新 commit view;失败时 resolver 不能关闭。 +- 二进制 conflict files 可以显示在 `Conflicting Changes` root 下,但 inline editing 可以禁用并给出明确反馈。 - Rebase continue 只能在没有 unresolved conflict files 时可用;只有存在、非二进制、无 conflict markers 的文本文件才能作为 `resolvedCandidate` 自动放行。 ### Changes Mode Context Menu Rules @@ -287,7 +288,7 @@ Improve AI provider setup flow - Changelist mode、changelist state、changelist operations 的 shared contracts 必须保留在 `packages/shared/messages.ts`。 - "某个改动属于哪个 changelist" 的路由规则只有一份,放在 `packages/shared/changelistRouting.ts`,webview 分组(`buildChangelists`)和 extension commit plan(`ChangelistStateService.buildCommitPlan`)都必须复用它,保证显示的 changelist 与实际提交的内容一致。 - 发给 webview 的 commit view payload 只携带 hunk identity 和行号范围,`fileHeader` / `content` 留在 extension 侧。 -- Conflict 的 Git stage 读取和 whole-side resolution 必须在 extension-side `GitService` 内完成。 +- Conflict resolver 的 shared RPC contract 必须保留在 `packages/shared/messages.ts`,Git stage 读取和保存必须在 extension-side `GitService` 内完成;editor-area panel 生命周期由 extension provider 管理。 - 任何新增的 commit view 用户可见文本都必须遵守仓库的 l10n 规则。 - 如果 commit view context menu 行为发生变化,必须同时更新 webview 的 `data-vscode-context` shape 和 `apps/extension/package.json` 中的 menu contributions。 @@ -295,7 +296,7 @@ Improve AI provider setup flow - 在 `staged` 和 `changes` mode 之间切换时,toolbar 和 context menu 行为必须正确。 - `changes` mode 绝不能显示 staged/unstaged split groups。 - Conflict files 必须只出现在 `Conflicting Changes` root 下。 -- 点击 conflict file 必须在 VS Code 标准 editor 中打开该文件。 +- 点击 conflict file 必须在 editor 区域打开 Intelli Git conflict resolver。 - `changes` mode 绝不能在 changelist root 上显示 active badge。 - `changes` mode 下 root hover 绝不能显示 inline operation icons。 - File items 绝不能显示 child hunk nodes。 diff --git a/apps/extension/ThirdPartyNotices.txt b/apps/extension/ThirdPartyNotices.txt index 8265e83..d5ee8cf 100644 --- a/apps/extension/ThirdPartyNotices.txt +++ b/apps/extension/ThirdPartyNotices.txt @@ -35,6 +35,486 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ======================================================================== +monaco-editor 0.52.2 +Source: https://github.com/microsoft/monaco-editor +License: MIT + +LICENSE + +The MIT License (MIT) + +Copyright (c) 2016 - present Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +ThirdPartyNotices.txt + +THIRD-PARTY SOFTWARE NOTICES AND INFORMATION +Do Not Translate or Localize + +This project incorporates components from the projects listed below. The original copyright notices and the licenses +under which Microsoft received such components are set forth below. Microsoft reserves all rights not expressly granted +herein, whether by implication, estoppel or otherwise. + + + +%% nodejs path library (https://github.com/nodejs/node/tree/43dd49c9782848c25e5b03448c8a0f923f13c158) +========================================= +Copyright Joyent, Inc. and other Node contributors. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +========================================= +END OF nodejs path library NOTICES AND INFORMATION + + + + +%% markedjs NOTICES AND INFORMATION BEGIN HERE +========================================= +The MIT License (MIT) + +Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/) +Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +========================================= +END OF markedjs NOTICES AND INFORMATION + + + +%% typescript version 4.4.4 (https://github.com/microsoft/TypeScript) +========================================= +Copyright (c) Microsoft Corporation. All rights reserved. + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and + +You must cause any modified files to carry prominent notices stating that You changed the files; and + +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + +The TypeScript software incorporates third party material from the projects listed below. The original copyright notice and the license under which Microsoft received such third party material are set forth below. Microsoft reserves all other rights not expressly granted, whether by implication, estoppel or otherwise. + + +------------------- DefinitelyTyped -------------------- +This file is based on or incorporates material from the projects listed below (collectively "Third Party Code"). Microsoft is not the original author of the Third Party Code. The original copyright notice and the license, under which Microsoft received such Third Party Code, are set forth below. Such licenses and notices are provided for informational purposes only. Microsoft, not the third party, licenses the Third Party Code to you under the terms set forth in the EULA for the Microsoft Product. Microsoft reserves all other rights not expressly granted under this agreement, whether by implication, estoppel or otherwise. +DefinitelyTyped +This project is licensed under the MIT license. Copyrights are respective of each contributor listed at the beginning of each definition file. Provided for Informational Purposes Only + +MIT License +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------------- + +------------------- Unicode -------------------- +UNICODE, INC. LICENSE AGREEMENT - DATA FILES AND SOFTWARE + +Unicode Data Files include all data files under the directories +http://www.unicode.org/Public/, http://www.unicode.org/reports/, +http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/, and +http://www.unicode.org/utility/trac/browser/. + +Unicode Data Files do not include PDF online code charts under the +directory http://www.unicode.org/Public/. + +Software includes any source code published in the Unicode Standard +or under the directories +http://www.unicode.org/Public/, http://www.unicode.org/reports/, +http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/, and +http://www.unicode.org/utility/trac/browser/. + +NOTICE TO USER: Carefully read the following legal agreement. +BY DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING UNICODE INC.'S +DATA FILES ("DATA FILES"), AND/OR SOFTWARE ("SOFTWARE"), +YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. +IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE +THE DATA FILES OR SOFTWARE. + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1991-2017 Unicode, Inc. All rights reserved. +Distributed under the Terms of Use in http://www.unicode.org/copyright.html. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Unicode data files and any associated documentation +(the "Data Files") or Unicode software and any associated documentation +(the "Software") to deal in the Data Files or Software +without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, and/or sell copies of +the Data Files or Software, and to permit persons to whom the Data Files +or Software are furnished to do so, provided that either +(a) this copyright and permission notice appear with all copies +of the Data Files or Software, or +(b) this copyright and permission notice appear in associated +Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT OF THIRD PARTY RIGHTS. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS +NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL +DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THE DATA FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, +use or other dealings in these Data Files or Software without prior +written authorization of the copyright holder. +------------------------------------------------------------------------------------- + +-------------------Document Object Model----------------------------- +DOM + +W3C License +This work is being provided by the copyright holders under the following license. +By obtaining and/or copying this work, you (the licensee) agree that you have read, understood, and will comply with the following terms and conditions. +Permission to copy, modify, and distribute this work, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the following +on ALL copies of the work or portions thereof, including modifications: +* The full text of this NOTICE in a location viewable to users of the redistributed or derivative work. +* Any pre-existing intellectual property disclaimers, notices, or terms and conditions. If none exist, the W3C Software and Document Short Notice should be included. +* Notice of any changes or modifications, through a copyright statement on the new code or document such as "This software or document includes material copied from or derived +from [title and URI of the W3C document]. Copyright © [YEAR] W3C® (MIT, ERCIM, Keio, Beihang)." +Disclaimers +THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR +FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR DOCUMENT WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS. +COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENT. +The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to the work without specific, written prior permission. +Title to copyright in this work will at all times remain with copyright holders. + +--------- + +DOM +Copyright © 2018 WHATWG (Apple, Google, Mozilla, Microsoft). This work is licensed under a Creative Commons Attribution 4.0 International License: Attribution 4.0 International +======================================================================= +Creative Commons Corporation ("Creative Commons") is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an "as-is" basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. Using Creative Commons Public Licenses Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC- licensed material, or material used under an exception or limitation to copyright. More considerations for licensors: + +wiki.creativecommons.org/Considerations_for_licensors Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor's permission is not necessary for any reason--for example, because of any applicable exception or limitation to copyright--then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More_considerations for the public: wiki.creativecommons.org/Considerations_for_licensees ======================================================================= +Creative Commons Attribution 4.0 International Public License By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. Section 1 -- Definitions. a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. h. Licensor means the individual(s) or entity(ies) granting rights under this Public License. i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. Section 2 -- Scope. a. License grant. 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: a. reproduce and Share the Licensed Material, in whole or in part; and b. produce, reproduce, and Share Adapted Material. 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. 3. Term. The term of this Public License is specified in Section 6(a). 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a) (4) never produces Adapted Material. 5. Downstream recipients. a. Offer from the Licensor -- Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. b. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). b. Other rights. 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. 2. Patent and trademark rights are not licensed under this Public License. 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. Section 3 -- License Conditions. Your exercise of the Licensed Rights is expressly made subject to the following conditions. a. Attribution. 1. If You Share the Licensed Material (including in modified form), You must: a. retain the following if it is supplied by the Licensor with the Licensed Material: i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); ii. a copyright notice; iii. a notice that refers to this Public License; iv. a notice that refers to the disclaimer of warranties; v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; b. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and c. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. 4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License. Section 4 -- Sui Generis Database Rights. Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. Section 5 -- Disclaimer of Warranties and Limitation of Liability. a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. Section 6 -- Term and Termination. a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 2. upon express reinstatement by the Licensor. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. Section 7 -- Other Terms and Conditions. a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. Section 8 -- Interpretation. a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. ======================================================================= Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the "Licensor." Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark "Creative Commons" or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. Creative Commons may be contacted at creativecommons.org. + +-------------------------------------------------------------------------------- + +----------------------Web Background Synchronization------------------------------ + +Web Background Synchronization Specification +Portions of spec © by W3C + +W3C Community Final Specification Agreement +To secure commitments from participants for the full text of a Community or Business Group Report, the group may call for voluntary commitments to the following terms; a "summary" is +available. See also the related "W3C Community Contributor License Agreement". +1. The Purpose of this Agreement. +This Agreement sets forth the terms under which I make certain copyright and patent rights available to you for your implementation of the Specification. +Any other capitalized terms not specifically defined herein have the same meaning as those terms have in the "W3C Patent Policy", and if not defined there, in the "W3C Process Document". +2. Copyrights. +2.1. Copyright Grant. I grant to you a perpetual (for the duration of the applicable copyright), worldwide, non-exclusive, no-charge, royalty-free, copyright license, without any obligation for accounting to me, to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, distribute, and implement the Specification to the full extent of my copyright interest in the Specification. +2.2. Attribution. As a condition of the copyright grant, you must include an attribution to the Specification in any derivative work you make based on the Specification. That attribution must include, at minimum, the Specification name and version number. +3. Patents. +3.1. Patent Licensing Commitment. I agree to license my Essential Claims under the W3C Community RF Licensing Requirements. This requirement includes Essential Claims that I own and any that I have the right to license without obligation of payment or other consideration to an unrelated third party. W3C Community RF Licensing Requirements obligations made concerning the Specification and described in this policy are binding on me for the life of the patents in question and encumber the patents containing Essential Claims, regardless of changes in participation status or W3C Membership. I also agree to license my Essential Claims under the W3C Community RF Licensing Requirements in derivative works of the Specification so long as all normative portions of the Specification are maintained and that this licensing commitment does not extend to any portion of the derivative work that was not included in the Specification. +3.2. Optional, Additional Patent Grant. In addition to the provisions of Section 3.1, I may also, at my option, make certain intellectual property rights infringed by implementations of the Specification, including Essential Claims, available by providing those terms via the W3C Web site. +4. No Other Rights. Except as specifically set forth in this Agreement, no other express or implied patent, trademark, copyright, or other property rights are granted under this Agreement, including by implication, waiver, or estoppel. +5. Antitrust Compliance. I acknowledge that I may compete with other participants, that I am under no obligation to implement the Specification, that each participant is free to develop competing technologies and standards, and that each party is free to license its patent rights to third parties, including for the purpose of enabling competing technologies and standards. +6. Non-Circumvention. I agree that I will not intentionally take or willfully assist any third party to take any action for the purpose of circumventing my obligations under this Agreement. +7. Transition to W3C Recommendation Track. The Specification developed by the Project may transition to the W3C Recommendation Track. The W3C Team is responsible for notifying me that a Corresponding Working Group has been chartered. I have no obligation to join the Corresponding Working Group. If the Specification developed by the Project transitions to the W3C Recommendation Track, the following terms apply: +7.1. If I join the Corresponding Working Group. If I join the Corresponding Working Group, I will be subject to all W3C rules, obligations, licensing commitments, and policies that govern that Corresponding Working Group. +7.2. If I Do Not Join the Corresponding Working Group. +7.2.1. Licensing Obligations to Resulting Specification. If I do not join the Corresponding Working Group, I agree to offer patent licenses according to the W3C Royalty-Free licensing requirements described in Section 5 of the W3C Patent Policy for the portions of the Specification included in the resulting Recommendation. This licensing commitment does not extend to any portion of an implementation of the Recommendation that was not included in the Specification. This licensing commitment may not be revoked but may be modified through the exclusion process defined in Section 4 of the W3C Patent Policy. I am not required to join the Corresponding Working Group to exclude patents from the W3C Royalty-Free licensing commitment, but must otherwise follow the normal exclusion procedures defined by the W3C Patent Policy. The W3C Team will notify me of any Call for Exclusion in the Corresponding Working Group as set forth in Section 4.5 of the W3C Patent Policy. +7.2.2. No Disclosure Obligation. If I do not join the Corresponding Working Group, I have no patent disclosure obligations outside of those set forth in Section 6 of the W3C Patent Policy. +8. Conflict of Interest. I will disclose significant relationships when those relationships might reasonably be perceived as creating a conflict of interest with my role. I will notify W3C of any change in my affiliation using W3C-provided mechanisms. +9. Representations, Warranties and Disclaimers. I represent and warrant that I am legally entitled to grant the rights and promises set forth in this Agreement. IN ALL OTHER RESPECTS THE SPECIFICATION IS PROVIDED “AS IS.” The entire risk as to implementing or otherwise using the Specification is assumed by the implementer and user. Except as stated herein, I expressly disclaim any warranties (express, implied, or otherwise), including implied warranties of merchantability, non-infringement, fitness for a particular purpose, or title, related to the Specification. IN NO EVENT WILL ANY PARTY BE LIABLE TO ANY OTHER PARTY FOR LOST PROFITS OR ANY FORM OF INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES OF ANY CHARACTER FROM ANY CAUSES OF ACTION OF ANY KIND WITH RESPECT TO THIS AGREEMENT, WHETHER BASED ON BREACH OF CONTRACT, TORT (INCLUDING NEGLIGENCE), OR OTHERWISE, AND WHETHER OR NOT THE OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. All of my obligations under Section 3 regarding the transfer, successors in interest, or assignment of Granted Claims will be satisfied if I notify the transferee or assignee of any patent that I know contains Granted Claims of the obligations under Section 3. Nothing in this Agreement requires me to undertake a patent search. +10. Definitions. +10.1. Agreement. “Agreement” means this W3C Community Final Specification Agreement. +10.2. Corresponding Working Group. “Corresponding Working Group” is a W3C Working Group that is chartered to develop a Recommendation, as defined in the W3C Process Document, that takes the Specification as an input. +10.3. Essential Claims. “Essential Claims” shall mean all claims in any patent or patent application in any jurisdiction in the world that would necessarily be infringed by implementation of the Specification. A claim is necessarily infringed hereunder only when it is not possible to avoid infringing it because there is no non-infringing alternative for implementing the normative portions of the Specification. Existence of a non-infringing alternative shall be judged based on the state of the art at the time of the publication of the Specification. The following are expressly excluded from and shall not be deemed to constitute Essential Claims: +10.3.1. any claims other than as set forth above even if contained in the same patent as Essential Claims; and +10.3.2. claims which would be infringed only by: +portions of an implementation that are not specified in the normative portions of the Specification, or +enabling technologies that may be necessary to make or use any product or portion thereof that complies with the Specification and are not themselves expressly set forth in the Specification (e.g., semiconductor manufacturing technology, compiler technology, object-oriented technology, basic operating system technology, and the like); or +the implementation of technology developed elsewhere and merely incorporated by reference in the body of the Specification. +10.3.3. design patents and design registrations. +For purposes of this definition, the normative portions of the Specification shall be deemed to include only architectural and interoperability requirements. Optional features in the RFC 2119 sense are considered normative unless they are specifically identified as informative. Implementation examples or any other material that merely illustrate the requirements of the Specification are informative, rather than normative. +10.4. I, Me, or My. “I,” “me,” or “my” refers to the signatory. +10.5 Project. “Project” means the W3C Community Group or Business Group for which I executed this Agreement. +10.6. Specification. “Specification” means the Specification identified by the Project as the target of this agreement in a call for Final Specification Commitments. W3C shall provide the authoritative mechanisms for the identification of this Specification. +10.7. W3C Community RF Licensing Requirements. “W3C Community RF Licensing Requirements” license shall mean a non-assignable, non-sublicensable license to make, have made, use, sell, have sold, offer to sell, import, and distribute and dispose of implementations of the Specification that: +10.7.1. shall be available to all, worldwide, whether or not they are W3C Members; +10.7.2. shall extend to all Essential Claims owned or controlled by me; +10.7.3. may be limited to implementations of the Specification, and to what is required by the Specification; +10.7.4. may be conditioned on a grant of a reciprocal RF license (as defined in this policy) to all Essential Claims owned or controlled by the licensee. A reciprocal license may be required to be available to all, and a reciprocal license may itself be conditioned on a further reciprocal license from all. +10.7.5. may not be conditioned on payment of royalties, fees or other consideration; +10.7.6. may be suspended with respect to any licensee when licensor issued by licensee for infringement of claims essential to implement the Specification or any W3C Recommendation; +10.7.7. may not impose any further conditions or restrictions on the use of any technology, intellectual property rights, or other restrictions on behavior of the licensee, but may include reasonable, customary terms relating to operation or maintenance of the license relationship such as the following: choice of law and dispute resolution; +10.7.8. shall not be considered accepted by an implementer who manifests an intent not to accept the terms of the W3C Community RF Licensing Requirements license as offered by the licensor. +10.7.9. The RF license conforming to the requirements in this policy shall be made available by the licensor as long as the Specification is in effect. The term of such license shall be for the life of the patents in question. +I am encouraged to provide a contact from which licensing information can be obtained and other relevant licensing information. Any such information will be made publicly available. +10.8. You or Your. “You,” “you,” or “your” means any person or entity who exercises copyright or patent rights granted under this Agreement, and any person that person or entity controls. + +------------------------------------------------------------------------------------- + +------------------- WebGL ----------------------------- +Copyright (c) 2018 The Khronos Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and/or associated documentation files (the +"Materials"), to deal in the Materials without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Materials, and to +permit persons to whom the Materials are furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Materials. + +THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS. +------------------------------------------------------ + + +========================================= +END OF typescript NOTICES AND INFORMATION + + + + +%% HTML 5.1 W3C Working Draft version 08 October 2015 (http://www.w3.org/TR/2015/WD-html51-20151008/) +========================================= +Copyright © 2015 W3C® (MIT, ERCIM, Keio, Beihang). This software or document includes material copied +from or derived from HTML 5.1 W3C Working Draft (http://www.w3.org/TR/2015/WD-html51-20151008/.) + +THIS DOCUMENT IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING, BUT +NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, OR TITLE; THAT THE CONTENTS OF +THE DOCUMENT ARE SUITABLE FOR ANY PURPOSE; NOR THAT THE IMPLEMENTATION OF SUCH CONTENTS WILL NOT INFRINGE ANY THIRD PARTY +PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS. + +COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE +DOCUMENT OR THE PERFORMANCE OR IMPLEMENTATION OF THE CONTENTS THEREOF. + +The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to this document or its contents +without specific, written prior permission. Title to copyright in this document will at all times remain with copyright holders. +========================================= +END OF HTML 5.1 W3C Working Draft NOTICES AND INFORMATION + + + + +%% JS Beautifier version 1.6.2 (https://github.com/beautify-web/js-beautify) +========================================= +The MIT License (MIT) + +Copyright (c) 2007-2017 Einar Lielmanis, Liam Newman, and contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +========================================= +END OF js-beautify NOTICES AND INFORMATION + + + + +%% Ionic documentation version 1.2.4 (https://github.com/driftyco/ionic-site) +========================================= +Copyright Drifty Co. http://drifty.com/. + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and + +You must cause any modified files to carry prominent notices stating that You changed the files; and + +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS +========================================= +END OF Ionic documentation NOTICES AND INFORMATION + + + + +%% vscode-swift version 0.0.1 (https://github.com/owensd/vscode-swift) +========================================= +The MIT License (MIT) + +Copyright (c) 2015 David Owens II + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +========================================= +END OF vscode-swift NOTICES AND INFORMATION + +======================================================================== + @babel/runtime 7.29.2 Source: https://github.com/babel/babel.git License: MIT diff --git a/apps/extension/media/intelli-git-icons.css b/apps/extension/media/intelli-git-icons.css index b0d52cb..6080fb0 100644 --- a/apps/extension/media/intelli-git-icons.css +++ b/apps/extension/media/intelli-git-icons.css @@ -31,3 +31,7 @@ .intelli-git-icon-logo::before { content: "\E003"; } + +.intelli-git-icon-merge::before { + content: "\E004"; +} diff --git a/apps/extension/package.json b/apps/extension/package.json index 143d1e1..6821e76 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -62,6 +62,13 @@ "fontPath": "resources/intelli-git-icons.woff", "fontCharacter": "\\E003" } + }, + "intelli-git-merge": { + "description": "Intelli Git merge conflict resolver icon", + "default": { + "fontPath": "resources/intelli-git-icons.woff", + "fontCharacter": "\\E004" + } } }, "viewsContainers": { @@ -363,6 +370,26 @@ "command": "intelli-git.changelist.markResolved", "title": "%intelli-git.commands.changelist.markResolved.title%" }, + { + "command": "intelli-git.merge.acceptLeft", + "title": "%intelli-git.commands.merge.acceptLeft.title%" + }, + { + "command": "intelli-git.merge.cancelLeft", + "title": "%intelli-git.commands.merge.cancelLeft.title%" + }, + { + "command": "intelli-git.merge.acceptRight", + "title": "%intelli-git.commands.merge.acceptRight.title%" + }, + { + "command": "intelli-git.merge.cancelRight", + "title": "%intelli-git.commands.merge.cancelRight.title%" + }, + { + "command": "intelli-git.merge.markReviewed", + "title": "%intelli-git.commands.merge.markReviewed.title%" + }, { "command": "intelli-git.copyAuthorEmail", "title": "%intelli-git.commands.copyAuthorEmail.title%" @@ -684,6 +711,26 @@ "command": "intelli-git.changelist.markResolved", "when": "false" }, + { + "command": "intelli-git.merge.acceptLeft", + "when": "false" + }, + { + "command": "intelli-git.merge.cancelLeft", + "when": "false" + }, + { + "command": "intelli-git.merge.acceptRight", + "when": "false" + }, + { + "command": "intelli-git.merge.cancelRight", + "when": "false" + }, + { + "command": "intelli-git.merge.markReviewed", + "when": "false" + }, { "command": "intelli-git.copyAuthorEmail", "when": "false" @@ -1089,6 +1136,31 @@ "when": "(webviewSection == 'changelistFile' || webviewSection == 'changelistFolder' || webviewSection == 'changelistRoot' || webviewSection == 'changelistRepository') && hasConflict == true && hasResolvedCandidate == true", "group": "1_modification@2" }, + { + "command": "intelli-git.merge.acceptLeft", + "when": "(webviewSection == 'mergeEditorLeft' || webviewSection == 'mergeEditorResult') && canReviewLeft == true", + "group": "1_left@1" + }, + { + "command": "intelli-git.merge.cancelLeft", + "when": "(webviewSection == 'mergeEditorLeft' || webviewSection == 'mergeEditorResult') && canReviewLeft == true", + "group": "1_left@2" + }, + { + "command": "intelli-git.merge.acceptRight", + "when": "(webviewSection == 'mergeEditorRight' || webviewSection == 'mergeEditorResult') && canReviewRight == true", + "group": "2_right@1" + }, + { + "command": "intelli-git.merge.cancelRight", + "when": "(webviewSection == 'mergeEditorRight' || webviewSection == 'mergeEditorResult') && canReviewRight == true", + "group": "2_right@2" + }, + { + "command": "intelli-git.merge.markReviewed", + "when": "webviewSection == 'mergeEditorResult' && canMarkReviewed == true", + "group": "3_review@1" + }, { "command": "intelli-git.changelist.markReviewed", "when": "(webviewSection == 'changelistFile' || webviewSection == 'changelistFolder' || webviewSection == 'changelistRoot') && allReviewed != true && hasConflict != true", @@ -1332,6 +1404,12 @@ ], "description": "%intelli-git.config.changelist.mode.description%" }, + "intelli-git.mergeEditor.enabled": { + "type": "boolean", + "default": false, + "scope": "window", + "description": "%intelli-git.config.mergeEditor.enabled.description%" + }, "intelli-git.push.confirmProtectedBranch": { "type": "boolean", "default": true, diff --git a/apps/extension/package.nls.json b/apps/extension/package.nls.json index 7f2b890..f5c0453 100644 --- a/apps/extension/package.nls.json +++ b/apps/extension/package.nls.json @@ -53,6 +53,11 @@ "intelli-git.commands.changelist.acceptCurrent.title": "Accept Current Change", "intelli-git.commands.changelist.acceptIncoming.title": "Accept Incoming Change", "intelli-git.commands.changelist.markResolved.title": "Mark as Resolved", + "intelli-git.commands.merge.acceptLeft.title": "Accept Left Change", + "intelli-git.commands.merge.cancelLeft.title": "Cancel Left Change", + "intelli-git.commands.merge.acceptRight.title": "Accept Right Change", + "intelli-git.commands.merge.cancelRight.title": "Cancel Right Change", + "intelli-git.commands.merge.markReviewed.title": "Mark Change as Reviewed", "intelli-git.commands.log.resetSoft.title": "Reset Current Branch to Here (Soft)", "intelli-git.commands.log.resetMixed.title": "Reset Current Branch to Here (Mixed)", "intelli-git.commands.log.resetHard.title": "Reset Current Branch to Here (Hard)", @@ -69,6 +74,7 @@ "intelli-git.config.changelist.mode.description": "Choose how the commit panel organizes local changes.", "intelli-git.config.changelist.mode.staged": "Separate staged and unstaged changes.", "intelli-git.config.changelist.mode.changes": "Use IntelliJ-style changelists with one active list.", + "intelli-git.config.mergeEditor.enabled.description": "Enable the experimental Intelli Git three-way merge editor for conflicting files. When disabled, conflicting files open in the standard editor.", "intelli-git.config.push.confirmProtectedBranch.description": "Require confirmation before pushing directly to protected targets such as origin/main or origin/master.", "intelli-git.config.backgroundFetch.enabled.description": "Optionally fetch origin in an isolated background process. Disabled by default; prefer VS Code Git: Autofetch for routine remote updates.", "intelli-git.config.backgroundFetch.onStartup.description": "Fetch origin shortly after Intelli Git starts.", diff --git a/apps/extension/package.nls.zh-cn.json b/apps/extension/package.nls.zh-cn.json index d7d60b8..9d32d76 100644 --- a/apps/extension/package.nls.zh-cn.json +++ b/apps/extension/package.nls.zh-cn.json @@ -53,6 +53,11 @@ "intelli-git.commands.changelist.acceptCurrent.title": "接受当前更改", "intelli-git.commands.changelist.acceptIncoming.title": "接受传入更改", "intelli-git.commands.changelist.markResolved.title": "标记为已解决", + "intelli-git.commands.merge.acceptLeft.title": "接受左侧修改", + "intelli-git.commands.merge.cancelLeft.title": "取消左侧修改", + "intelli-git.commands.merge.acceptRight.title": "接受右侧修改", + "intelli-git.commands.merge.cancelRight.title": "取消右侧修改", + "intelli-git.commands.merge.markReviewed.title": "将修改标记为已审核", "intelli-git.commands.log.resetSoft.title": "重置当前分支到此提交 (Soft)", "intelli-git.commands.log.resetMixed.title": "重置当前分支到此提交 (Mixed)", "intelli-git.commands.log.resetHard.title": "重置当前分支到此提交 (Hard)", @@ -69,6 +74,7 @@ "intelli-git.config.changelist.mode.description": "选择提交面板组织本地更改的方式。", "intelli-git.config.changelist.mode.staged": "区分已暂存与未暂存更改。", "intelli-git.config.changelist.mode.changes": "使用 IntelliJ 风格的变更列表,并保持唯一 active 列表。", + "intelli-git.config.mergeEditor.enabled.description": "为冲突文件启用实验性的 Intelli Git 三方合并编辑器。关闭时,冲突文件将在标准编辑器中打开。", "intelli-git.config.push.confirmProtectedBranch.description": "推送到 origin/main 或 origin/master 这类受保护目标前要求确认。", "intelli-git.config.backgroundFetch.enabled.description": "可选地使用独立后台进程 fetch origin。默认关闭;常规远端更新建议优先使用 VS Code Git: Autofetch。", "intelli-git.config.backgroundFetch.onStartup.description": "Intelli Git 启动后稍等片刻自动 fetch origin。", diff --git a/apps/extension/resources/icons/icons.json b/apps/extension/resources/icons/icons.json index 4f4b3b8..040dfec 100644 --- a/apps/extension/resources/icons/icons.json +++ b/apps/extension/resources/icons/icons.json @@ -16,5 +16,11 @@ "id": "intelli-git-logo", "codepoint": "E003", "description": "Intelli Git logo icon" + }, + { + "name": "merge", + "id": "intelli-git-merge", + "codepoint": "E004", + "description": "Intelli Git merge conflict resolver icon" } ] diff --git a/apps/extension/resources/icons/merge.svg b/apps/extension/resources/icons/merge.svg new file mode 100644 index 0000000..c13effe --- /dev/null +++ b/apps/extension/resources/icons/merge.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/extension/resources/intelli-git-icons.woff b/apps/extension/resources/intelli-git-icons.woff index 47d5aae..368379f 100644 Binary files a/apps/extension/resources/intelli-git-icons.woff and b/apps/extension/resources/intelli-git-icons.woff differ diff --git a/apps/extension/src/commands/commandPalette.regression.test.ts b/apps/extension/src/commands/commandPalette.regression.test.ts index e505150..6aa4dc2 100644 --- a/apps/extension/src/commands/commandPalette.regression.test.ts +++ b/apps/extension/src/commands/commandPalette.regression.test.ts @@ -46,6 +46,11 @@ const CONTEXT_ONLY_COMMANDS = [ 'intelli-git.changelist.acceptCurrent', 'intelli-git.changelist.acceptIncoming', 'intelli-git.changelist.markResolved', + 'intelli-git.merge.acceptLeft', + 'intelli-git.merge.cancelLeft', + 'intelli-git.merge.acceptRight', + 'intelli-git.merge.cancelRight', + 'intelli-git.merge.markReviewed', 'intelli-git.copyAuthorEmail', 'intelli-git.sendAuthorEmail', 'intelli-git.log.file.showDiff', @@ -177,3 +182,33 @@ test('mark resolved is available for resolved conflict groups only', () => { assert.match(item.when, /hasConflict == true/); assert.match(item.when, /hasResolvedCandidate == true/); }); + +test('merge resolver commands are scoped to native webview context sections', () => { + const packageJson = readPackageJson(); + const webviewContext = packageJson.contributes.menus['webview/context'] ?? []; + const expected = new Map([ + [ + 'intelli-git.merge.acceptLeft', + "(webviewSection == 'mergeEditorLeft' || webviewSection == 'mergeEditorResult') && canReviewLeft == true", + ], + [ + 'intelli-git.merge.cancelLeft', + "(webviewSection == 'mergeEditorLeft' || webviewSection == 'mergeEditorResult') && canReviewLeft == true", + ], + [ + 'intelli-git.merge.acceptRight', + "(webviewSection == 'mergeEditorRight' || webviewSection == 'mergeEditorResult') && canReviewRight == true", + ], + [ + 'intelli-git.merge.cancelRight', + "(webviewSection == 'mergeEditorRight' || webviewSection == 'mergeEditorResult') && canReviewRight == true", + ], + ['intelli-git.merge.markReviewed', "webviewSection == 'mergeEditorResult' && canMarkReviewed == true"], + ]); + + for (const [command, when] of expected) { + const item = webviewContext.find((entry: { command?: string }) => entry.command === command); + assert.ok(item, `${command} should be registered in the merge resolver context menu`); + assert.equal(item.when, when); + } +}); diff --git a/apps/extension/src/extension.ts b/apps/extension/src/extension.ts index dfbaa29..51f851a 100644 --- a/apps/extension/src/extension.ts +++ b/apps/extension/src/extension.ts @@ -1,6 +1,12 @@ import * as vscode from 'vscode'; import * as path from 'path'; -import { CommitViewProvider, GitLogViewProvider, RepositoryFileSystemProvider } from './providers'; +import { + CommitViewProvider, + ConflictResolverPanel, + GitLogViewProvider, + openConflictFile, + RepositoryFileSystemProvider, +} from './providers'; import { RepositoryManager, type RepositoryScope } from './services/RepositoryManager'; import { createGitWatcher } from './services/GitRepositoryWatcher'; import { BackgroundFetchService } from './services/BackgroundFetchService'; @@ -20,7 +26,8 @@ import { } from './commands'; import { logger } from './utils/logger'; import { ChangeBlockEditorController } from './editor/ChangeBlockEditorController'; -import type { RefreshScope } from '@shared/messages'; +import type { ConflictResolverContextAction, RefreshScope } from '@shared/messages'; +import type { MergeEditorContext } from '@shared/webviewContext'; interface RepositoryQuickPickItem extends vscode.QuickPickItem { repo: RepositoryScope; @@ -510,6 +517,41 @@ export async function activate(context: vscode.ExtensionContext) { // Register webview providers context.subscriptions.push(vscode.window.registerWebviewViewProvider(CommitViewProvider.viewType, provider)); context.subscriptions.push(vscode.window.registerWebviewViewProvider(GitLogViewProvider.viewType, gitLogProvider)); + context.subscriptions.push( + vscode.commands.registerCommand( + 'intelli-git.openConflictResolver', + (file?: { path?: string; repoPath?: string }) => { + if (!file?.path) { + return; + } + + void openConflictFile(providerOptions, { path: file.path, repoPath: file.repoPath }); + } + ) + ); + const conflictResolverContextCommands: ReadonlyArray<[string, ConflictResolverContextAction]> = [ + ['intelli-git.merge.acceptLeft', 'acceptLeft'], + ['intelli-git.merge.cancelLeft', 'cancelLeft'], + ['intelli-git.merge.acceptRight', 'acceptRight'], + ['intelli-git.merge.cancelRight', 'cancelRight'], + ['intelli-git.merge.markReviewed', 'markReviewed'], + ]; + context.subscriptions.push( + ...conflictResolverContextCommands.map(([command, action]) => + vscode.commands.registerCommand(command, (args?: MergeEditorContext) => { + if (!args?.path || !args.changeGroupId) { + return; + } + ConflictResolverPanel.dispatchContextAction({ + path: args.path, + repoPath: args.repoPath, + groupId: args.changeGroupId, + action, + }); + }) + ) + ); + registerAiCommands(context, provider); registerGlobalNavigationCommands(context, { gitLogProvider, diff --git a/apps/extension/src/providers/CommitViewProvider.ts b/apps/extension/src/providers/CommitViewProvider.ts index 62c557a..f295f71 100644 --- a/apps/extension/src/providers/CommitViewProvider.ts +++ b/apps/extension/src/providers/CommitViewProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { randomUUID } from 'node:crypto'; import type { ChangelistFileSelection, CommitAiAction, FileDiagnosticsChange, RefreshEvent } from '@shared/messages'; import { BaseWebviewProvider, WebviewProviderOptions } from './BaseWebviewProvider'; +import { openConflictFile } from './ConflictResolverPanel'; import type { ExtensionRpcHandlerOptions } from '../rpc'; export class CommitViewProvider extends BaseWebviewProvider implements vscode.WebviewViewProvider { @@ -107,6 +108,9 @@ export class CommitViewProvider extends BaseWebviewProvider implements vscode.We this._isChangelistTreeFocused = focused; void vscode.commands.executeCommand('setContext', 'intelli-git.changelistTreeFocus', focused); }, + openConflictResolver: (file) => { + void openConflictFile(this.options, file); + }, }; } diff --git a/apps/extension/src/providers/ConflictResolverPanel.test.ts b/apps/extension/src/providers/ConflictResolverPanel.test.ts new file mode 100644 index 0000000..c83b617 --- /dev/null +++ b/apps/extension/src/providers/ConflictResolverPanel.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type * as vscode from 'vscode'; +import * as vscodeMock from 'vscode'; +import type { RepositoryManager } from '../services/RepositoryManager'; +import { openConflictFile } from './ConflictResolverPanel'; +import type { WebviewProviderOptions } from './BaseWebviewProvider'; + +const testVscode = vscodeMock as unknown as { + __getExecutedCommands(): Array<{ command: string; args: unknown[] }>; + __resetExecutedCommands(): void; +}; + +describe('openConflictFile', () => { + beforeEach(() => { + testVscode.__resetExecutedCommands(); + }); + + it('opens conflicts in the standard editor when the merge editor is disabled by default', async () => { + const options: WebviewProviderOptions = { + extensionUri: {} as vscode.Uri, + context: {} as vscode.ExtensionContext, + repositoryManager: {} as RepositoryManager, + }; + + await openConflictFile(options, { + path: 'src/conflict.ts', + repoPath: '/workspace/repository', + }); + + const [command] = testVscode.__getExecutedCommands(); + expect(command.command).toBe('vscode.open'); + expect((command.args[0] as vscode.Uri).fsPath).toBe('/workspace/repository/src/conflict.ts'); + }); +}); diff --git a/apps/extension/src/providers/ConflictResolverPanel.ts b/apps/extension/src/providers/ConflictResolverPanel.ts new file mode 100644 index 0000000..5e9c457 --- /dev/null +++ b/apps/extension/src/providers/ConflictResolverPanel.ts @@ -0,0 +1,157 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import type { + ConflictResolverContextActionRequest, + ConflictResolverOpenRequest, + RepositoryFileReference, +} from '@shared/messages'; +import { BaseWebviewProvider, WebviewProviderOptions } from './BaseWebviewProvider'; + +function getPanelKey(file: RepositoryFileReference): string { + return `${file.repoPath || ''}:${file.path}`; +} + +export async function openConflictFile( + options: WebviewProviderOptions, + file: ConflictResolverOpenRequest +): Promise { + const enabled = vscode.workspace.getConfiguration('intelli-git.mergeEditor').get('enabled', false); + + if (enabled) { + ConflictResolverPanel.createOrShow(options, file); + return; + } + + const repoPath = file.repoPath || options.repositoryManager.getActiveService()?.getWorkspaceRoot(); + if (!repoPath) { + return; + } + + await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(path.join(repoPath, file.path))); +} + +export class ConflictResolverPanel extends BaseWebviewProvider { + private static panels = new Map(); + private key: string; + private disposed = false; + + private constructor( + private readonly panel: vscode.WebviewPanel, + options: WebviewProviderOptions, + private readonly file: ConflictResolverOpenRequest + ) { + super(options); + this.key = getPanelKey(file); + this.panel.iconPath = new vscode.ThemeIcon('intelli-git-merge'); + + this.panel.onDidDispose(() => this.dispose(), null, this._disposables); + + this.setupWebview(this.panel.webview, () => this.disposed); + this.panel.webview.html = this.getHtml(this.panel.webview); + } + + protected getTitle(): string { + return `Merge: ${path.basename(this.file.path)}`; + } + + protected getInitialRoute(): string { + return '/conflict-resolver'; + } + + protected getInitialState(): unknown { + const conflictFile: RepositoryFileReference = { + path: this.file.path, + repoPath: this.file.repoPath, + }; + + return { + conflictFile, + }; + } + + protected getOnDispose(): () => void { + return () => this.dispose(); + } + + protected getRpcHandlerOptions() { + return { + updateConflictResolverTitle: (file: RepositoryFileReference) => { + this.updateActiveFile(file); + }, + }; + } + + private updateActiveFile(file: RepositoryFileReference): void { + if (this.disposed) { + return; + } + + const nextKey = getPanelKey(file); + this.panel.title = `Merge: ${path.basename(file.path)}`; + + if (nextKey === this.key) { + return; + } + + if (ConflictResolverPanel.panels.get(this.key) === this) { + ConflictResolverPanel.panels.delete(this.key); + } + + const existing = ConflictResolverPanel.panels.get(nextKey); + if (existing && existing !== this) { + existing.dispose(); + } + + this.key = nextKey; + ConflictResolverPanel.panels.set(nextKey, this); + } + + public static dispatchContextAction(request: ConflictResolverContextActionRequest): void { + const panel = ConflictResolverPanel.panels.get(getPanelKey(request)); + if (!panel || panel.disposed) { + return; + } + void panel.rpc?.triggerConflictResolverAction(request); + } + + public static createOrShow(options: WebviewProviderOptions, file: ConflictResolverOpenRequest): void { + const key = getPanelKey(file); + const existing = ConflictResolverPanel.panels.get(key); + if (existing) { + existing.updateActiveFile(file); + existing.panel.reveal(vscode.ViewColumn.Active); + void existing.rpc?.revealConflictResolverFile(file); + return; + } + + const panel = vscode.window.createWebviewPanel( + 'intelliGitConflictResolver', + `Merge: ${path.basename(file.path)}`, + { + viewColumn: vscode.ViewColumn.Active, + preserveFocus: false, + }, + { + enableScripts: true, + localResourceRoots: [options.extensionUri], + retainContextWhenHidden: true, + } + ); + + const resolver = new ConflictResolverPanel(panel, options, file); + ConflictResolverPanel.panels.set(key, resolver); + } + + public dispose(): void { + if (this.disposed) { + return; + } + + this.disposed = true; + if (ConflictResolverPanel.panels.get(this.key) === this) { + ConflictResolverPanel.panels.delete(this.key); + } + this.panel.dispose(); + super.dispose(); + } +} diff --git a/apps/extension/src/providers/index.ts b/apps/extension/src/providers/index.ts index 46c1654..3262477 100644 --- a/apps/extension/src/providers/index.ts +++ b/apps/extension/src/providers/index.ts @@ -1,5 +1,6 @@ export { BaseWebviewProvider, type WebviewProviderOptions } from './BaseWebviewProvider'; export { CommitViewProvider } from './CommitViewProvider'; +export { ConflictResolverPanel, openConflictFile } from './ConflictResolverPanel'; export { GitLogViewProvider } from './GitLogViewProvider'; export { PushPanel } from './PushPanel'; export { RepositoryFileSystemProvider } from './RepositoryFileSystemProvider'; diff --git a/apps/extension/src/rpc/ExtensionRpcHandler.test.ts b/apps/extension/src/rpc/ExtensionRpcHandler.test.ts index 77cc4bb..3ad4f8e 100644 --- a/apps/extension/src/rpc/ExtensionRpcHandler.test.ts +++ b/apps/extension/src/rpc/ExtensionRpcHandler.test.ts @@ -589,6 +589,60 @@ describe('ExtensionRpcHandler push', () => { }); }); +describe('ExtensionRpcHandler conflict resolver', () => { + it('pins a resolver opened without repoPath to the current repository', async () => { + const openConflictResolver = vi.fn(); + const gitService = { + getWorkspaceRoot: () => '/workspace/repository', + } as Partial; + const handler = new ExtensionRpcHandler({ + context: {} as vscode.ExtensionContext, + repositoryManager: { getActiveService: () => gitService as GitService } as any, + openConflictResolver, + }); + + await handler.openConflictResolver({ path: 'conflict.txt' }); + + expect(openConflictResolver).toHaveBeenCalledWith({ + path: 'conflict.txt', + repoPath: '/workspace/repository', + }); + }); + + it('always forwards the complete conflict snapshot for whole-side resolution', async () => { + const resolveConflict = vi.fn().mockResolvedValue(undefined); + const handler = createHandler({ resolveConflict }); + + await handler.resolveConflict({ + path: 'conflict.txt', + side: 'ours', + stageSignature: 'stage-signature', + resultFingerprint: 'result-fingerprint', + }); + + expect(resolveConflict).toHaveBeenCalledWith('conflict.txt', 'ours', { + stageSignature: 'stage-signature', + resultFingerprint: 'result-fingerprint', + }); + }); + + it('confirms discarding merge edits before restarting whitespace comparison', async () => { + vscodeTestMock.__resetWindowMessages(); + vscodeTestMock.__setWarningMessageResponse('Discard Changes and Restart'); + const handler = createHandler({}); + + await expect(handler.confirmConflictResolverRestart()).resolves.toBe(true); + + expect(vscodeTestMock.__getWarningMessages()).toEqual([ + { + message: + 'Changing whitespace comparison requires restarting the merge. Reviewed changes and result edits will be discarded.', + args: [{ modal: true }, 'Discard Changes and Restart'], + }, + ]); + }); +}); + describe('ExtensionRpcHandler review branch', () => { it('persists review branch options in workspace state scoped to the repository', async () => { const storageKey = 'ideaCommitPanel.reviewBranchOptions.v1._workspace_repo'; diff --git a/apps/extension/src/rpc/ExtensionRpcHandler.ts b/apps/extension/src/rpc/ExtensionRpcHandler.ts index 746f0a6..e08c05b 100644 --- a/apps/extension/src/rpc/ExtensionRpcHandler.ts +++ b/apps/extension/src/rpc/ExtensionRpcHandler.ts @@ -21,12 +21,16 @@ import type { CommitMessageGenerationMode, CommitMessageGenerationRequest, CommitMessageGenerationResult, + ConflictResolverOpenRequest, FileIconTheme, PushFailedResult, PushFailureCode, PushRequest, PublishReviewBranchResult, CommitDetails, + ConflictFileContent, + ResolveConflictRequest, + SaveConflictResolutionRequest, PublishReviewBranchRequest, } from '@shared/messages'; import { GitService } from '../services/GitService'; @@ -222,6 +226,8 @@ export interface ExtensionRpcHandlerOptions { onChangelistSelectionChange?: (selection: ChangelistFileSelection | null) => void; onChangelistFocusChange?: (focused: boolean) => void; consumePendingGitLogReveal?: () => GitLogRevealRequest | undefined; + openConflictResolver?: (file: ConflictResolverOpenRequest) => void; + updateConflictResolverTitle?: (file: RepositoryFileReference) => void; getFileIconTheme?: () => Promise; } @@ -235,6 +241,8 @@ export class ExtensionRpcHandler { private onDispose: () => void; private onChangelistSelectionChange?: (selection: ChangelistFileSelection | null) => void; private onChangelistFocusChange?: (focused: boolean) => void; + private openConflictResolverPanel?: (file: ConflictResolverOpenRequest) => void; + private updateConflictResolverPanelTitle?: (file: RepositoryFileReference) => void; private fileIconThemeProvider?: () => Promise; private gitReadRpcHandler: GitReadRpcHandler; private changelistRpcHandler: ChangelistRpcHandler; @@ -246,6 +254,8 @@ export class ExtensionRpcHandler { this.onDispose = options.onDispose || (() => {}); this.onChangelistSelectionChange = options.onChangelistSelectionChange; this.onChangelistFocusChange = options.onChangelistFocusChange; + this.openConflictResolverPanel = options.openConflictResolver; + this.updateConflictResolverPanelTitle = options.updateConflictResolverTitle; this.fileIconThemeProvider = options.getFileIconTheme; this.gitReadRpcHandler = new GitReadRpcHandler(this.repositoryManager, options.consumePendingGitLogReveal); this.changelistRpcHandler = new ChangelistRpcHandler( @@ -687,6 +697,12 @@ export class ExtensionRpcHandler { pickBranch: this.pickBranch, continueRebase: this.continueRebase, abortRebase: this.abortRebase, + openConflictResolver: this.openConflictResolver, + updateConflictResolverTitle: this.updateConflictResolverTitle, + getConflictFileContent: this.getConflictFileContent, + confirmConflictResolverRestart: this.confirmConflictResolverRestart, + saveConflictResolution: this.saveConflictResolution, + resolveConflict: this.resolveConflict, openFile: this.openFile, openStashDiff: this.openStashDiff, getBranchListData: this.gitReadRpcHandler.getBranchListData, @@ -765,6 +781,19 @@ export class ExtensionRpcHandler { return selected === action; }; + confirmConflictResolverRestart = async (): Promise => { + const action = i18n.t('Discard Changes and Restart'); + const selected = await vscode.window.showWarningMessage( + i18n.t( + 'Changing whitespace comparison requires restarting the merge. Reviewed changes and result edits will be discarded.' + ), + { modal: true }, + action + ); + + return selected === action; + }; + private confirmProtectedBranchPush = async (params: { remote: string; branch: string }): Promise => { const action = i18n.t('Push Anyway'); const selected = await vscode.window.showWarningMessage( @@ -1876,6 +1905,62 @@ export class ExtensionRpcHandler { } }; + openConflictResolver = async (params: ConflictResolverOpenRequest): Promise => { + const request = params.repoPath ? params : { ...params, repoPath: this.gitService.getWorkspaceRoot() }; + if (this.openConflictResolverPanel) { + this.openConflictResolverPanel(request); + return; + } + + await vscode.commands.executeCommand('intelli-git.openConflictResolver', request); + }; + + updateConflictResolverTitle = async (params: { path: string; repoPath?: string }): Promise => { + this.updateConflictResolverPanelTitle?.({ + path: params.path, + repoPath: params.repoPath, + }); + }; + + getConflictFileContent = async (params: { path: string; repoPath?: string }): Promise => { + const content = await this.getServiceForRepo(params.repoPath).getConflictFileContent(params.path); + return { + ...content, + repoPath: params.repoPath, + }; + }; + + saveConflictResolution = async (params: SaveConflictResolutionRequest): Promise => { + try { + await this.getServiceForRepo(params.repoPath).saveConflictResolution( + params.path, + params.content, + { + stageSignature: params.stageSignature, + resultFingerprint: params.resultFingerprint, + }, + params.resultExists + ); + await vscode.commands.executeCommand('intelli-git.refresh'); + } catch (e) { + vscode.window.showErrorMessage(i18n.t('extension.resolveConflictFailed', `${e}`)); + throw e; + } + }; + + resolveConflict = async (params: ResolveConflictRequest): Promise => { + try { + await this.getServiceForRepo(params.repoPath).resolveConflict(params.path, params.side, { + stageSignature: params.stageSignature, + resultFingerprint: params.resultFingerprint, + }); + await vscode.commands.executeCommand('intelli-git.refresh'); + } catch (e) { + vscode.window.showErrorMessage(i18n.t('extension.resolveConflictFailed', `${e}`)); + throw e; + } + }; + getAIProviderStatus = async (): Promise => { const provider = this.getCurrentAiProvider(); const model = this.getAiProviderModel(provider); diff --git a/apps/extension/src/services/GitService.test.ts b/apps/extension/src/services/GitService.test.ts index 3f29fd5..c3db50a 100644 --- a/apps/extension/src/services/GitService.test.ts +++ b/apps/extension/src/services/GitService.test.ts @@ -289,6 +289,217 @@ describe('GitService conflict resolution', () => { return new GitService(tempDir, tempDir, git); } + it('reads conflict stage contents and stages a saved resolution', async () => { + fs.writeFileSync(path.join(tempDir, 'conflict.txt'), 'base\n'); + await git.add('conflict.txt'); + await git.commit('Initial commit'); + + const baseBranch = (await git.branch()).current; + await git.checkoutLocalBranch('feature'); + fs.writeFileSync(path.join(tempDir, 'conflict.txt'), 'incoming\n'); + await git.add('conflict.txt'); + await git.commit('Incoming change'); + + await git.checkout(baseBranch); + fs.writeFileSync(path.join(tempDir, 'conflict.txt'), 'current\n'); + await git.add('conflict.txt'); + await git.commit('Current change'); + + try { + await git.merge(['feature']); + } catch { + // The conflict state is the scenario under test. + } + + const service = new GitService(tempDir, tempDir, git); + const conflict = await service.getConflictFileContent('conflict.txt'); + + expect(conflict.base).toEqual({ exists: true, content: 'base\n' }); + expect(conflict.current).toEqual({ exists: true, content: 'current\n' }); + expect(conflict.incoming).toEqual({ exists: true, content: 'incoming\n' }); + expect(conflict.currentChanges).toEqual([ + expect.objectContaining({ baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 1 }), + ]); + expect(conflict.incomingChanges).toEqual([ + expect.objectContaining({ baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 1 }), + ]); + expect(conflict.baseLabel).toBe('base'); + expect(conflict.currentLabel).toBe(baseBranch); + expect(conflict.incomingLabel).toBe('feature'); + expect(conflict.result).toContain('<<<<<<<'); + expect(conflict.stageSignature).toMatch(/^1:100644:[0-9a-f]+\|2:100644:[0-9a-f]+\|3:100644:[0-9a-f]+$/); + expect(conflict.resultFingerprint).toMatch(/^[0-9a-f]{64}$/); + expect(conflict.isBinary).toBe(false); + expect(conflict.kind).toBe('text'); + + await service.saveConflictResolution('conflict.txt', 'resolved\n', conflict); + + const status = await git.status(); + expect(status.conflicted).toEqual([]); + expect(status.staged).toEqual(['conflict.txt']); + expect(fs.readFileSync(path.join(tempDir, 'conflict.txt'), 'utf8')).toBe('resolved\n'); + }); + + it('rejects saved resolutions that still contain conflict markers', async () => { + fs.writeFileSync(path.join(tempDir, 'conflict.txt'), 'base\n'); + await git.add('conflict.txt'); + await git.commit('Initial commit'); + + const baseBranch = (await git.branch()).current; + await git.checkoutLocalBranch('feature'); + fs.writeFileSync(path.join(tempDir, 'conflict.txt'), 'incoming\n'); + await git.add('conflict.txt'); + await git.commit('Incoming change'); + + await git.checkout(baseBranch); + fs.writeFileSync(path.join(tempDir, 'conflict.txt'), 'current\n'); + await git.add('conflict.txt'); + await git.commit('Current change'); + + try { + await git.merge(['feature']); + } catch { + // The conflict state is the scenario under test. + } + + const service = new GitService(tempDir, tempDir, git); + const conflict = await service.getConflictFileContent('conflict.txt'); + const markerContent = ['<<<<<<< HEAD\n', 'current\n', '=======\n', 'incoming\n', '>>>>>>> feature\n'].join(''); + + await expect(service.saveConflictResolution('conflict.txt', markerContent, conflict)).rejects.toThrow( + 'conflict markers' + ); + + expect(await git.raw(['ls-files', '-u', '--', 'conflict.txt'])).toContain('\tconflict.txt'); + expect(fs.readFileSync(path.join(tempDir, 'conflict.txt'), 'utf8')).toContain('<<<<<<<'); + }); + + it('preserves the exact worktree result and saved line endings', async () => { + const service = await createTextConflicts(); + const resultPath = path.join(tempDir, 'conflict.txt'); + const worktreeResult = `manual prefix\r\n${fs.readFileSync(resultPath, 'utf8')}`; + fs.writeFileSync(resultPath, worktreeResult); + + const conflict = await service.getConflictFileContent('conflict.txt'); + + expect(conflict.result).toBe(worktreeResult); + const resolved = 'resolved\r\nwithout final newline'; + await service.saveConflictResolution('conflict.txt', resolved, conflict); + expect(fs.readFileSync(resultPath, 'utf8')).toBe(resolved); + }); + + it('rejects a stale resolution after the worktree result changes externally', async () => { + const service = await createTextConflicts(); + const conflict = await service.getConflictFileContent('conflict.txt'); + const resultPath = path.join(tempDir, 'conflict.txt'); + fs.writeFileSync(resultPath, 'external edit\n'); + + await expect(service.saveConflictResolution('conflict.txt', 'resolver edit\n', conflict)).rejects.toThrow( + 'result file changed' + ); + + expect(fs.readFileSync(resultPath, 'utf8')).toBe('external edit\n'); + expect(await git.raw(['ls-files', '-u', '--', 'conflict.txt'])).toContain('\tconflict.txt'); + }); + + it('rejects a stale resolution after the worktree executable bit changes externally', async () => { + if (process.platform === 'win32') { + return; + } + + const service = await createTextConflicts(); + const resultPath = path.join(tempDir, 'conflict.txt'); + fs.chmodSync(resultPath, 0o644); + const conflict = await service.getConflictFileContent('conflict.txt'); + const originalResult = fs.readFileSync(resultPath, 'utf8'); + fs.chmodSync(resultPath, 0o755); + + await expect(service.saveConflictResolution('conflict.txt', 'resolver edit\n', conflict)).rejects.toThrow( + 'result file changed' + ); + + expect(fs.readFileSync(resultPath, 'utf8')).toBe(originalResult); + expect(fs.statSync(resultPath).mode & 0o111).not.toBe(0); + expect(await git.raw(['ls-files', '-u', '--', 'conflict.txt'])).toContain('\tconflict.txt'); + }); + + it('reports when the existing worktree result no longer contains conflict markers', async () => { + const service = await createTextConflicts(); + const resultPath = path.join(tempDir, 'conflict.txt'); + + let conflict = await service.getConflictFileContent('conflict.txt'); + expect(conflict.resolvedCandidate).toBe(false); + + fs.writeFileSync(resultPath, 'manual resolution\n'); + conflict = await service.getConflictFileContent('conflict.txt'); + + expect(conflict.result).toBe('manual resolution\n'); + expect(conflict.resolvedCandidate).toBe(true); + }); + + it('rejects saving through a worktree symlink without changing its target', async () => { + if (process.platform === 'win32') { + return; + } + + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'intelli-git-conflict-target-')); + try { + const service = await createTextConflicts(); + const resultPath = path.join(tempDir, 'conflict.txt'); + const outsidePath = path.join(outsideDir, 'outside.txt'); + fs.writeFileSync(outsidePath, 'outside sentinel\n'); + fs.unlinkSync(resultPath); + fs.symlinkSync(outsidePath, resultPath); + const conflict = await service.getConflictFileContent('conflict.txt'); + + expect(conflict.isBinary).toBe(true); + await expect(service.saveConflictResolution('conflict.txt', 'resolver edit\n', conflict)).rejects.toThrow( + 'cannot be edited' + ); + + expect(fs.readFileSync(outsidePath, 'utf8')).toBe('outside sentinel\n'); + expect(await git.raw(['ls-files', '-u', '--', 'conflict.txt'])).toContain('\tconflict.txt'); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it('rejects conflict paths whose parent directory became a symlink', async () => { + if (process.platform === 'win32') { + return; + } + + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'intelli-git-conflict-parent-')); + try { + const service = await createTextConflicts(['nested/conflict.txt']); + const outsidePath = path.join(outsideDir, 'conflict.txt'); + fs.writeFileSync(outsidePath, 'outside parent sentinel\n'); + fs.rmSync(path.join(tempDir, 'nested'), { recursive: true, force: true }); + fs.symlinkSync(outsideDir, path.join(tempDir, 'nested'), 'dir'); + + await expect(service.getConflictFileContent('nested/conflict.txt')).rejects.toThrow('cannot be edited'); + + expect(fs.readFileSync(outsidePath, 'utf8')).toBe('outside parent sentinel\n'); + expect(await git.raw(['ls-files', '-u', '--', 'nested/conflict.txt'])).toContain('\tnested/conflict.txt'); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it('rejects a stale resolution after the conflict stages change', async () => { + const service = await createTextConflicts(); + const conflict = await service.getConflictFileContent('conflict.txt'); + const resultPath = path.join(tempDir, 'conflict.txt'); + const originalResult = fs.readFileSync(resultPath, 'utf8'); + await git.add('conflict.txt'); + + await expect(service.saveConflictResolution('conflict.txt', 'resolver edit\n', conflict)).rejects.toThrow( + 'conflict changed' + ); + + expect(fs.readFileSync(resultPath, 'utf8')).toBe(originalResult); + }); + it('rejects whole-side resolution after the conflict was staged externally', async () => { const service = await createTextConflicts(); const resultPath = path.join(tempDir, 'conflict.txt'); @@ -303,7 +514,7 @@ describe('GitService conflict resolution', () => { expect((await git.status()).staged).toContain('conflict.txt'); }); - it('resolves a conflict filename as a literal pathspec', async () => { + it('reads and resolves a conflict filename as a literal pathspec', async () => { const service = await createTextConflicts(['*.txt', 'other.txt']); const literalPath = path.join(tempDir, '*.txt'); fs.writeFileSync(literalPath, 'manual literal resolution\n'); @@ -312,7 +523,12 @@ describe('GitService conflict resolution', () => { expect(status.find((file) => file.path === '*.txt')?.resolvedCandidate).toBe(true); expect(status.find((file) => file.path === 'other.txt')?.resolvedCandidate).toBe(false); - await service.resolveConflict('*.txt', 'ours'); + const conflict = await service.getConflictFileContent('*.txt'); + expect(conflict.current.content).toBe('current:*.txt\n'); + expect(conflict.incoming.content).toBe('incoming:*.txt\n'); + expect(conflict.result).toBe('manual literal resolution\n'); + + await service.resolveConflict('*.txt', 'ours', conflict); expect(fs.readFileSync(literalPath, 'utf8')).toBe('current:*.txt\n'); const unmerged = await git.raw(['ls-files', '-u', '-z']); @@ -320,6 +536,19 @@ describe('GitService conflict resolution', () => { expect(unmerged).toContain('\tother.txt\0'); }); + it('saves a conflict filename without staging another pathspec match', async () => { + const service = await createTextConflicts(['*.txt', 'other.txt']); + const conflict = await service.getConflictFileContent('*.txt'); + + await service.saveConflictResolution('*.txt', 'resolved literal\n', conflict); + + expect(fs.readFileSync(path.join(tempDir, '*.txt'), 'utf8')).toBe('resolved literal\n'); + expect(fs.readFileSync(path.join(tempDir, 'other.txt'), 'utf8')).toContain('<<<<<<<'); + const unmerged = await git.raw(['ls-files', '-u', '-z']); + expect(unmerged).not.toContain('\t*.txt\0'); + expect(unmerged).toContain('\tother.txt\0'); + }); + it('resolves modify/delete conflicts by staging the deleted side instead of writing an empty file', async () => { fs.writeFileSync(path.join(tempDir, 'delete-on-incoming.txt'), 'base\n'); await git.add('delete-on-incoming.txt'); @@ -343,6 +572,18 @@ describe('GitService conflict resolution', () => { } const service = new GitService(tempDir, tempDir, git); + const conflict = await service.getConflictFileContent('delete-on-incoming.txt'); + + expect(conflict.base).toEqual({ exists: true, content: 'base\n' }); + expect(conflict.current).toEqual({ exists: true, content: 'current edit\n' }); + expect(conflict.incoming).toEqual({ exists: false, content: '' }); + expect(conflict.currentChanges).toEqual([ + expect.objectContaining({ baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 1 }), + ]); + expect(conflict.incomingChanges).toEqual([ + expect.objectContaining({ baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 0 }), + ]); + await service.resolveConflict('delete-on-incoming.txt', 'theirs'); expect(fs.existsSync(path.join(tempDir, 'delete-on-incoming.txt'))).toBe(false); @@ -352,6 +593,111 @@ describe('GitService conflict resolution', () => { ); }); + it('describes both sides of an add/add conflict as Base insertions', async () => { + fs.writeFileSync(path.join(tempDir, 'seed.txt'), 'seed\n'); + await git.add('seed.txt'); + await git.commit('Initial commit'); + + const baseBranch = (await git.branch()).current; + await git.checkoutLocalBranch('feature'); + fs.writeFileSync(path.join(tempDir, 'added.txt'), 'incoming\n'); + await git.add('added.txt'); + await git.commit('Incoming add'); + + await git.checkout(baseBranch); + fs.writeFileSync(path.join(tempDir, 'added.txt'), 'current\n'); + await git.add('added.txt'); + await git.commit('Current add'); + + try { + await git.merge(['feature']); + } catch { + // The add/add conflict state is the scenario under test. + } + + const service = new GitService(tempDir, tempDir, git); + const conflict = await service.getConflictFileContent('added.txt'); + + expect(conflict.base).toEqual({ exists: false, content: '' }); + expect(conflict.currentChanges).toEqual([ + expect.objectContaining({ baseStart: 0, baseLineCount: 0, sideStart: 0, sideLineCount: 1 }), + ]); + expect(conflict.incomingChanges).toEqual([ + expect.objectContaining({ baseStart: 0, baseLineCount: 0, sideStart: 0, sideLineCount: 1 }), + ]); + + await service.saveConflictResolution('added.txt', '', conflict, false); + + expect(fs.existsSync(path.join(tempDir, 'added.txt'))).toBe(false); + expect((await git.raw(['ls-files', '-u', '--', 'added.txt'])).trim()).toBe(''); + expect(await git.raw(['diff', '--cached', '--name-status', '--', 'added.txt'])).toContain('D\tadded.txt'); + }); + + it('labels rebase conflict sides with their source branches', async () => { + fs.writeFileSync(path.join(tempDir, 'conflict.txt'), 'base\n'); + await git.add('conflict.txt'); + await git.commit('Initial commit'); + + const baseBranch = (await git.branch()).current; + await git.checkoutLocalBranch('feature'); + fs.writeFileSync(path.join(tempDir, 'conflict.txt'), 'feature\n'); + await git.add('conflict.txt'); + await git.commit('Feature change'); + + await git.checkout(baseBranch); + fs.writeFileSync(path.join(tempDir, 'conflict.txt'), 'main\n'); + await git.add('conflict.txt'); + await git.commit('Main change'); + await git.checkout('feature'); + + try { + await git.raw(['rebase', baseBranch]); + } catch { + // The rebase conflict state is the scenario under test. + } + + const service = new GitService(tempDir, tempDir, git); + const conflict = await service.getConflictFileContent('conflict.txt'); + + expect(conflict.currentLabel).toBe(baseBranch); + expect(conflict.incomingLabel).toBe('feature'); + expect(conflict.base.content).toBe('base\n'); + expect(conflict.current.content).toBe('main\n'); + expect(conflict.incoming.content).toBe('feature\n'); + }); + + it('marks binary conflict content as unsupported for inline editing', async () => { + fs.writeFileSync(path.join(tempDir, 'binary.dat'), Buffer.from([0, 1, 2, 3])); + await git.add('binary.dat'); + await git.commit('Initial commit'); + + const baseBranch = (await git.branch()).current; + await git.checkoutLocalBranch('feature'); + fs.writeFileSync(path.join(tempDir, 'binary.dat'), Buffer.from([0, 2, 2, 3])); + await git.add('binary.dat'); + await git.commit('Incoming binary change'); + + await git.checkout(baseBranch); + fs.writeFileSync(path.join(tempDir, 'binary.dat'), Buffer.from([0, 1, 3, 3])); + await git.add('binary.dat'); + await git.commit('Current binary change'); + + try { + await git.merge(['feature']); + } catch { + // The binary conflict state is the scenario under test. + } + + const service = new GitService(tempDir, tempDir, git); + const conflict = await service.getConflictFileContent('binary.dat'); + + expect(conflict.base.exists).toBe(true); + expect(conflict.current.exists).toBe(true); + expect(conflict.incoming.exists).toBe(true); + expect(conflict.isBinary).toBe(true); + expect(conflict.kind).toBe('binary'); + }); + it('treats invalid UTF-8 without NUL bytes as binary while preserving whole-side resolution', async () => { const baseContent = Buffer.concat([Buffer.from([0xff]), Buffer.from('base\n')]); const currentContent = Buffer.concat([Buffer.from([0xff]), Buffer.from('current\n')]); @@ -378,15 +724,19 @@ describe('GitService conflict resolution', () => { } const service = new GitService(tempDir, tempDir, git); + const conflict = await service.getConflictFileContent('invalid-utf8.dat'); const incomingObjectId = (await git.raw(['ls-files', '-u', '--', 'invalid-utf8.dat'])) .split('\n') .find((line) => /\s3\t/.test(line)) ?.split(/\s+/)[1]; - const conflict = (await service.getStatus()).find((file) => file.path === 'invalid-utf8.dat'); - expect(conflict?.resolvedCandidate).toBe(false); + expect(conflict.isBinary).toBe(true); + expect(conflict.kind).toBe('binary'); + expect(conflict.resolvedCandidate).toBe(false); + expect(conflict.currentChanges).toEqual([]); + expect(conflict.incomingChanges).toEqual([]); - await service.resolveConflict('invalid-utf8.dat', 'theirs'); + await service.resolveConflict('invalid-utf8.dat', 'theirs', conflict); expect(fs.readFileSync(path.join(tempDir, 'invalid-utf8.dat'))).toEqual(incomingContent); expect((await git.revparse([':invalid-utf8.dat'])).trim()).toBe(incomingObjectId); @@ -442,10 +792,17 @@ describe('GitService conflict resolution', () => { } const service = new GitService(tempDir, tempDir, git); + const conflict = await service.getConflictFileContent('module'); const indexBefore = await git.raw(['ls-files', '-u', '--', 'module']); const worktreeHeadBefore = (await moduleGit.revparse(['HEAD'])).trim(); - await expect(service.resolveConflict('module', 'theirs')).rejects.toThrow('cannot be edited'); + expect(conflict.isBinary).toBe(true); + expect(conflict.kind).toBe('submodule'); + expect(conflict.base).toEqual({ exists: true, content: '', objectId: baseObjectId }); + expect(conflict.current).toEqual({ exists: true, content: '', objectId: currentObjectId }); + expect(conflict.incoming).toEqual({ exists: true, content: '', objectId: incomingObjectId }); + + await expect(service.resolveConflict('module', 'theirs', conflict)).rejects.toThrow('cannot be edited'); expect(await git.raw(['ls-files', '-u', '--', 'module'])).toBe(indexBefore); expect((await moduleGit.revparse(['HEAD'])).trim()).toBe(worktreeHeadBefore); diff --git a/apps/extension/src/services/GitService.ts b/apps/extension/src/services/GitService.ts index 1edd9c4..bcea04d 100644 --- a/apps/extension/src/services/GitService.ts +++ b/apps/extension/src/services/GitService.ts @@ -1,6 +1,15 @@ import simpleGit, { SimpleGit, StatusResult } from 'simple-git'; import * as vscode from 'vscode'; -import type { FileStatus, CommitFile, GitStatusCode, GitHunk } from '@shared/messages'; +import type { + ConflictChange, + ConflictFileContent, + ConflictResolutionSnapshot, + ConflictSideContent, + FileStatus, + CommitFile, + GitStatusCode, + GitHunk, +} from '@shared/messages'; import { hasConflictMarkerBlocks } from '@shared/conflictMarkers'; import { logger } from '../utils/logger'; import { i18n } from '../utils/i18n'; @@ -9,7 +18,8 @@ import * as path from 'path'; import * as os from 'os'; import { AsyncLocalStorage } from 'node:async_hooks'; import { isUtf8 } from 'node:buffer'; -import { parseDiffToFileHunks } from '../utils/diffParser'; +import { createHash } from 'node:crypto'; +import { parseDiffToFileHunks, parseDiffToHunks } from '../utils/diffParser'; import { GitLogService } from './GitLogService'; import { GitBranchRemoteService } from './GitBranchRemoteService'; import type { ChangelistStateService, ChangelistStateSnapshot, CommitPlan } from './ChangelistStateService'; @@ -26,6 +36,7 @@ interface ConflictStageEntry { interface WorktreeFileSnapshot { content: Buffer; + fingerprint: string; kind: 'file' | 'missing' | 'symlink' | 'other'; } @@ -77,26 +88,68 @@ function formatGitError(error: unknown): string { return message.replace(/\s+/g, ' ').trim() || 'Unknown git error'; } +function createWorktreeFingerprint(kind: WorktreeFileSnapshot['kind'], content: Buffer, executable = false): string { + return createHash('sha256') + .update(kind) + .update('\0') + .update(executable ? 'executable' : 'non-executable') + .update('\0') + .update(content) + .digest('hex'); +} + +function countContentLines(content: string): number { + if (!content) { + return 0; + } + const lines = content.split(/\r\n|\r|\n/); + return lines.length - (/(?:\r\n|\r|\n)$/.test(content) ? 1 : 0); +} + +function getDiffRangeStart(start: number, lineCount: number): number { + return lineCount === 0 ? start : Math.max(0, start - 1); +} + function readWorktreeFileSnapshot(filePath: string): WorktreeFileSnapshot { let stat: fs.Stats; try { stat = fs.lstatSync(filePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { content: Buffer.alloc(0), kind: 'missing' }; + const content = Buffer.alloc(0); + return { + content, + fingerprint: createWorktreeFingerprint('missing', content), + kind: 'missing', + }; } throw error; } if (stat.isSymbolicLink()) { - return { content: Buffer.from(fs.readlinkSync(filePath)), kind: 'symlink' }; + const content = Buffer.from(fs.readlinkSync(filePath)); + return { + content, + fingerprint: createWorktreeFingerprint('symlink', content), + kind: 'symlink', + }; } if (stat.isFile()) { - return { content: fs.readFileSync(filePath), kind: 'file' }; + const content = fs.readFileSync(filePath); + return { + content, + fingerprint: createWorktreeFingerprint('file', content, (stat.mode & 0o111) !== 0), + kind: 'file', + }; } - return { content: Buffer.alloc(0), kind: 'other' }; + const content = Buffer.from(`${stat.mode}:${stat.size}:${stat.mtimeMs}`); + return { + content: Buffer.alloc(0), + fingerprint: createWorktreeFingerprint('other', content), + kind: 'other', + }; } function isRegularConflictMode(mode: string): boolean { @@ -688,9 +741,272 @@ export class GitService implements vscode.Disposable { return stages; } - private async readConflictStages(repoPath: string): Promise> { + private getConflictStageSignature(stages: Map): string { + return [...stages.entries()] + .sort(([left], [right]) => left - right) + .map(([stage, entry]) => `${stage}:${entry.mode}:${entry.objectId}`) + .join('|'); + } + + private async readConflictStages(repoPath: string): Promise<{ + stages: Map; + signature: string; + }> { const unmerged = await this.git.raw(['ls-files', '-u', '-z', '--', toLiteralGitPathspec(repoPath)]); - return this.parseUnmergedStages(unmerged); + const stages = this.parseUnmergedStages(unmerged); + return { + stages, + signature: this.getConflictStageSignature(stages), + }; + } + + private async readConflictSide(stage: ConflictStageEntry | undefined): Promise<{ + content: Buffer; + side: ConflictSideContent; + }> { + if (!stage) { + return { + content: Buffer.alloc(0), + side: { exists: false, content: '' }, + }; + } + + const content = Buffer.from(await this.git.binaryCatFile(['-p', stage.objectId])); + return { + content, + side: { + exists: true, + content: content.toString('utf8'), + }, + }; + } + + private async readConflictChanges( + filePath: string, + baseStage: ConflictStageEntry | undefined, + sideStage: ConflictStageEntry | undefined, + base: ConflictSideContent, + side: ConflictSideContent, + sideName: 'current' | 'incoming' + ): Promise { + if (!baseStage && !sideStage) { + return []; + } + if (!baseStage && sideStage) { + return [ + { + id: `${filePath}:${sideName}:add`, + baseStart: 0, + baseLineCount: 0, + sideStart: 0, + sideLineCount: countContentLines(side.content), + }, + ]; + } + if (baseStage && !sideStage) { + return [ + { + id: `${filePath}:${sideName}:delete`, + baseStart: 0, + baseLineCount: countContentLines(base.content), + sideStart: 0, + sideLineCount: 0, + }, + ]; + } + if (baseStage?.objectId === sideStage?.objectId) { + return []; + } + + const diff = await this.git.raw([ + 'diff', + '--no-ext-diff', + '--no-color', + '--unified=0', + baseStage!.objectId, + sideStage!.objectId, + ]); + return parseDiffToHunks(diff, filePath, { idPrefix: `conflict-${sideName}` }).map((hunk) => ({ + id: hunk.id, + baseStart: getDiffRangeStart(hunk.oldStart, hunk.oldLineCount), + baseLineCount: hunk.oldLineCount, + sideStart: getDiffRangeStart(hunk.newStart, hunk.newLineCount), + sideLineCount: hunk.newLineCount, + })); + } + + private async assertConflictResolutionSnapshot( + filePath: string, + expected: ConflictResolutionSnapshot + ): Promise> { + const repoPath = this.toRepoPath(filePath); + const currentStages = await this.readConflictStages(repoPath); + if (!currentStages.signature || currentStages.signature !== expected.stageSignature) { + throw new Error(i18n.t('extension.conflictStagesChanged')); + } + + const resultSnapshot = readWorktreeFileSnapshot(resolveSafeConflictWorktreePath(this._workspaceRoot, filePath)); + if (resultSnapshot.fingerprint !== expected.resultFingerprint) { + throw new Error(i18n.t('extension.conflictResultChanged')); + } + + return currentStages.stages; + } + + private async getAbsoluteGitDir(): Promise { + const gitDir = (await this.git.revparse(['--git-dir'])).trim(); + return path.isAbsolute(gitDir) ? gitDir : path.join(this._gitRoot, gitDir); + } + + private readGitStateFile(gitDir: string, name: string): string { + try { + const filePath = path.join(gitDir, name); + return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8').trim() : ''; + } catch { + return ''; + } + } + + private getMergeIncomingLabelFromMessage(message: string): string | undefined { + const quoted = message.match(/^Merge (?:remote-tracking )?branch ['"]([^'"]+)['"]/i); + if (quoted?.[1]) { + return quoted[1]; + } + + const unquoted = message.match(/^Merge (?:remote-tracking )?branch ([^\s]+)(?:\s|$)/i); + if (unquoted?.[1]) { + return unquoted[1]; + } + + const pull = message.match(/^Merge branch ['"]([^'"]+)['"] of /i); + if (pull?.[1]) { + return pull[1]; + } + + return undefined; + } + + private async getConflictLabels(): Promise<{ baseLabel: string; currentLabel: string; incomingLabel: string }> { + let currentLabel = 'ours'; + let incomingLabel = 'theirs'; + + try { + currentLabel = (await this.git.revparse(['--abbrev-ref', 'HEAD'])).trim() || currentLabel; + } catch { + // Keep the stable fallback. + } + + try { + const gitDir = await this.getAbsoluteGitDir(); + const rebaseDir = ['rebase-merge', 'rebase-apply'] + .map((name) => path.join(gitDir, name)) + .find((candidate) => fs.existsSync(candidate)); + if (rebaseDir) { + const headName = this.readGitStateFile(rebaseDir, 'head-name'); + const onto = this.readGitStateFile(rebaseDir, 'onto'); + if (headName) { + incomingLabel = headName.replace(/^refs\/heads\//, '').replace(/^refs\/remotes\//, ''); + } + if (onto) { + currentLabel = + (await this.git.raw(['name-rev', '--name-only', '--exclude=tags/*', onto])).trim() || + currentLabel; + } + return { baseLabel: 'base', currentLabel, incomingLabel }; + } + + const mergeMessage = this.readGitStateFile(gitDir, 'MERGE_MSG'); + incomingLabel = this.getMergeIncomingLabelFromMessage(mergeMessage) ?? incomingLabel; + + if (incomingLabel === 'theirs') { + const mergeHead = this.readGitStateFile(gitDir, 'MERGE_HEAD').split(/\s+/)[0]; + if (mergeHead) { + incomingLabel = + (await this.git.raw(['name-rev', '--name-only', '--exclude=tags/*', mergeHead])).trim() || + incomingLabel; + } + } + } catch { + // Keep the stable fallback. + } + + return { + baseLabel: 'base', + currentLabel, + incomingLabel, + }; + } + + public async getConflictFileContent(filePath: string): Promise { + const repoPath = this.toRepoPath(filePath); + const { stages, signature } = await this.readConflictStages(repoPath); + + if (stages.size === 0) { + throw new Error(`No unresolved conflict found for ${filePath}`); + } + + const absPath = resolveSafeConflictWorktreePath(this._workspaceRoot, filePath); + const resultSnapshot = readWorktreeFileSnapshot(absPath); + const result = resultSnapshot.content.toString('utf8'); + const stageNumbers = new Set(stages.keys()); + const labels = await this.getConflictLabels(); + const stageEntries = [...stages.values()]; + const hasUnsupportedMode = stageEntries.some((stage) => !isRegularConflictMode(stage.mode)); + if (hasUnsupportedMode) { + const kind = stageEntries.every((stage) => stage.mode === '160000') ? 'submodule' : 'unsupported'; + return { + path: filePath, + ...labels, + base: { exists: stages.has(1), content: '', objectId: stages.get(1)?.objectId }, + current: { exists: stages.has(2), content: '', objectId: stages.get(2)?.objectId }, + incoming: { exists: stages.has(3), content: '', objectId: stages.get(3)?.objectId }, + currentChanges: [], + incomingChanges: [], + result, + stageSignature: signature, + resultFingerprint: resultSnapshot.fingerprint, + resolvedCandidate: false, + isBinary: true, + kind, + }; + } + + const [baseSide, currentSide, incomingSide] = await Promise.all([ + this.readConflictSide(stages.get(1)), + this.readConflictSide(stages.get(2)), + this.readConflictSide(stages.get(3)), + ]); + const base = baseSide.side; + const current = currentSide.side; + const incoming = incomingSide.side; + const isBinary = + (resultSnapshot.kind !== 'file' && resultSnapshot.kind !== 'missing') || + isBinaryConflictContent(resultSnapshot.content) || + isBinaryConflictContent(baseSide.content) || + isBinaryConflictContent(currentSide.content) || + isBinaryConflictContent(incomingSide.content); + const [currentChanges, incomingChanges] = isBinary + ? [[], []] + : await Promise.all([ + this.readConflictChanges(filePath, stages.get(1), stages.get(2), base, current, 'current'), + this.readConflictChanges(filePath, stages.get(1), stages.get(3), base, incoming, 'incoming'), + ]); + + return { + path: filePath, + ...labels, + base, + current, + incoming, + currentChanges, + incomingChanges, + result, + stageSignature: signature, + resultFingerprint: resultSnapshot.fingerprint, + resolvedCandidate: this.isResolvedConflictCandidate(stageNumbers, resultSnapshot), + isBinary, + kind: isBinary ? 'binary' : 'text', + }; } public getStatus = async (): Promise => this.loadStatus(); @@ -977,11 +1293,17 @@ export class GitService implements vscode.Disposable { }); } - public async resolveConflict(filePath: string, side: 'ours' | 'theirs'): Promise { + public async resolveConflict( + filePath: string, + side: 'ours' | 'theirs', + expected?: ConflictResolutionSnapshot + ): Promise { return this.runGitMutation(async () => { const repoPath = this.toRepoPath(filePath); const pathspec = toLiteralGitPathspec(repoPath); - const stages = await this.readConflictStages(repoPath); + const stages = expected + ? await this.assertConflictResolutionSnapshot(filePath, expected) + : (await this.readConflictStages(repoPath)).stages; if (stages.size === 0) { throw new Error(`No unresolved conflict found for ${filePath}`); } @@ -1003,6 +1325,49 @@ export class GitService implements vscode.Disposable { }); } + public async saveConflictResolution( + filePath: string, + content: string, + expected: ConflictResolutionSnapshot, + resultExists = true + ): Promise { + return this.runGitMutation(async () => { + if (hasConflictMarkerBlocks(content)) { + throw new Error('The final result still contains conflict markers.'); + } + if (!resultExists && content.length > 0) { + throw new Error('A deleted merge result cannot contain text.'); + } + + const stages = await this.assertConflictResolutionSnapshot(filePath, expected); + if ([...stages.values()].some((stage) => !isRegularConflictMode(stage.mode))) { + throw new Error(i18n.t('extension.unsupportedConflictFile')); + } + + const absPath = resolveSafeConflictWorktreePath(this._workspaceRoot, filePath); + const currentResultSnapshot = readWorktreeFileSnapshot(absPath); + if (currentResultSnapshot.fingerprint !== expected.resultFingerprint) { + throw new Error(i18n.t('extension.conflictResultChanged')); + } + if (currentResultSnapshot.kind !== 'file' && currentResultSnapshot.kind !== 'missing') { + throw new Error(i18n.t('extension.unsupportedConflictFile')); + } + + const repoPath = this.toRepoPath(filePath); + const pathspec = toLiteralGitPathspec(repoPath); + if (!resultExists) { + await this.git.raw(['rm', '--', pathspec]); + this.fireChange(); + return; + } + + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content, 'utf8'); + await this.git.raw(['add', '--', pathspec]); + this.fireChange(); + }); + } + public async stageAll(): Promise { return this.runGitMutation(async () => { const currentStatus = await this.getStatus(); diff --git a/apps/extension/src/utils/webviewHtml.test.ts b/apps/extension/src/utils/webviewHtml.test.ts index 18fc5e4..57c5d1c 100644 --- a/apps/extension/src/utils/webviewHtml.test.ts +++ b/apps/extension/src/utils/webviewHtml.test.ts @@ -3,7 +3,7 @@ import * as vscode from 'vscode'; import { getWebviewHtml } from './webviewHtml'; describe('webview HTML', () => { - it('restricts styles and scripts to the webview source and nonce', () => { + it('allows Monaco row layout styles without allowing inline scripts', () => { const uri = vscode.Uri as unknown as { joinPath(base: vscode.Uri, ...segments: string[]): vscode.Uri; }; @@ -18,8 +18,8 @@ describe('webview HTML', () => { title: 'Test', }); - expect(html).toContain('style-src https://webview.test;'); - expect(html).not.toContain('unsafe-inline'); + expect(html).toContain("style-src https://webview.test; style-src-attr 'unsafe-inline';"); expect(html).toMatch(/script-src 'nonce-[A-Za-z0-9]{32}' https:\/\/webview\.test;/); + expect(html).not.toContain("script-src 'unsafe-inline'"); }); }); diff --git a/apps/extension/src/utils/webviewHtml.ts b/apps/extension/src/utils/webviewHtml.ts index b52c4e8..9bb3d3f 100644 --- a/apps/extension/src/utils/webviewHtml.ts +++ b/apps/extension/src/utils/webviewHtml.ts @@ -23,6 +23,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string { const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'out', 'webview', 'webview.js')); const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'out', 'webview', 'index.css')); + const editorStyleUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'out', 'webview', 'editor.css')); const iconStyleUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'media', 'intelli-git-icons.css')); const language = vscode.env.language; @@ -34,14 +35,17 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string { window.initialState = ${initialStateJson}; `; + // Monaco replaces visible rows with HTML that contains inline layout attributes. + // Allow style attributes while keeping every script nonce-restricted. return ` - + + ${title} diff --git a/apps/webview-ui/package.json b/apps/webview-ui/package.json index d0a89da..e36a753 100644 --- a/apps/webview-ui/package.json +++ b/apps/webview-ui/package.json @@ -18,6 +18,7 @@ "@vscode/codicons": "^0.0.45", "@vscode/webview-ui-toolkit": "^1.4.0", "i18next": "^26.0.1", + "monaco-editor": "0.52.2", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^17.0.1", diff --git a/apps/webview-ui/src/App.tsx b/apps/webview-ui/src/App.tsx index 3ede00d..0b24425 100644 --- a/apps/webview-ui/src/App.tsx +++ b/apps/webview-ui/src/App.tsx @@ -2,6 +2,7 @@ import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-route import { useEffect } from 'react'; import { LocalChangesView } from './components/local-changes/LocalChangesView'; import { GitLogView } from './components/git-log/GitLogView'; +import { ConflictResolverPage } from './components/merge/ConflictResolver'; import './index.css'; function AppContent() { @@ -23,6 +24,7 @@ function AppContent() { } /> } /> + } /> ); } diff --git a/apps/webview-ui/src/components/commit/CommitView.tsx b/apps/webview-ui/src/components/commit/CommitView.tsx index 456ba6d..b2aab7e 100644 --- a/apps/webview-ui/src/components/commit/CommitView.tsx +++ b/apps/webview-ui/src/components/commit/CommitView.tsx @@ -351,7 +351,7 @@ export function CommitView({ }, []); const handleOpenConflict = useCallback((file: RepositoryFileReference) => { - void rpc.openFile(file); + void rpc.openConflictResolver(file); }, []); const hasUnresolvedConflicts = useMemo( diff --git a/apps/webview-ui/src/components/merge/ConflictResolver.module.css b/apps/webview-ui/src/components/merge/ConflictResolver.module.css new file mode 100644 index 0000000..0fcad2c --- /dev/null +++ b/apps/webview-ui/src/components/merge/ConflictResolver.module.css @@ -0,0 +1,494 @@ +.resolver { + display: flex; + flex-direction: column; + width: 100%; + height: 100vh; + min-height: 0; + background: var(--vscode-editor-background); + color: var(--vscode-foreground); +} + +.resolver:focus { + outline: none; +} + +.header { + display: flex; + min-height: 34px; + padding: 4px 8px; + box-sizing: border-box; + align-items: center; + justify-content: space-between; + gap: 8px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.actions { + display: flex; + min-width: 0; + align-items: center; + justify-content: flex-start; + gap: 5px; + flex: 1; + flex-wrap: nowrap; + overflow: hidden; +} + +.specialHeaderActions > :not(.specialPath) { + display: none; +} + +.specialPath { + overflow: hidden; + color: var(--vscode-descriptionForeground); + font-size: var(--ig-font-size-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.button, +.iconButton, +.compactButton, +.footerButton, +.inlineActionButton, +.inlineIconActionButton, +.segmentButton { + display: inline-flex; + min-height: 24px; + box-sizing: border-box; + align-items: center; + justify-content: center; + gap: 5px; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 3px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + cursor: pointer; + font: inherit; + font-size: var(--ig-font-size-xs); + line-height: 16px; + white-space: nowrap; +} + +.button { + padding: 2px 8px; +} + +.iconButton { + width: 24px; + padding: 2px; +} + +.compactButton { + min-height: 24px; + padding: 2px 8px; +} + +.toolbarSelectControl { + position: relative; + display: inline-flex; + min-width: 0; + flex: 0 1 auto; + align-items: center; +} + +.toolbarSelect { + height: 24px; + min-width: 112px; + max-width: 172px; + padding: 2px 24px 2px 8px; + box-sizing: border-box; + appearance: none; + overflow: hidden; + border: 1px solid var(--vscode-dropdown-border, var(--vscode-button-border, transparent)); + border-radius: 3px; + background: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + cursor: pointer; + font: inherit; + font-size: var(--ig-font-size-xs); + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolbarSelect:hover:not(:disabled) { + border-color: var(--vscode-focusBorder); +} + +.toolbarSelect:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.toolbarSelect:disabled { + cursor: default; + opacity: 0.55; +} + +.toolbarSelectChevron { + position: absolute; + right: 6px; + color: var(--vscode-dropdown-foreground); + pointer-events: none; +} + +.inlineActionButton { + min-height: 22px; + padding: 2px 7px; +} + +.inlineIconActionButton { + width: 22px; + min-height: 22px; + padding: 0; +} + +.nonConflictControls { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 0; +} + +.toolbarLabel { + flex: 0 1 auto; + margin-right: 6px; + overflow: hidden; + color: var(--vscode-descriptionForeground); + font-size: var(--ig-font-size-xs); + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.segmentButton { + min-height: 24px; + padding: 2px 7px; + border-radius: 0; + border-right-width: 0; +} + +.segmentButton:first-of-type { + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} + +.segmentButton:last-of-type { + border-right-width: 1px; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} + +.activeSegmentButton { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.button:hover:not(:disabled), +.iconButton:hover:not(:disabled), +.compactButton:hover:not(:disabled), +.footerButton:hover:not(:disabled), +.inlineActionButton:hover:not(:disabled), +.inlineIconActionButton:hover:not(:disabled), +.segmentButton:hover:not(:disabled) { + background: var(--vscode-button-secondaryHoverBackground); +} + +.activeSegmentButton:hover:not(:disabled) { + background: var(--vscode-button-hoverBackground); +} + +.button:focus-visible, +.iconButton:focus-visible, +.compactButton:focus-visible, +.footerButton:focus-visible, +.inlineActionButton:focus-visible, +.inlineIconActionButton:focus-visible, +.segmentButton:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.button:disabled, +.iconButton:disabled, +.compactButton:disabled, +.footerButton:disabled, +.inlineActionButton:disabled, +.inlineIconActionButton:disabled, +.segmentButton:disabled { + opacity: 0.55; + cursor: default; +} + +.toolbarSeparator { + display: inline-block; + width: 1px; + height: 18px; + margin: 0 3px; + background: var(--vscode-panel-border); +} + +.statusText { + flex: 0 0 auto; + margin-left: 8px; + overflow: hidden; + color: var(--vscode-foreground); + font-size: var(--ig-font-size-xs); + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.paneHeaderGrid { + display: grid; + min-height: 28px; + grid-template-columns: minmax(0, 1fr) 44px minmax(0, 1fr) 44px minmax(0, 1fr); + border-bottom: 1px solid var(--vscode-panel-border); + background: var(--vscode-sideBar-background); +} + +.integratedPaneHeader { + display: flex; + min-width: 0; + padding: 4px 8px; + box-sizing: border-box; + align-items: center; + gap: 5px; + border-right: 1px solid var(--vscode-panel-border); + color: var(--vscode-foreground); + font-size: var(--ig-font-size-sm); + font-weight: 600; + line-height: 18px; +} + +.resultHeader { + grid-column: 3; + justify-content: center; +} + +.rightHeader { + grid-column: 5; + justify-content: flex-end; + border-right: 0; +} + +.resultPath { + min-width: 0; + overflow: hidden; + color: var(--vscode-descriptionForeground); + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.content { + display: flex; + min-height: 0; + padding: 0; + box-sizing: border-box; + flex: 1; + flex-direction: column; + overflow: hidden; +} + +:global(.intelli-git-merge-inserted) { + --intelli-git-merge-line-background: var(--vscode-diffEditor-insertedLineBackground, rgba(52, 125, 57, 0.15)); + --intelli-git-merge-word-background: var(--vscode-diffEditor-insertedTextBackground, rgba(87, 171, 90, 0.3)); + --intelli-git-merge-marker: var(--vscode-editorGutter-addedBackground, #72c892); +} + +:global(.intelli-git-merge-deleted) { + --intelli-git-merge-line-background: var(--vscode-diffEditor-unchangedCodeBackground, rgba(128, 128, 128, 0.16)); + --intelli-git-merge-word-background: color-mix( + in srgb, + var(--vscode-diffEditor-move-border, #8b8b8b) 34%, + transparent + ); + --intelli-git-merge-marker: var(--vscode-diffEditor-move-border, #8b8b8b); +} + +:global(.intelli-git-merge-modified) { + --intelli-git-merge-line-background: color-mix( + in srgb, + var(--vscode-editorGutter-modifiedBackground, #0078d4) 18%, + transparent + ); + --intelli-git-merge-word-background: color-mix( + in srgb, + var(--vscode-editorGutter-modifiedBackground, #0078d4) 34%, + transparent + ); + --intelli-git-merge-marker: var(--vscode-editorGutter-modifiedBackground, #0078d4); +} + +:global(.intelli-git-merge-conflict) { + --intelli-git-merge-line-background: var(--vscode-diffEditor-removedLineBackground, rgba(201, 60, 55, 0.15)); + --intelli-git-merge-word-background: var(--vscode-diffEditor-removedTextBackground, rgba(244, 112, 103, 0.3)); + --intelli-git-merge-marker: var(--vscode-editorGutter-deletedBackground, #f28772); +} + +:global(.monaco-editor .view-overlays .cdr.intelli-git-merge-pending), +:global(.monaco-editor .view-overlays .current-line.intelli-git-merge-pending), +:global(.intelli-git-merge-pending) { + width: 100% !important; + background: var(--intelli-git-merge-line-background) !important; + box-shadow: none !important; +} + +:global(.monaco-editor .intelli-git-merge-word) { + border-radius: 0; + background: var(--intelli-git-merge-word-background) !important; +} + +:global(.monaco-editor .view-overlays .cdr.intelli-git-merge-resolved-boundary) { + width: 100% !important; +} + +:global(.intelli-git-merge-resolved-boundary) { + box-sizing: border-box; + background: transparent !important; + box-shadow: none !important; +} + +:global(.intelli-git-merge-resolved-top) { + border-top: 1px dotted color-mix(in srgb, var(--intelli-git-merge-marker) 72%, transparent); +} + +:global(.intelli-git-merge-resolved-bottom) { + border-bottom: 1px dotted color-mix(in srgb, var(--intelli-git-merge-marker) 72%, transparent); +} + +:global(.intelli-git-merge-padding-zone), +:global(.intelli-git-merge-empty-pending), +:global(.intelli-git-merge-empty-resolved) { + box-sizing: border-box; + background-color: var(--vscode-editor-background); + background-image: repeating-linear-gradient( + -45deg, + transparent 0 4px, + color-mix(in srgb, var(--vscode-diffEditor-diagonalFill, var(--vscode-panel-border)) 38%, transparent) 4px 5px + ); +} + +:global(.intelli-git-merge-empty-pending) { + border-top: 2px solid color-mix(in srgb, var(--intelli-git-merge-marker) 82%, transparent); +} + +:global(.intelli-git-merge-empty-resolved) { + border-top: 1px dotted color-mix(in srgb, var(--intelli-git-merge-marker) 72%, transparent); +} + +.message { + padding: 10px 12px; + color: var(--vscode-descriptionForeground); + font-size: var(--ig-font-size-sm); + line-height: 18px; +} + +.error { + color: var(--vscode-errorForeground); +} + +.specialConflictState { + display: flex; + width: min(520px, calc(100% - 48px)); + margin: auto; + padding: 24px; + box-sizing: border-box; + align-items: center; + flex-direction: column; + gap: 10px; + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + background: var(--vscode-sideBar-background); +} + +.specialConflictIcon { + color: var(--vscode-descriptionForeground); + font-size: 30px; +} + +.specialConflictTitle { + font-size: var(--ig-font-size-xl); + font-weight: 600; +} + +.specialConflictDescription { + max-width: 440px; + color: var(--vscode-descriptionForeground); + font-size: var(--ig-font-size-sm); + line-height: 18px; + text-align: center; +} + +.specialConflictActions { + display: flex; + margin-top: 4px; + gap: 6px; + flex-wrap: wrap; + justify-content: center; +} + +.commitChoices { + display: grid; + width: min(400px, 100%); + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.commitChoice { + display: flex; + min-width: 0; + padding: 8px 10px; + flex-direction: column; + gap: 4px; + border: 1px solid var(--vscode-panel-border); + border-radius: 3px; + color: var(--vscode-descriptionForeground); + font-size: var(--ig-font-size-xs); +} + +.commitChoice code { + overflow: hidden; + color: var(--vscode-foreground); + font-family: var(--ig-code-font-family); + font-size: var(--ig-code-font-size); + text-overflow: ellipsis; +} + +.footer { + display: flex; + min-height: 44px; + padding: 6px 12px; + box-sizing: border-box; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--vscode-panel-border); + background: var(--vscode-editor-background); +} + +.footerLeft, +.footerRight { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.footerButton { + min-height: 30px; + padding: 4px 14px; + font-size: var(--ig-font-size-sm); +} + +.applyButton { + min-width: 86px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.applyButton:hover:not(:disabled) { + background: var(--vscode-button-hoverBackground); +} diff --git a/apps/webview-ui/src/components/merge/ConflictResolver.tsx b/apps/webview-ui/src/components/merge/ConflictResolver.tsx new file mode 100644 index 0000000..9ef06e6 --- /dev/null +++ b/apps/webview-ui/src/components/merge/ConflictResolver.tsx @@ -0,0 +1,1310 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ConflictFileContent, RepositoryFileReference } from '@shared/messages'; +import type { MergeEditorContext } from '@shared/webviewContext'; +import { emitRefresh, rpc, rpcEvents } from '../../lib/rpc_client'; +import { + applyMergeContentChanges, + applyMergeGroupDecision, + buildLineAlignmentBlocks, + buildMergeSessionDocument, + buildMergeSideInlineDiffRanges, + getContentLineCount, + getMergeGroupApplyMode, + getMergeTextRange, + hasConflictBlocks, + isMergeReviewRangePending, + splitContentLines, + type ConflictInlineDiffRange, + type MergeChangeGroup, + type MergeReviewDecision, + type MergeReviewRange, + type MergeReviewSide, + type WhitespaceCompareMode, +} from './conflictModel'; +import type { CodeDecoration, CodeEditorContentChange, CodeViewZone } from './MonacoCodeEditor'; +import { ThreeWayMergeEditor, type MergeBlockAction, type MergeDecorationType } from './ThreeWayMergeEditor'; +import styles from './ConflictResolver.module.css'; + +interface ConflictResolverProps { + file: RepositoryFileReference; + onClose: () => void; +} + +interface ConflictResolverInitialState { + conflictFile?: RepositoryFileReference; +} + +interface ConflictResolverInitialSession { + file: RepositoryFileReference; +} + +type MergeHighlightMode = 'words' | 'lines'; + +interface MergePaneLayout { + leftViewZones: CodeViewZone[]; + resultViewZones: CodeViewZone[]; + rightViewZones: CodeViewZone[]; + displayRows: Map; + resultLineCounts: Map; + resultTextRanges: Map>; +} + +interface MergeGapLayout { + visualLineCount: number; + leftViewZones: CodeViewZone[]; + resultViewZones: CodeViewZone[]; + rightViewZones: CodeViewZone[]; +} + +interface MergeHistoryEntry { + resultDraft: string | null; + resultExists: boolean; + usingResolvedResult: boolean; + groups: MergeChangeGroup[]; + reviewRanges: MergeReviewRange[]; + activeGroupId: string | null; + whitespaceMode: WhitespaceCompareMode; +} + +const MAX_MERGE_HISTORY_ENTRIES = 50; + +function isSameFileReference(left: RepositoryFileReference, right: RepositoryFileReference): boolean { + return left.path === right.path && (left.repoPath || '') === (right.repoPath || ''); +} + +function getFileReferenceKey(file: RepositoryFileReference): string { + return `${file.repoPath || ''}\u0000${file.path}`; +} + +function getShortObjectId(objectId: string | undefined): string { + return objectId ? objectId.slice(0, 12) : '—'; +} + +function isEditableKeyboardTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + return ( + target instanceof HTMLTextAreaElement || + target instanceof HTMLInputElement || + target.isContentEditable || + Boolean(target.closest('.monaco-editor')) + ); +} + +function getInitialConflictSession(): ConflictResolverInitialSession | null { + const state = window.initialState as ConflictResolverInitialState | null | undefined; + const file = state?.conflictFile; + if (!file?.path) { + return null; + } + return { + file: { + path: file.path, + repoPath: file.repoPath, + }, + }; +} + +function buildMergeEditorContext( + file: RepositoryFileReference, + section: MergeEditorContext['webviewSection'], + group: MergeChangeGroup | undefined, + range: MergeReviewRange | undefined, + disabled: boolean +): MergeEditorContext { + return { + webviewSection: section, + path: file.path, + repoPath: file.repoPath, + changeGroupId: group?.id, + canReviewLeft: Boolean(group?.hasLeftChange && range?.leftDecision === 'pending' && !disabled), + canReviewRight: Boolean(group?.hasRightChange && range?.rightDecision === 'pending' && !disabled), + canMarkReviewed: Boolean(range && isMergeReviewRangePending(range) && !disabled), + preventDefaultContextMenuItems: true, + }; +} + +function getMergeDecorationType(group: MergeChangeGroup): MergeDecorationType { + if (group.kind === 'conflict') { + return 'conflict'; + } + if (group.baseLineCount === 0) { + return 'inserted'; + } + const changedLineCount = group.hasLeftChange ? group.leftLineCount : group.rightLineCount; + return changedLineCount === 0 ? 'deleted' : 'modified'; +} + +function buildResolvedDecorations( + startLine: number, + endLine: number, + decorationType: MergeDecorationType +): CodeDecoration[] { + const typeClass = `intelli-git-merge-${decorationType}`; + const topClasses = `${typeClass} intelli-git-merge-resolved-boundary intelli-git-merge-resolved-top${ + startLine === endLine ? ' intelli-git-merge-resolved-bottom' : '' + }`; + const topDecoration = { + startLine, + endLine: startLine, + isWholeLine: true, + className: topClasses, + marginClassName: topClasses, + } satisfies CodeDecoration; + if (startLine === endLine) { + return [topDecoration]; + } + const bottomClasses = `${typeClass} intelli-git-merge-resolved-boundary intelli-git-merge-resolved-bottom`; + return [ + topDecoration, + { + startLine: endLine, + endLine, + isWholeLine: true, + className: bottomClasses, + marginClassName: bottomClasses, + }, + ]; +} + +function buildSideDecorations( + groups: MergeChangeGroup[], + ranges: MergeReviewRange[], + side: 'current' | 'incoming', + inlineRanges: ConflictInlineDiffRange[] +): CodeDecoration[] { + const rangeById = new Map(ranges.map((range) => [range.groupId, range])); + const groupById = new Map(groups.map((group) => [group.id, group])); + const pendingGroupIds = new Set(); + const wholeLineDecorations = groups.flatMap((group) => { + const hasChange = side === 'current' ? group.hasLeftChange : group.hasRightChange; + const start = side === 'current' ? group.leftStart : group.rightStart; + const lineCount = side === 'current' ? group.leftLineCount : group.rightLineCount; + if (!hasChange || lineCount === 0) { + return []; + } + const range = rangeById.get(group.id); + const decision = side === 'current' ? range?.leftDecision : range?.rightDecision; + const decorationType = getMergeDecorationType(group); + if (decision !== 'pending') { + return buildResolvedDecorations(start + 1, start + lineCount, decorationType); + } + pendingGroupIds.add(group.id); + const typeClass = `intelli-git-merge-${decorationType}`; + return [ + { + startLine: start + 1, + endLine: start + lineCount, + isWholeLine: true, + className: `${typeClass} intelli-git-merge-pending`, + marginClassName: `${typeClass} intelli-git-merge-pending`, + overviewRulerType: decorationType, + } satisfies CodeDecoration, + ]; + }); + const inlineDecorations = inlineRanges.flatMap((range) => { + const group = groupById.get(range.conflictId); + if (!group || !pendingGroupIds.has(group.id)) { + return []; + } + return [ + { + startLine: range.startLine, + startColumn: range.startColumn, + endLine: range.endLine, + endColumn: range.endColumn, + inlineClassName: `intelli-git-merge-${getMergeDecorationType(group)} intelli-git-merge-word`, + } satisfies CodeDecoration, + ]; + }); + return [...wholeLineDecorations, ...inlineDecorations]; +} + +function buildResultDecorations( + resultText: string, + groups: MergeChangeGroup[], + ranges: MergeReviewRange[] +): CodeDecoration[] { + const groupById = new Map(groups.map((group) => [group.id, group])); + return ranges.flatMap((range) => { + const group = groupById.get(range.groupId); + if (!group) { + return []; + } + const textRange = getMergeTextRange(resultText, range.startOffset, range.endOffset); + const decorationType = getMergeDecorationType(group); + if (range.startOffset === range.endOffset) { + return []; + } + if (!isMergeReviewRangePending(range)) { + return buildResolvedDecorations(textRange.startLine, textRange.endLine, decorationType); + } + const typeClass = `intelli-git-merge-${decorationType}`; + return [ + { + ...textRange, + isWholeLine: true, + className: `${typeClass} intelli-git-merge-pending`, + marginClassName: `${typeClass} intelli-git-merge-pending`, + } satisfies CodeDecoration, + ]; + }); +} + +function getZoneClass(pane: 'left' | 'result' | 'right', group: MergeChangeGroup, range: MergeReviewRange): string { + const typeClass = `intelli-git-merge-${getMergeDecorationType(group)}`; + if (pane === 'left' && group.hasLeftChange && group.leftLineCount === 0) { + return `${typeClass} ${ + range.leftDecision === 'pending' ? 'intelli-git-merge-empty-pending' : 'intelli-git-merge-empty-resolved' + }`; + } + if (pane === 'right' && group.hasRightChange && group.rightLineCount === 0) { + return `${typeClass} ${ + range.rightDecision === 'pending' ? 'intelli-git-merge-empty-pending' : 'intelli-git-merge-empty-resolved' + }`; + } + if (pane === 'result' && range.startOffset === range.endOffset) { + return `${typeClass} ${ + isMergeReviewRangePending(range) ? 'intelli-git-merge-empty-pending' : 'intelli-git-merge-empty-resolved' + }`; + } + return 'intelli-git-merge-padding-zone'; +} + +function buildMergeGapLayout( + id: string, + referenceLines: string[], + resultLines: string[], + panes: { + leftStart: number; + leftLineCount: number; + resultStart: number; + rightStart: number; + rightLineCount: number; + } +): MergeGapLayout { + const leftViewZones: CodeViewZone[] = []; + const resultViewZones: CodeViewZone[] = []; + const rightViewZones: CodeViewZone[] = []; + let sideExtraLineCount = 0; + let resultExtraLineCount = 0; + + for (const [index, block] of buildLineAlignmentBlocks(referenceLines, resultLines).entries()) { + const lineCountDelta = block.resultLineCount - block.referenceLineCount; + if (lineCountDelta > 0) { + const referenceEnd = block.referenceStart + block.referenceLineCount; + const leftAfterLineNumber = panes.leftStart + Math.min(panes.leftLineCount, referenceEnd); + const rightAfterLineNumber = panes.rightStart + Math.min(panes.rightLineCount, referenceEnd); + leftViewZones.push({ + id: `${id}:alignment:${index}:left`, + afterLineNumber: leftAfterLineNumber, + heightInLines: lineCountDelta, + className: 'intelli-git-merge-padding-zone', + }); + rightViewZones.push({ + id: `${id}:alignment:${index}:right`, + afterLineNumber: rightAfterLineNumber, + heightInLines: lineCountDelta, + className: 'intelli-git-merge-padding-zone', + }); + sideExtraLineCount += lineCountDelta; + } else if (lineCountDelta < 0) { + const resultEnd = block.resultStart + block.resultLineCount; + resultViewZones.push({ + id: `${id}:alignment:${index}:result`, + afterLineNumber: panes.resultStart + Math.min(resultLines.length, resultEnd), + heightInLines: -lineCountDelta, + className: 'intelli-git-merge-padding-zone', + }); + resultExtraLineCount -= lineCountDelta; + } + } + + const leftVisualLineCount = panes.leftLineCount + sideExtraLineCount; + const resultVisualLineCount = resultLines.length + resultExtraLineCount; + const rightVisualLineCount = panes.rightLineCount + sideExtraLineCount; + const visualLineCount = Math.max(leftVisualLineCount, resultVisualLineCount, rightVisualLineCount); + + if (leftVisualLineCount < visualLineCount) { + leftViewZones.push({ + id: `${id}:alignment:tail:left`, + afterLineNumber: panes.leftStart + panes.leftLineCount, + heightInLines: visualLineCount - leftVisualLineCount, + className: 'intelli-git-merge-padding-zone', + }); + } + if (resultVisualLineCount < visualLineCount) { + resultViewZones.push({ + id: `${id}:alignment:tail:result`, + afterLineNumber: panes.resultStart + resultLines.length, + heightInLines: visualLineCount - resultVisualLineCount, + className: 'intelli-git-merge-padding-zone', + }); + } + if (rightVisualLineCount < visualLineCount) { + rightViewZones.push({ + id: `${id}:alignment:tail:right`, + afterLineNumber: panes.rightStart + panes.rightLineCount, + heightInLines: visualLineCount - rightVisualLineCount, + className: 'intelli-git-merge-padding-zone', + }); + } + + return { visualLineCount, leftViewZones, resultViewZones, rightViewZones }; +} + +function buildMergePaneLayout( + groups: MergeChangeGroup[], + ranges: MergeReviewRange[], + baseText: string, + leftText: string, + resultText: string, + rightText: string +): MergePaneLayout { + const leftViewZones: CodeViewZone[] = []; + const resultViewZones: CodeViewZone[] = []; + const rightViewZones: CodeViewZone[] = []; + const displayRows = new Map(); + const resultLineCounts = new Map(); + const resultTextRanges = new Map>(); + const rangeById = new Map(ranges.map((range) => [range.groupId, range])); + const baseLines = splitContentLines(baseText); + const leftLines = splitContentLines(leftText); + const resultLines = splitContentLines(resultText); + const rightLines = splitContentLines(rightText); + let previousBaseEnd = 0; + let previousLeftEnd = 0; + let previousResultEnd = 0; + let previousRightEnd = 0; + let displayRow = 0; + + for (const group of groups) { + const range = rangeById.get(group.id); + if (!range) { + continue; + } + const resultRange = getMergeTextRange(resultText, range.startOffset, range.endOffset); + resultTextRanges.set(group.id, resultRange); + const resultLineCount = getContentLineCount(resultText.slice(range.startOffset, range.endOffset)); + resultLineCounts.set(group.id, resultLineCount); + const resultStart = resultRange.startLine - 1; + const gapLayout = buildMergeGapLayout( + `${group.id}:before`, + baseLines.slice(previousBaseEnd, group.baseStart), + resultLines.slice(previousResultEnd, resultStart), + { + leftStart: previousLeftEnd, + leftLineCount: Math.max(0, group.leftStart - previousLeftEnd), + resultStart: previousResultEnd, + rightStart: previousRightEnd, + rightLineCount: Math.max(0, group.rightStart - previousRightEnd), + } + ); + leftViewZones.push(...gapLayout.leftViewZones); + resultViewZones.push(...gapLayout.resultViewZones); + rightViewZones.push(...gapLayout.rightViewZones); + displayRow += gapLayout.visualLineCount; + displayRows.set(group.id, displayRow); + + const maxLineCount = Math.max(1, group.leftLineCount, resultLineCount, group.rightLineCount); + + if (group.leftLineCount < maxLineCount) { + leftViewZones.push({ + id: `${group.id}:left`, + afterLineNumber: group.leftStart + group.leftLineCount, + heightInLines: maxLineCount - group.leftLineCount, + className: getZoneClass('left', group, range), + }); + } + if (resultLineCount < maxLineCount) { + resultViewZones.push({ + id: `${group.id}:result`, + afterLineNumber: resultStart + resultLineCount, + heightInLines: maxLineCount - resultLineCount, + className: getZoneClass('result', group, range), + }); + } + if (group.rightLineCount < maxLineCount) { + rightViewZones.push({ + id: `${group.id}:right`, + afterLineNumber: group.rightStart + group.rightLineCount, + heightInLines: maxLineCount - group.rightLineCount, + className: getZoneClass('right', group, range), + }); + } + + displayRow += maxLineCount; + previousBaseEnd = Math.max(previousBaseEnd, group.baseStart + group.baseLineCount); + previousLeftEnd = Math.max(previousLeftEnd, group.leftStart + group.leftLineCount); + previousResultEnd = Math.max(previousResultEnd, resultStart + resultLineCount); + previousRightEnd = Math.max(previousRightEnd, group.rightStart + group.rightLineCount); + } + + const trailingLayout = buildMergeGapLayout( + 'trailing', + baseLines.slice(previousBaseEnd), + resultLines.slice(previousResultEnd), + { + leftStart: previousLeftEnd, + leftLineCount: Math.max(0, leftLines.length - previousLeftEnd), + resultStart: previousResultEnd, + rightStart: previousRightEnd, + rightLineCount: Math.max(0, rightLines.length - previousRightEnd), + } + ); + leftViewZones.push(...trailingLayout.leftViewZones); + resultViewZones.push(...trailingLayout.resultViewZones); + rightViewZones.push(...trailingLayout.rightViewZones); + + return { leftViewZones, resultViewZones, rightViewZones, displayRows, resultLineCounts, resultTextRanges }; +} + +export function ConflictResolver({ file, onClose }: ConflictResolverProps) { + const { t } = useTranslation(); + const [content, setContent] = useState(null); + const [groups, setGroups] = useState([]); + const [reviewRanges, setReviewRanges] = useState([]); + const [resultDraft, setResultDraft] = useState(null); + const [resultExists, setResultExists] = useState(false); + const [usingResolvedResult, setUsingResolvedResult] = useState(false); + const [activeGroupId, setActiveGroupId] = useState(null); + const [whitespaceMode, setWhitespaceMode] = useState('none'); + const [highlightMode, setHighlightMode] = useState('words'); + const [changingWhitespaceMode, setChangingWhitespaceMode] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const undoHistoryRef = useRef([]); + const redoHistoryRef = useRef([]); + const visibleContent = content && isSameFileReference(content, file) ? content : null; + const isContentLoading = loading || Boolean(content && !visibleContent); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + setContent(null); + setGroups([]); + setReviewRanges([]); + setResultDraft(null); + setResultExists(false); + setUsingResolvedResult(false); + setActiveGroupId(null); + setWhitespaceMode('none'); + setHighlightMode('words'); + setChangingWhitespaceMode(false); + undoHistoryRef.current = []; + redoHistoryRef.current = []; + + rpc.getConflictFileContent(file) + .then((next) => { + if (cancelled) { + return; + } + const session = buildMergeSessionDocument( + next.base.content, + next.current.content, + next.incoming.content, + next.currentChanges, + next.incomingChanges + ); + const preserveResolvedResult = next.resolvedCandidate; + setContent(next); + setGroups(preserveResolvedResult ? [] : session.groups); + setReviewRanges(preserveResolvedResult ? [] : session.reviewRanges); + setResultDraft(preserveResolvedResult ? next.result : session.resultText); + setResultExists(preserveResolvedResult || next.base.exists); + setUsingResolvedResult(preserveResolvedResult); + setActiveGroupId(preserveResolvedResult ? null : (session.groups[0]?.id ?? null)); + }) + .catch((e) => { + if (!cancelled) { + setError(e instanceof Error ? e : new Error(String(e))); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [file]); + + const resultText = resultDraft ?? ''; + const rangeById = useMemo(() => new Map(reviewRanges.map((range) => [range.groupId, range])), [reviewRanges]); + const pendingGroups = useMemo( + () => + groups.filter((group) => { + const range = rangeById.get(group.id); + return range ? isMergeReviewRangePending(range) : false; + }), + [groups, rangeById] + ); + const pendingGroupCount = pendingGroups.length; + const activeGroup = groups.find((group) => group.id === activeGroupId) ?? pendingGroups[0]; + const activeRange = activeGroup ? rangeById.get(activeGroup.id) : undefined; + const activeGroupIndex = activeGroup ? groups.findIndex((group) => group.id === activeGroup.id) : -1; + const previousPendingGroup = + activeGroupIndex > 0 + ? [...groups.slice(0, activeGroupIndex)].reverse().find((group) => { + const range = rangeById.get(group.id); + return range ? isMergeReviewRangePending(range) : false; + }) + : undefined; + const nextPendingGroup = + activeGroupIndex >= 0 + ? groups.slice(activeGroupIndex + 1).find((group) => { + const range = rangeById.get(group.id); + return range ? isMergeReviewRangePending(range) : false; + }) + : pendingGroups[0]; + const layout = useMemo( + () => + buildMergePaneLayout( + groups, + reviewRanges, + visibleContent?.base.content ?? '', + visibleContent?.current.content ?? '', + resultText, + visibleContent?.incoming.content ?? '' + ), + [groups, reviewRanges, resultText, visibleContent] + ); + const leftInlineRanges = useMemo( + () => (highlightMode === 'words' ? buildMergeSideInlineDiffRanges(groups, 'current', whitespaceMode) : []), + [groups, highlightMode, whitespaceMode] + ); + const rightInlineRanges = useMemo( + () => (highlightMode === 'words' ? buildMergeSideInlineDiffRanges(groups, 'incoming', whitespaceMode) : []), + [groups, highlightMode, whitespaceMode] + ); + const leftDecorations = useMemo( + () => buildSideDecorations(groups, reviewRanges, 'current', leftInlineRanges), + [groups, leftInlineRanges, reviewRanges] + ); + const rightDecorations = useMemo( + () => buildSideDecorations(groups, reviewRanges, 'incoming', rightInlineRanges), + [groups, reviewRanges, rightInlineRanges] + ); + const resultDecorations = useMemo( + () => buildResultDecorations(resultText, groups, reviewRanges), + [groups, resultText, reviewRanges] + ); + const currentBranchLabel = visibleContent?.currentLabel || t('Left'); + const incomingBranchLabel = visibleContent?.incomingLabel || t('Right'); + const acceptLeftFileLabel = + visibleContent && !visibleContent.current.exists ? t('Accept Left (delete file)') : t('Accept Left'); + const acceptRightFileLabel = + visibleContent && !visibleContent.incoming.exists ? t('Accept Right (delete file)') : t('Accept Right'); + const controlsDisabled = saving || isContentLoading || changingWhitespaceMode; + const completeDisabled = + controlsDisabled || + !visibleContent || + visibleContent.isBinary || + resultDraft === null || + pendingGroupCount > 0 || + hasConflictBlocks(resultText); + const statusLabel = !visibleContent + ? '' + : visibleContent.kind !== 'text' + ? '' + : pendingGroupCount > 0 + ? t('{{count}} unresolved', { count: pendingGroupCount }) + : t('No conflicts remaining'); + + const completeAction = useCallback(() => { + emitRefresh(['commit', 'branch'], 'conflict-resolved'); + onClose(); + }, [onClose]); + + const createMergeHistoryEntry = useCallback( + (): MergeHistoryEntry => ({ + resultDraft, + resultExists, + usingResolvedResult, + groups, + reviewRanges, + activeGroupId, + whitespaceMode, + }), + [activeGroupId, groups, resultDraft, resultExists, reviewRanges, usingResolvedResult, whitespaceMode] + ); + + const restoreMergeHistoryEntry = useCallback((entry: MergeHistoryEntry) => { + setResultDraft(entry.resultDraft); + setResultExists(entry.resultExists); + setUsingResolvedResult(entry.usingResolvedResult); + setGroups(entry.groups); + setReviewRanges(entry.reviewRanges); + setActiveGroupId(entry.activeGroupId); + setWhitespaceMode(entry.whitespaceMode); + setError(null); + }, []); + + const pushMergeHistory = useCallback(() => { + undoHistoryRef.current.push(createMergeHistoryEntry()); + if (undoHistoryRef.current.length > MAX_MERGE_HISTORY_ENTRIES) { + undoHistoryRef.current.shift(); + } + redoHistoryRef.current = []; + }, [createMergeHistoryEntry]); + + const undoMergeOperation = useCallback((): boolean => { + const previous = undoHistoryRef.current.pop(); + if (!previous) { + return false; + } + redoHistoryRef.current.push(createMergeHistoryEntry()); + if (redoHistoryRef.current.length > MAX_MERGE_HISTORY_ENTRIES) { + redoHistoryRef.current.shift(); + } + restoreMergeHistoryEntry(previous); + return true; + }, [createMergeHistoryEntry, restoreMergeHistoryEntry]); + + const redoMergeOperation = useCallback((): boolean => { + const next = redoHistoryRef.current.pop(); + if (!next) { + return false; + } + undoHistoryRef.current.push(createMergeHistoryEntry()); + if (undoHistoryRef.current.length > MAX_MERGE_HISTORY_ENTRIES) { + undoHistoryRef.current.shift(); + } + restoreMergeHistoryEntry(next); + return true; + }, [createMergeHistoryEntry, restoreMergeHistoryEntry]); + + const changeWhitespaceMode = useCallback( + async (nextMode: WhitespaceCompareMode) => { + if (!visibleContent || nextMode === whitespaceMode) { + return; + } + + const hasReviewedChanges = reviewRanges.some( + (range) => + (range.leftDecision !== null && range.leftDecision !== 'pending') || + (range.rightDecision !== null && range.rightDecision !== 'pending') + ); + const hasResultEdits = usingResolvedResult || resultDraft !== visibleContent.base.content; + setChangingWhitespaceMode(true); + try { + if ((hasReviewedChanges || hasResultEdits) && !(await rpc.confirmConflictResolverRestart())) { + return; + } + + const session = buildMergeSessionDocument( + visibleContent.base.content, + visibleContent.current.content, + visibleContent.incoming.content, + visibleContent.currentChanges, + visibleContent.incomingChanges, + nextMode + ); + pushMergeHistory(); + setWhitespaceMode(nextMode); + setGroups(session.groups); + setReviewRanges(session.reviewRanges); + setResultDraft(session.resultText); + setResultExists(visibleContent.base.exists); + setUsingResolvedResult(false); + setActiveGroupId(session.groups[0]?.id ?? null); + setError(null); + } catch (e) { + setError(e instanceof Error ? e : new Error(String(e))); + } finally { + setChangingWhitespaceMode(false); + } + }, + [pushMergeHistory, resultDraft, reviewRanges, usingResolvedResult, visibleContent, whitespaceMode] + ); + + const acceptFileSide = useCallback( + async (side: 'ours' | 'theirs') => { + if (!visibleContent) { + return; + } + setSaving(true); + try { + await rpc.resolveConflict({ + ...file, + side, + stageSignature: visibleContent.stageSignature, + resultFingerprint: visibleContent.resultFingerprint, + }); + completeAction(); + } catch (e) { + setError(e instanceof Error ? e : new Error(String(e))); + } finally { + setSaving(false); + } + }, + [completeAction, file, visibleContent] + ); + + const applyGroupDecision = useCallback( + ( + group: MergeChangeGroup, + side: MergeReviewSide | 'both', + decision: Exclude + ) => { + const range = reviewRanges.find((candidate) => candidate.groupId === group.id); + if (!range) { + return; + } + const pendingDecision = + side === 'both' + ? isMergeReviewRangePending(range) + : side === 'left' + ? range.leftDecision === 'pending' + : range.rightDecision === 'pending'; + if (!pendingDecision) { + return; + } + pushMergeHistory(); + const next = applyMergeGroupDecision(resultText, reviewRanges, group, side, decision); + setResultDraft(next.content); + setReviewRanges(next.ranges); + if (visibleContent && side !== 'both') { + const sideDecision = side === 'left' ? range.leftDecision : range.rightDecision; + const otherDecision = side === 'left' ? range.rightDecision : range.leftDecision; + if (decision === 'applied') { + const sideExists = side === 'left' ? visibleContent.current.exists : visibleContent.incoming.exists; + setResultExists( + group.kind === 'conflict' && otherDecision === 'applied' + ? resultExists || sideExists + : sideExists + ); + } else if (sideDecision === 'applied') { + setResultExists( + otherDecision === 'applied' + ? side === 'left' + ? visibleContent.incoming.exists + : visibleContent.current.exists + : visibleContent.base.exists + ); + } + } + setError(null); + }, + [pushMergeHistory, resultExists, resultText, reviewRanges, visibleContent] + ); + + useEffect( + () => + rpcEvents.conflictResolverAction.subscribe((request) => { + if (!isSameFileReference(request, file)) { + return; + } + const group = groups.find((candidate) => candidate.id === request.groupId); + if (!group) { + return; + } + switch (request.action) { + case 'acceptLeft': + applyGroupDecision(group, 'left', 'applied'); + break; + case 'cancelLeft': + applyGroupDecision(group, 'left', 'cancelled'); + break; + case 'acceptRight': + applyGroupDecision(group, 'right', 'applied'); + break; + case 'cancelRight': + applyGroupDecision(group, 'right', 'cancelled'); + break; + case 'markReviewed': + applyGroupDecision(group, 'both', 'manual'); + break; + } + }), + [applyGroupDecision, file, groups] + ); + + const handleResultChange = useCallback( + (value: string, changes: CodeEditorContentChange[]) => { + if (resultDraft === null || (value === resultDraft && changes.length === 0)) { + return; + } + pushMergeHistory(); + const next = applyMergeContentChanges(reviewRanges, changes); + setResultDraft(value); + setResultExists(true); + setReviewRanges(next.ranges); + if (next.touchedGroupIds[0]) { + setActiveGroupId(next.touchedGroupIds[0]); + } + setError(null); + }, + [pushMergeHistory, resultDraft, reviewRanges] + ); + + useEffect(() => { + const handleUndoRedo = (event: KeyboardEvent) => { + if ( + event.isComposing || + event.altKey || + (!event.metaKey && !event.ctrlKey) || + event.key.toLowerCase() !== 'z' + ) { + return; + } + const changed = event.shiftKey ? redoMergeOperation() : undoMergeOperation(); + if (!changed) { + return; + } + event.preventDefault(); + event.stopPropagation(); + }; + window.addEventListener('keydown', handleUndoRedo, true); + return () => window.removeEventListener('keydown', handleUndoRedo, true); + }, [redoMergeOperation, undoMergeOperation]); + + const goToPreviousConflict = useCallback(() => { + if (previousPendingGroup) { + setActiveGroupId(previousPendingGroup.id); + } + }, [previousPendingGroup]); + + const goToNextConflict = useCallback(() => { + if (nextPendingGroup) { + setActiveGroupId(nextPendingGroup.id); + } + }, [nextPendingGroup]); + + const selectConflictByResultLine = useCallback( + (lineNumber: number) => { + const group = groups.find((candidate) => { + const range = layout.resultTextRanges.get(candidate.id); + return range && lineNumber >= range.startLine && lineNumber <= range.endLine; + }); + if (group) { + setActiveGroupId(group.id); + } + }, + [groups, layout.resultTextRanges] + ); + + const selectConflictBySideLine = useCallback( + (side: MergeReviewSide, lineNumber: number) => { + const group = groups.find((candidate) => { + const start = side === 'left' ? candidate.leftStart : candidate.rightStart; + const lineCount = side === 'left' ? candidate.leftLineCount : candidate.rightLineCount; + return lineCount > 0 && lineNumber >= start + 1 && lineNumber <= start + lineCount; + }); + if (group) { + setActiveGroupId(group.id); + } + }, + [groups] + ); + + const saveResolution = useCallback(async () => { + if (!visibleContent || resultDraft === null) { + return; + } + if (pendingGroupCount > 0) { + setError(new Error(t('Resolve all conflict blocks before completing the merge.'))); + return; + } + if (hasConflictBlocks(resultDraft)) { + setError(new Error(t('The final result still contains conflict markers.'))); + return; + } + + setSaving(true); + try { + await rpc.saveConflictResolution({ + ...file, + content: resultDraft, + resultExists, + stageSignature: visibleContent.stageSignature, + resultFingerprint: visibleContent.resultFingerprint, + }); + completeAction(); + } catch (e) { + setError(e instanceof Error ? e : new Error(String(e))); + } finally { + setSaving(false); + } + }, [completeAction, file, pendingGroupCount, resultDraft, resultExists, t, visibleContent]); + + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && !completeDisabled) { + event.preventDefault(); + event.stopPropagation(); + void saveResolution(); + return; + } + if ( + isEditableKeyboardTarget(event.target) || + !event.altKey || + event.metaKey || + event.ctrlKey || + event.shiftKey + ) { + return; + } + if (event.key === 'ArrowUp' && previousPendingGroup) { + event.preventDefault(); + goToPreviousConflict(); + } else if (event.key === 'ArrowDown' && nextPendingGroup) { + event.preventDefault(); + goToNextConflict(); + } + }, + [ + completeDisabled, + goToNextConflict, + goToPreviousConflict, + nextPendingGroup, + previousPendingGroup, + saveResolution, + ] + ); + + const reviewActionsDisabled = controlsDisabled; + const blockActions: MergeBlockAction[] = groups.map((group) => { + const range = rangeById.get(group.id); + return { + id: group.id, + displayRow: layout.displayRows.get(group.id) ?? group.baseStart, + leftLineCount: group.leftLineCount, + resultLineCount: layout.resultLineCounts.get(group.id) ?? 0, + rightLineCount: group.rightLineCount, + decorationType: getMergeDecorationType(group), + hasLeftChange: group.hasLeftChange, + hasRightChange: group.hasRightChange, + leftDecision: range?.leftDecision ?? null, + rightDecision: range?.rightDecision ?? null, + leftApplyAppends: Boolean(range && getMergeGroupApplyMode(resultText, range, group, 'left') === 'append'), + rightApplyAppends: Boolean(range && getMergeGroupApplyMode(resultText, range, group, 'right') === 'append'), + leftContextData: buildMergeEditorContext(file, 'mergeEditorLeft', group, range, reviewActionsDisabled), + resultContextData: buildMergeEditorContext(file, 'mergeEditorResult', group, range, reviewActionsDisabled), + rightContextData: buildMergeEditorContext(file, 'mergeEditorRight', group, range, reviewActionsDisabled), + disabled: reviewActionsDisabled, + onCancelLeft: () => applyGroupDecision(group, 'left', 'cancelled'), + onAcceptLeft: () => applyGroupDecision(group, 'left', 'applied'), + onAcceptRight: () => applyGroupDecision(group, 'right', 'applied'), + onCancelRight: () => applyGroupDecision(group, 'right', 'cancelled'), + }; + }); + const leftContextData = buildMergeEditorContext( + file, + 'mergeEditorLeft', + activeGroup, + activeRange, + reviewActionsDisabled + ); + const resultContextData = buildMergeEditorContext( + file, + 'mergeEditorResult', + activeGroup, + activeRange, + reviewActionsDisabled + ); + const rightContextData = buildMergeEditorContext( + file, + 'mergeEditorRight', + activeGroup, + activeRange, + reviewActionsDisabled + ); + const activeResultRange = activeGroup ? layout.resultTextRanges.get(activeGroup.id) : undefined; + + return ( +
+
+
+ {visibleContent && visibleContent.kind !== 'text' && ( + {file.path} + )} + + + +
+ + +
+
+ + +
+ {activeGroup && activeRange && isMergeReviewRangePending(activeRange) && ( + <> + + + + )} +
+
{statusLabel}
+
+ + {(!visibleContent || visibleContent.kind === 'text') && ( +
+
+ {t('Changes from {{name}}', { name: currentBranchLabel })} +
+
+ {t('Result')} + {file.path} +
+
+ {t('Changes from {{name}}', { name: incomingBranchLabel })} +
+
+ )} + +
+ {isContentLoading &&
{t('Loading...')}
} + {error &&
{error.message}
} + {visibleContent?.kind === 'binary' && ( +
+ +
{t('Binary file conflict')}
+
+ {t('Choose one complete version of the file. Binary content cannot be merged inline.')} +
+
+ + + +
+
+ )} + {visibleContent?.kind === 'submodule' && ( +
+ +
{t('Submodule conflict')}
+
+ {t( + 'Resolve the submodule to the commit you want, then stage the submodule path in the parent repository.' + )} +
+
+
+ {t('Current commit')} + + {getShortObjectId(visibleContent.current.objectId)} + +
+
+ {t('Incoming commit')} + + {getShortObjectId(visibleContent.incoming.objectId)} + +
+
+
+ +
+
+ )} + {visibleContent?.kind === 'unsupported' && ( +
+ +
{t('Unsupported conflict')}
+
+ {t( + 'This Git entry type cannot be resolved in Intelli Git. Resolve and stage it with Git, then refresh the view.' + )} +
+
+ )} + {visibleContent && visibleContent.kind === 'text' && resultDraft !== null && ( + selectConflictBySideLine('left', lineNumber)} + onResultLineClick={selectConflictByResultLine} + onRightLineClick={(lineNumber) => selectConflictBySideLine('right', lineNumber)} + /> + )} +
+
+
+ {visibleContent?.kind === 'text' && ( + <> + + + + )} +
+
+ + {(!visibleContent || visibleContent.kind === 'text') && ( + + )} +
+
+
+ ); +} + +export function ConflictResolverPage() { + const { t } = useTranslation(); + const initialSession = useMemo(() => getInitialConflictSession(), []); + const [file, setFile] = useState(initialSession?.file ?? null); + + useEffect(() => { + if (file) { + void rpc.updateConflictResolverTitle(file); + } + }, [file]); + + useEffect( + () => + rpcEvents.revealConflictResolverFile.subscribe((next) => { + const nextFile = { path: next.path, repoPath: next.repoPath }; + setFile((current) => (current && isSameFileReference(current, nextFile) ? current : nextFile)); + }), + [] + ); + + if (!file) { + return
{t('No conflict file selected.')}
; + } + + return void rpc.closeWebView()} />; +} diff --git a/apps/webview-ui/src/components/merge/MonacoCodeEditor.module.css b/apps/webview-ui/src/components/merge/MonacoCodeEditor.module.css new file mode 100644 index 0000000..05e667e --- /dev/null +++ b/apps/webview-ui/src/components/merge/MonacoCodeEditor.module.css @@ -0,0 +1,38 @@ +.editorHost { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + flex: 1; + box-sizing: border-box; + border-top: 1px solid var(--vscode-panel-border); + overflow: hidden; + background: var(--vscode-editor-background); +} + +.editorHost[data-editable='true'] :global(.monaco-editor .cursors-layer .cursor) { + border-color: var(--vscode-editorCursor-foreground, #aeafad); + background-color: var(--vscode-editorCursor-foreground, #aeafad); +} + +.editorHost :global(.monaco-scrollable-element > .decorationsOverviewRuler) { + opacity: 0; + transition: opacity 800ms linear; +} + +.editorHost :global(.monaco-scrollable-element:has(> .visible.scrollbar.vertical) > .decorationsOverviewRuler) { + opacity: 0.4; + transition-duration: 100ms; +} + +.emptyHost { + min-height: 30px; + padding: 6px 8px; + box-sizing: border-box; + border-top: 1px solid var(--vscode-panel-border); + color: var(--vscode-descriptionForeground); + font-family: var(--ig-code-font-family); + font-size: var(--ig-code-font-size); + font-style: italic; + line-height: 18px; +} diff --git a/apps/webview-ui/src/components/merge/MonacoCodeEditor.tsx b/apps/webview-ui/src/components/merge/MonacoCodeEditor.tsx new file mode 100644 index 0000000..45e9f37 --- /dev/null +++ b/apps/webview-ui/src/components/merge/MonacoCodeEditor.tsx @@ -0,0 +1,491 @@ +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; +import type * as Monaco from 'monaco-editor'; +import type { MergeEditorContext } from '@shared/webviewContext'; +import { applyVsCodeMonacoTheme, getMonacoLanguageIdForPath, loadMonaco, type MonacoApi } from './monacoRuntime'; +import styles from './MonacoCodeEditor.module.css'; + +type MonacoEditor = Monaco.editor.IStandaloneCodeEditor; +// Monaco uses this internal hook for wheel events over its own diff editor gutters. +type WheelDelegatingMonacoEditor = MonacoEditor & { + delegateScrollFromMouseWheelEvent(event: WheelEvent): void; +}; +type DecorationCollection = Monaco.editor.IEditorDecorationsCollection; +type EditorDimension = Monaco.editor.IDimension; + +export const CODE_EDITOR_LINE_HEIGHT = 18; +export const CODE_EDITOR_TOP_PADDING = 6; + +export type CodeOverviewRulerType = 'inserted' | 'deleted' | 'modified' | 'conflict'; + +export interface CodeDecoration { + startLine: number; + startColumn?: number; + endLine: number; + endColumn?: number; + isWholeLine?: boolean; + className?: string; + inlineClassName?: string; + marginClassName?: string; + overviewRulerType?: CodeOverviewRulerType; +} + +export interface CodeViewZone { + id: string; + afterLineNumber: number; + heightInLines: number; + className?: string; +} + +export interface CodeEditorContentChange { + rangeOffset: number; + rangeLength: number; + text: string; +} + +export interface MonacoCodeEditorHandle { + delegateScrollFromWheelEvent(event: WheelEvent): void; +} + +interface MonacoCodeEditorProps { + value: string; + ariaLabel: string; + filePath?: string; + readOnly?: boolean; + disabled?: boolean; + className?: string; + decorations?: CodeDecoration[]; + viewZones?: CodeViewZone[]; + contextData?: MergeEditorContext; + revealLine?: number; + scrollTop?: number; + scrollLeft?: number; + onChange?: (value: string, changes: CodeEditorContentChange[]) => void; + onLineClick?: (lineNumber: number) => void; + onScroll?: (scrollTop: number | undefined, scrollLeft: number | undefined) => void; +} + +function getCssVar(name: string, fallback: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +} + +function getOverviewRulerColor(type: CodeOverviewRulerType): string { + switch (type) { + case 'inserted': + return getCssVar('--vscode-editorGutter-addedBackground', '#72c892'); + case 'deleted': + return getCssVar('--vscode-diffEditor-move-border', '#8b8b8b'); + case 'modified': + return getCssVar('--vscode-editorGutter-modifiedBackground', '#0078d4'); + case 'conflict': + return getCssVar('--vscode-editorGutter-deletedBackground', '#f28772'); + } +} + +function getEditorOptions( + model: Monaco.editor.ITextModel, + ariaLabel: string, + readOnly: boolean +): Monaco.editor.IStandaloneEditorConstructionOptions { + const fontSize = Number.parseInt(getCssVar('--vscode-editor-font-size', '13'), 10) || 13; + return { + model, + ariaLabel, + automaticLayout: true, + contextmenu: false, + fontFamily: getCssVar('--vscode-editor-font-family', 'monospace'), + fontSize, + folding: false, + glyphMargin: false, + lineDecorationsWidth: 12, + lineHeight: CODE_EDITOR_LINE_HEIGHT, + lineNumbers: 'on', + lineNumbersMinChars: 3, + minimap: { enabled: false }, + padding: { top: CODE_EDITOR_TOP_PADDING, bottom: 6 }, + readOnly, + scrollBeyondLastLine: false, + theme: 'intelli-git-vscode', + useShadowDOM: false, + }; +} + +function normalizeLineEndings(value: string): string { + return value.replace(/\r\n/g, '\n'); +} + +function getElementDimension(element: HTMLElement): EditorDimension | null { + const rect = element.getBoundingClientRect(); + const width = Math.floor(rect.width); + const height = Math.floor(rect.height); + if (width <= 0 || height <= 0) { + return null; + } + return { width, height }; +} + +function getEditorDecorations( + monaco: MonacoApi, + model: Monaco.editor.ITextModel, + decorations: CodeDecoration[] | undefined +): Monaco.editor.IModelDeltaDecoration[] { + const lineCount = model.getLineCount(); + return (decorations ?? []).map((decoration) => { + const startLine = Math.max(1, Math.min(decoration.startLine, lineCount)); + const endLine = Math.max(startLine, Math.min(decoration.endLine, lineCount)); + const startColumn = decoration.isWholeLine + ? 1 + : Math.max(1, Math.min(decoration.startColumn ?? 1, model.getLineMaxColumn(startLine))); + const endColumn = decoration.isWholeLine + ? model.getLineMaxColumn(endLine) + : Math.max( + 1, + Math.min(decoration.endColumn ?? model.getLineMaxColumn(endLine), model.getLineMaxColumn(endLine)) + ); + + return { + range: new monaco.Range(startLine, startColumn, endLine, endColumn), + options: { + className: decoration.className, + inlineClassName: decoration.inlineClassName, + isWholeLine: decoration.isWholeLine, + shouldFillLineOnLineBreak: decoration.isWholeLine, + marginClassName: decoration.marginClassName, + overviewRuler: decoration.overviewRulerType + ? { + color: getOverviewRulerColor(decoration.overviewRulerType), + position: monaco.editor.OverviewRulerLane.Right, + } + : undefined, + }, + }; + }); +} + +function isClickableLineTarget(monaco: MonacoApi, targetType: Monaco.editor.MouseTargetType): boolean { + return ( + targetType === monaco.editor.MouseTargetType.CONTENT_TEXT || + targetType === monaco.editor.MouseTargetType.CONTENT_EMPTY || + targetType === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS || + targetType === monaco.editor.MouseTargetType.GUTTER_LINE_DECORATIONS + ); +} + +function replaceViewZones(editor: MonacoEditor, currentIds: string[], zones: CodeViewZone[] | undefined): string[] { + const nextIds: string[] = []; + editor.changeViewZones((accessor) => { + currentIds.forEach((id) => accessor.removeZone(id)); + for (const zone of zones ?? []) { + if (zone.heightInLines <= 0) { + continue; + } + const domNode = document.createElement('div'); + const marginDomNode = document.createElement('div'); + if (zone.className) { + domNode.className = zone.className; + marginDomNode.className = zone.className; + } + nextIds.push( + accessor.addZone({ + afterLineNumber: Math.max(0, zone.afterLineNumber), + heightInLines: zone.heightInLines, + domNode, + marginDomNode, + suppressMouseDown: true, + }) + ); + } + }); + return nextIds; +} + +export const MonacoCodeEditor = forwardRef(function MonacoCodeEditor( + { + value, + ariaLabel, + filePath, + readOnly = false, + disabled = false, + className, + decorations, + viewZones, + contextData, + revealLine, + scrollTop, + scrollLeft, + onChange, + onLineClick, + onScroll, + }, + ref +) { + const hostRef = useRef(null); + const editorRef = useRef(null); + const decorationsRef = useRef(null); + const viewZoneIdsRef = useRef([]); + const applyingExternalValueRef = useRef(false); + const applyingExternalScrollRef = useRef(false); + const layoutFrameRef = useRef(0); + const latestValueRef = useRef(value); + const latestReadOnlyRef = useRef(readOnly || disabled); + const latestDecorationsRef = useRef(decorations); + const latestViewZonesRef = useRef(viewZones); + const latestRevealLineRef = useRef(revealLine); + const onChangeRef = useRef(onChange); + const onLineClickRef = useRef(onLineClick); + const onScrollRef = useRef(onScroll); + + useImperativeHandle( + ref, + () => ({ + delegateScrollFromWheelEvent(event) { + editorRef.current?.delegateScrollFromMouseWheelEvent(event); + }, + }), + [] + ); + + useEffect(() => { + latestValueRef.current = value; + latestReadOnlyRef.current = readOnly || disabled; + latestDecorationsRef.current = decorations; + latestViewZonesRef.current = viewZones; + latestRevealLineRef.current = revealLine; + onChangeRef.current = onChange; + onLineClickRef.current = onLineClick; + onScrollRef.current = onScroll; + }, [decorations, disabled, onChange, onLineClick, onScroll, readOnly, revealLine, value, viewZones]); + + useEffect(() => { + let disposed = false; + let editor: WheelDelegatingMonacoEditor | null = null; + let model: Monaco.editor.ITextModel | null = null; + let contentSubscription: Monaco.IDisposable | null = null; + let mouseSubscription: Monaco.IDisposable | null = null; + let scrollSubscription: Monaco.IDisposable | null = null; + let resizeObserver: ResizeObserver | null = null; + let monacoApi: MonacoApi | null = null; + const host = hostRef.current; + if (!host) { + return; + } + const languageId = getMonacoLanguageIdForPath(filePath); + + const layoutEditor = (dimension?: EditorDimension | null) => { + if (layoutFrameRef.current) { + window.cancelAnimationFrame(layoutFrameRef.current); + } + + layoutFrameRef.current = window.requestAnimationFrame(() => { + layoutFrameRef.current = 0; + const nextDimension = dimension ?? getElementDimension(host); + if (!editor || !nextDimension) { + return; + } + editor.layout(nextDimension); + }); + }; + + const createEditor = (monaco: MonacoApi, languageId: string | undefined, dimension: EditorDimension) => { + if (disposed || editor) { + return; + } + + applyVsCodeMonacoTheme(monaco); + const nextModel = monaco.editor.createModel(latestValueRef.current, languageId); + const nextEditor = monaco.editor.create( + host, + getEditorOptions(nextModel, ariaLabel, latestReadOnlyRef.current) + ) as WheelDelegatingMonacoEditor; + model = nextModel; + editor = nextEditor; + editorRef.current = nextEditor; + decorationsRef.current = nextEditor.createDecorationsCollection( + getEditorDecorations(monaco, nextModel, latestDecorationsRef.current) + ); + viewZoneIdsRef.current = replaceViewZones(nextEditor, [], latestViewZonesRef.current); + nextEditor.layout(dimension); + if (latestRevealLineRef.current !== undefined) { + nextEditor.revealLineInCenter(latestRevealLineRef.current); + } + onScrollRef.current?.(nextEditor.getScrollTop(), nextEditor.getScrollLeft()); + + contentSubscription = nextEditor.onDidChangeModelContent((event) => { + if (!applyingExternalValueRef.current) { + onChangeRef.current?.( + nextEditor.getValue(), + event.changes.map((change) => ({ + rangeOffset: change.rangeOffset, + rangeLength: change.rangeLength, + text: change.text, + })) + ); + } + }); + mouseSubscription = nextEditor.onMouseDown((event) => { + if (!isClickableLineTarget(monaco, event.target.type)) { + return; + } + const lineNumber = event.target.position?.lineNumber; + if (lineNumber !== undefined) { + onLineClickRef.current?.(lineNumber); + } + }); + scrollSubscription = nextEditor.onDidScrollChange((event) => { + if (!applyingExternalScrollRef.current && (event.scrollTopChanged || event.scrollLeftChanged)) { + onScrollRef.current?.( + event.scrollTopChanged ? event.scrollTop : undefined, + event.scrollLeftChanged ? event.scrollLeft : undefined + ); + } + }); + }; + + resizeObserver = new ResizeObserver(() => { + if (disposed) { + return; + } + + const dimension = getElementDimension(host); + if (!dimension) { + return; + } + if (editor) { + layoutEditor(dimension); + } else if (monacoApi) { + createEditor(monacoApi, languageId, dimension); + } + }); + resizeObserver.observe(host); + + void loadMonaco() + .then((monaco) => { + if (disposed) { + return; + } + + monacoApi = monaco; + const dimension = getElementDimension(host); + if (dimension) { + createEditor(monaco, languageId, dimension); + } + }) + .catch((error) => { + console.error('Failed to initialize Monaco code editor.', error); + if (!disposed) { + window.setTimeout(() => { + throw error; + }); + } + }); + + return () => { + disposed = true; + if (layoutFrameRef.current) { + window.cancelAnimationFrame(layoutFrameRef.current); + layoutFrameRef.current = 0; + } + resizeObserver?.disconnect(); + scrollSubscription?.dispose(); + mouseSubscription?.dispose(); + contentSubscription?.dispose(); + decorationsRef.current?.clear(); + decorationsRef.current = null; + viewZoneIdsRef.current = []; + editorRef.current = null; + editor?.dispose(); + model?.dispose(); + }; + }, [ariaLabel, filePath]); + + useEffect(() => { + editorRef.current?.updateOptions({ + readOnly: readOnly || disabled, + }); + }, [disabled, readOnly]); + + useEffect(() => { + const editor = editorRef.current; + const model = editor?.getModel(); + if (!editor || !model) { + return; + } + if (normalizeLineEndings(model.getValue()) === normalizeLineEndings(value)) { + return; + } + + applyingExternalValueRef.current = true; + try { + model.setValue(value); + } finally { + applyingExternalValueRef.current = false; + } + }, [value]); + + useEffect(() => { + let cancelled = false; + void loadMonaco().then((monaco) => { + if (cancelled) { + return; + } + const model = editorRef.current?.getModel(); + if (model) { + const nextDecorations = getEditorDecorations(monaco, model, decorations); + decorationsRef.current?.clear(); + decorationsRef.current?.set(nextDecorations); + editorRef.current?.render(true); + } + }); + return () => { + cancelled = true; + }; + }, [decorations, value]); + + useEffect(() => { + const editor = editorRef.current; + if (!editor) { + return; + } + viewZoneIdsRef.current = replaceViewZones(editor, viewZoneIdsRef.current, viewZones); + editor.render(true); + }, [viewZones]); + + useEffect(() => { + const editor = editorRef.current; + if (!editor || revealLine === undefined) { + return; + } + editor.revealLineInCenter(revealLine); + }, [revealLine]); + + useEffect(() => { + const editor = editorRef.current; + if (!editor) { + return; + } + const nextPosition: Monaco.editor.INewScrollPosition = {}; + if (scrollTop !== undefined && editor.getScrollTop() !== scrollTop) { + nextPosition.scrollTop = scrollTop; + } + if (scrollLeft !== undefined && editor.getScrollLeft() !== scrollLeft) { + nextPosition.scrollLeft = scrollLeft; + } + if (nextPosition.scrollTop === undefined && nextPosition.scrollLeft === undefined) { + return; + } + applyingExternalScrollRef.current = true; + try { + editor.setScrollPosition(nextPosition); + } finally { + applyingExternalScrollRef.current = false; + } + }, [scrollLeft, scrollTop]); + + return ( +
+ ); +}); diff --git a/apps/webview-ui/src/components/merge/ThreeWayMergeEditor.module.css b/apps/webview-ui/src/components/merge/ThreeWayMergeEditor.module.css new file mode 100644 index 0000000..854719d --- /dev/null +++ b/apps/webview-ui/src/components/merge/ThreeWayMergeEditor.module.css @@ -0,0 +1,148 @@ +.mergeDocument { + display: grid; + min-height: 0; + flex: 1; + grid-template-columns: minmax(0, 1fr) 44px minmax(0, 1fr) 44px minmax(0, 1fr); + overflow: hidden; + background: var(--vscode-editor-background); +} + +.mergePane { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; +} + +.fullHeightEditor { + flex: 1; + min-height: 0; +} + +.resultPane { + background: color-mix( + in srgb, + var(--vscode-editor-lineHighlightBackground, transparent) 45%, + var(--vscode-editor-background) + ); +} + +.editorGutter { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--vscode-editorGutter-background, var(--vscode-editor-background)); +} + +.editorGutter::before, +.editorGutter::after { + position: absolute; + z-index: 0; + top: 0; + bottom: 0; + width: 1px; + background: var(--vscode-panel-border); + content: ''; + pointer-events: none; +} + +.editorGutter::before { + left: 0; +} + +.editorGutter::after { + right: 0; +} + +.gutterConnector { + position: absolute; + z-index: 1; + left: 0; + width: 100%; + overflow: visible; + pointer-events: none; + shape-rendering: geometricPrecision; +} + +.gutterConnector[data-decoration-type='inserted'] { + --intelli-git-merge-line-background: var(--vscode-diffEditor-insertedLineBackground, rgba(52, 125, 57, 0.15)); + --intelli-git-merge-marker: var(--vscode-editorGutter-addedBackground, #72c892); +} + +.gutterConnector[data-decoration-type='deleted'] { + --intelli-git-merge-line-background: var(--vscode-diffEditor-unchangedCodeBackground, rgba(128, 128, 128, 0.16)); + --intelli-git-merge-marker: var(--vscode-diffEditor-move-border, #8b8b8b); +} + +.gutterConnector[data-decoration-type='modified'] { + --intelli-git-merge-line-background: color-mix( + in srgb, + var(--vscode-editorGutter-modifiedBackground, #0078d4) 18%, + transparent + ); + --intelli-git-merge-marker: var(--vscode-editorGutter-modifiedBackground, #0078d4); +} + +.gutterConnector[data-decoration-type='conflict'] { + --intelli-git-merge-line-background: var(--vscode-diffEditor-removedLineBackground, rgba(201, 60, 55, 0.15)); + --intelli-git-merge-marker: var(--vscode-editorGutter-deletedBackground, #f28772); +} + +.gutterConnectorFill { + fill: var(--intelli-git-merge-line-background); +} + +.gutterConnectorEdge { + fill: none; + stroke: color-mix(in srgb, var(--intelli-git-merge-marker) 72%, transparent); + stroke-dasharray: 2 2; + stroke-width: 1px; + vector-effect: non-scaling-stroke; +} + +.leftGutterActionGroup, +.rightGutterActionGroup { + position: absolute; + z-index: 2; + display: flex; + width: 100%; + min-height: 18px; + box-sizing: border-box; + align-items: flex-start; + justify-content: center; + gap: 2px; + background: transparent; +} + +.gutterActionButton { + display: inline-flex; + width: 18px; + height: 18px; + padding: 0; + align-items: center; + justify-content: center; + border: 0; + border-radius: 2px; + background: transparent; + color: var(--vscode-icon-foreground); + cursor: pointer; +} + +.gutterActionButton:hover:not(:disabled) { + background: var(--vscode-toolbar-hoverBackground); +} + +.gutterActionButton:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 0; +} + +.gutterActionButton:disabled { + opacity: 0.45; + cursor: default; +} + +.leftAppendIcon { + transform: scaleX(-1); +} diff --git a/apps/webview-ui/src/components/merge/ThreeWayMergeEditor.tsx b/apps/webview-ui/src/components/merge/ThreeWayMergeEditor.tsx new file mode 100644 index 0000000..0b82867 --- /dev/null +++ b/apps/webview-ui/src/components/merge/ThreeWayMergeEditor.tsx @@ -0,0 +1,325 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { MergeEditorContext } from '@shared/webviewContext'; +import { + CODE_EDITOR_LINE_HEIGHT, + CODE_EDITOR_TOP_PADDING, + MonacoCodeEditor, + type CodeDecoration, + type CodeEditorContentChange, + type CodeViewZone, + type MonacoCodeEditorHandle, +} from './MonacoCodeEditor'; +import type { MergeReviewDecision } from './conflictModel'; +import styles from './ThreeWayMergeEditor.module.css'; + +export type MergeDecorationType = 'inserted' | 'deleted' | 'modified' | 'conflict'; + +export interface MergeBlockAction { + id: string; + displayRow: number; + leftLineCount: number; + resultLineCount: number; + rightLineCount: number; + decorationType: MergeDecorationType; + hasLeftChange: boolean; + hasRightChange: boolean; + leftDecision: MergeReviewDecision | null; + rightDecision: MergeReviewDecision | null; + leftApplyAppends: boolean; + rightApplyAppends: boolean; + leftContextData: MergeEditorContext; + resultContextData: MergeEditorContext; + rightContextData: MergeEditorContext; + disabled: boolean; + onCancelLeft: () => void; + onAcceptLeft: () => void; + onAcceptRight: () => void; + onCancelRight: () => void; +} + +const MERGE_GUTTER_WIDTH = 44; +const MERGE_EDITOR_TOP_OFFSET = CODE_EDITOR_TOP_PADDING + 1; + +interface MergeConnectorProps { + action: MergeBlockAction; + side: 'left' | 'right'; + scrollTop: number; +} + +function MergeConnector({ action, side, scrollTop }: MergeConnectorProps) { + const decision = side === 'left' ? action.leftDecision : action.rightDecision; + const hasChange = side === 'left' ? action.hasLeftChange : action.hasRightChange; + if (!hasChange || decision === null) { + return null; + } + + const sideLineCount = side === 'left' ? action.leftLineCount : action.rightLineCount; + const startLineCount = side === 'left' ? sideLineCount : action.resultLineCount; + const endLineCount = side === 'left' ? action.resultLineCount : sideLineCount; + const startHeight = startLineCount * CODE_EDITOR_LINE_HEIGHT; + const endHeight = endLineCount * CODE_EDITOR_LINE_HEIGHT; + const connectorHeight = Math.max(CODE_EDITOR_LINE_HEIGHT, startHeight, endHeight); + const top = MERGE_EDITOR_TOP_OFFSET + action.displayRow * CODE_EDITOR_LINE_HEIGHT - scrollTop; + const bottomPath = `M 0 ${startHeight} L ${MERGE_GUTTER_WIDTH} ${endHeight}`; + + return ( + + ); +} + +interface ThreeWayMergeEditorProps { + filePath: string; + leftText: string; + resultText: string; + rightText: string; + leftDecorations: CodeDecoration[]; + resultDecorations: CodeDecoration[]; + rightDecorations: CodeDecoration[]; + leftViewZones: CodeViewZone[]; + resultViewZones: CodeViewZone[]; + rightViewZones: CodeViewZone[]; + leftRevealLine?: number; + resultRevealLine?: number; + rightRevealLine?: number; + blockActions: MergeBlockAction[]; + leftContextData: MergeEditorContext; + resultContextData: MergeEditorContext; + rightContextData: MergeEditorContext; + resultDisabled: boolean; + onResultChange: (value: string, changes: CodeEditorContentChange[]) => void; + onLeftLineClick: (lineNumber: number) => void; + onResultLineClick: (lineNumber: number) => void; + onRightLineClick: (lineNumber: number) => void; +} + +export function ThreeWayMergeEditor({ + filePath, + leftText, + resultText, + rightText, + leftDecorations, + resultDecorations, + rightDecorations, + leftViewZones, + resultViewZones, + rightViewZones, + leftRevealLine, + resultRevealLine, + rightRevealLine, + blockActions, + leftContextData, + resultContextData, + rightContextData, + resultDisabled, + onResultChange, + onLeftLineClick, + onResultLineClick, + onRightLineClick, +}: ThreeWayMergeEditorProps) { + const { t } = useTranslation(); + const resultEditorRef = useRef(null); + const leftGutterRef = useRef(null); + const rightGutterRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + const handleScroll = useCallback((nextScrollTop: number | undefined, nextScrollLeft: number | undefined) => { + if (nextScrollTop !== undefined) { + setScrollTop((current) => (current === nextScrollTop ? current : nextScrollTop)); + } + if (nextScrollLeft !== undefined) { + setScrollLeft((current) => (current === nextScrollLeft ? current : nextScrollLeft)); + } + }, []); + + useEffect(() => { + const handleGutterWheel = (event: WheelEvent) => { + resultEditorRef.current?.delegateScrollFromWheelEvent(event); + }; + const gutters = [leftGutterRef.current, rightGutterRef.current]; + gutters.forEach((gutter) => gutter?.addEventListener('wheel', handleGutterWheel, { passive: false })); + return () => { + gutters.forEach((gutter) => gutter?.removeEventListener('wheel', handleGutterWheel)); + }; + }, []); + + return ( +
+
+ +
+
+ {blockActions.map((action) => ( + + ))} + {blockActions + .filter((action) => action.hasLeftChange && action.leftDecision === 'pending') + .map((action) => { + const acceptLabel = action.leftApplyAppends ? t('Append Left Change Below') : t('Accept Left'); + return ( +
+ + +
+ ); + })} +
+
+ +
+
+ {blockActions.map((action) => ( + + ))} + {blockActions + .filter((action) => action.hasRightChange && action.rightDecision === 'pending') + .map((action) => { + const acceptLabel = action.rightApplyAppends + ? t('Append Right Change Below') + : t('Accept Right'); + return ( +
+ + +
+ ); + })} +
+
+ +
+
+ ); +} diff --git a/apps/webview-ui/src/components/merge/conflictModel.test.ts b/apps/webview-ui/src/components/merge/conflictModel.test.ts new file mode 100644 index 0000000..496e4f9 --- /dev/null +++ b/apps/webview-ui/src/components/merge/conflictModel.test.ts @@ -0,0 +1,773 @@ +import { describe, expect, it } from 'vitest'; +import { + applyConflictBlockResolution, + applyMergeContentChanges, + applyMergeGroupDecision, + buildConflictDocumentResult, + buildConflictInlineDiffRanges, + buildInlineDiffSegments, + buildLineAlignmentBlocks, + buildMergeSessionDocument, + getConflictBaseText, + getConflictResolutionText, + getMergeGroupApplyMode, + getMergeResultReviewState, + getUnresolvedConflictIds, + hasConflictBlocks, + isMergeReviewRangePending, + parseConflictBlocks, + parseConflictDocument, + splitContentLines, +} from './conflictModel'; + +describe('conflictModel', () => { + it('finds the exact stable-gap position of manually inserted result lines', () => { + expect( + buildLineAlignmentBlocks(['68', '69', '70', '71'], ['68', '69', 'manual-a', 'manual-b', '70', '71']) + ).toEqual([ + { + referenceStart: 2, + referenceLineCount: 0, + resultStart: 2, + resultLineCount: 2, + }, + ]); + }); + + it('keeps separate insertion and deletion alignment blocks in a stable gap', () => { + expect( + buildLineAlignmentBlocks(['a', 'b', 'c', 'd', 'e', 'f'], ['a', 'insert', 'b', 'c', 'e', 'replace', 'f']) + ).toEqual([ + { + referenceStart: 1, + referenceLineCount: 0, + resultStart: 1, + resultLineCount: 1, + }, + { + referenceStart: 3, + referenceLineCount: 1, + resultStart: 4, + resultLineCount: 0, + }, + { + referenceStart: 5, + referenceLineCount: 0, + resultStart: 5, + resultLineCount: 1, + }, + ]); + }); + + it('normalizes line endings before aligning merge panes', () => { + expect(splitContentLines('one\r\ntwo\nthree\r')).toEqual(['one', 'two', 'three']); + expect(buildLineAlignmentBlocks(splitContentLines('one\r\ntwo\r\n'), splitContentLines('one\ntwo\n'))).toEqual( + [] + ); + }); + + it('builds Base-backed change groups without exposing worktree markers', () => { + const base = 'header\nbase-first\nmiddle\nbase-second\nfooter\n'; + const left = 'header\nleft-first\nmiddle\nbase-second\nfooter\n'; + const right = 'header\nright-first\nmiddle\nright-second\nfooter\n'; + const session = buildMergeSessionDocument( + base, + left, + right, + [{ id: 'left-1', baseStart: 1, baseLineCount: 1, sideStart: 1, sideLineCount: 1 }], + [ + { id: 'right-1', baseStart: 1, baseLineCount: 1, sideStart: 1, sideLineCount: 1 }, + { id: 'right-2', baseStart: 3, baseLineCount: 1, sideStart: 3, sideLineCount: 1 }, + ] + ); + + expect(session.resultText).toBe(base); + expect(session.groups).toEqual([ + expect.objectContaining({ + id: 'change-0', + kind: 'conflict', + baseStart: 1, + baseText: 'base-first\n', + leftText: 'left-first\n', + rightText: 'right-first\n', + }), + expect.objectContaining({ + id: 'change-1', + kind: 'right-only', + baseStart: 3, + baseText: 'base-second\n', + leftText: 'base-second\n', + rightText: 'right-second\n', + }), + ]); + expect(session.reviewRanges).toEqual([ + expect.objectContaining({ leftDecision: 'pending', rightDecision: 'pending' }), + expect.objectContaining({ leftDecision: null, rightDecision: 'pending' }), + ]); + }); + + it('groups an insertion and overlapping deletion around the same Base range', () => { + const session = buildMergeSessionDocument( + 'a\nb\n', + 'a\nleft\nb\n', + 'a\n', + [{ id: 'left-insert', baseStart: 1, baseLineCount: 0, sideStart: 1, sideLineCount: 1 }], + [{ id: 'right-delete', baseStart: 1, baseLineCount: 1, sideStart: 1, sideLineCount: 0 }] + ); + + expect(session.groups).toEqual([ + expect.objectContaining({ + kind: 'conflict', + baseText: 'b\n', + leftText: 'left\nb\n', + rightText: '', + leftLineCount: 2, + rightLineCount: 0, + }), + ]); + }); + + it('rebuilds merge groups with the selected whitespace comparison mode', () => { + const base = 'value\nconst item = 1;\n'; + const left = 'value \nconst item = 1;\n'; + const changes = [{ id: 'mixed-whitespace', baseStart: 0, baseLineCount: 2, sideStart: 0, sideLineCount: 2 }]; + + const defaultSession = buildMergeSessionDocument(base, left, base, changes, [], 'none'); + const trimmedSession = buildMergeSessionDocument(base, left, base, changes, [], 'trim'); + const ignoredSession = buildMergeSessionDocument(base, left, base, changes, [], 'ignore'); + + expect(defaultSession.groups).toHaveLength(1); + expect(trimmedSession.groups).toEqual([ + expect.objectContaining({ baseStart: 1, leftText: 'const item = 1;\n' }), + ]); + expect(ignoredSession.groups).toHaveLength(0); + expect(ignoredSession.reviewRanges).toHaveLength(0); + }); + + it('filters a large whitespace-only hunk without building an LCS table', () => { + const lineCount = 501; + const base = 'value\n'.repeat(lineCount); + const left = ' value \n'.repeat(lineCount); + const changes = [ + { + id: 'large-whitespace', + baseStart: 0, + baseLineCount: lineCount, + sideStart: 0, + sideLineCount: lineCount, + }, + ]; + + expect(buildMergeSessionDocument(base, left, base, changes, [], 'trim').groups).toHaveLength(0); + expect(buildMergeSessionDocument(base, left, base, changes, [], 'ignore').groups).toHaveLength(0); + }); + + it('starts an add/add conflict from an empty Base result', () => { + const session = buildMergeSessionDocument( + '', + 'left add\n', + 'right add\n', + [{ id: 'left-add', baseStart: 0, baseLineCount: 0, sideStart: 0, sideLineCount: 1 }], + [{ id: 'right-add', baseStart: 0, baseLineCount: 0, sideStart: 0, sideLineCount: 1 }] + ); + + expect(session.resultText).toBe(''); + expect(session.groups).toEqual([ + expect.objectContaining({ + kind: 'conflict', + baseText: '', + leftText: 'left add\n', + rightText: 'right add\n', + }), + ]); + expect(session.reviewRanges[0]).toMatchObject({ + startOffset: 0, + endOffset: 0, + leftDecision: 'pending', + rightDecision: 'pending', + lastAppliedSide: null, + }); + }); + + it('reviews each side independently and keeps later review ranges aligned', () => { + const session = buildMergeSessionDocument( + 'base-one\nbase-two\n', + 'left-one-longer\nbase-two\n', + 'right-one\nright-two\n', + [{ id: 'left-1', baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 1 }], + [ + { id: 'right-1', baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 1 }, + { id: 'right-2', baseStart: 1, baseLineCount: 1, sideStart: 1, sideLineCount: 1 }, + ] + ); + const first = applyMergeGroupDecision( + session.resultText, + session.reviewRanges, + session.groups[0], + 'left', + 'applied' + ); + + expect(first.content).toBe('left-one-longer\nbase-two\n'); + expect(first.ranges[0]).toMatchObject({ + leftDecision: 'applied', + rightDecision: 'pending', + lastAppliedSide: 'left', + }); + expect(isMergeReviewRangePending(first.ranges[0])).toBe(true); + expect(first.content.slice(first.ranges[1].startOffset, first.ranges[1].endOffset)).toBe('base-two\n'); + + const second = applyMergeGroupDecision(first.content, first.ranges, session.groups[0], 'right', 'cancelled'); + + expect(second.content).toBe(first.content); + expect(second.ranges[0]).toMatchObject({ + leftDecision: 'applied', + rightDecision: 'cancelled', + lastAppliedSide: 'left', + }); + expect(isMergeReviewRangePending(second.ranges[0])).toBe(false); + }); + + it('marks only the remaining pending sides as manually reviewed', () => { + const session = buildMergeSessionDocument( + 'base\n', + 'left\n', + 'right\n', + [{ id: 'left', baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 1 }], + [{ id: 'right', baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 1 }] + ); + const left = applyMergeGroupDecision( + session.resultText, + session.reviewRanges, + session.groups[0], + 'left', + 'applied' + ); + const reviewed = applyMergeGroupDecision(left.content, left.ranges, session.groups[0], 'both', 'manual'); + + expect(reviewed.content).toBe('left\n'); + expect(reviewed.ranges[0]).toMatchObject({ + leftDecision: 'applied', + rightDecision: 'manual', + lastAppliedSide: 'left', + }); + expect(isMergeReviewRangePending(reviewed.ranges[0])).toBe(false); + }); + + it('appends the second applied conflict side and restores the other side when cancelled', () => { + const session = buildMergeSessionDocument( + 'base\n', + 'left\n', + 'right\n', + [{ id: 'left', baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 1 }], + [{ id: 'right', baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 1 }] + ); + const left = applyMergeGroupDecision( + session.resultText, + session.reviewRanges, + session.groups[0], + 'left', + 'applied' + ); + expect(getMergeGroupApplyMode(left.content, left.ranges[0], session.groups[0], 'right')).toBe('append'); + const right = applyMergeGroupDecision(left.content, left.ranges, session.groups[0], 'right', 'applied'); + const cancelledRight = applyMergeGroupDecision( + right.content, + right.ranges, + session.groups[0], + 'right', + 'cancelled' + ); + + expect(right.content).toBe('left\nright\n'); + expect(cancelledRight.content).toBe('left\n'); + expect(cancelledRight.ranges[0]).toMatchObject({ + leftDecision: 'applied', + rightDecision: 'cancelled', + lastAppliedSide: 'left', + }); + }); + + it('appends add/add sides in review order with a line boundary', () => { + const session = buildMergeSessionDocument( + '', + 'left add', + 'right add', + [{ id: 'left', baseStart: 0, baseLineCount: 0, sideStart: 0, sideLineCount: 1 }], + [{ id: 'right', baseStart: 0, baseLineCount: 0, sideStart: 0, sideLineCount: 1 }] + ); + const right = applyMergeGroupDecision( + session.resultText, + session.reviewRanges, + session.groups[0], + 'right', + 'applied' + ); + expect(getMergeGroupApplyMode(right.content, right.ranges[0], session.groups[0], 'left')).toBe('append'); + const left = applyMergeGroupDecision(right.content, right.ranges, session.groups[0], 'left', 'applied'); + + expect(left.content).toBe('right add\nleft add'); + expect(left.ranges[0]).toMatchObject({ + leftDecision: 'applied', + rightDecision: 'applied', + lastAppliedSide: 'left', + }); + }); + + it('preserves an applied side when the remaining conflict side is a deletion', () => { + const session = buildMergeSessionDocument( + 'base\n', + 'left\n', + '', + [{ id: 'left', baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 1 }], + [{ id: 'right', baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 0 }] + ); + const left = applyMergeGroupDecision( + session.resultText, + session.reviewRanges, + session.groups[0], + 'left', + 'applied' + ); + + expect(getMergeGroupApplyMode(left.content, left.ranges[0], session.groups[0], 'right')).toBe('preserve'); + + const right = applyMergeGroupDecision(left.content, left.ranges, session.groups[0], 'right', 'applied'); + expect(right.content).toBe('left\n'); + }); + + it('moves review ranges after manual edits and reopens touched groups', () => { + const ranges = [ + { + groupId: 'change-0', + startOffset: 2, + endOffset: 6, + leftDecision: 'applied' as const, + rightDecision: 'cancelled' as const, + lastAppliedSide: 'left' as const, + }, + { + groupId: 'change-1', + startOffset: 10, + endOffset: 12, + leftDecision: null, + rightDecision: 'applied' as const, + lastAppliedSide: 'right' as const, + }, + ]; + const updated = applyMergeContentChanges(ranges, [{ rangeOffset: 3, rangeLength: 1, text: 'manual' }]); + + expect(updated.touchedGroupIds).toEqual(['change-0']); + expect(updated.ranges[0]).toMatchObject({ + startOffset: 2, + endOffset: 11, + leftDecision: 'pending', + rightDecision: 'pending', + lastAppliedSide: null, + }); + expect(updated.ranges[1]).toMatchObject({ + startOffset: 15, + endOffset: 17, + leftDecision: null, + rightDecision: 'applied', + lastAppliedSide: 'right', + }); + }); + + it('does not expand a pending range into a manual edit that starts at its end', () => { + const session = buildMergeSessionDocument( + 'a\r\nstable', + 'A\r\nstable', + 'a\r\nstable', + [{ id: 'left', baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 1 }], + [] + ); + const editedContent = 'a\r\nStable'; + const updated = applyMergeContentChanges(session.reviewRanges, [{ rangeOffset: 3, rangeLength: 1, text: 'S' }]); + const accepted = applyMergeGroupDecision(editedContent, updated.ranges, session.groups[0], 'left', 'applied'); + + expect(updated.touchedGroupIds).toEqual([]); + expect(updated.ranges[0]).toMatchObject({ startOffset: 0, endOffset: 3 }); + expect(accepted.content).toBe('A\r\nStable'); + }); + + it('keeps adjacent ranges disjoint when inserting at their shared boundary', () => { + const session = buildMergeSessionDocument( + 'a\nb\n', + 'A\nb\n', + 'a\nB\n', + [{ id: 'left', baseStart: 0, baseLineCount: 1, sideStart: 0, sideLineCount: 1 }], + [{ id: 'right', baseStart: 1, baseLineCount: 1, sideStart: 1, sideLineCount: 1 }] + ); + const editedContent = 'a\nmanual\nb\n'; + const updated = applyMergeContentChanges(session.reviewRanges, [ + { rangeOffset: 2, rangeLength: 0, text: 'manual\n' }, + ]); + const accepted = applyMergeGroupDecision(editedContent, updated.ranges, session.groups[0], 'left', 'applied'); + + expect(updated.touchedGroupIds).toEqual(['change-1']); + expect(updated.ranges[0]).toMatchObject({ startOffset: 0, endOffset: 2 }); + expect(updated.ranges[1]).toMatchObject({ startOffset: 2, endOffset: 11 }); + expect(updated.ranges[0].endOffset).toBeLessThanOrEqual(updated.ranges[1].startOffset); + expect(accepted.content).toBe('A\nmanual\nb\n'); + }); + + it('maps both sides of a shared boundary to the same offset when a replacement spans it', () => { + const ranges = [ + { + groupId: 'change-0', + startOffset: 0, + endOffset: 2, + leftDecision: 'pending' as const, + rightDecision: null, + lastAppliedSide: null, + }, + { + groupId: 'change-1', + startOffset: 2, + endOffset: 4, + leftDecision: null, + rightDecision: 'pending' as const, + lastAppliedSide: null, + }, + ]; + const updated = applyMergeContentChanges(ranges, [{ rangeOffset: 1, rangeLength: 2, text: 'X' }]); + + expect(updated.touchedGroupIds).toEqual(['change-0', 'change-1']); + expect(updated.ranges[0]).toMatchObject({ startOffset: 0, endOffset: 1 }); + expect(updated.ranges[1]).toMatchObject({ startOffset: 1, endOffset: 3 }); + }); + + it('keeps an empty range anchored when a following range owns a boundary insertion', () => { + const updated = applyMergeContentChanges( + [ + { + groupId: 'change-0', + startOffset: 0, + endOffset: 0, + leftDecision: 'pending' as const, + rightDecision: null, + lastAppliedSide: null, + }, + { + groupId: 'change-1', + startOffset: 0, + endOffset: 4, + leftDecision: null, + rightDecision: 'pending' as const, + lastAppliedSide: null, + }, + ], + [{ rangeOffset: 0, rangeLength: 0, text: 'manual' }] + ); + + expect(updated.touchedGroupIds).toEqual(['change-1']); + expect(updated.ranges[0]).toMatchObject({ startOffset: 0, endOffset: 0 }); + expect(updated.ranges[1]).toMatchObject({ startOffset: 0, endOffset: 10 }); + }); + + it('transforms multiple Monaco changes from original document offsets', () => { + const ranges = [ + { + groupId: 'change-0', + startOffset: 2, + endOffset: 6, + leftDecision: 'applied' as const, + rightDecision: null, + lastAppliedSide: 'left' as const, + }, + { + groupId: 'change-1', + startOffset: 10, + endOffset: 12, + leftDecision: null, + rightDecision: 'cancelled' as const, + lastAppliedSide: null, + }, + ]; + const updated = applyMergeContentChanges(ranges, [ + { rangeOffset: 10, rangeLength: 0, text: 'xy' }, + { rangeOffset: 3, rangeLength: 1, text: 'manual' }, + ]); + + expect(updated.touchedGroupIds).toEqual(['change-0', 'change-1']); + expect(updated.ranges[0]).toMatchObject({ startOffset: 2, endOffset: 11 }); + expect(updated.ranges[1]).toMatchObject({ startOffset: 15, endOffset: 19 }); + }); + + it('expands an empty EOF range for all insertions at the same offset', () => { + const updated = applyMergeContentChanges( + [ + { + groupId: 'change-0', + startOffset: 6, + endOffset: 6, + leftDecision: 'pending', + rightDecision: 'pending', + lastAppliedSide: null, + }, + ], + [ + { rangeOffset: 6, rangeLength: 0, text: 'right' }, + { rangeOffset: 6, rangeLength: 0, text: 'left' }, + ] + ); + + expect(updated.touchedGroupIds).toEqual(['change-0']); + expect(updated.ranges[0]).toMatchObject({ startOffset: 6, endOffset: 15 }); + }); + + it('builds inline diff segments while preserving unchanged tokens', () => { + expect(buildInlineDiffSegments('const value = 2;\n', 'const value = 1;\n')).toEqual([ + { text: 'const value = ', changed: false }, + { text: '2', changed: true }, + { text: ';\n', changed: false }, + ]); + expect(buildInlineDiffSegments('same line\n', 'same line\n')).toEqual([ + { text: 'same line\n', changed: false }, + ]); + }); + + it('applies IDEA-style whitespace policies to inline word highlights', () => { + expect(buildInlineDiffSegments('const value\n', 'const value\n')).toEqual([ + { text: 'const', changed: false }, + { text: ' ', changed: true }, + { text: 'value\n', changed: false }, + ]); + expect(buildInlineDiffSegments('const value\n', 'const value\n', 'ignore')).toEqual([ + { text: 'const value\n', changed: false }, + ]); + expect(buildInlineDiffSegments(' value \n', 'value\n', 'trim')).toEqual([ + { text: ' value \n', changed: false }, + ]); + expect(buildInlineDiffSegments('const value\n', 'const value\n', 'trim')).toEqual([ + { text: 'const', changed: false }, + { text: ' ', changed: true }, + { text: 'value\n', changed: false }, + ]); + }); + + it('builds exact inline ranges for both sides of a conflict block', () => { + const content = [ + '<<<<<<< current\n', + 'const value = 2;\n', + '||||||| base\n', + 'const value = 1;\n', + '=======\n', + 'const value = 3;\n', + '>>>>>>> incoming\n', + ].join(''); + + expect(buildConflictInlineDiffRanges(content, '')).toEqual([ + { + conflictId: 'conflict-0', + side: 'current', + startLine: 2, + startColumn: 15, + endLine: 2, + endColumn: 16, + }, + { + conflictId: 'conflict-0', + side: 'incoming', + startLine: 6, + startColumn: 15, + endLine: 6, + endColumn: 16, + }, + ]); + }); + + it('parses standard Git conflict markers', () => { + const content = [ + 'before\n', + '<<<<<<< HEAD\n', + 'current line\n', + '=======\n', + 'incoming line\n', + '>>>>>>> feature\n', + 'after\n', + ].join(''); + + const [block] = parseConflictBlocks(content); + + expect(block).toMatchObject({ + id: 'conflict-0', + markerSize: 7, + startLine: 2, + endLine: 6, + currentLabel: 'HEAD', + incomingLabel: 'feature', + currentText: 'current line\n', + incomingText: 'incoming line\n', + baseText: '', + }); + expect(content.slice(block.currentStartOffset, block.currentEndOffset)).toBe(block.currentText); + expect(content.slice(block.incomingStartOffset, block.incomingEndOffset)).toBe(block.incomingText); + expect(block.markerText).toBe(content.slice('before\n'.length, content.length - 'after\n'.length)); + }); + + it('parses diff3 base markers', () => { + const content = [ + '<<<<<<< ours\n', + 'current line\n', + '||||||| base\n', + 'base line\n', + '=======\n', + 'incoming line\n', + '>>>>>>> theirs\n', + ].join(''); + + expect(parseConflictBlocks(content)[0]).toMatchObject({ + currentLabel: 'ours', + baseLabel: 'base', + incomingLabel: 'theirs', + currentText: 'current line\n', + baseText: 'base line\n', + incomingText: 'incoming line\n', + }); + }); + + it('supports custom Git conflict marker sizes', () => { + const content = [ + '<<<<<<<<<< current\n', + 'ours\n', + '|||||||||| base\n', + 'base\n', + '==========\n', + 'theirs\n', + '>>>>>>>>>> incoming\n', + ].join(''); + + expect(parseConflictBlocks(content)[0]).toMatchObject({ + markerSize: 10, + currentLabel: 'current', + baseLabel: 'base', + incomingLabel: 'incoming', + }); + }); + + it('does not treat marker-like source text as a conflict block', () => { + expect( + hasConflictBlocks('const marker = "<<<<<<<";\nconst separator = "=======";\nconst end = ">>>>>>>";\n') + ).toBe(false); + expect(hasConflictBlocks('<<<<<<< incomplete\n=======\n')).toBe(false); + }); + + it('requires explicit review before applying an unchanged marker-free result', () => { + expect(getMergeResultReviewState('resolved\n', 'resolved\n', false)).toEqual({ + conflictMarkerCount: 0, + isResolutionConfirmed: false, + }); + expect(getMergeResultReviewState('resolved\n', 'resolved\n', true)).toEqual({ + conflictMarkerCount: 0, + isResolutionConfirmed: true, + }); + expect( + getMergeResultReviewState('<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n', 'manual\n', false) + ).toEqual({ + conflictMarkerCount: 0, + isResolutionConfirmed: true, + }); + expect( + getMergeResultReviewState('original\n', '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n', true) + ).toEqual({ + conflictMarkerCount: 1, + isResolutionConfirmed: false, + }); + }); + + it('preserves CRLF and a missing final newline while applying resolutions', () => { + const content = 'a\r\n<<<<<<< HEAD\r\nours\r\n=======\r\ntheirs\r\n>>>>>>> branch\r\nz'; + + expect(applyConflictBlockResolution(content, 'conflict-0', 'incoming')).toBe('a\r\ntheirs\r\nz'); + }); + + it('applies base, current, incoming, and both choices', () => { + const content = [ + 'before\n', + '<<<<<<< HEAD\n', + 'ours\n', + '||||||| base\n', + 'base\n', + '=======\n', + 'theirs\n', + '>>>>>>> branch\n', + 'after\n', + ].join(''); + + expect(applyConflictBlockResolution(content, 'conflict-0', 'base')).toBe('before\nbase\nafter\n'); + expect(applyConflictBlockResolution(content, 'conflict-0', 'current')).toBe('before\nours\nafter\n'); + expect(applyConflictBlockResolution(content, 'conflict-0', 'incoming')).toBe('before\ntheirs\nafter\n'); + expect(applyConflictBlockResolution(content, 'conflict-0', 'both')).toBe('before\nours\ntheirs\nafter\n'); + }); + + it('renumbers remaining blocks after resolving an earlier conflict', () => { + const content = [ + '<<<<<<< HEAD\nours 1\n=======\ntheirs 1\n>>>>>>> branch\n', + 'middle\n', + '<<<<<<< HEAD\nours 2\n=======\ntheirs 2\n>>>>>>> branch\n', + ].join(''); + + const next = applyConflictBlockResolution(content, 'conflict-0', 'current'); + + expect(parseConflictBlocks(next)).toHaveLength(1); + expect(parseConflictBlocks(next)[0]).toMatchObject({ id: 'conflict-0', currentText: 'ours 2\n' }); + }); + + it('splits a conflicted file into preserved text and conflict parts', () => { + const content = 'before\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nafter\n'; + const parts = parseConflictDocument(content); + + expect(parts).toHaveLength(3); + expect(parts[0]).toMatchObject({ type: 'text', text: 'before\n' }); + expect(parts[1]).toMatchObject({ type: 'conflict', id: 'conflict-0' }); + expect(parts[2]).toMatchObject({ type: 'text', text: 'after\n' }); + }); + + it('infers base text when markers do not include a base section', () => { + const content = [ + 'before\n', + '<<<<<<< HEAD\nours 1\n=======\ntheirs 1\n>>>>>>> branch\n', + 'middle\n', + '<<<<<<< HEAD\nours 2\n=======\ntheirs 2\n>>>>>>> branch\n', + 'after\n', + ].join(''); + const base = 'before\nbase 1\nmiddle\nbase 2\nafter\n'; + const conflicts = parseConflictDocument(content, base).filter( + (part): part is Extract[number], { type: 'conflict' }> => + part.type === 'conflict' + ); + + expect(getConflictBaseText(conflicts[0].block)).toBe('base 1\n'); + expect(getConflictBaseText(conflicts[1].block)).toBe('base 2\n'); + }); + + it('reconstructs partial and complete results from explicit resolutions', () => { + const content = [ + '<<<<<<< HEAD\nours 1\n=======\ntheirs 1\n>>>>>>> branch\n', + 'middle\n', + '<<<<<<< HEAD\nours 2\n=======\ntheirs 2\n>>>>>>> branch\n', + ].join(''); + const parts = parseConflictDocument(content); + const firstConflict = parts.find((part) => part.type === 'conflict'); + if (!firstConflict || firstConflict.type !== 'conflict') { + throw new Error('Expected a conflict part'); + } + + const partialResolutions = { + [firstConflict.id]: getConflictResolutionText(firstConflict.block, 'incoming'), + }; + const partial = buildConflictDocumentResult(parts, partialResolutions); + + expect(partial).toContain('theirs 1\n'); + expect(partial).toContain('<<<<<<< HEAD\n'); + expect(getUnresolvedConflictIds(parts, partialResolutions)).toEqual(['conflict-1']); + expect( + buildConflictDocumentResult(parts, { + 'conflict-0': '', + 'conflict-1': 'resolved 2\n', + }) + ).toBe('middle\nresolved 2\n'); + }); +}); diff --git a/apps/webview-ui/src/components/merge/conflictModel.ts b/apps/webview-ui/src/components/merge/conflictModel.ts new file mode 100644 index 0000000..8c19b11 --- /dev/null +++ b/apps/webview-ui/src/components/merge/conflictModel.ts @@ -0,0 +1,1116 @@ +import { parseConflictMarkerBlocks, type ConflictMarkerBlock } from '@shared/conflictMarkers'; +import type { ConflictChange } from '@shared/messages'; + +export type ConflictResolutionChoice = 'base' | 'current' | 'incoming' | 'both'; + +export interface ConflictBlock extends ConflictMarkerBlock { + inferredBaseText?: string; +} + +export interface InlineDiffSegment { + text: string; + changed: boolean; +} + +export interface ConflictInlineDiffRange { + conflictId: string; + side: 'current' | 'incoming'; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; +} + +export type ConflictDocumentPart = + | { + type: 'text'; + id: string; + text: string; + } + | { + type: 'conflict'; + id: string; + block: ConflictBlock; + }; + +export type ConflictResolutionMap = Record; +export type ConflictResolutionState = Record; + +export interface MergeResultReviewState { + conflictMarkerCount: number; + isResolutionConfirmed: boolean; +} + +export type MergeReviewDecision = 'pending' | 'applied' | 'cancelled' | 'manual'; +export type MergeReviewSide = 'left' | 'right'; +export type WhitespaceCompareMode = 'none' | 'ignore' | 'trim'; + +export interface MergeChangeGroup { + id: string; + baseStart: number; + baseLineCount: number; + leftStart: number; + leftLineCount: number; + rightStart: number; + rightLineCount: number; + baseText: string; + leftText: string; + rightText: string; + hasLeftChange: boolean; + hasRightChange: boolean; + kind: 'left-only' | 'right-only' | 'identical' | 'conflict'; +} + +export interface MergeReviewRange { + groupId: string; + startOffset: number; + endOffset: number; + leftDecision: MergeReviewDecision | null; + rightDecision: MergeReviewDecision | null; + lastAppliedSide: MergeReviewSide | null; +} + +export interface MergeSessionDocument { + resultText: string; + groups: MergeChangeGroup[]; + reviewRanges: MergeReviewRange[]; +} + +export interface MergeTextRange { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; +} + +export interface LineAlignmentBlock { + referenceStart: number; + referenceLineCount: number; + resultStart: number; + resultLineCount: number; +} + +interface ContentLineSlice { + startOffset: number; + endOffset: number; + text: string; +} + +interface TaggedConflictChange { + side: 'left' | 'right'; + change: ConflictChange; + index: number; +} + +function tokenizeInlineDiffText(text: string): string[] { + return text.match(/\r\n|\r|\n|[^\S\r\n]+|[A-Za-z0-9_]+|[^\sA-Za-z0-9_]+/g) ?? []; +} + +function isWhitespaceToken(token: string): boolean { + return /^\s+$/.test(token); +} + +function isHorizontalWhitespaceToken(token: string): boolean { + return /^[^\S\r\n]+$/.test(token); +} + +function isLineBreak(value: string | undefined): boolean { + return value === '\r' || value === '\n'; +} + +function shouldIgnoreInlineToken(tokens: string[], index: number, whitespaceMode: WhitespaceCompareMode): boolean { + const token = tokens[index]; + if (whitespaceMode === 'ignore') { + return isWhitespaceToken(token); + } + if (whitespaceMode !== 'trim' || !isHorizontalWhitespaceToken(token)) { + return false; + } + + const previousCharacter = tokens[index - 1]?.at(-1); + const nextCharacter = tokens[index + 1]?.[0]; + return ( + previousCharacter === undefined || + nextCharacter === undefined || + isLineBreak(previousCharacter) || + isLineBreak(nextCharacter) + ); +} + +function computeLcsPairs(left: string[], right: string[]): Array<[number, number]> { + const table: number[][] = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0)); + + for (let i = left.length - 1; i >= 0; i--) { + for (let j = right.length - 1; j >= 0; j--) { + if (left[i] === right[j]) { + table[i][j] = table[i + 1][j + 1] + 1; + } else { + table[i][j] = Math.max(table[i + 1][j], table[i][j + 1]); + } + } + } + + const pairs: Array<[number, number]> = []; + let i = 0; + let j = 0; + while (i < left.length && j < right.length) { + if (left[i] === right[j]) { + pairs.push([i, j]); + i++; + j++; + } else if (table[i + 1][j] >= table[i][j + 1]) { + i++; + } else { + j++; + } + } + + return pairs; +} + +export function buildInlineDiffSegments( + text: string, + baseText: string, + whitespaceMode: WhitespaceCompareMode = 'none' +): InlineDiffSegment[] { + if (!text) { + return []; + } + + if (text === baseText) { + return [{ text, changed: false }]; + } + + const tokens = tokenizeInlineDiffText(text); + const baseTokens = tokenizeInlineDiffText(baseText); + if (tokens.length === 0) { + return [{ text, changed: true }]; + } + + const indexedTokens = tokens.map((token, index) => ({ token, index })); + const indexedBaseTokens = baseTokens.map((token, index) => ({ token, index })); + const comparableTokens = indexedTokens.filter( + ({ index }) => !shouldIgnoreInlineToken(tokens, index, whitespaceMode) + ); + const comparableBaseTokens = indexedBaseTokens.filter( + ({ index }) => !shouldIgnoreInlineToken(baseTokens, index, whitespaceMode) + ); + + if (comparableTokens.length * comparableBaseTokens.length > 50_000) { + return [{ text, changed: false }]; + } + + const unchangedTokenIndexes = new Set(); + for (const [, comparableIndex] of computeLcsPairs( + comparableBaseTokens.map(({ token }) => token), + comparableTokens.map(({ token }) => token) + )) { + unchangedTokenIndexes.add(comparableTokens[comparableIndex].index); + } + + const segments: InlineDiffSegment[] = []; + for (let index = 0; index < tokens.length; index++) { + const changed = !shouldIgnoreInlineToken(tokens, index, whitespaceMode) && !unchangedTokenIndexes.has(index); + const previous = segments[segments.length - 1]; + if (previous?.changed === changed) { + previous.text += tokens[index]; + } else { + segments.push({ text: tokens[index], changed }); + } + } + + return segments; +} + +function getLineStartOffsets(content: string): number[] { + const offsets = [0]; + for (let index = 0; index < content.length; index++) { + if (content[index] === '\r' && content[index + 1] === '\n') { + index++; + offsets.push(index + 1); + } else if (content[index] === '\r' || content[index] === '\n') { + offsets.push(index + 1); + } + } + return offsets; +} + +function getPositionAtOffset(lineStarts: number[], offset: number): { line: number; column: number } { + let low = 0; + let high = lineStarts.length - 1; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + if (lineStarts[middle] <= offset) { + low = middle + 1; + } else { + high = middle - 1; + } + } + + const lineIndex = Math.max(0, high); + return { + line: lineIndex + 1, + column: offset - lineStarts[lineIndex] + 1, + }; +} + +function splitContentLineSlices(content: string): ContentLineSlice[] { + const lines: ContentLineSlice[] = []; + let offset = 0; + while (offset < content.length) { + const startOffset = offset; + while (offset < content.length && content[offset] !== '\r' && content[offset] !== '\n') { + offset++; + } + if (content[offset] === '\r' && content[offset + 1] === '\n') { + offset += 2; + } else if (offset < content.length) { + offset++; + } + lines.push({ + startOffset, + endOffset: offset, + text: content.slice(startOffset, offset), + }); + } + return lines; +} + +export function splitContentLines(content: string): string[] { + return splitContentLineSlices(content).map((line) => line.text.replace(/\r\n$|\r$|\n$/, '')); +} + +export function buildLineAlignmentBlocks(referenceLines: string[], resultLines: string[]): LineAlignmentBlock[] { + let commonPrefixLineCount = 0; + while ( + commonPrefixLineCount < referenceLines.length && + commonPrefixLineCount < resultLines.length && + referenceLines[commonPrefixLineCount] === resultLines[commonPrefixLineCount] + ) { + commonPrefixLineCount++; + } + + let referenceEnd = referenceLines.length; + let resultEnd = resultLines.length; + while ( + referenceEnd > commonPrefixLineCount && + resultEnd > commonPrefixLineCount && + referenceLines[referenceEnd - 1] === resultLines[resultEnd - 1] + ) { + referenceEnd--; + resultEnd--; + } + + const referenceMiddle = referenceLines.slice(commonPrefixLineCount, referenceEnd); + const resultMiddle = resultLines.slice(commonPrefixLineCount, resultEnd); + if (referenceMiddle.length === 0 && resultMiddle.length === 0) { + return []; + } + + if (referenceMiddle.length * resultMiddle.length > 250_000) { + return [ + { + referenceStart: commonPrefixLineCount, + referenceLineCount: referenceMiddle.length, + resultStart: commonPrefixLineCount, + resultLineCount: resultMiddle.length, + }, + ]; + } + + const matchingLines = computeLcsPairs(referenceMiddle, resultMiddle); + const blocks: LineAlignmentBlock[] = []; + let referenceCursor = 0; + let resultCursor = 0; + + for (let index = 0; index <= matchingLines.length; index++) { + const [referenceMatch, resultMatch] = + index < matchingLines.length ? matchingLines[index] : [referenceMiddle.length, resultMiddle.length]; + const referenceLineCount = referenceMatch - referenceCursor; + const resultLineCount = resultMatch - resultCursor; + if (referenceLineCount > 0 || resultLineCount > 0) { + blocks.push({ + referenceStart: commonPrefixLineCount + referenceCursor, + referenceLineCount, + resultStart: commonPrefixLineCount + resultCursor, + resultLineCount, + }); + } + if (index < matchingLines.length) { + referenceCursor = referenceMatch + 1; + resultCursor = resultMatch + 1; + } + } + + return blocks; +} + +function getLineBoundaryOffset(lines: ContentLineSlice[], contentLength: number, lineIndex: number): number { + if (lineIndex <= 0) { + return 0; + } + if (lineIndex >= lines.length) { + return contentLength; + } + return lines[lineIndex].startOffset; +} + +function sliceLineRange(content: string, lines: ContentLineSlice[], start: number, lineCount: number): string { + const startOffset = getLineBoundaryOffset(lines, content.length, start); + const endOffset = getLineBoundaryOffset(lines, content.length, start + lineCount); + return content.slice(startOffset, endOffset); +} + +function changesOverlap(left: ConflictChange, right: ConflictChange): boolean { + const leftEnd = left.baseStart + left.baseLineCount; + const rightEnd = right.baseStart + right.baseLineCount; + if (left.baseLineCount === 0 && right.baseLineCount === 0) { + return left.baseStart === right.baseStart; + } + if (left.baseLineCount === 0) { + return left.baseStart >= right.baseStart && left.baseStart <= rightEnd; + } + if (right.baseLineCount === 0) { + return right.baseStart >= left.baseStart && right.baseStart <= leftEnd; + } + return left.baseStart < rightEnd && right.baseStart < leftEnd; +} + +function buildChangeComponents( + leftChanges: ConflictChange[], + rightChanges: ConflictChange[] +): TaggedConflictChange[][] { + const tagged: TaggedConflictChange[] = [ + ...leftChanges.map((change, index) => ({ side: 'left' as const, change, index })), + ...rightChanges.map((change, index) => ({ side: 'right' as const, change, index: leftChanges.length + index })), + ]; + const parents = tagged.map((_, index) => index); + const find = (index: number): number => { + while (parents[index] !== index) { + parents[index] = parents[parents[index]]; + index = parents[index]; + } + return index; + }; + const union = (left: number, right: number) => { + const leftRoot = find(left); + const rightRoot = find(right); + if (leftRoot !== rightRoot) { + parents[rightRoot] = leftRoot; + } + }; + + for (let leftIndex = 0; leftIndex < leftChanges.length; leftIndex++) { + for (let rightIndex = 0; rightIndex < rightChanges.length; rightIndex++) { + if (changesOverlap(leftChanges[leftIndex], rightChanges[rightIndex])) { + union(leftIndex, leftChanges.length + rightIndex); + } + } + } + + const components = new Map(); + for (const item of tagged) { + const root = find(item.index); + const component = components.get(root) ?? []; + component.push(item); + components.set(root, component); + } + return [...components.values()].sort((left, right) => { + const leftStart = Math.min(...left.map((item) => item.change.baseStart)); + const rightStart = Math.min(...right.map((item) => item.change.baseStart)); + return leftStart - rightStart; + }); +} + +function applySideChanges( + baseContent: string, + baseLines: ContentLineSlice[], + sideContent: string, + sideLines: ContentLineSlice[], + groupStart: number, + groupEnd: number, + changes: ConflictChange[] +): string { + if (changes.length === 0) { + return sliceLineRange(baseContent, baseLines, groupStart, groupEnd - groupStart); + } + + let cursor = groupStart; + let result = ''; + for (const change of [...changes].sort((left, right) => left.baseStart - right.baseStart)) { + result += sliceLineRange(baseContent, baseLines, cursor, change.baseStart - cursor); + result += sliceLineRange(sideContent, sideLines, change.sideStart, change.sideLineCount); + cursor = Math.max(cursor, change.baseStart + change.baseLineCount); + } + result += sliceLineRange(baseContent, baseLines, cursor, groupEnd - cursor); + return result; +} + +function normalizeWhitespaceForComparison(content: string, whitespaceMode: WhitespaceCompareMode): string { + const normalized = content.replace(/\r\n?|\n/g, '\n'); + if (whitespaceMode === 'none') { + return normalized; + } + + return normalized + .split('\n') + .map((line) => + whitespaceMode === 'ignore' ? line.replace(/[^\S\r\n]+/g, '') : line.replace(/^[^\S\r\n]+|[^\S\r\n]+$/g, '') + ) + .join('\n'); +} + +function filterChangesByWhitespaceMode( + baseLines: ContentLineSlice[], + sideLines: ContentLineSlice[], + changes: ConflictChange[], + whitespaceMode: WhitespaceCompareMode +): ConflictChange[] { + if (whitespaceMode === 'none') { + return changes; + } + + return changes.flatMap((change) => { + const comparableBaseLines = baseLines + .slice(change.baseStart, change.baseStart + change.baseLineCount) + .map((line) => normalizeWhitespaceForComparison(line.text, whitespaceMode)); + const comparableSideLines = sideLines + .slice(change.sideStart, change.sideStart + change.sideLineCount) + .map((line) => normalizeWhitespaceForComparison(line.text, whitespaceMode)); + + if ( + comparableBaseLines.length === comparableSideLines.length && + comparableBaseLines.every((line, index) => line === comparableSideLines[index]) + ) { + return []; + } + + if (comparableBaseLines.length * comparableSideLines.length > 250_000) { + return [change]; + } + + const matchingLines = computeLcsPairs(comparableBaseLines, comparableSideLines); + const refined: ConflictChange[] = []; + let baseCursor = 0; + let sideCursor = 0; + let partIndex = 0; + + for (let index = 0; index <= matchingLines.length; index++) { + const [baseMatch, sideMatch] = + index < matchingLines.length + ? matchingLines[index] + : [comparableBaseLines.length, comparableSideLines.length]; + const baseLineCount = baseMatch - baseCursor; + const sideLineCount = sideMatch - sideCursor; + if (baseLineCount > 0 || sideLineCount > 0) { + refined.push({ + id: `${change.id}:${partIndex++}`, + baseStart: change.baseStart + baseCursor, + baseLineCount, + sideStart: change.sideStart + sideCursor, + sideLineCount, + }); + } + if (index < matchingLines.length) { + baseCursor = baseMatch + 1; + sideCursor = sideMatch + 1; + } + } + + return refined; + }); +} + +export function buildMergeSessionDocument( + baseContent: string, + leftContent: string, + rightContent: string, + leftChanges: ConflictChange[], + rightChanges: ConflictChange[], + whitespaceMode: WhitespaceCompareMode = 'none' +): MergeSessionDocument { + const baseLines = splitContentLineSlices(baseContent); + const leftLines = splitContentLineSlices(leftContent); + const rightLines = splitContentLineSlices(rightContent); + const visibleLeftChanges = filterChangesByWhitespaceMode(baseLines, leftLines, leftChanges, whitespaceMode); + const visibleRightChanges = filterChangesByWhitespaceMode(baseLines, rightLines, rightChanges, whitespaceMode); + const components = buildChangeComponents(visibleLeftChanges, visibleRightChanges); + const groups: MergeChangeGroup[] = []; + const reviewRanges: MergeReviewRange[] = []; + let previousBaseEnd = 0; + let previousLeftEnd = 0; + let previousRightEnd = 0; + + for (let index = 0; index < components.length; index++) { + const component = components[index]; + const componentLeftChanges = component.filter((item) => item.side === 'left').map((item) => item.change); + const componentRightChanges = component.filter((item) => item.side === 'right').map((item) => item.change); + const allChanges = component.map((item) => item.change); + const baseStart = Math.min(...allChanges.map((change) => change.baseStart)); + const baseEnd = Math.max(...allChanges.map((change) => change.baseStart + change.baseLineCount)); + const baseLineCount = baseEnd - baseStart; + const contextLineCount = Math.max(0, baseStart - previousBaseEnd); + const leftStart = previousLeftEnd + contextLineCount; + const rightStart = previousRightEnd + contextLineCount; + const leftLineCount = + baseLineCount + + componentLeftChanges.reduce((total, change) => total + change.sideLineCount - change.baseLineCount, 0); + const rightLineCount = + baseLineCount + + componentRightChanges.reduce((total, change) => total + change.sideLineCount - change.baseLineCount, 0); + const baseText = sliceLineRange(baseContent, baseLines, baseStart, baseLineCount); + const leftText = applySideChanges( + baseContent, + baseLines, + leftContent, + leftLines, + baseStart, + baseEnd, + componentLeftChanges + ); + const rightText = applySideChanges( + baseContent, + baseLines, + rightContent, + rightLines, + baseStart, + baseEnd, + componentRightChanges + ); + const hasLeftChange = componentLeftChanges.length > 0; + const hasRightChange = componentRightChanges.length > 0; + const kind = + hasLeftChange && hasRightChange + ? leftText === rightText + ? 'identical' + : 'conflict' + : hasLeftChange + ? 'left-only' + : 'right-only'; + const id = `change-${index}`; + groups.push({ + id, + baseStart, + baseLineCount, + leftStart, + leftLineCount, + rightStart, + rightLineCount, + baseText, + leftText, + rightText, + hasLeftChange, + hasRightChange, + kind, + }); + reviewRanges.push({ + groupId: id, + startOffset: getLineBoundaryOffset(baseLines, baseContent.length, baseStart), + endOffset: getLineBoundaryOffset(baseLines, baseContent.length, baseEnd), + leftDecision: hasLeftChange ? 'pending' : null, + rightDecision: hasRightChange ? 'pending' : null, + lastAppliedSide: null, + }); + previousBaseEnd = baseEnd; + previousLeftEnd = leftStart + leftLineCount; + previousRightEnd = rightStart + rightLineCount; + } + + return { + resultText: baseContent, + groups, + reviewRanges, + }; +} + +export function getMergeGroupApplyMode( + content: string, + range: MergeReviewRange, + group: MergeChangeGroup, + side: MergeReviewSide +): 'replace' | 'append' | 'preserve' { + const otherDecision = side === 'left' ? range.rightDecision : range.leftDecision; + if (group.kind !== 'conflict' || otherDecision !== 'applied') { + return 'replace'; + } + + const sideText = side === 'left' ? group.leftText : group.rightText; + if (!sideText) { + return 'preserve'; + } + return content.slice(range.startOffset, range.endOffset) ? 'append' : 'replace'; +} + +export function applyMergeGroupDecision( + content: string, + ranges: MergeReviewRange[], + group: MergeChangeGroup, + side: MergeReviewSide | 'both', + decision: Exclude +): { content: string; ranges: MergeReviewRange[] } { + const target = ranges.find((range) => range.groupId === group.id); + if (!target) { + return { content, ranges }; + } + + if (side === 'both') { + if (decision !== 'manual') { + return { content, ranges }; + } + return { + content, + ranges: ranges.map((range) => + range.groupId === group.id + ? { + ...range, + leftDecision: range.leftDecision === 'pending' ? 'manual' : range.leftDecision, + rightDecision: range.rightDecision === 'pending' ? 'manual' : range.rightDecision, + } + : range + ), + }; + } + + const sideDecision = side === 'left' ? target.leftDecision : target.rightDecision; + if (sideDecision === null || decision === 'manual') { + return { content, ranges }; + } + + const otherSide: MergeReviewSide = side === 'left' ? 'right' : 'left'; + const otherDecision = otherSide === 'left' ? target.leftDecision : target.rightDecision; + const sideText = side === 'left' ? group.leftText : group.rightText; + const otherText = otherSide === 'left' ? group.leftText : group.rightText; + const applyMode = getMergeGroupApplyMode(content, target, group, side); + let replacement: string | undefined; + let lastAppliedSide = target.lastAppliedSide; + + if (decision === 'applied') { + const currentText = content.slice(target.startOffset, target.endOffset); + if (applyMode === 'append') { + const lineSeparator = + [currentText, sideText, group.baseText] + .map((text) => text.match(/\r\n|\n|\r/)?.[0]) + .find((separator): separator is string => Boolean(separator)) ?? '\n'; + replacement = + /[\r\n]$/.test(currentText) || /^[\r\n]/.test(sideText) + ? currentText + sideText + : currentText + lineSeparator + sideText; + } else if (applyMode === 'preserve') { + replacement = currentText; + } else { + replacement = sideText; + } + lastAppliedSide = side; + } else if (sideDecision === 'applied') { + if (otherDecision === 'applied') { + replacement = otherText; + lastAppliedSide = otherSide; + } else { + replacement = group.baseText; + lastAppliedSide = null; + } + } + + const nextContent = + replacement === undefined + ? content + : content.slice(0, target.startOffset) + replacement + content.slice(target.endOffset); + const delta = replacement === undefined ? 0 : replacement.length - (target.endOffset - target.startOffset); + return { + content: nextContent, + ranges: ranges.map((range) => { + if (range.groupId === group.id) { + return { + ...range, + endOffset: replacement === undefined ? range.endOffset : range.startOffset + replacement.length, + leftDecision: side === 'left' ? decision : range.leftDecision, + rightDecision: side === 'right' ? decision : range.rightDecision, + lastAppliedSide, + }; + } + if (delta !== 0 && range.startOffset >= target.endOffset) { + return { + ...range, + startOffset: range.startOffset + delta, + endOffset: range.endOffset + delta, + }; + } + return range; + }), + }; +} + +export function isMergeReviewRangePending(range: MergeReviewRange): boolean { + return range.leftDecision === 'pending' || range.rightDecision === 'pending'; +} + +function changeTouchesRange(change: { rangeOffset: number; rangeLength: number }, range: MergeReviewRange): boolean { + const changeEnd = change.rangeOffset + change.rangeLength; + if (change.rangeLength === 0) { + if (range.startOffset === range.endOffset) { + return change.rangeOffset === range.startOffset; + } + return change.rangeOffset >= range.startOffset && change.rangeOffset < range.endOffset; + } + if (range.startOffset === range.endOffset) { + return change.rangeOffset <= range.startOffset && changeEnd > range.endOffset; + } + return change.rangeOffset < range.endOffset && changeEnd > range.startOffset; +} + +function transformOffset( + offset: number, + affinity: 'start' | 'empty-end', + changes: Array<{ rangeOffset: number; rangeLength: number; text: string }> +): number { + let delta = 0; + for (const change of changes) { + const changeStart = change.rangeOffset; + const changeEnd = change.rangeOffset + change.rangeLength; + if (offset < changeStart) { + break; + } + if (offset > changeEnd) { + delta += change.text.length - change.rangeLength; + continue; + } + if (offset === changeStart) { + if (affinity === 'empty-end' && change.rangeLength === 0) { + delta += change.text.length; + continue; + } + return changeStart + delta; + } + if (offset === changeEnd && change.rangeLength > 0) { + return changeStart + delta + change.text.length; + } + return changeStart + delta; + } + return offset + delta; +} + +export function applyMergeContentChanges( + ranges: MergeReviewRange[], + rawChanges: Array<{ rangeOffset: number; rangeLength: number; text: string }> +): { ranges: MergeReviewRange[]; touchedGroupIds: string[] } { + const changes = [...rawChanges].sort((left, right) => left.rangeOffset - right.rangeOffset); + const touched = new Set(); + for (const change of changes) { + const touchingRanges = ranges.filter((range) => changeTouchesRange(change, range)); + if (change.rangeLength === 0) { + const target = touchingRanges.find((range) => range.startOffset < range.endOffset) ?? touchingRanges[0]; + if (target) { + touched.add(target.groupId); + } + continue; + } + for (const range of touchingRanges) { + touched.add(range.groupId); + } + } + const touchedGroupIds = ranges.filter((range) => touched.has(range.groupId)).map((range) => range.groupId); + return { + ranges: ranges.map((range) => { + const isTouched = touched.has(range.groupId); + const isEmpty = range.startOffset === range.endOffset; + return { + ...range, + startOffset: transformOffset(range.startOffset, 'start', changes), + endOffset: transformOffset(range.endOffset, isEmpty && isTouched ? 'empty-end' : 'start', changes), + leftDecision: isTouched && range.leftDecision !== null ? 'pending' : range.leftDecision, + rightDecision: isTouched && range.rightDecision !== null ? 'pending' : range.rightDecision, + lastAppliedSide: isTouched ? null : range.lastAppliedSide, + }; + }), + touchedGroupIds, + }; +} + +export function getMergeTextRange(content: string, startOffset: number, endOffset: number): MergeTextRange { + const lineStarts = getLineStartOffsets(content); + const visualEndOffset = + endOffset > startOffset && /[\r\n]/.test(content[endOffset - 1]) + ? endOffset - (content[endOffset - 1] === '\n' && content[endOffset - 2] === '\r' ? 2 : 1) + : endOffset; + const start = getPositionAtOffset(lineStarts, startOffset); + const end = getPositionAtOffset(lineStarts, Math.max(startOffset, visualEndOffset)); + return { + startLine: start.line, + startColumn: start.column, + endLine: end.line, + endColumn: end.column, + }; +} + +export function getContentLineCount(content: string): number { + return splitContentLineSlices(content).length; +} + +export function buildMergeSideInlineDiffRanges( + groups: MergeChangeGroup[], + side: 'current' | 'incoming', + whitespaceMode: WhitespaceCompareMode = 'none' +): ConflictInlineDiffRange[] { + const ranges: ConflictInlineDiffRange[] = []; + for (const group of groups) { + const hasChange = side === 'current' ? group.hasLeftChange : group.hasRightChange; + if (!hasChange) { + continue; + } + const text = side === 'current' ? group.leftText : group.rightText; + const startLine = side === 'current' ? group.leftStart : group.rightStart; + const lineStarts = getLineStartOffsets(text); + let localOffset = 0; + for (const segment of buildInlineDiffSegments(text, group.baseText, whitespaceMode)) { + const segmentStart = localOffset; + localOffset += segment.text.length; + if (!segment.changed || !segment.text) { + continue; + } + const start = getPositionAtOffset(lineStarts, segmentStart); + const end = getPositionAtOffset(lineStarts, localOffset); + ranges.push({ + conflictId: group.id, + side, + startLine: startLine + start.line, + startColumn: start.column, + endLine: startLine + end.line, + endColumn: end.column, + }); + } + } + return ranges; +} + +export function buildConflictInlineDiffRanges( + content: string, + baseContent: string, + whitespaceMode: WhitespaceCompareMode = 'none' +): ConflictInlineDiffRange[] { + const lineStarts = getLineStartOffsets(content); + const parts = parseConflictDocument(content, baseContent); + const ranges: ConflictInlineDiffRange[] = []; + + for (const part of parts) { + if (part.type !== 'conflict') { + continue; + } + + const baseText = getConflictBaseText(part.block); + const sides = [ + { + side: 'current' as const, + text: part.block.currentText, + startOffset: part.block.currentStartOffset, + }, + { + side: 'incoming' as const, + text: part.block.incomingText, + startOffset: part.block.incomingStartOffset, + }, + ]; + + for (const side of sides) { + let localOffset = 0; + for (const segment of buildInlineDiffSegments(side.text, baseText, whitespaceMode)) { + const startOffset = side.startOffset + localOffset; + localOffset += segment.text.length; + if (!segment.changed || !segment.text) { + continue; + } + + const start = getPositionAtOffset(lineStarts, startOffset); + const end = getPositionAtOffset(lineStarts, side.startOffset + localOffset); + ranges.push({ + conflictId: part.id, + side: side.side, + startLine: start.line, + startColumn: start.column, + endLine: end.line, + endColumn: end.column, + }); + } + } + } + + return ranges; +} + +export function parseConflictBlocks(content: string): ConflictBlock[] { + return parseConflictMarkerBlocks(content); +} + +export function hasConflictBlocks(content: string): boolean { + return parseConflictBlocks(content).length > 0; +} + +export function getMergeResultReviewState( + originalResult: string, + result: string, + resolutionAcknowledged: boolean +): MergeResultReviewState { + const conflictMarkerCount = parseConflictBlocks(result).length; + return { + conflictMarkerCount, + isResolutionConfirmed: conflictMarkerCount === 0 && (resolutionAcknowledged || result !== originalResult), + }; +} + +export function getConflictResolutionText(block: ConflictBlock, choice: ConflictResolutionChoice): string { + if (choice === 'base') { + return getConflictBaseText(block); + } + if (choice === 'current') { + return block.currentText; + } + if (choice === 'incoming') { + return block.incomingText; + } + return block.currentText + block.incomingText; +} + +export function applyConflictBlockResolution( + content: string, + blockId: string, + choice: ConflictResolutionChoice +): string { + const block = parseConflictBlocks(content).find((candidate) => candidate.id === blockId); + if (!block) { + return content; + } + + const replacement = getConflictResolutionText(block, choice); + return content.slice(0, block.startOffset) + replacement + content.slice(block.endOffset); +} + +function inferBaseTextForConflict( + baseContent: string, + parts: ConflictDocumentPart[], + conflictIndex: number, + searchOffset: number +): { text: string; nextOffset: number } { + const previousText = + parts + .slice(0, conflictIndex) + .reverse() + .find((part) => part.type === 'text')?.text ?? ''; + const nextText = parts.slice(conflictIndex + 1).find((part) => part.type === 'text')?.text ?? ''; + + let startOffset = searchOffset; + if (previousText) { + const previousIndex = baseContent.indexOf(previousText, searchOffset); + if (previousIndex !== -1) { + startOffset = previousIndex + previousText.length; + } + } + + let endOffset = baseContent.length; + if (nextText) { + const nextIndex = baseContent.indexOf(nextText, startOffset); + if (nextIndex !== -1) { + endOffset = nextIndex; + } + } + + if (endOffset < startOffset) { + return { text: '', nextOffset: searchOffset }; + } + + return { + text: baseContent.slice(startOffset, endOffset), + nextOffset: endOffset, + }; +} + +export function parseConflictDocument(content: string, baseContent = ''): ConflictDocumentPart[] { + const blocks = parseConflictBlocks(content); + if (blocks.length === 0) { + return [ + { + type: 'text', + id: 'text-0', + text: content, + }, + ]; + } + + const parts: ConflictDocumentPart[] = []; + let offset = 0; + let textIndex = 0; + + for (const block of blocks) { + if (block.startOffset > offset) { + parts.push({ + type: 'text', + id: `text-${textIndex}`, + text: content.slice(offset, block.startOffset), + }); + textIndex++; + } + + parts.push({ + type: 'conflict', + id: block.id, + block, + }); + offset = block.endOffset; + } + + if (offset < content.length) { + parts.push({ + type: 'text', + id: `text-${textIndex}`, + text: content.slice(offset), + }); + } + + if (baseContent) { + let searchOffset = 0; + for (let index = 0; index < parts.length; index++) { + const part = parts[index]; + if (part.type !== 'conflict' || part.block.baseText) { + continue; + } + + const inferred = inferBaseTextForConflict(baseContent, parts, index, searchOffset); + part.block.inferredBaseText = inferred.text; + searchOffset = inferred.nextOffset; + } + } + + return parts; +} + +export function getConflictBaseText(block: ConflictBlock): string { + if (block.baseText) { + return block.baseText; + } + + return block.inferredBaseText ?? ''; +} + +export function getUnresolvedConflictIds( + parts: ConflictDocumentPart[], + resolutions: ConflictResolutionState +): string[] { + return parts + .filter((part): part is Extract => part.type === 'conflict') + .filter((part) => !Object.prototype.hasOwnProperty.call(resolutions, part.id)) + .map((part) => part.id); +} + +export function buildConflictDocumentResult(parts: ConflictDocumentPart[], resolutions: ConflictResolutionMap): string { + return parts + .map((part) => { + if (part.type === 'text') { + return part.text; + } + + if (Object.prototype.hasOwnProperty.call(resolutions, part.id)) { + return resolutions[part.id]; + } + + return part.block.markerText; + }) + .join(''); +} diff --git a/apps/webview-ui/src/components/merge/monacoRuntime.ts b/apps/webview-ui/src/components/merge/monacoRuntime.ts new file mode 100644 index 0000000..af889ba --- /dev/null +++ b/apps/webview-ui/src/components/merge/monacoRuntime.ts @@ -0,0 +1,140 @@ +import type * as Monaco from 'monaco-editor/esm/vs/editor/editor.api'; + +export type MonacoApi = typeof Monaco; + +let monacoLoadPromise: Promise | null = null; + +const fileNameLanguages = new Map([ + ['dockerfile', 'dockerfile'], + ['containerfile', 'dockerfile'], + ['package.json', 'json'], + ['package-lock.json', 'json'], + ['tsconfig.json', 'json'], + ['jsconfig.json', 'json'], + ['go.mod', 'go'], + ['go.sum', 'go'], + ['gemfile', 'ruby'], + ['rakefile', 'ruby'], + ['podfile', 'ruby'], +]); + +const extensionLanguages = new Map([ + ['.bat', 'bat'], + ['.c', 'cpp'], + ['.cc', 'cpp'], + ['.cjs', 'javascript'], + ['.cpp', 'cpp'], + ['.cs', 'csharp'], + ['.css', 'css'], + ['.cts', 'typescript'], + ['.dart', 'dart'], + ['.env', 'ini'], + ['.go', 'go'], + ['.graphql', 'graphql'], + ['.gql', 'graphql'], + ['.h', 'cpp'], + ['.hcl', 'hcl'], + ['.hpp', 'cpp'], + ['.html', 'html'], + ['.htm', 'html'], + ['.hxx', 'cpp'], + ['.ini', 'ini'], + ['.java', 'java'], + ['.js', 'javascript'], + ['.json', 'json'], + ['.jsx', 'javascript'], + ['.kt', 'kotlin'], + ['.kts', 'kotlin'], + ['.less', 'less'], + ['.lua', 'lua'], + ['.m', 'objective-c'], + ['.md', 'markdown'], + ['.mdx', 'markdown'], + ['.mjs', 'javascript'], + ['.mm', 'objective-c'], + ['.mts', 'typescript'], + ['.php', 'php'], + ['.plist', 'xml'], + ['.pl', 'perl'], + ['.pm', 'perl'], + ['.proto', 'protobuf'], + ['.ps1', 'powershell'], + ['.py', 'python'], + ['.rb', 'ruby'], + ['.rs', 'rust'], + ['.scss', 'scss'], + ['.sh', 'shell'], + ['.sql', 'sql'], + ['.swift', 'swift'], + ['.tf', 'hcl'], + ['.ts', 'typescript'], + ['.tsx', 'typescript'], + ['.vue', 'html'], + ['.xml', 'xml'], + ['.yaml', 'yaml'], + ['.yml', 'yaml'], + ['.zsh', 'shell'], +]); + +function normalizeFilePath(filePath: string): string { + return filePath.replace(/\\/g, '/').toLowerCase(); +} + +export function getMonacoLanguageIdForPath(filePath: string | undefined): string | undefined { + if (!filePath) { + return undefined; + } + + const normalizedPath = normalizeFilePath(filePath); + const fileName = normalizedPath.split('/').pop() ?? normalizedPath; + const languageFromName = fileNameLanguages.get(fileName); + if (languageFromName) { + return languageFromName; + } + + const extension = fileName.includes('.') ? `.${fileName.split('.').pop()}` : ''; + return extensionLanguages.get(extension); +} + +function getCssVar(name: string, fallback: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +} + +function getThemeBase(): Monaco.editor.BuiltinTheme { + if (document.body.classList.contains('vscode-light')) { + return 'vs'; + } + if (document.body.classList.contains('vscode-high-contrast')) { + return 'hc-black'; + } + return 'vs-dark'; +} + +export async function loadMonaco(): Promise { + monacoLoadPromise ??= import('monaco-editor/esm/vs/editor/editor.main').then(async () => { + const monaco = await import('monaco-editor/esm/vs/editor/editor.api'); + return monaco; + }); + + return monacoLoadPromise; +} + +export function applyVsCodeMonacoTheme(monaco: MonacoApi) { + monaco.editor.defineTheme('intelli-git-vscode', { + base: getThemeBase(), + inherit: true, + rules: [], + colors: { + 'editor.background': getCssVar('--vscode-editor-background', '#1e1e1e'), + 'editor.foreground': getCssVar('--vscode-editor-foreground', '#d4d4d4'), + 'editorLineNumber.foreground': getCssVar('--vscode-editorLineNumber-foreground', '#858585'), + 'editorLineNumber.activeForeground': getCssVar('--vscode-editorLineNumber-activeForeground', '#c6c6c6'), + 'editorCursor.foreground': getCssVar('--vscode-editorCursor-foreground', '#aeafad'), + 'editor.selectionBackground': getCssVar('--vscode-editor-selectionBackground', '#264f78'), + 'editor.inactiveSelectionBackground': getCssVar('--vscode-editor-inactiveSelectionBackground', '#3a3d41'), + 'editor.lineHighlightBackground': getCssVar('--vscode-editor-lineHighlightBackground', '#2a2d2e'), + 'editor.lineHighlightBorder': getCssVar('--vscode-editor-lineHighlightBorder', '#00000000'), + }, + }); + monaco.editor.setTheme('intelli-git-vscode'); +} diff --git a/apps/webview-ui/src/lib/rpc_client.ts b/apps/webview-ui/src/lib/rpc_client.ts index 150fbae..ae31321 100644 --- a/apps/webview-ui/src/lib/rpc_client.ts +++ b/apps/webview-ui/src/lib/rpc_client.ts @@ -3,6 +3,8 @@ import { AI_GENERATION_TIMEOUT_MS } from '@shared/messages'; import { vscode } from './vscode'; import type { CommitAiAction, + ConflictResolverContextActionRequest, + ConflictResolverOpenRequest, ExtensionMethods, FileDiagnosticsChange, GitLogRevealRequest, @@ -28,6 +30,8 @@ export class EventStream { export const rpcEvents = { activeFileChange: new EventStream<{ path: string; commitHash?: string }>(), + revealConflictResolverFile: new EventStream(), + conflictResolverAction: new EventStream(), revealLog: new EventStream(), filterLogByBranch: new EventStream<{ branch: string }>(), refresh: new EventStream(), @@ -63,6 +67,8 @@ export const rpc = _rpc.proxy; _rpc.registerAll({ activeFileChange: (params) => rpcEvents.activeFileChange.emit(params), + revealConflictResolverFile: (params) => rpcEvents.revealConflictResolverFile.emit(params), + triggerConflictResolverAction: (params) => rpcEvents.conflictResolverAction.emit(params), revealLog: (params) => rpcEvents.revealLog.emit(params), filterLogByBranch: (params) => rpcEvents.filterLogByBranch.emit(params), refresh: (event) => rpcEvents.refresh.emit(event), diff --git a/apps/webview-ui/src/vite-env.d.ts b/apps/webview-ui/src/vite-env.d.ts index 11f02fe..8f51889 100644 --- a/apps/webview-ui/src/vite-env.d.ts +++ b/apps/webview-ui/src/vite-env.d.ts @@ -1 +1,3 @@ /// + +declare module 'monaco-editor/esm/vs/editor/editor.main'; diff --git a/apps/webview-ui/vite.config.ts b/apps/webview-ui/vite.config.ts index 7e8e6ec..e102727 100644 --- a/apps/webview-ui/vite.config.ts +++ b/apps/webview-ui/vite.config.ts @@ -1,11 +1,56 @@ -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +const MONACO_ENV_MODULE_ID = 'virtual:intelli-git-monaco-env'; +const RESOLVED_MONACO_ENV_MODULE_ID = `\0${MONACO_ENV_MODULE_ID}`; + +function monacoWorkers(): Plugin { + let entryId = ''; + + return { + name: 'intelli-git-monaco-workers', + enforce: 'pre', + configResolved(config) { + entryId = path.resolve(config.root, 'src/main.tsx').replace(/\\/g, '/'); + }, + resolveId(id) { + return id === MONACO_ENV_MODULE_ID ? RESOLVED_MONACO_ENV_MODULE_ID : null; + }, + load(id) { + if (id !== RESOLVED_MONACO_ENV_MODULE_ID) { + return null; + } + + return [ + "import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker&inline';", + '', + 'self.MonacoEnvironment = {', + ' getWorker() {', + ' return new EditorWorker();', + ' },', + '};', + '', + ].join('\n'); + }, + transform(code, id) { + const normalizedId = id.replace(/\\/g, '/').split('?')[0]; + if (normalizedId !== entryId || code.includes(MONACO_ENV_MODULE_ID)) { + return null; + } + + return { + code: `import ${JSON.stringify(MONACO_ENV_MODULE_ID)};\n${code}`, + map: null, + }; + }, + }; +} + // https://vite.dev/config/ export default defineConfig({ envDir: path.resolve(__dirname, '../..'), - plugins: [react()], + plugins: [monacoWorkers(), react()], base: './', resolve: { alias: { diff --git a/package-lock.json b/package-lock.json index 8d423aa..566675d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "@vscode/codicons": "^0.0.45", "@vscode/webview-ui-toolkit": "^1.4.0", "i18next": "^26.0.1", + "monaco-editor": "0.52.2", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^17.0.1", @@ -77,6 +78,12 @@ "vite": "^8.0.3" } }, + "apps/webview-ui/node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, "node_modules/@azu/format-text": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", diff --git a/packages/shared/l10n/bundle.l10n.json b/packages/shared/l10n/bundle.l10n.json index 416c2ab..84ea1be 100644 --- a/packages/shared/l10n/bundle.l10n.json +++ b/packages/shared/l10n/bundle.l10n.json @@ -55,17 +55,71 @@ "Delete Changelist": "Delete Changelist", "Set Active Changelist": "Set Active Changelist", "Active": "Active", + "Mark as Resolved": "Mark as Resolved", "Mark as Reviewed": "Mark as Reviewed", "Mark as Unreviewed": "Mark as Unreviewed", + "Review required": "Review required", + "Review the result and mark it as resolved before applying.": "Review the result and mark it as resolved before applying.", "Conflicts": "Conflicts", + "Accept Current Change": "Accept Current Change", + "Accept Incoming Change": "Accept Incoming Change", + "Binary file conflict": "Binary file conflict", + "Choose one complete version of the file. Binary content cannot be merged inline.": "Choose one complete version of the file. Binary content cannot be merged inline.", + "Submodule conflict": "Submodule conflict", + "Resolve the submodule to the commit you want, then stage the submodule path in the parent repository.": "Resolve the submodule to the commit you want, then stage the submodule path in the parent repository.", "Current commit": "Current commit", + "Incoming commit": "Incoming commit", + "Open Submodule": "Open Submodule", + "Unsupported conflict": "Unsupported conflict", + "This Git entry type cannot be resolved in Intelli Git. Resolve and stage it with Git, then refresh the view.": "This Git entry type cannot be resolved in Intelli Git. Resolve and stage it with Git, then refresh the view.", + "Accept Left": "Accept Left", + "Accept Left (delete file)": "Accept Left (delete file)", + "Accept Right": "Accept Right", + "Accept Right (delete file)": "Accept Right (delete file)", + "Append Left Change Below": "Append Left Change Below", + "Append Right Change Below": "Append Right Change Below", + "Cancel Left Change": "Cancel Left Change", + "Cancel Right Change": "Cancel Right Change", + "Accept Current": "Accept Current", + "Accept Incoming": "Accept Incoming", + "Complete Merge": "Complete Merge", + "Apply non-conflicting changes:": "Apply non-conflicting changes:", "Ignore": "Ignore", + "Whitespace comparison": "Whitespace comparison", + "Do not ignore": "Do not ignore", + "Ignore whitespaces": "Ignore whitespaces", + "Trim whitespaces": "Trim whitespaces", + "Highlighting mode": "Highlighting mode", + "Highlight words": "Highlight words", + "Highlight lines": "Highlight lines", + "Changing whitespace comparison requires restarting the merge. Reviewed changes and result edits will be discarded.": "Changing whitespace comparison requires restarting the merge. Reviewed changes and result edits will be discarded.", + "Discard Changes and Restart": "Discard Changes and Restart", "Left": "Left", "Right": "Right", "All": "All", + "Changes from {{name}}": "Changes from {{name}}", + "No conflicts remaining": "No conflicts remaining", + "Unchanged content": "Unchanged content", + "Automatically merged change": "Automatically merged change", "Resolved": "Resolved", "Unresolved": "Unresolved", "No conflict markers remain. Mark this file as resolved to stage it.": "No conflict markers remain. Mark this file as resolved to stage it.", + "Edit final result": "Edit final result", + "Base / final result": "Base / final result", + "Clear Result": "Clear Result", + "Choose a side or type the final result.": "Choose a side or type the final result.", + "Deleted on this side. Use file-level accept to keep the deletion.": "Deleted on this side. Use file-level accept to keep the deletion.", + "Edit Full Result": "Edit Full Result", + "Show Merge Blocks": "Show Merge Blocks", + "The final result still contains conflict markers.": "The final result still contains conflict markers.", + "Previous Conflict": "Previous Conflict", + "Next Conflict": "Next Conflict", + "Lines {{start}}-{{end}}": "Lines {{start}}-{{end}}", + "{{count}} unresolved": "{{count}} unresolved", + "Resolve all conflict blocks before completing the merge.": "Resolve all conflict blocks before completing the merge.", + "Current block": "Current block", + "Base block": "Base block", + "Incoming block": "Incoming block", "Rebase in progress": "Rebase in progress", "Rebase in progress ({{status}})": "Rebase in progress ({{status}})", "Merge in progress": "Merge in progress", @@ -83,6 +137,7 @@ "Toggle Details": "Toggle Details", "Fetch": "Fetch", "{{count}} Incoming Commits\nClick to Update Project": "{{count}} Incoming Commits\nClick to Update Project", + "Open File": "Open File", "Show Diff": "Show Diff", "Delete from Disk": "Delete from Disk", "Move to Changelist...": "Move to Changelist...", @@ -116,10 +171,14 @@ "Settings": "Settings", "Switch Repository...": "Switch Repository...", "No repositories available": "No repositories available", + "Conflict Resolver": "Conflict Resolver", "Base": "Base", "Current": "Current", "Incoming": "Incoming", "Result": "Result", + "No conflict file selected.": "No conflict file selected.", + "This side deleted the file.": "This side deleted the file.", + "Binary conflict files cannot be edited in Intelli Git yet.": "Binary conflict files cannot be edited in Intelli Git yet.", "Workspace": "Workspace", "Submodule": "Submodule", "Worktree": "Worktree", @@ -287,6 +346,8 @@ "extension.resolveConflictsBeforeContinue": "There are still unresolved conflict files. Resolve them completely before continuing.", "extension.stageResolvedFilesBeforeContinue": "Stage the resolved files before continuing.", "extension.resolveConflictFailed": "Resolve conflict failed: {0}", + "extension.conflictStagesChanged": "The conflict changed after the merge editor opened. Reopen it before applying a resolution.", + "extension.conflictResultChanged": "The result file changed outside the merge editor. Reopen it before applying a resolution.", "extension.unsupportedConflictFile": "This conflict file type cannot be edited in Intelli Git.", "extension.abortRebaseFailed": "Abort rebase failed: {0}", "extension.commitGenFailed": "Failed to generate commit message: {0}", diff --git a/packages/shared/l10n/bundle.l10n.zh-cn.json b/packages/shared/l10n/bundle.l10n.zh-cn.json index 68f33a0..551f205 100644 --- a/packages/shared/l10n/bundle.l10n.zh-cn.json +++ b/packages/shared/l10n/bundle.l10n.zh-cn.json @@ -55,17 +55,71 @@ "Delete Changelist": "删除变更列表", "Set Active Changelist": "设为活动变更列表", "Active": "活动", + "Mark as Resolved": "标记为已解决", "Mark as Reviewed": "标记为已审核", "Mark as Unreviewed": "标记为未审核", + "Review required": "需要确认", + "Review the result and mark it as resolved before applying.": "请检查结果并标记为已解决后再应用。", "Conflicts": "冲突", + "Accept Current Change": "接受当前更改", + "Accept Incoming Change": "接受传入更改", + "Binary file conflict": "二进制文件冲突", + "Choose one complete version of the file. Binary content cannot be merged inline.": "请选择一个完整文件版本,二进制内容无法进行行内合并。", + "Submodule conflict": "子模块冲突", + "Resolve the submodule to the commit you want, then stage the submodule path in the parent repository.": "请将子模块切换到需要的提交,然后在父仓库中暂存子模块路径。", "Current commit": "当前提交", + "Incoming commit": "传入提交", + "Open Submodule": "打开子模块", + "Unsupported conflict": "不支持的冲突", + "This Git entry type cannot be resolved in Intelli Git. Resolve and stage it with Git, then refresh the view.": "Intelli Git 无法解决此 Git 条目类型。请使用 Git 解决并暂存,然后刷新视图。", + "Accept Left": "接受左侧", + "Accept Left (delete file)": "接受左侧(删除文件)", + "Accept Right": "接受右侧", + "Accept Right (delete file)": "接受右侧(删除文件)", + "Append Left Change Below": "在下方追加左侧修改", + "Append Right Change Below": "在下方追加右侧修改", + "Cancel Left Change": "取消左侧修改", + "Cancel Right Change": "取消右侧修改", + "Accept Current": "接受当前", + "Accept Incoming": "接受传入", + "Complete Merge": "完成合并", + "Apply non-conflicting changes:": "应用不冲突的更改:", "Ignore": "忽略", + "Whitespace comparison": "空白比较", + "Do not ignore": "不要忽略", + "Ignore whitespaces": "忽略空白", + "Trim whitespaces": "修整空白", + "Highlighting mode": "高亮模式", + "Highlight words": "高亮显示单词", + "Highlight lines": "高亮显示行", + "Changing whitespace comparison requires restarting the merge. Reviewed changes and result edits will be discarded.": "更改空白比较方式需要重新开始合并。已审核的修改和结果编辑将被丢弃。", + "Discard Changes and Restart": "丢弃更改并重新开始", "Left": "左侧", "Right": "右侧", "All": "所有", + "Changes from {{name}}": "来自 {{name}} 的更改", + "No conflicts remaining": "没有剩余冲突", + "Unchanged content": "未变化内容", + "Automatically merged change": "自动合并的更改", "Resolved": "已解决", "Unresolved": "未解决", "No conflict markers remain. Mark this file as resolved to stage it.": "冲突标记已移除。标记为已解决以暂存该文件。", + "Edit final result": "编辑最终结果", + "Base / final result": "基线 / 最终结果", + "Clear Result": "清空结果", + "Choose a side or type the final result.": "选择一侧或输入最终结果。", + "Deleted on this side. Use file-level accept to keep the deletion.": "此侧已删除。使用文件级接受以保留删除。", + "Edit Full Result": "编辑完整结果", + "Show Merge Blocks": "显示合并块", + "The final result still contains conflict markers.": "最终结果仍包含冲突标记。", + "Previous Conflict": "上一个冲突", + "Next Conflict": "下一个冲突", + "Lines {{start}}-{{end}}": "第 {{start}}-{{end}} 行", + "{{count}} unresolved": "{{count}} 个未解决", + "Resolve all conflict blocks before completing the merge.": "请先解决所有冲突块,再完成合并。", + "Current block": "当前块", + "Base block": "基线块", + "Incoming block": "传入块", "Rebase in progress": "变基进行中", "Rebase in progress ({{status}})": "变基进行中({{status}})", "Merge in progress": "合并进行中", @@ -83,6 +137,7 @@ "Toggle Details": "显示/隐藏详情", "Fetch": "获取(Fetch)", "{{count}} Incoming Commits\nClick to Update Project": "{{count}} 个传入提交\n点击更新项目", + "Open File": "打开文件", "Show Diff": "显示差异", "Delete from Disk": "从磁盘删除", "Move to Changelist...": "移动到变更列表...", @@ -116,10 +171,14 @@ "Settings": "设置", "Switch Repository...": "切换仓库...", "No repositories available": "没有可用仓库", + "Conflict Resolver": "冲突处理器", "Base": "基线", "Current": "当前", "Incoming": "传入", "Result": "结果", + "No conflict file selected.": "未选择冲突文件。", + "This side deleted the file.": "这一侧删除了该文件。", + "Binary conflict files cannot be edited in Intelli Git yet.": "Intelli Git 暂不支持编辑二进制冲突文件。", "Workspace": "工作区", "Submodule": "子模块", "Worktree": "Worktree", @@ -289,6 +348,8 @@ "extension.resolveConflictsBeforeContinue": "仍然存在未解决的冲突文件,请先彻底解决后再继续。", "extension.stageResolvedFilesBeforeContinue": "继续之前请先暂存已解决的文件。", "extension.resolveConflictFailed": "解决冲突失败: {0}", + "extension.conflictStagesChanged": "合并编辑器打开后冲突状态已变化,请重新打开后再应用解决结果。", + "extension.conflictResultChanged": "结果文件已在合并编辑器外部发生变化,请重新打开后再应用解决结果。", "extension.unsupportedConflictFile": "Intelli Git 无法编辑此类型的冲突文件。", "extension.abortRebaseFailed": "中止变基失败: {0}", "extension.commitGenFailed": "生成提交信息失败: {0}", diff --git a/packages/shared/messages.ts b/packages/shared/messages.ts index 5dccb20..54ef122 100644 --- a/packages/shared/messages.ts +++ b/packages/shared/messages.ts @@ -102,6 +102,16 @@ export interface RepositoryFileReference { path: string; } +export type ConflictResolverOpenRequest = RepositoryFileReference; + +export type ConflictResolverContextAction = + 'acceptLeft' | 'cancelLeft' | 'acceptRight' | 'cancelRight' | 'markReviewed'; + +export interface ConflictResolverContextActionRequest extends RepositoryFileReference { + groupId: string; + action: ConflictResolverContextAction; +} + export type FileReferenceInput = string | RepositoryFileReference; export interface ChangelistGroup { @@ -206,6 +216,55 @@ export interface ChangelistFileSelection { isConflict?: boolean; } +export interface ConflictSideContent { + exists: boolean; + content: string; + objectId?: string; +} + +export type ConflictFileKind = 'text' | 'binary' | 'submodule' | 'unsupported'; + +export interface ConflictChange { + id: string; + baseStart: number; + baseLineCount: number; + sideStart: number; + sideLineCount: number; +} + +export interface ConflictFileContent { + repoPath?: string; + path: string; + baseLabel?: string; + currentLabel?: string; + incomingLabel?: string; + base: ConflictSideContent; + current: ConflictSideContent; + incoming: ConflictSideContent; + currentChanges: ConflictChange[]; + incomingChanges: ConflictChange[]; + result: string; + stageSignature: string; + resultFingerprint: string; + resolvedCandidate: boolean; + isBinary: boolean; + kind: ConflictFileKind; +} + +export interface ConflictResolutionSnapshot { + stageSignature: string; + resultFingerprint: string; +} + +export interface SaveConflictResolutionRequest extends RepositoryFileReference, ConflictResolutionSnapshot { + content: string; + resultExists: boolean; +} + +export interface ResolveConflictRequest extends RepositoryFileReference, ConflictResolutionSnapshot { + side: 'ours' | 'theirs'; +} + export interface BranchInfo { current: string; all: string[]; @@ -377,6 +436,12 @@ export interface ExtensionMethods { pickBranch: () => Promise; continueRebase: (params: { message?: string; files?: string[] }) => Promise; abortRebase: () => Promise; + openConflictResolver: (params: ConflictResolverOpenRequest) => Promise; + updateConflictResolverTitle: (params: { path: string; repoPath?: string }) => Promise; + getConflictFileContent: (params: { path: string; repoPath?: string }) => Promise; + confirmConflictResolverRestart: () => Promise; + saveConflictResolution: (params: SaveConflictResolutionRequest) => Promise; + resolveConflict: (params: ResolveConflictRequest) => Promise; openFile: (params: { path: string; repoPath?: string; preserveFocus?: boolean }) => Promise; openStashDiff: (params: { index: number; path: string }) => Promise; getBranchListData: () => Promise; @@ -502,6 +567,8 @@ export interface FileIconTheme { export interface WebviewMethods { activeFileChange: (params: { path: string; commitHash?: string }) => void; fileIconThemeChange: () => void; + revealConflictResolverFile: (params: ConflictResolverOpenRequest) => void; + triggerConflictResolverAction: (params: ConflictResolverContextActionRequest) => void; revealLog: (params: GitLogRevealRequest) => void; filterLogByBranch: (params: { branch: string }) => void; refresh: (event: RefreshEvent) => void; diff --git a/packages/shared/webviewContext.ts b/packages/shared/webviewContext.ts index b8fdcc2..356b482 100644 --- a/packages/shared/webviewContext.ts +++ b/packages/shared/webviewContext.ts @@ -17,6 +17,9 @@ export const WEBVIEW_CONTEXT_SECTIONS = [ 'changelistHunk', 'changelistBackground', 'worktreeItem', + 'mergeEditorLeft', + 'mergeEditorResult', + 'mergeEditorRight', ] as const; export type WebviewContextSection = (typeof WEBVIEW_CONTEXT_SECTIONS)[number]; @@ -163,6 +166,17 @@ export interface WorktreeItemContext extends BaseWebviewContext { preventDefaultContextMenuItems: true; } +export interface MergeEditorContext extends BaseWebviewContext { + webviewSection: 'mergeEditorLeft' | 'mergeEditorResult' | 'mergeEditorRight'; + repoPath?: string; + path: string; + changeGroupId?: string; + canReviewLeft: boolean; + canReviewRight: boolean; + canMarkReviewed: boolean; + preventDefaultContextMenuItems: true; +} + export type WebviewContextPayload = | CommitAiContext | StashItemContext @@ -176,4 +190,5 @@ export type WebviewContextPayload = | ChangelistFileContext | ChangelistBackgroundContext | WorktreeItemContext + | MergeEditorContext | BaseWebviewContext;