mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-23 01:04:25 +00:00
Compare commits
8 Commits
composer
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e77b2cfd61 | ||
|
|
d0ce2950e4 | ||
|
|
5a1aca9189 | ||
|
|
f07e877204 | ||
|
|
58ad4359da | ||
|
|
ce2763720e | ||
|
|
950df3de19 | ||
|
|
1d9f05e4f5 |
@@ -5,8 +5,16 @@ import DESCRIPTION from "./github-triage.txt"
|
||||
const TEAM = {
|
||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||
zen: ["fwang", "MrMushrooooom"],
|
||||
tui: ["thdxr", "kommander", "rekram1-node"],
|
||||
core: ["thdxr", "rekram1-node", "jlongster"],
|
||||
tui: [
|
||||
"thdxr",
|
||||
"kommander",
|
||||
// "rekram1-node" (on vacation)
|
||||
],
|
||||
core: [
|
||||
"thdxr",
|
||||
// "rekram1-node", (on vacation)
|
||||
"jlongster",
|
||||
],
|
||||
docs: ["R44VC0RP"],
|
||||
windows: ["Hona"],
|
||||
} as const
|
||||
@@ -42,10 +50,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
export default tool({
|
||||
description: DESCRIPTION,
|
||||
args: {
|
||||
assignee: tool.schema
|
||||
.enum(ASSIGNEES as [string, ...string[]])
|
||||
.describe("The username of the assignee")
|
||||
.default("rekram1-node"),
|
||||
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
|
||||
labels: tool.schema
|
||||
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
||||
.describe("The labels(s) to add to the issue")
|
||||
@@ -68,7 +73,8 @@ export default tool({
|
||||
results.push("Dropped label: nix (issue does not mention nix)")
|
||||
}
|
||||
|
||||
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||
const assignee = web ? pick(TEAM.desktop) : args.assignee
|
||||
|
||||
if (labels.includes("zen") && !zen) {
|
||||
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||
|
||||
@@ -4,3 +4,5 @@ Choose labels and assignee using the current triage policy and ownership rules.
|
||||
Pick the most fitting labels for the issue and assign one owner.
|
||||
|
||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||
|
||||
(Note: rekram1-node is on vacation, do not assign issues to him.)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
139
README.bn.md
Normal file
139
README.bn.md
Normal file
@@ -0,0 +1,139 @@
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai">
|
||||
<picture>
|
||||
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">ওপেন সোর্স এআই কোডিং এজেন্ট।</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh.md">简体中文</a> |
|
||||
<a href="README.zht.md">繁體中文</a> |
|
||||
<a href="README.ko.md">한국어</a> |
|
||||
<a href="README.de.md">Deutsch</a> |
|
||||
<a href="README.es.md">Español</a> |
|
||||
<a href="README.fr.md">Français</a> |
|
||||
<a href="README.it.md">Italiano</a> |
|
||||
<a href="README.da.md">Dansk</a> |
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
---
|
||||
|
||||
### ইনস্টলেশন (Installation)
|
||||
|
||||
```bash
|
||||
# YOLO
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Package managers
|
||||
npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
||||
brew install opencode # macOS and Linux (official brew formula, updated less)
|
||||
sudo pacman -S opencode # Arch Linux (Stable)
|
||||
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||
mise use -g opencode # Any OS
|
||||
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> ইনস্টল করার আগে ০.১.x এর চেয়ে পুরোনো ভার্সনগুলো মুছে ফেলুন।
|
||||
|
||||
### ডেস্কটপ অ্যাপ (BETA)
|
||||
|
||||
OpenCode ডেস্কটপ অ্যাপ্লিকেশন হিসেবেও উপলব্ধ। সরাসরি [রিলিজ পেজ](https://github.com/anomalyco/opencode/releases) অথবা [opencode.ai/download](https://opencode.ai/download) থেকে ডাউনলোড করুন।
|
||||
|
||||
| প্ল্যাটফর্ম | ডাউনলোড |
|
||||
| --------------------- | ------------------------------------- |
|
||||
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||
| Linux | `.deb`, `.rpm`, or AppImage |
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask opencode-desktop
|
||||
# Windows (Scoop)
|
||||
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||
```
|
||||
|
||||
#### ইনস্টলেশন ডিরেক্টরি (Installation Directory)
|
||||
|
||||
ইনস্টল স্ক্রিপ্টটি ইনস্টলেশন পাতের জন্য নিম্নলিখিত অগ্রাধিকার ক্রম মেনে চলে:
|
||||
|
||||
1. `$OPENCODE_INSTALL_DIR` - কাস্টম ইনস্টলেশন ডিরেক্টরি
|
||||
2. `$XDG_BIN_DIR` - XDG বেস ডিরেক্টরি স্পেসিফিকেশন সমর্থিত পাথ
|
||||
3. `$HOME/bin` - সাধারণ ব্যবহারকারী বাইনারি ডিরেক্টরি (যদি বিদ্যমান থাকে বা তৈরি করা যায়)
|
||||
4. `$HOME/.opencode/bin` - ডিফল্ট ফলব্যাক
|
||||
|
||||
```bash
|
||||
# উদাহরণ
|
||||
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
### এজেন্টস (Agents)
|
||||
|
||||
OpenCode এ দুটি বিল্ট-ইন এজেন্ট রয়েছে যা আপনি `Tab` কি(key) দিয়ে পরিবর্তন করতে পারবেন।
|
||||
|
||||
- **build** - ডিফল্ট, ডেভেলপমেন্টের কাজের জন্য সম্পূর্ণ অ্যাক্সেসযুক্ত এজেন্ট
|
||||
- **plan** - বিশ্লেষণ এবং কোড এক্সপ্লোরেশনের জন্য রিড-ওনলি এজেন্ট
|
||||
- ডিফল্টভাবে ফাইল এডিট করতে দেয় না
|
||||
- ব্যাশ কমান্ড চালানোর আগে অনুমতি চায়
|
||||
- অপরিচিত কোডবেস এক্সপ্লোর করা বা পরিবর্তনের পরিকল্পনা করার জন্য আদর্শ
|
||||
|
||||
এছাড়াও জটিল অনুসন্ধান এবং মাল্টিস্টেপ টাস্কের জন্য একটি **general** সাবএজেন্ট অন্তর্ভুক্ত রয়েছে।
|
||||
এটি অভ্যন্তরীণভাবে ব্যবহৃত হয় এবং মেসেজে `@general` লিখে ব্যবহার করা যেতে পারে।
|
||||
|
||||
এজেন্টদের সম্পর্কে আরও জানুন: [docs](https://opencode.ai/docs/agents)।
|
||||
|
||||
### ডকুমেন্টেশন (Documentation)
|
||||
|
||||
কিভাবে OpenCode কনফিগার করবেন সে সম্পর্কে আরও তথ্যের জন্য, [**আমাদের ডকস দেখুন**](https://opencode.ai/docs)।
|
||||
|
||||
### অবদান (Contributing)
|
||||
|
||||
আপনি যদি OpenCode এ অবদান রাখতে চান, অনুগ্রহ করে একটি পুল রিকোয়েস্ট সাবমিট করার আগে আমাদের [কন্ট্রিবিউটিং ডকস](./CONTRIBUTING.md) পড়ে নিন।
|
||||
|
||||
### OpenCode এর উপর বিল্ডিং (Building on OpenCode)
|
||||
|
||||
আপনি যদি এমন প্রজেক্টে কাজ করেন যা OpenCode এর সাথে সম্পর্কিত এবং প্রজেক্টের নামের অংশ হিসেবে "opencode" ব্যবহার করেন, উদাহরণস্বরূপ "opencode-dashboard" বা "opencode-mobile", তবে দয়া করে আপনার README তে একটি নোট যোগ করে স্পষ্ট করুন যে এই প্রজেক্টটি OpenCode দল দ্বারা তৈরি হয়নি এবং আমাদের সাথে এর কোনো সরাসরি সম্পর্ক নেই।
|
||||
|
||||
### সচরাচর জিজ্ঞাসিত প্রশ্নাবলী (FAQ)
|
||||
|
||||
#### এটি ক্লড কোড (Claude Code) থেকে কীভাবে আলাদা?
|
||||
|
||||
ক্যাপাবিলিটির দিক থেকে এটি ক্লড কোডের (Claude Code) মতই। এখানে মূল পার্থক্যগুলো দেওয়া হলো:
|
||||
|
||||
- ১০০% ওপেন সোর্স
|
||||
- কোনো প্রোভাইডারের সাথে আবদ্ধ নয়। যদিও আমরা [OpenCode Zen](https://opencode.ai/zen) এর মাধ্যমে মডেলসমূহ ব্যবহারের পরামর্শ দিই, OpenCode ক্লড (Claude), ওপেনএআই (OpenAI), গুগল (Google), অথবা লোকাল মডেলগুলোর সাথেও ব্যবহার করা যেতে পারে। যেমন যেমন মডেলগুলো উন্নত হবে, তাদের মধ্যকার পার্থক্য কমে আসবে এবং দামও কমবে, তাই প্রোভাইডার-অজ্ঞাস্টিক হওয়া খুবই গুরুত্বপূর্ণ।
|
||||
- আউট-অফ-দ্য-বক্স LSP সাপোর্ট
|
||||
- TUI এর উপর ফোকাস। OpenCode নিওভিম (neovim) ব্যবহারকারী এবং [terminal.shop](https://terminal.shop) এর নির্মাতাদের দ্বারা তৈরি; আমরা টার্মিনালে কী কী সম্ভব তার সীমাবদ্ধতা ছাড়িয়ে যাওয়ার চেষ্টা করছি।
|
||||
- ক্লায়েন্ট/সার্ভার আর্কিটেকচার। এটি যেমন OpenCode কে আপনার কম্পিউটারে চালানোর সুযোগ দেয়, তেমনি আপনি মোবাইল অ্যাপ থেকে রিমোটলি এটি নিয়ন্ত্রণ করতে পারবেন, অর্থাৎ TUI ফ্রন্টএন্ড কেবল সম্ভাব্য ক্লায়েন্টগুলোর মধ্যে একটি।
|
||||
|
||||
---
|
||||
|
||||
**আমাদের কমিউনিটিতে যুক্ত হোন** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a>
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -452,7 +452,10 @@ export function SessionHeader() {
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
|
||||
classList: { "rounded-r-none": share.shareUrl() !== undefined },
|
||||
classList: {
|
||||
"rounded-r-none": share.shareUrl() !== undefined,
|
||||
"border-r-0": share.shareUrl() !== undefined,
|
||||
},
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
|
||||
|
||||
@@ -20,12 +20,17 @@ let demoSoundState = {
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const playDemoSound = (src: string) => {
|
||||
const stopDemoSound = () => {
|
||||
if (demoSoundState.cleanup) {
|
||||
demoSoundState.cleanup()
|
||||
}
|
||||
|
||||
clearTimeout(demoSoundState.timeout)
|
||||
demoSoundState.cleanup = undefined
|
||||
}
|
||||
|
||||
const playDemoSound = (src: string | undefined) => {
|
||||
stopDemoSound()
|
||||
if (!src) return
|
||||
|
||||
demoSoundState.timeout = setTimeout(() => {
|
||||
demoSoundState.cleanup = playSound(src)
|
||||
@@ -132,11 +137,17 @@ export const SettingsGeneral: Component = () => {
|
||||
] as const
|
||||
const fontOptionsList = [...fontOptions]
|
||||
|
||||
const soundOptions = [...SOUND_OPTIONS]
|
||||
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
|
||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
|
||||
const soundSelectProps = (current: () => string, set: (id: string) => void) => ({
|
||||
const soundSelectProps = (
|
||||
enabled: () => boolean,
|
||||
current: () => string,
|
||||
setEnabled: (value: boolean) => void,
|
||||
set: (id: string) => void,
|
||||
) => ({
|
||||
options: soundOptions,
|
||||
current: soundOptions.find((o) => o.id === current()),
|
||||
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
||||
value: (o: (typeof soundOptions)[number]) => o.id,
|
||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
@@ -145,6 +156,12 @@ export const SettingsGeneral: Component = () => {
|
||||
},
|
||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
if (option.id === "none") {
|
||||
setEnabled(false)
|
||||
stopDemoSound()
|
||||
return
|
||||
}
|
||||
setEnabled(true)
|
||||
set(option.id)
|
||||
playDemoSound(option.src)
|
||||
},
|
||||
@@ -319,66 +336,45 @@ export const SettingsGeneral: Component = () => {
|
||||
title={language.t("settings.general.sounds.agent.title")}
|
||||
description={language.t("settings.general.sounds.agent.description")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div data-action="settings-sounds-agent-enabled">
|
||||
<Switch
|
||||
checked={settings.sounds.agentEnabled()}
|
||||
onChange={(checked) => settings.sounds.setAgentEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
disabled={!settings.sounds.agentEnabled()}
|
||||
data-action="settings-sounds-agent"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.agent(),
|
||||
(id) => settings.sounds.setAgent(id),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
data-action="settings-sounds-agent"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.agentEnabled(),
|
||||
() => settings.sounds.agent(),
|
||||
(value) => settings.sounds.setAgentEnabled(value),
|
||||
(id) => settings.sounds.setAgent(id),
|
||||
)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.sounds.permissions.title")}
|
||||
description={language.t("settings.general.sounds.permissions.description")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div data-action="settings-sounds-permissions-enabled">
|
||||
<Switch
|
||||
checked={settings.sounds.permissionsEnabled()}
|
||||
onChange={(checked) => settings.sounds.setPermissionsEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
disabled={!settings.sounds.permissionsEnabled()}
|
||||
data-action="settings-sounds-permissions"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.permissions(),
|
||||
(id) => settings.sounds.setPermissions(id),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
data-action="settings-sounds-permissions"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.permissionsEnabled(),
|
||||
() => settings.sounds.permissions(),
|
||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
||||
(id) => settings.sounds.setPermissions(id),
|
||||
)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.sounds.errors.title")}
|
||||
description={language.t("settings.general.sounds.errors.description")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div data-action="settings-sounds-errors-enabled">
|
||||
<Switch
|
||||
checked={settings.sounds.errorsEnabled()}
|
||||
onChange={(checked) => settings.sounds.setErrorsEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
disabled={!settings.sounds.errorsEnabled()}
|
||||
data-action="settings-sounds-errors"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.errors(),
|
||||
(id) => settings.sounds.setErrors(id),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
data-action="settings-sounds-errors"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.errorsEnabled(),
|
||||
() => settings.sounds.errors(),
|
||||
(value) => settings.sounds.setErrorsEnabled(value),
|
||||
(id) => settings.sounds.setErrors(id),
|
||||
)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,17 +44,6 @@ function aggregate(comments: Record<string, LineComment[]>) {
|
||||
.sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
|
||||
const next: SelectedLineRange = {
|
||||
start: selection.start,
|
||||
end: selection.end,
|
||||
}
|
||||
|
||||
if (selection.side) next.side = selection.side
|
||||
if (selection.endSide) next.endSide = selection.endSide
|
||||
return next
|
||||
}
|
||||
|
||||
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
|
||||
const [state, setState] = createStore({
|
||||
focus: null as CommentFocus | null,
|
||||
@@ -81,7 +70,6 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
id: uuid(),
|
||||
time: Date.now(),
|
||||
...input,
|
||||
selection: cloneSelection(input.selection),
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
|
||||
@@ -9,7 +9,7 @@ const MAX_FILE_VIEW_SESSIONS = 20
|
||||
const MAX_VIEW_FILES = 500
|
||||
|
||||
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
||||
if (range.start <= range.end) return { ...range }
|
||||
if (range.start <= range.end) return range
|
||||
|
||||
const startSide = range.side
|
||||
const endSide = range.endSide ?? startSide
|
||||
|
||||
@@ -565,6 +565,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "بلا",
|
||||
"sound.option.alert01": "تنبيه 01",
|
||||
"sound.option.alert02": "تنبيه 02",
|
||||
"sound.option.alert03": "تنبيه 03",
|
||||
|
||||
@@ -571,6 +571,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Nenhum",
|
||||
"sound.option.alert01": "Alerta 01",
|
||||
"sound.option.alert02": "Alerta 02",
|
||||
"sound.option.alert03": "Alerta 03",
|
||||
|
||||
@@ -639,6 +639,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Nijedan",
|
||||
"sound.option.alert01": "Upozorenje 01",
|
||||
"sound.option.alert02": "Upozorenje 02",
|
||||
"sound.option.alert03": "Upozorenje 03",
|
||||
|
||||
@@ -635,6 +635,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Ingen",
|
||||
"sound.option.alert01": "Alarm 01",
|
||||
"sound.option.alert02": "Alarm 02",
|
||||
"sound.option.alert03": "Alarm 03",
|
||||
|
||||
@@ -580,6 +580,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Keine",
|
||||
"sound.option.alert01": "Alarm 01",
|
||||
"sound.option.alert02": "Alarm 02",
|
||||
"sound.option.alert03": "Alarm 03",
|
||||
|
||||
@@ -642,6 +642,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "None",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
"sound.option.alert03": "Alert 03",
|
||||
|
||||
@@ -643,6 +643,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Ninguno",
|
||||
"sound.option.alert01": "Alerta 01",
|
||||
"sound.option.alert02": "Alerta 02",
|
||||
"sound.option.alert03": "Alerta 03",
|
||||
|
||||
@@ -579,6 +579,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Aucun",
|
||||
"sound.option.alert01": "Alerte 01",
|
||||
"sound.option.alert02": "Alerte 02",
|
||||
"sound.option.alert03": "Alerte 03",
|
||||
|
||||
@@ -569,6 +569,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "なし",
|
||||
"sound.option.alert01": "アラート 01",
|
||||
"sound.option.alert02": "アラート 02",
|
||||
"sound.option.alert03": "アラート 03",
|
||||
|
||||
@@ -570,6 +570,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "없음",
|
||||
"sound.option.alert01": "알림 01",
|
||||
"sound.option.alert02": "알림 02",
|
||||
"sound.option.alert03": "알림 03",
|
||||
|
||||
@@ -642,6 +642,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Ingen",
|
||||
"sound.option.alert01": "Varsel 01",
|
||||
"sound.option.alert02": "Varsel 02",
|
||||
"sound.option.alert03": "Varsel 03",
|
||||
|
||||
@@ -570,6 +570,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Brak",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
"sound.option.alert03": "Alert 03",
|
||||
|
||||
@@ -640,6 +640,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Нет",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
"sound.option.alert03": "Alert 03",
|
||||
|
||||
@@ -634,6 +634,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "ไม่มี",
|
||||
"sound.option.alert01": "เสียงเตือน 01",
|
||||
"sound.option.alert02": "เสียงเตือน 02",
|
||||
"sound.option.alert03": "เสียงเตือน 03",
|
||||
|
||||
@@ -633,6 +633,7 @@ export const dict = {
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
|
||||
"sound.option.none": "无",
|
||||
"sound.option.alert01": "警报 01",
|
||||
"sound.option.alert02": "警报 02",
|
||||
"sound.option.alert03": "警报 03",
|
||||
|
||||
@@ -629,6 +629,7 @@ export const dict = {
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "無",
|
||||
"sound.option.alert01": "警報 01",
|
||||
"sound.option.alert02": "警報 02",
|
||||
"sound.option.alert03": "警報 03",
|
||||
|
||||
@@ -373,32 +373,11 @@ export default function Page() {
|
||||
})
|
||||
}
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null | undefined) => {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
|
||||
}
|
||||
|
||||
const deepActiveElement = () => {
|
||||
let current: Element | null = document.activeElement
|
||||
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
|
||||
current = current.shadowRoot.activeElement
|
||||
}
|
||||
return current instanceof HTMLElement ? current : undefined
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const path = event.composedPath()
|
||||
const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
|
||||
const activeElement = deepActiveElement()
|
||||
|
||||
const protectedTarget = path.some(
|
||||
(item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null,
|
||||
)
|
||||
if (protectedTarget || isEditableTarget(target)) return
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement | undefined
|
||||
if (activeElement) {
|
||||
const isProtected = activeElement.closest("[data-prevent-autofocus]")
|
||||
const isInput = isEditableTarget(activeElement)
|
||||
const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable
|
||||
if (isProtected || isInput) return
|
||||
}
|
||||
if (dialog.active) return
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||
import { createHoverCommentUtility } from "@opencode-ai/ui/pierre/comment-hover"
|
||||
import { cloneSelectedLineRange, lineInSelectedRange } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
import {
|
||||
createLineCommentAnnotationRenderer,
|
||||
type LineCommentAnnotationMeta,
|
||||
} from "@opencode-ai/ui/line-comment-annotations"
|
||||
import { sampledChecksum } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
@@ -102,11 +97,11 @@ export function FileTabContent(props: { tab: string }) {
|
||||
const c = state()?.content
|
||||
return `data:${c?.mimeType};base64,${c?.content}`
|
||||
})
|
||||
const selectedLines = createMemo<SelectedLineRange | null>(() => {
|
||||
const selectedLines = createMemo(() => {
|
||||
const p = path()
|
||||
if (!p) return null
|
||||
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
|
||||
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
|
||||
if (file.ready()) return file.selectedLines(p) ?? null
|
||||
return getSessionHandoff(sessionKey())?.files[p] ?? null
|
||||
})
|
||||
|
||||
const selectionPreview = (source: string, selection: FileSelection) => {
|
||||
@@ -150,148 +145,127 @@ export function FileTabContent(props: { tab: string }) {
|
||||
})
|
||||
}
|
||||
|
||||
let wrap: HTMLDivElement | undefined
|
||||
|
||||
const fileComments = createMemo(() => {
|
||||
const p = path()
|
||||
if (!p) return []
|
||||
return comments.list(p)
|
||||
})
|
||||
|
||||
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
|
||||
const commentLayout = createMemo(() => {
|
||||
return fileComments()
|
||||
.map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`)
|
||||
.join("|")
|
||||
})
|
||||
|
||||
type Annotation = LineCommentAnnotationMeta<ReturnType<typeof fileComments>[number]>
|
||||
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
|
||||
|
||||
const [note, setNote] = createStore({
|
||||
openedComment: null as string | null,
|
||||
commenting: null as SelectedLineRange | null,
|
||||
selected: null as SelectedLineRange | null,
|
||||
draft: "",
|
||||
positions: {} as Record<string, number>,
|
||||
draftTop: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const activeSelection = () => note.selected ?? selectedLines()
|
||||
|
||||
const setCommenting = (range: SelectedLineRange | null) => {
|
||||
setNote("commenting", range)
|
||||
scheduleComments()
|
||||
if (!range) return
|
||||
setNote("draft", "")
|
||||
setNote("commenting", range ? cloneSelectedLineRange(range) : null)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
path,
|
||||
() => {
|
||||
setNote("selected", null)
|
||||
setNote("openedComment", null)
|
||||
setNote("commenting", null)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const getRoot = () => {
|
||||
const el = wrap
|
||||
if (!el) return
|
||||
|
||||
const annotationLine = (range: SelectedLineRange) => Math.max(range.start, range.end)
|
||||
const annotations = createMemo(() => {
|
||||
const list = fileComments().map((comment) => ({
|
||||
lineNumber: annotationLine(comment.selection),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${comment.id}`,
|
||||
comment,
|
||||
} satisfies Annotation,
|
||||
}))
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
|
||||
if (note.commenting) {
|
||||
return [
|
||||
...list,
|
||||
{
|
||||
lineNumber: annotationLine(note.commenting),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${path() ?? props.tab}`,
|
||||
range: note.commenting,
|
||||
} satisfies Annotation,
|
||||
},
|
||||
]
|
||||
const root = host.shadowRoot
|
||||
if (!root) return
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
|
||||
const line = Math.max(range.start, range.end)
|
||||
const node = root.querySelector(`[data-line="${line}"]`)
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
return node
|
||||
}
|
||||
|
||||
const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const rect = marker.getBoundingClientRect()
|
||||
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
||||
}
|
||||
|
||||
const updateComments = () => {
|
||||
const el = wrap
|
||||
const root = getRoot()
|
||||
if (!el || !root) {
|
||||
setNote("positions", {})
|
||||
setNote("draftTop", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const range = activeSelection()
|
||||
if (!range || note.openedComment) return list
|
||||
return list
|
||||
})
|
||||
const estimateTop = (range: SelectedLineRange) => {
|
||||
const line = Math.max(range.start, range.end)
|
||||
const height = 24
|
||||
const offset = 2
|
||||
return Math.max(0, (line - 1) * height + offset)
|
||||
}
|
||||
|
||||
const annotationRenderer = createLineCommentAnnotationRenderer<ReturnType<typeof fileComments>[number]>({
|
||||
renderComment: (comment) => ({
|
||||
id: comment.id,
|
||||
open: note.openedComment === comment.id,
|
||||
comment: comment.comment,
|
||||
selection: formatCommentLabel(comment.selection),
|
||||
onMouseEnter: () => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, cloneSelectedLineRange(comment.selection))
|
||||
},
|
||||
onClick: () => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
setCommenting(null)
|
||||
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
|
||||
file.setSelectedLines(p, cloneSelectedLineRange(comment.selection))
|
||||
},
|
||||
}),
|
||||
renderDraft: (range) => ({
|
||||
value: note.draft,
|
||||
selection: formatCommentLabel(range),
|
||||
onInput: (value) => setNote("draft", value),
|
||||
onCancel: () => setCommenting(null),
|
||||
onSubmit: (value) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({ file: p, selection: range, comment: value, origin: "file" })
|
||||
setCommenting(null)
|
||||
},
|
||||
onPopoverFocusOut: (e: FocusEvent) => {
|
||||
const current = e.currentTarget as HTMLDivElement
|
||||
const target = e.relatedTarget
|
||||
if (target instanceof Node && current.contains(target)) return
|
||||
const large = contents().length > 500_000
|
||||
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement || !current.contains(document.activeElement)) {
|
||||
setCommenting(null)
|
||||
const next: Record<string, number> = {}
|
||||
for (const comment of fileComments()) {
|
||||
const marker = findMarker(root, comment.selection)
|
||||
if (marker) next[comment.id] = markerTop(el, marker)
|
||||
else if (large) next[comment.id] = estimateTop(comment.selection)
|
||||
}
|
||||
|
||||
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
|
||||
const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top)
|
||||
if (removed.length > 0 || changed.length > 0) {
|
||||
setNote(
|
||||
"positions",
|
||||
produce((draft) => {
|
||||
for (const id of removed) {
|
||||
delete draft[id]
|
||||
}
|
||||
}, 0)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const renderAnnotation = annotationRenderer.render
|
||||
for (const [id, top] of changed) {
|
||||
draft[id] = top
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const openDraft = (range: SelectedLineRange) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
const next = cloneSelectedLineRange(range)
|
||||
setNote("openedComment", null)
|
||||
setNote("selected", next)
|
||||
file.setSelectedLines(p, cloneSelectedLineRange(next))
|
||||
setCommenting(next)
|
||||
const range = note.commenting
|
||||
if (!range) {
|
||||
setNote("draftTop", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const marker = findMarker(root, range)
|
||||
if (marker) {
|
||||
setNote("draftTop", markerTop(el, marker))
|
||||
return
|
||||
}
|
||||
|
||||
setNote("draftTop", large ? estimateTop(range) : undefined)
|
||||
}
|
||||
|
||||
const renderHoverUtility = (getHoveredLine: () => { lineNumber: number; side?: "additions" | "deletions" }) =>
|
||||
createHoverCommentUtility({
|
||||
label: language.t("ui.lineComment.submit"),
|
||||
getHoveredLine,
|
||||
onSelect: (hovered) => {
|
||||
const selected = note.openedComment ? null : activeSelection()
|
||||
const range =
|
||||
selected && lineInSelectedRange(selected, hovered.lineNumber, hovered.side)
|
||||
? cloneSelectedLineRange(selected)
|
||||
: { start: hovered.lineNumber, end: hovered.lineNumber }
|
||||
openDraft(range)
|
||||
},
|
||||
})
|
||||
const scheduleComments = () => {
|
||||
requestAnimationFrame(updateComments)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
annotationRenderer.reconcile(annotations())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
annotationRenderer.cleanup()
|
||||
commentLayout()
|
||||
scheduleComments()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -305,9 +279,8 @@ export function FileTabContent(props: { tab: string }) {
|
||||
if (!target) return
|
||||
|
||||
setNote("openedComment", target.id)
|
||||
setNote("selected", cloneSelectedLineRange(target.selection))
|
||||
setCommenting(null)
|
||||
file.setSelectedLines(p, cloneSelectedLineRange(target.selection))
|
||||
file.setSelectedLines(p, target.selection)
|
||||
requestAnimationFrame(() => comments.clearFocus())
|
||||
})
|
||||
|
||||
@@ -441,7 +414,13 @@ export function FileTabContent(props: { tab: string }) {
|
||||
})
|
||||
|
||||
const renderCode = (source: string, wrapperClass: string) => (
|
||||
<div class={`relative overflow-hidden ${wrapperClass}`}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
wrap = el
|
||||
scheduleComments()
|
||||
}}
|
||||
class={`relative overflow-hidden ${wrapperClass}`}
|
||||
>
|
||||
<Dynamic
|
||||
component={codeComponent}
|
||||
file={{
|
||||
@@ -450,39 +429,83 @@ export function FileTabContent(props: { tab: string }) {
|
||||
cacheKey: cacheKey(),
|
||||
}}
|
||||
enableLineSelection
|
||||
enableHoverUtility
|
||||
selectedLines={activeSelection()}
|
||||
selectedLines={selectedLines()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
requestAnimationFrame(restoreScroll)
|
||||
requestAnimationFrame(scheduleComments)
|
||||
}}
|
||||
annotations={annotations()}
|
||||
renderAnnotation={renderAnnotation}
|
||||
renderHoverUtility={renderHoverUtility}
|
||||
onLineSelected={(range: SelectedLineRange | null) => {
|
||||
setNote("selected", range ? cloneSelectedLineRange(range) : null)
|
||||
}}
|
||||
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
|
||||
if (!range) return
|
||||
openDraft(range)
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, range)
|
||||
if (!range) setCommenting(null)
|
||||
}}
|
||||
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
||||
const next = range ? cloneSelectedLineRange(range) : null
|
||||
setNote("selected", next)
|
||||
const p = path()
|
||||
if (p) file.setSelectedLines(p, next ? cloneSelectedLineRange(next) : null)
|
||||
|
||||
if (!next) {
|
||||
if (!range) {
|
||||
setCommenting(null)
|
||||
return
|
||||
}
|
||||
|
||||
setNote("openedComment", null)
|
||||
setCommenting(null)
|
||||
setCommenting(range)
|
||||
}}
|
||||
overflow="scroll"
|
||||
class="select-text"
|
||||
/>
|
||||
<For each={fileComments()}>
|
||||
{(comment) => (
|
||||
<LineCommentView
|
||||
id={comment.id}
|
||||
top={note.positions[comment.id]}
|
||||
open={note.openedComment === comment.id}
|
||||
comment={comment.comment}
|
||||
selection={formatCommentLabel(comment.selection)}
|
||||
onMouseEnter={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
onClick={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
setCommenting(null)
|
||||
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={note.commenting}>
|
||||
{(range) => (
|
||||
<Show when={note.draftTop !== undefined}>
|
||||
<LineCommentEditor
|
||||
top={note.draftTop}
|
||||
value={note.draft}
|
||||
selection={formatCommentLabel(range())}
|
||||
onInput={(value) => setNote("draft", value)}
|
||||
onCancel={() => setCommenting(null)}
|
||||
onSubmit={(value) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
|
||||
setCommenting(null)
|
||||
}}
|
||||
onPopoverFocusOut={(e: FocusEvent) => {
|
||||
const current = e.currentTarget as HTMLDivElement
|
||||
const target = e.relatedTarget
|
||||
if (target instanceof Node && current.contains(target)) return
|
||||
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement || !current.contains(document.activeElement)) {
|
||||
setCommenting(null)
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ const getModelsInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
return {
|
||||
all: Object.entries(ZenData.list().models)
|
||||
all: Object.entries(ZenData.list("full").models)
|
||||
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
|
||||
.filter(([id, _model]) => !id.startsWith("alpha-"))
|
||||
.sort(([idA, modelA], [idB, modelB]) => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
format: "oa-compat",
|
||||
modelList: "lite",
|
||||
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
||||
parseModel: (url: string, body: any) => body.model,
|
||||
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||
})
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export async function handler(
|
||||
input: APIEvent,
|
||||
opts: {
|
||||
format: ZenData.Format
|
||||
modelList: "lite" | "full"
|
||||
parseApiKey: (headers: Headers) => string | undefined
|
||||
parseModel: (url: string, body: any) => string
|
||||
parseIsStream: (url: string, body: any) => boolean
|
||||
@@ -77,7 +78,7 @@ export async function handler(
|
||||
request: requestId,
|
||||
client: ocClient,
|
||||
})
|
||||
const zenData = ZenData.list()
|
||||
const zenData = ZenData.list(opts.modelList)
|
||||
const modelInfo = validateModel(zenData, model)
|
||||
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
||||
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { handler } from "~/routes/zen/util/handler"
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
format: "oa-compat",
|
||||
modelList: "full",
|
||||
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
||||
parseModel: (url: string, body: any) => body.model,
|
||||
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { handler } from "~/routes/zen/util/handler"
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
format: "anthropic",
|
||||
modelList: "full",
|
||||
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
|
||||
parseModel: (url: string, body: any) => body.model,
|
||||
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function OPTIONS(input: APIEvent) {
|
||||
}
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const zenData = ZenData.list()
|
||||
const zenData = ZenData.list("full")
|
||||
const disabledModels = await authenticate()
|
||||
|
||||
return new Response(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { handler } from "~/routes/zen/util/handler"
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
format: "google",
|
||||
modelList: "full",
|
||||
parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined,
|
||||
parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
|
||||
parseIsStream: (url: string, body: any) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { handler } from "~/routes/zen/util/handler"
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
format: "openai",
|
||||
modelList: "full",
|
||||
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
||||
parseModel: (url: string, body: any) => body.model,
|
||||
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||
|
||||
@@ -73,6 +73,7 @@ export namespace ZenData {
|
||||
|
||||
const ModelsSchema = z.object({
|
||||
models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
|
||||
liteModels: z.record(z.string(), ModelSchema),
|
||||
providers: z.record(z.string(), ProviderSchema),
|
||||
providerFamilies: z.record(z.string(), ProviderFamilySchema),
|
||||
})
|
||||
@@ -81,7 +82,7 @@ export namespace ZenData {
|
||||
return input
|
||||
})
|
||||
|
||||
export const list = fn(z.void(), () => {
|
||||
export const list = fn(z.enum(["lite", "full"]), (modelList) => {
|
||||
const json = JSON.parse(
|
||||
Resource.ZEN_MODELS1.value +
|
||||
Resource.ZEN_MODELS2.value +
|
||||
@@ -114,9 +115,9 @@ export namespace ZenData {
|
||||
Resource.ZEN_MODELS29.value +
|
||||
Resource.ZEN_MODELS30.value,
|
||||
)
|
||||
const { models, providers, providerFamilies } = ModelsSchema.parse(json)
|
||||
const { models, liteModels, providers, providerFamilies } = ModelsSchema.parse(json)
|
||||
return {
|
||||
models,
|
||||
models: modelList === "lite" ? liteModels : models,
|
||||
providers: Object.fromEntries(
|
||||
Object.entries(providers).map(([id, provider]) => [
|
||||
id,
|
||||
|
||||
@@ -25,6 +25,12 @@ if (envPath) {
|
||||
const scriptPath = fs.realpathSync(__filename)
|
||||
const scriptDir = path.dirname(scriptPath)
|
||||
|
||||
//
|
||||
const cached = path.join(scriptDir, ".opencode")
|
||||
if (fs.existsSync(cached)) {
|
||||
run(cached)
|
||||
}
|
||||
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
|
||||
@@ -109,8 +109,14 @@ async function main() {
|
||||
// On non-Windows platforms, just verify the binary package exists
|
||||
// Don't replace the wrapper script - it handles binary execution
|
||||
const { binaryPath } = findBinary()
|
||||
console.log(`Platform binary verified at: ${binaryPath}`)
|
||||
console.log("Wrapper script will handle binary execution")
|
||||
const target = path.join(__dirname, "bin", ".opencode")
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||
try {
|
||||
fs.linkSync(binaryPath, target)
|
||||
} catch {
|
||||
fs.copyFileSync(binaryPath, target)
|
||||
}
|
||||
fs.chmodSync(target, 0o755)
|
||||
} catch (error) {
|
||||
console.error("Failed to setup opencode binary:", error.message)
|
||||
process.exit(1)
|
||||
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { createDefaultOptions, styleVariables } from "../pierre"
|
||||
import { markCommentedFileLines } from "../pierre/commented-lines"
|
||||
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
|
||||
import { getWorkerPool } from "../pierre/worker"
|
||||
import { Icon } from "./icon"
|
||||
|
||||
@@ -31,7 +29,6 @@ export type CodeProps<T = {}> = FileOptions<T> & {
|
||||
annotations?: LineAnnotation<T>[]
|
||||
selectedLines?: SelectedLineRange | null
|
||||
commentedLines?: SelectedLineRange[]
|
||||
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
|
||||
onRendered?: () => void
|
||||
onLineSelectionEnd?: (selection: SelectedLineRange | null) => void
|
||||
class?: string
|
||||
@@ -158,7 +155,6 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
let dragMoved = false
|
||||
let lastSelection: SelectedLineRange | null = null
|
||||
let pendingSelectionEnd = false
|
||||
const bridge = createLineNumberSelectionBridge()
|
||||
|
||||
const [local, others] = splitProps(props, [
|
||||
"file",
|
||||
@@ -167,9 +163,6 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onLineSelected",
|
||||
"onLineSelectionEnd",
|
||||
"onLineNumberSelectionEnd",
|
||||
"onRendered",
|
||||
])
|
||||
|
||||
@@ -205,16 +198,6 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
const options = createMemo(() => ({
|
||||
...createDefaultOptions<T>("unified"),
|
||||
...others,
|
||||
onLineSelected: (range: SelectedLineRange | null) => {
|
||||
lastSelection = range
|
||||
local.onLineSelected?.(range)
|
||||
},
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => {
|
||||
lastSelection = range
|
||||
local.onLineSelectionEnd?.(range)
|
||||
if (!bridge.consume(range)) return
|
||||
requestAnimationFrame(() => local.onLineNumberSelectionEnd?.(range))
|
||||
},
|
||||
}))
|
||||
|
||||
const getRoot = () => {
|
||||
@@ -573,6 +556,41 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
})
|
||||
})
|
||||
|
||||
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||
for (const node of existing) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
|
||||
const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = Math.max(1, Math.min(range.start, range.end))
|
||||
const end = Math.max(range.start, range.end)
|
||||
|
||||
for (let line = start; line <= end; line++) {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const line = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
|
||||
if (Number.isNaN(line)) continue
|
||||
if (line < start || line > end) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = () => {
|
||||
const value = local.file.contents as unknown
|
||||
if (typeof value === "string") return value
|
||||
@@ -700,7 +718,7 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
observer.observe(container, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
const updateSelection = (preserveTextSelection = false) => {
|
||||
const updateSelection = () => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
@@ -739,9 +757,6 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
if (endSide && side && endSide !== side) selected.endSide = endSide
|
||||
|
||||
setSelectedLines(selected)
|
||||
|
||||
if (!preserveTextSelection || !domRange) return
|
||||
restoreShadowTextSelection(root, domRange.cloneRange())
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: SelectedLineRange | null) => {
|
||||
@@ -754,12 +769,11 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
|
||||
selectionFrame = requestAnimationFrame(() => {
|
||||
selectionFrame = undefined
|
||||
const finishing = pendingSelectionEnd
|
||||
updateSelection(finishing)
|
||||
updateSelection()
|
||||
|
||||
if (!pendingSelectionEnd) return
|
||||
pendingSelectionEnd = false
|
||||
local.onLineSelectionEnd?.(lastSelection)
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -808,13 +822,9 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
if (event.button !== 0) return
|
||||
|
||||
const { line, numberColumn } = lineFromMouseEvent(event)
|
||||
if (numberColumn) {
|
||||
bridge.begin(true, line)
|
||||
return
|
||||
}
|
||||
if (numberColumn) return
|
||||
if (line === undefined) return
|
||||
|
||||
bridge.begin(false, line)
|
||||
dragStart = line
|
||||
dragEnd = line
|
||||
dragMoved = false
|
||||
@@ -822,21 +832,16 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
|
||||
const next = lineFromMouseEvent(event)
|
||||
if (bridge.track(event.buttons, next.line)) return
|
||||
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if ((event.buttons & 1) === 0) {
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragMoved = false
|
||||
bridge.finish()
|
||||
return
|
||||
}
|
||||
|
||||
const { line } = next
|
||||
const { line } = lineFromMouseEvent(event)
|
||||
if (line === undefined) return
|
||||
|
||||
dragEnd = line
|
||||
@@ -846,18 +851,13 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
|
||||
if (bridge.finish() === "numbers") {
|
||||
return
|
||||
}
|
||||
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if (!dragMoved) {
|
||||
pendingSelectionEnd = false
|
||||
const line = dragStart
|
||||
setSelectedLines({ start: line, end: line })
|
||||
local.onLineSelectionEnd?.(lastSelection)
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragMoved = false
|
||||
@@ -920,7 +920,7 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
const value = text()
|
||||
instance.render({
|
||||
file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
|
||||
lineAnnotations: [],
|
||||
lineAnnotations: local.annotations,
|
||||
containerWrapper: container,
|
||||
})
|
||||
|
||||
@@ -942,22 +942,10 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
onCleanup(() => monitor.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
rendered()
|
||||
const active = instance
|
||||
if (!active) return
|
||||
active.setLineAnnotations(local.annotations ?? [])
|
||||
active.rerender()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
rendered()
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
markCommentedFileLines(root, ranges)
|
||||
})
|
||||
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -1010,7 +998,6 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragMoved = false
|
||||
bridge.reset()
|
||||
lastSelection = null
|
||||
pendingSelectionEnd = false
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Dynamic, isServer } from "solid-js/web"
|
||||
import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
|
||||
import { findDiffSide, markCommentedDiffLines } from "../pierre/commented-lines"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { useWorkerPool } from "../context/worker-pool"
|
||||
|
||||
@@ -22,7 +21,6 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onLineNumberSelectionEnd",
|
||||
])
|
||||
const workerPool = useWorkerPool(props.diffStyle)
|
||||
|
||||
@@ -74,7 +72,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findDiffSide(node) === targetSide) return lineIndex(split, node)
|
||||
if (findSide(node) === targetSide) return lineIndex(split, node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
|
||||
}
|
||||
}
|
||||
@@ -123,6 +121,100 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
diff.setSelectedLines(fixed)
|
||||
}
|
||||
|
||||
const findSide = (element: HTMLElement): "additions" | "deletions" => {
|
||||
const line = element.closest("[data-line], [data-alt-line]")
|
||||
if (line instanceof HTMLElement) {
|
||||
const type = line.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") return "additions"
|
||||
}
|
||||
|
||||
const code = element.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return "additions"
|
||||
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||
for (const node of existing) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
|
||||
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
const lineIndex = (element: HTMLElement) => {
|
||||
const raw = element.dataset.lineIndex
|
||||
if (!raw) return
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((value) => parseInt(value, 10))
|
||||
.filter((value) => !Number.isNaN(value))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findSide(node) === targetSide) return lineIndex(node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
|
||||
}
|
||||
}
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = rowIndex(range.start, range.side)
|
||||
if (start === undefined) continue
|
||||
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return rowIndex(range.end, range.endSide ?? range.side)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
const first = Math.min(start, end)
|
||||
const last = Math.max(start, end)
|
||||
|
||||
for (const row of rows) {
|
||||
const idx = lineIndex(row)
|
||||
if (idx === undefined) continue
|
||||
if (idx < first || idx > last) continue
|
||||
row.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
|
||||
if (Number.isNaN(idx)) continue
|
||||
if (idx < first || idx > last) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isServer || !props.preloadedDiff) return
|
||||
|
||||
@@ -169,10 +261,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
|
||||
createEffect(() => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
diff.setLineAnnotations(local.annotations ?? [])
|
||||
diff.rerender()
|
||||
fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -181,11 +270,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
|
||||
createEffect(() => {
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
markCommentedDiffLines(root, ranges)
|
||||
})
|
||||
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||
})
|
||||
|
||||
// Hydrate annotation slots with interactive SolidJS components
|
||||
|
||||
@@ -3,8 +3,6 @@ import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFile
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
|
||||
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
|
||||
import { findDiffSide, markCommentedDiffLines } from "../pierre/commented-lines"
|
||||
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { getWorkerPool } from "../pierre/worker"
|
||||
|
||||
@@ -37,7 +35,19 @@ function findLineNumber(node: Node | null): number | undefined {
|
||||
function findSide(node: Node | null): SelectionSide | undefined {
|
||||
const element = findElement(node)
|
||||
if (!element) return
|
||||
return findDiffSide(element)
|
||||
|
||||
const line = element.closest("[data-line], [data-alt-line]")
|
||||
if (line instanceof HTMLElement) {
|
||||
const type = line.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") return "additions"
|
||||
}
|
||||
|
||||
const code = element.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return
|
||||
|
||||
if (code.hasAttribute("data-deletions")) return "deletions"
|
||||
return "additions"
|
||||
}
|
||||
|
||||
export function Diff<T>(props: DiffProps<T>) {
|
||||
@@ -54,7 +64,6 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
let dragMoved = false
|
||||
let lastSelection: SelectedLineRange | null = null
|
||||
let pendingSelectionEnd = false
|
||||
const bridge = createLineNumberSelectionBridge()
|
||||
|
||||
const [local, others] = splitProps(props, [
|
||||
"before",
|
||||
@@ -64,9 +73,6 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onLineSelected",
|
||||
"onLineSelectionEnd",
|
||||
"onLineNumberSelectionEnd",
|
||||
"onRendered",
|
||||
])
|
||||
|
||||
@@ -88,20 +94,6 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
const base = {
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
onLineSelected: (range: SelectedLineRange | null) => {
|
||||
const fixed = fixSelection(range)
|
||||
const next = fixed === undefined ? range : fixed
|
||||
lastSelection = next
|
||||
local.onLineSelected?.(next)
|
||||
},
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => {
|
||||
const fixed = fixSelection(range)
|
||||
const next = fixed === undefined ? range : fixed
|
||||
lastSelection = next
|
||||
local.onLineSelectionEnd?.(next)
|
||||
if (!bridge.consume(next)) return
|
||||
requestAnimationFrame(() => local.onLineNumberSelectionEnd?.(next))
|
||||
},
|
||||
}
|
||||
|
||||
const perf = large() ? { ...base, ...largeOptions } : base
|
||||
@@ -286,7 +278,61 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
observer.observe(container, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => {
|
||||
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||
for (const node of existing) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
|
||||
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = rowIndex(root, split, range.start, range.side)
|
||||
if (start === undefined) continue
|
||||
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return rowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
const first = Math.min(start, end)
|
||||
const last = Math.max(start, end)
|
||||
|
||||
for (const row of rows) {
|
||||
const idx = lineIndex(split, row)
|
||||
if (idx === undefined) continue
|
||||
if (idx < first || idx > last) continue
|
||||
row.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
|
||||
if (Number.isNaN(idx)) continue
|
||||
if (idx < first || idx > last) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: SelectedLineRange | null) => {
|
||||
const active = current()
|
||||
if (!active) return
|
||||
|
||||
@@ -298,10 +344,9 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
|
||||
lastSelection = fixed
|
||||
active.setSelectedLines(fixed)
|
||||
restoreShadowTextSelection(preserve?.root, preserve?.text)
|
||||
}
|
||||
|
||||
const updateSelection = (preserveTextSelection = false) => {
|
||||
const updateSelection = () => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
@@ -339,12 +384,6 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
if (side) selected.side = side
|
||||
if (endSide && side && endSide !== side) selected.endSide = endSide
|
||||
|
||||
const text = preserveTextSelection && domRange ? domRange.cloneRange() : undefined
|
||||
if (text) {
|
||||
setSelectedLines(selected, { root, text })
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedLines(selected)
|
||||
}
|
||||
|
||||
@@ -353,12 +392,11 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
|
||||
selectionFrame = requestAnimationFrame(() => {
|
||||
selectionFrame = undefined
|
||||
const finishing = pendingSelectionEnd
|
||||
updateSelection(finishing)
|
||||
updateSelection()
|
||||
|
||||
if (!pendingSelectionEnd) return
|
||||
pendingSelectionEnd = false
|
||||
local.onLineSelectionEnd?.(lastSelection)
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -428,13 +466,9 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
if (event.button !== 0) return
|
||||
|
||||
const { line, numberColumn, side } = lineFromMouseEvent(event)
|
||||
if (numberColumn) {
|
||||
bridge.begin(true, line)
|
||||
return
|
||||
}
|
||||
if (numberColumn) return
|
||||
if (line === undefined) return
|
||||
|
||||
bridge.begin(false, line)
|
||||
dragStart = line
|
||||
dragEnd = line
|
||||
dragSide = side
|
||||
@@ -444,10 +478,6 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
|
||||
const next = lineFromMouseEvent(event)
|
||||
if (bridge.track(event.buttons, next.line)) return
|
||||
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if ((event.buttons & 1) === 0) {
|
||||
@@ -456,11 +486,10 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
dragMoved = false
|
||||
bridge.finish()
|
||||
return
|
||||
}
|
||||
|
||||
const { line, side } = next
|
||||
const { line, side } = lineFromMouseEvent(event)
|
||||
if (line === undefined) return
|
||||
|
||||
dragEnd = line
|
||||
@@ -471,11 +500,6 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
|
||||
if (bridge.finish() === "numbers") {
|
||||
return
|
||||
}
|
||||
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if (!dragMoved) {
|
||||
@@ -487,7 +511,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
}
|
||||
if (dragSide) selected.side = dragSide
|
||||
setSelectedLines(selected)
|
||||
local.onLineSelectionEnd?.(lastSelection)
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragSide = undefined
|
||||
@@ -521,6 +545,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
const opts = options()
|
||||
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
|
||||
const virtualizer = getVirtualizer()
|
||||
const annotations = local.annotations
|
||||
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
|
||||
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
|
||||
|
||||
@@ -547,7 +572,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
contents: afterContents,
|
||||
cacheKey: cacheKey(afterContents),
|
||||
},
|
||||
lineAnnotations: [],
|
||||
lineAnnotations: annotations,
|
||||
containerWrapper: container,
|
||||
})
|
||||
|
||||
@@ -569,22 +594,10 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
onCleanup(() => monitor.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
rendered()
|
||||
const active = current()
|
||||
if (!active) return
|
||||
active.setLineAnnotations(local.annotations ?? [])
|
||||
active.rerender()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
rendered()
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
markCommentedDiffLines(root, ranges)
|
||||
})
|
||||
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -626,7 +639,6 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
dragMoved = false
|
||||
bridge.reset()
|
||||
lastSelection = null
|
||||
pendingSelectionEnd = false
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { createMemo, createSignal, type JSX } from "solid-js"
|
||||
import { render as renderSolid } from "solid-js/web"
|
||||
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||
|
||||
export type LineCommentAnnotationMeta<T> =
|
||||
| { kind: "comment"; key: string; comment: T }
|
||||
| { kind: "draft"; key: string; range: SelectedLineRange }
|
||||
|
||||
type CommentProps = {
|
||||
id?: string
|
||||
open: boolean
|
||||
comment: JSX.Element
|
||||
selection: JSX.Element
|
||||
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
}
|
||||
|
||||
type DraftProps = {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
onInput: (value: string) => void
|
||||
onCancel: VoidFunction
|
||||
onSubmit: (value: string) => void
|
||||
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
}
|
||||
|
||||
export function createLineCommentAnnotationRenderer<T>(props: {
|
||||
renderComment: (comment: T) => CommentProps
|
||||
renderDraft: (range: SelectedLineRange) => DraftProps
|
||||
}) {
|
||||
const nodes = new Map<
|
||||
string,
|
||||
{
|
||||
host: HTMLDivElement
|
||||
dispose: VoidFunction
|
||||
setMeta: (meta: LineCommentAnnotationMeta<T>) => void
|
||||
}
|
||||
>()
|
||||
|
||||
const mount = (meta: LineCommentAnnotationMeta<T>) => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const host = document.createElement("div")
|
||||
host.setAttribute("data-prevent-autofocus", "")
|
||||
const [current, setCurrent] = createSignal(meta)
|
||||
if (meta.kind === "comment") {
|
||||
const view = createMemo(() => {
|
||||
const next = current()
|
||||
if (next.kind !== "comment") return props.renderComment(meta.comment)
|
||||
return props.renderComment(next.comment)
|
||||
})
|
||||
const dispose = renderSolid(
|
||||
() => (
|
||||
<LineComment
|
||||
inline
|
||||
id={view().id}
|
||||
open={view().open}
|
||||
comment={view().comment}
|
||||
selection={view().selection}
|
||||
onClick={view().onClick}
|
||||
onMouseEnter={view().onMouseEnter}
|
||||
/>
|
||||
),
|
||||
host,
|
||||
)
|
||||
|
||||
const node = { host, dispose, setMeta: setCurrent }
|
||||
nodes.set(meta.key, node)
|
||||
return node
|
||||
}
|
||||
|
||||
const view = createMemo(() => {
|
||||
const next = current()
|
||||
if (next.kind !== "draft") return props.renderDraft(meta.range)
|
||||
return props.renderDraft(next.range)
|
||||
})
|
||||
const dispose = renderSolid(
|
||||
() => (
|
||||
<LineCommentEditor
|
||||
inline
|
||||
value={view().value}
|
||||
selection={view().selection}
|
||||
onInput={view().onInput}
|
||||
onCancel={view().onCancel}
|
||||
onSubmit={view().onSubmit}
|
||||
onPopoverFocusOut={view().onPopoverFocusOut}
|
||||
/>
|
||||
),
|
||||
host,
|
||||
)
|
||||
|
||||
const node = { host, dispose, setMeta: setCurrent }
|
||||
nodes.set(meta.key, node)
|
||||
return node
|
||||
}
|
||||
|
||||
const render = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotation: A) => {
|
||||
const meta = annotation.metadata
|
||||
const node = nodes.get(meta.key) ?? mount(meta)
|
||||
if (!node) return
|
||||
node.setMeta(meta)
|
||||
return node.host
|
||||
}
|
||||
|
||||
const reconcile = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotations: A[]) => {
|
||||
const next = new Set(annotations.map((annotation) => annotation.metadata.key))
|
||||
for (const [key, node] of nodes) {
|
||||
if (next.has(key)) continue
|
||||
node.dispose()
|
||||
nodes.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
for (const [, node] of nodes) node.dispose()
|
||||
nodes.clear()
|
||||
}
|
||||
|
||||
return { render, reconcile, cleanup }
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
export const lineCommentStyles = `
|
||||
[data-component="line-comment"] {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
z-index: var(--line-comment-z, 30);
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] {
|
||||
position: relative;
|
||||
right: auto;
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-open] {
|
||||
z-index: var(--line-comment-open-z, 100);
|
||||
}
|
||||
@@ -29,20 +21,10 @@ export const lineCommentStyles = `
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
|
||||
background: var(--syntax-diff-add);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-component="icon"] {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-icon"] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-button"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
@@ -64,21 +46,6 @@ export const lineCommentStyles = `
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline][data-variant="default"] [data-slot="line-comment-popover"][data-inline-body] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-variant="editor"] [data-slot="line-comment-popover"] {
|
||||
width: 380px;
|
||||
max-width: min(380px, calc(100vw - 48px));
|
||||
@@ -146,50 +113,3 @@ export const lineCommentStyles = `
|
||||
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"] {
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-strong);
|
||||
border-radius: var(--radius-md);
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="ghost"] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="primary"] {
|
||||
background: var(--text-strong);
|
||||
border-color: var(--text-strong);
|
||||
color: var(--background-base);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"]:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
|
||||
let installed = false
|
||||
|
||||
export function installLineCommentStyles() {
|
||||
if (installed) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const id = "opencode-line-comment-styles"
|
||||
if (document.getElementById(id)) {
|
||||
installed = true
|
||||
return
|
||||
}
|
||||
|
||||
const style = document.createElement("style")
|
||||
style.id = id
|
||||
style.textContent = lineCommentStyles
|
||||
document.head.appendChild(style)
|
||||
installed = true
|
||||
}
|
||||
@@ -1,121 +1,52 @@
|
||||
import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { Button } from "./button"
|
||||
import { Icon } from "./icon"
|
||||
import { installLineCommentStyles } from "./line-comment-styles"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
||||
installLineCommentStyles()
|
||||
|
||||
export type LineCommentVariant = "default" | "editor" | "add"
|
||||
|
||||
function InlineGlyph(props: { icon: "comment" | "plus" }) {
|
||||
return (
|
||||
<svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<Show
|
||||
when={props.icon === "comment"}
|
||||
fallback={
|
||||
<path
|
||||
d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
|
||||
</Show>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export type LineCommentVariant = "default" | "editor"
|
||||
|
||||
export type LineCommentAnchorProps = {
|
||||
id?: string
|
||||
top?: number
|
||||
inline?: boolean
|
||||
hideButton?: boolean
|
||||
open: boolean
|
||||
variant?: LineCommentVariant
|
||||
icon?: "comment" | "plus"
|
||||
buttonLabel?: string
|
||||
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
class?: string
|
||||
popoverClass?: string
|
||||
children?: JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
|
||||
const hidden = () => !props.inline && props.top === undefined
|
||||
const hidden = () => props.top === undefined
|
||||
const variant = () => props.variant ?? "default"
|
||||
const icon = () => props.icon ?? "comment"
|
||||
const inlineBody = () => props.inline && props.hideButton
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="line-comment"
|
||||
data-prevent-autofocus=""
|
||||
data-variant={variant()}
|
||||
data-comment-id={props.id}
|
||||
data-open={props.open ? "" : undefined}
|
||||
data-inline={props.inline ? "" : undefined}
|
||||
classList={{
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
style={
|
||||
props.inline
|
||||
? undefined
|
||||
: {
|
||||
top: `${props.top ?? 0}px`,
|
||||
opacity: hidden() ? 0 : 1,
|
||||
"pointer-events": hidden() ? "none" : "auto",
|
||||
}
|
||||
}
|
||||
style={{
|
||||
top: `${props.top ?? 0}px`,
|
||||
opacity: hidden() ? 0 : 1,
|
||||
"pointer-events": hidden() ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={inlineBody()}
|
||||
fallback={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.buttonLabel}
|
||||
data-slot="line-comment-button"
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:mouseup={(e) => e.stopPropagation()}
|
||||
on:click={props.onClick as any}
|
||||
on:mouseenter={props.onMouseEnter as any}
|
||||
>
|
||||
<Show
|
||||
when={props.inline}
|
||||
fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
|
||||
>
|
||||
<InlineGlyph icon={icon()} />
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
data-slot="line-comment-popover"
|
||||
classList={{
|
||||
[props.popoverClass ?? ""]: !!props.popoverClass,
|
||||
}}
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:focusout={props.onPopoverFocusOut as any}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<button type="button" data-slot="line-comment-button" onClick={props.onClick} onMouseEnter={props.onMouseEnter}>
|
||||
<Icon name="comment" size="small" />
|
||||
</button>
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
data-slot="line-comment-popover"
|
||||
data-inline-body=""
|
||||
classList={{
|
||||
[props.popoverClass ?? ""]: !!props.popoverClass,
|
||||
}}
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:click={props.onClick as any}
|
||||
on:mouseenter={props.onMouseEnter as any}
|
||||
on:focusout={props.onPopoverFocusOut as any}
|
||||
onFocusOut={props.onPopoverFocusOut}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
@@ -134,7 +65,7 @@ export const LineComment = (props: LineCommentProps) => {
|
||||
const [split, rest] = splitProps(props, ["comment", "selection"])
|
||||
|
||||
return (
|
||||
<LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
|
||||
<LineCommentAnchor {...rest} variant="default">
|
||||
<div data-slot="line-comment-content">
|
||||
<div data-slot="line-comment-text">{split.comment}</div>
|
||||
<div data-slot="line-comment-label">
|
||||
@@ -147,25 +78,6 @@ export const LineComment = (props: LineCommentProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const LineCommentAdd = (props: LineCommentAddProps) => {
|
||||
const [split, rest] = splitProps(props, ["label"])
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<LineCommentAnchor
|
||||
{...rest}
|
||||
open={false}
|
||||
variant="add"
|
||||
icon="plus"
|
||||
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
@@ -197,16 +109,11 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
const refs = {
|
||||
textarea: undefined as HTMLTextAreaElement | undefined,
|
||||
}
|
||||
const [text, setText] = createSignal(split.value)
|
||||
|
||||
const focus = () => refs.textarea?.focus()
|
||||
|
||||
createEffect(() => {
|
||||
setText(split.value)
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
const value = text().trim()
|
||||
const value = split.value.trim()
|
||||
if (!value) return
|
||||
split.onSubmit(value)
|
||||
}
|
||||
@@ -217,7 +124,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
})
|
||||
|
||||
return (
|
||||
<LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
|
||||
<LineCommentAnchor {...rest} open={true} variant="editor" onClick={() => focus()}>
|
||||
<div data-slot="line-comment-editor">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
@@ -226,23 +133,19 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
data-slot="line-comment-textarea"
|
||||
rows={split.rows ?? 3}
|
||||
placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
|
||||
value={text()}
|
||||
on:input={(e) => {
|
||||
const value = (e.currentTarget as HTMLTextAreaElement).value
|
||||
setText(value)
|
||||
split.onInput(value)
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
const event = e as KeyboardEvent
|
||||
event.stopPropagation()
|
||||
value={split.value}
|
||||
onInput={(e) => split.onInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
split.onCancel()
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter") return
|
||||
if (e.shiftKey) return
|
||||
event.preventDefault()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
submit()
|
||||
}}
|
||||
/>
|
||||
@@ -252,37 +155,12 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
{split.selection}
|
||||
{i18n.t("ui.lineComment.editorLabel.suffix")}
|
||||
</div>
|
||||
<Show
|
||||
when={!props.inline}
|
||||
fallback={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-action"
|
||||
data-variant="ghost"
|
||||
on:click={split.onCancel as any}
|
||||
>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-action"
|
||||
data-variant="primary"
|
||||
disabled={text().trim().length === 0}
|
||||
on:click={submit as any}
|
||||
>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button size="small" variant="ghost" onClick={split.onCancel}>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</Button>
|
||||
<Button size="small" variant="primary" disabled={text().trim().length === 0} onClick={submit}>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button size="small" variant="ghost" onClick={split.onCancel}>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</Button>
|
||||
<Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</LineCommentAnchor>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RadioGroup } from "./radio-group"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { ScrollView } from "./scroll-view"
|
||||
@@ -11,15 +12,12 @@ import { useDiffComponent } from "../context/diff"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
||||
import { cloneSelectedLineRange, lineInSelectedRange } from "../pierre/selection-bridge"
|
||||
import { createLineCommentAnnotationRenderer, type LineCommentAnnotationMeta } from "./line-comment-annotations"
|
||||
|
||||
const MAX_DIFF_CHANGED_LINES = 500
|
||||
|
||||
@@ -139,7 +137,42 @@ type SessionReviewSelection = {
|
||||
range: SelectedLineRange
|
||||
}
|
||||
|
||||
type SessionReviewAnnotation = LineCommentAnnotationMeta<SessionReviewComment>
|
||||
function findSide(element: HTMLElement): "additions" | "deletions" | undefined {
|
||||
const typed = element.closest("[data-line-type]")
|
||||
if (typed instanceof HTMLElement) {
|
||||
const type = typed.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") return "additions"
|
||||
}
|
||||
|
||||
const code = element.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return
|
||||
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
function findMarker(root: ShadowRoot, range: SelectedLineRange) {
|
||||
const marker = (line: number, side?: "additions" | "deletions") => {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (nodes.length === 0) return
|
||||
if (!side) return nodes[0]
|
||||
const match = nodes.find((node) => findSide(node) === side)
|
||||
return match ?? nodes[0]
|
||||
}
|
||||
|
||||
const a = marker(range.start, range.side)
|
||||
const b = marker(range.end, range.endSide ?? range.side)
|
||||
if (!a) return b
|
||||
if (!b) return a
|
||||
return a.getBoundingClientRect().top > b.getBoundingClientRect().top ? a : b
|
||||
}
|
||||
|
||||
function markerTop(wrapper: HTMLElement, marker: HTMLElement) {
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const rect = marker.getBoundingClientRect()
|
||||
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
||||
}
|
||||
|
||||
export const SessionReview = (props: SessionReviewProps) => {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
@@ -203,7 +236,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
setOpened(focus)
|
||||
|
||||
const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
|
||||
if (comment) setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) })
|
||||
if (comment) setSelection({ file: comment.file, range: comment.selection })
|
||||
|
||||
const current = open()
|
||||
if (!current.includes(focus.file)) {
|
||||
@@ -216,11 +249,11 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const root = scroll
|
||||
if (!root) return
|
||||
|
||||
const wrapper = anchors.get(focus.file)
|
||||
const anchor = wrapper?.querySelector(`[data-comment-id="${focus.id}"]`)
|
||||
const ready = anchor instanceof HTMLElement
|
||||
const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`)
|
||||
const ready =
|
||||
anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0"
|
||||
|
||||
const target = ready ? anchor : wrapper
|
||||
const target = ready ? anchor : anchors.get(focus.file)
|
||||
if (!target) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => scrollTo(attempt + 1))
|
||||
@@ -343,114 +376,51 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
})
|
||||
|
||||
const [draft, setDraft] = createSignal("")
|
||||
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
||||
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
||||
|
||||
const annotationLine = (range: SelectedLineRange) => Math.max(range.start, range.end)
|
||||
const annotationSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
|
||||
const selected = () => selectedLines()
|
||||
const getRoot = () => {
|
||||
const el = wrapper
|
||||
if (!el) return
|
||||
|
||||
const annotations = createMemo<DiffLineAnnotation<SessionReviewAnnotation>[]>(() => {
|
||||
const list = comments().map((comment) => ({
|
||||
side: annotationSide(comment.selection),
|
||||
lineNumber: annotationLine(comment.selection),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${comment.id}`,
|
||||
comment,
|
||||
} satisfies SessionReviewAnnotation,
|
||||
}))
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
return host.shadowRoot ?? undefined
|
||||
}
|
||||
|
||||
const updateAnchors = () => {
|
||||
const el = wrapper
|
||||
if (!el) return
|
||||
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const next: Record<string, number> = {}
|
||||
for (const item of comments()) {
|
||||
const marker = findMarker(root, item.selection)
|
||||
if (!marker) continue
|
||||
next[item.id] = markerTop(el, marker)
|
||||
}
|
||||
setPositions(next)
|
||||
|
||||
const range = draftRange()
|
||||
if (range) {
|
||||
return [
|
||||
...list,
|
||||
{
|
||||
side: annotationSide(range),
|
||||
lineNumber: annotationLine(range),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${file}`,
|
||||
range,
|
||||
} satisfies SessionReviewAnnotation,
|
||||
},
|
||||
]
|
||||
if (!range) {
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
const marker = findMarker(root, range)
|
||||
if (!marker) {
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const annotationRenderer = createLineCommentAnnotationRenderer<SessionReviewComment>({
|
||||
renderComment: (comment) => ({
|
||||
id: comment.id,
|
||||
open: isCommentOpen(comment),
|
||||
comment: comment.comment,
|
||||
selection: selectionLabel(comment.selection),
|
||||
onMouseEnter: () =>
|
||||
setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) }),
|
||||
onClick: () => {
|
||||
if (isCommentOpen(comment)) {
|
||||
setOpened(null)
|
||||
return
|
||||
}
|
||||
setDraftTop(markerTop(el, marker))
|
||||
}
|
||||
|
||||
openComment(comment)
|
||||
},
|
||||
}),
|
||||
renderDraft: (range) => ({
|
||||
value: draft(),
|
||||
selection: selectionLabel(range),
|
||||
onInput: setDraft,
|
||||
onCancel: () => {
|
||||
setDraft("")
|
||||
setCommenting(null)
|
||||
},
|
||||
onSubmit: (comment) => {
|
||||
props.onLineComment?.({
|
||||
file,
|
||||
selection: cloneSelectedLineRange(range),
|
||||
comment,
|
||||
preview: selectionPreview(item(), range),
|
||||
})
|
||||
setDraft("")
|
||||
setCommenting(null)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const renderAnnotation = (annotation: DiffLineAnnotation<SessionReviewAnnotation>) =>
|
||||
annotationRenderer.render(annotation)
|
||||
|
||||
const renderHoverUtility = (
|
||||
getHoveredLine: () => { lineNumber: number; side?: "additions" | "deletions" },
|
||||
) =>
|
||||
createHoverCommentUtility({
|
||||
label: i18n.t("ui.lineComment.submit"),
|
||||
getHoveredLine,
|
||||
onSelect: (hovered) => {
|
||||
const current = opened()?.file === file ? null : selected()
|
||||
const range = (() => {
|
||||
if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) {
|
||||
return cloneSelectedLineRange(current)
|
||||
}
|
||||
const next: SelectedLineRange = {
|
||||
start: hovered.lineNumber,
|
||||
end: hovered.lineNumber,
|
||||
}
|
||||
if (hovered.side) next.side = hovered.side
|
||||
return next
|
||||
})()
|
||||
|
||||
openDraft(range)
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
annotationRenderer.reconcile(annotations())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
anchors.delete(file)
|
||||
annotationRenderer.cleanup()
|
||||
})
|
||||
const scheduleAnchors = () => {
|
||||
requestAnimationFrame(updateAnchors)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!isImage()) return
|
||||
@@ -468,8 +438,15 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
draftRange()
|
||||
comments()
|
||||
scheduleAnchors()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const range = draftRange()
|
||||
if (!range) return
|
||||
setDraft("")
|
||||
scheduleAnchors()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -529,39 +506,27 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
|
||||
if (!range) {
|
||||
setSelection(null)
|
||||
setDraft("")
|
||||
setCommenting(null)
|
||||
return
|
||||
}
|
||||
|
||||
setSelection({ file, range: cloneSelectedLineRange(range) })
|
||||
}
|
||||
|
||||
const openDraft = (range: SelectedLineRange) => {
|
||||
const next = cloneSelectedLineRange(range)
|
||||
setOpened(null)
|
||||
setSelection({ file, range: cloneSelectedLineRange(next) })
|
||||
setCommenting({ file, range: next })
|
||||
setSelection({ file, range })
|
||||
}
|
||||
|
||||
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!props.onLineComment) return
|
||||
|
||||
if (!range) {
|
||||
setDraft("")
|
||||
setCommenting(null)
|
||||
return
|
||||
}
|
||||
|
||||
const next = cloneSelectedLineRange(range)
|
||||
setOpened(null)
|
||||
setSelection({ file, range: next })
|
||||
setCommenting(null)
|
||||
setSelection({ file, range })
|
||||
setCommenting({ file, range })
|
||||
}
|
||||
|
||||
const openComment = (comment: SessionReviewComment) => {
|
||||
setOpened({ file: comment.file, id: comment.id })
|
||||
setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) })
|
||||
setSelection({ file: comment.file, range: comment.selection })
|
||||
}
|
||||
|
||||
const isCommentOpen = (comment: SessionReviewComment) => {
|
||||
@@ -642,6 +607,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
ref={(el) => {
|
||||
wrapper = el
|
||||
anchors.set(file, el)
|
||||
scheduleAnchors()
|
||||
}}
|
||||
>
|
||||
<Show when={expanded()}>
|
||||
@@ -692,18 +658,11 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
diffStyle={diffStyle()}
|
||||
onRendered={() => {
|
||||
props.onDiffRendered?.()
|
||||
scheduleAnchors()
|
||||
}}
|
||||
enableLineSelection={props.onLineComment != null}
|
||||
enableHoverUtility={props.onLineComment != null}
|
||||
onLineSelected={handleLineSelected}
|
||||
onLineSelectionEnd={handleLineSelectionEnd}
|
||||
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
|
||||
if (!range) return
|
||||
openDraft(range)
|
||||
}}
|
||||
annotations={annotations()}
|
||||
renderAnnotation={renderAnnotation}
|
||||
renderHoverUtility={props.onLineComment ? renderHoverUtility : undefined}
|
||||
selectedLines={selectedLines()}
|
||||
commentedLines={commentedLines()}
|
||||
before={{
|
||||
@@ -717,6 +676,50 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<For each={comments()}>
|
||||
{(comment) => (
|
||||
<LineComment
|
||||
id={comment.id}
|
||||
top={positions()[comment.id]}
|
||||
onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
|
||||
onClick={() => {
|
||||
if (isCommentOpen(comment)) {
|
||||
setOpened(null)
|
||||
return
|
||||
}
|
||||
|
||||
openComment(comment)
|
||||
}}
|
||||
open={isCommentOpen(comment)}
|
||||
comment={comment.comment}
|
||||
selection={selectionLabel(comment.selection)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={draftRange()}>
|
||||
{(range) => (
|
||||
<Show when={draftTop() !== undefined}>
|
||||
<LineCommentEditor
|
||||
top={draftTop()}
|
||||
value={draft()}
|
||||
selection={selectionLabel(range())}
|
||||
onInput={setDraft}
|
||||
onCancel={() => setCommenting(null)}
|
||||
onSubmit={(comment) => {
|
||||
props.onLineComment?.({
|
||||
file,
|
||||
selection: range(),
|
||||
comment,
|
||||
preview: selectionPreview(item(), range()),
|
||||
})
|
||||
setCommenting(null)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
|
||||
@@ -91,7 +91,7 @@ function partState(part: PartType, showReasoningSummaries: boolean) {
|
||||
}
|
||||
if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined
|
||||
if (part.type === "reasoning") {
|
||||
if (showReasoningSummaries) return "visible" as const
|
||||
if (showReasoningSummaries && part.text?.trim()) return "visible" as const
|
||||
return
|
||||
}
|
||||
if (PART_MAPPING[part.type]) return "visible" as const
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
export type HoverCommentLine = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
export function createHoverCommentUtility(props: {
|
||||
label: string
|
||||
getHoveredLine: () => HoverCommentLine | undefined
|
||||
onSelect: (line: HoverCommentLine) => void
|
||||
}) {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const button = document.createElement("button")
|
||||
button.type = "button"
|
||||
button.ariaLabel = props.label
|
||||
button.textContent = "+"
|
||||
button.style.width = "20px"
|
||||
button.style.height = "20px"
|
||||
button.style.display = "flex"
|
||||
button.style.alignItems = "center"
|
||||
button.style.justifyContent = "center"
|
||||
button.style.border = "none"
|
||||
button.style.borderRadius = "var(--radius-md)"
|
||||
button.style.background = "var(--syntax-diff-add)"
|
||||
button.style.color = "var(--white)"
|
||||
button.style.boxShadow = "var(--shadow-xs)"
|
||||
button.style.fontSize = "14px"
|
||||
button.style.lineHeight = "1"
|
||||
button.style.cursor = "pointer"
|
||||
button.style.position = "relative"
|
||||
button.style.left = "22px"
|
||||
|
||||
let line: HoverCommentLine | undefined
|
||||
|
||||
const sync = () => {
|
||||
const next = props.getHoveredLine()
|
||||
if (!next) return
|
||||
line = next
|
||||
}
|
||||
|
||||
const loop = () => {
|
||||
if (!button.isConnected) return
|
||||
sync()
|
||||
requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
const next = props.getHoveredLine() ?? line
|
||||
if (!next) return
|
||||
props.onSelect(next)
|
||||
}
|
||||
|
||||
requestAnimationFrame(loop)
|
||||
button.addEventListener("mouseenter", sync)
|
||||
button.addEventListener("mousemove", sync)
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sync()
|
||||
})
|
||||
button.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sync()
|
||||
})
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
open()
|
||||
})
|
||||
|
||||
return button
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
|
||||
export type CommentSide = "additions" | "deletions"
|
||||
|
||||
function annotationIndex(node: HTMLElement) {
|
||||
const value = node.dataset.lineAnnotation?.split(",")[1]
|
||||
if (!value) return
|
||||
const line = parseInt(value, 10)
|
||||
if (Number.isNaN(line)) return
|
||||
return line
|
||||
}
|
||||
|
||||
function clear(root: ShadowRoot) {
|
||||
const marked = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||
for (const node of marked) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
}
|
||||
|
||||
export function findDiffSide(node: HTMLElement): CommentSide {
|
||||
const line = node.closest("[data-line], [data-alt-line]")
|
||||
if (line instanceof HTMLElement) {
|
||||
const type = line.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") return "additions"
|
||||
}
|
||||
|
||||
const code = node.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return "additions"
|
||||
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
function lineIndex(split: boolean, node: HTMLElement) {
|
||||
const raw = node.dataset.lineIndex
|
||||
if (!raw) return
|
||||
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((x) => parseInt(x, 10))
|
||||
.filter((x) => !Number.isNaN(x))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
function rowIndex(root: ShadowRoot, split: boolean, line: number, side: CommentSide | undefined) {
|
||||
const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const target = side ?? "additions"
|
||||
for (const row of rows) {
|
||||
if (findDiffSide(row) === target) return lineIndex(split, row)
|
||||
if (parseInt(row.dataset.altLine ?? "", 10) === line) return lineIndex(split, row)
|
||||
}
|
||||
}
|
||||
|
||||
export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
|
||||
clear(root)
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = rowIndex(root, split, range.start, range.side)
|
||||
if (start === undefined) continue
|
||||
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return rowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
const first = Math.min(start, end)
|
||||
const last = Math.max(start, end)
|
||||
|
||||
for (const row of rows) {
|
||||
const idx = lineIndex(split, row)
|
||||
if (idx === undefined || idx < first || idx > last) continue
|
||||
row.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const idx = annotationIndex(annotation)
|
||||
if (idx === undefined || idx < first || idx > last) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
|
||||
clear(root)
|
||||
|
||||
const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = Math.max(1, Math.min(range.start, range.end))
|
||||
const end = Math.max(range.start, range.end)
|
||||
|
||||
for (let line = start; line <= end; line++) {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const line = annotationIndex(annotation)
|
||||
if (line === undefined || line < start || line > end) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { ComponentProps } from "solid-js"
|
||||
import { lineCommentStyles } from "../components/line-comment-styles"
|
||||
|
||||
export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
before: FileContents
|
||||
@@ -8,7 +7,6 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
annotations?: DiffLineAnnotation<T>[]
|
||||
selectedLines?: SelectedLineRange | null
|
||||
commentedLines?: SelectedLineRange[]
|
||||
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
|
||||
onRendered?: () => void
|
||||
class?: string
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
@@ -127,11 +125,7 @@ const unsafeCSS = `
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
}
|
||||
|
||||
${lineCommentStyles}
|
||||
|
||||
`
|
||||
}`
|
||||
|
||||
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
|
||||
return {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
|
||||
type PointerMode = "none" | "text" | "numbers"
|
||||
type Side = SelectedLineRange["side"]
|
||||
|
||||
export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange {
|
||||
const next: SelectedLineRange = {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}
|
||||
|
||||
if (range.side) next.side = range.side
|
||||
if (range.endSide) next.endSide = range.endSide
|
||||
return next
|
||||
}
|
||||
|
||||
export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) {
|
||||
if (!range) return false
|
||||
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
if (line < start || line > end) return false
|
||||
if (!side) return true
|
||||
|
||||
const first = range.side
|
||||
const last = range.endSide ?? first
|
||||
if (!first && !last) return true
|
||||
if (!first || !last) return (first ?? last) === side
|
||||
if (first === last) return first === side
|
||||
if (line === start) return first === side
|
||||
if (line === end) return last === side
|
||||
return true
|
||||
}
|
||||
|
||||
export function isSingleLineSelection(range: SelectedLineRange | null) {
|
||||
if (!range) return false
|
||||
return range.start === range.end && (range.endSide == null || range.endSide === range.side)
|
||||
}
|
||||
|
||||
export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) {
|
||||
if (!root || !range) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const selection =
|
||||
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
try {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
|
||||
export function createLineNumberSelectionBridge() {
|
||||
let mode: PointerMode = "none"
|
||||
let line: number | undefined
|
||||
let moved = false
|
||||
let pending = false
|
||||
|
||||
const clear = () => {
|
||||
mode = "none"
|
||||
line = undefined
|
||||
moved = false
|
||||
}
|
||||
|
||||
return {
|
||||
begin(numberColumn: boolean, next: number | undefined) {
|
||||
if (!numberColumn) {
|
||||
mode = "text"
|
||||
return
|
||||
}
|
||||
|
||||
mode = "numbers"
|
||||
line = next
|
||||
moved = false
|
||||
},
|
||||
track(buttons: number, next: number | undefined) {
|
||||
if (mode !== "numbers") return false
|
||||
|
||||
if ((buttons & 1) === 0) {
|
||||
clear()
|
||||
return true
|
||||
}
|
||||
|
||||
if (next !== undefined && line !== undefined && next !== line) moved = true
|
||||
return true
|
||||
},
|
||||
finish() {
|
||||
const current = mode
|
||||
pending = current === "numbers" && moved
|
||||
clear()
|
||||
return current
|
||||
},
|
||||
consume(range: SelectedLineRange | null) {
|
||||
const result = pending && !isSingleLineSelection(range)
|
||||
pending = false
|
||||
return result
|
||||
},
|
||||
reset() {
|
||||
pending = false
|
||||
clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
@import "../components/icon-button.css" layer(components);
|
||||
@import "../components/image-preview.css" layer(components);
|
||||
@import "../components/keybind.css" layer(components);
|
||||
@import "../components/line-comment.css" layer(components);
|
||||
@import "../components/text-field.css" layer(components);
|
||||
@import "../components/inline-input.css" layer(components);
|
||||
@import "../components/list.css" layer(components);
|
||||
|
||||
Reference in New Issue
Block a user