Compare commits
423
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc760ff84 | ||
|
|
ef984993bd | ||
|
|
95a6aa789f | ||
|
|
c9008b36c8 | ||
|
|
f73698b8fc | ||
|
|
8d553fa0a3 | ||
|
|
f0c56c8295 | ||
|
|
fb955eeed3 | ||
|
|
f28d3e0781 | ||
|
|
6f0ce9d84b | ||
|
|
973715f3da | ||
|
|
f2090b26c1 | ||
|
|
fca0166488 | ||
|
|
686dd330a0 | ||
|
|
193013a44d | ||
|
|
824ab4cecc | ||
|
|
7a42ecdddb | ||
|
|
dd011e879c | ||
|
|
04cf2b8268 | ||
|
|
1a1437e78b | ||
|
|
c76a81434d | ||
|
|
49cc872c44 | ||
|
|
f8dad0ae17 | ||
|
|
40a939f5f0 | ||
|
|
7729c6d895 | ||
|
|
7fb2081dce | ||
|
|
3d9f6c0fe0 | ||
|
|
190d2957eb | ||
|
|
b64d0768ba | ||
|
|
1867f1acaa | ||
|
|
00c079868a | ||
|
|
8b99648790 | ||
|
|
cb8b74d3f1 | ||
|
|
7e35d0c610 | ||
|
|
5364ab74a2 | ||
|
|
91f8dd5f57 | ||
|
|
850402f093 | ||
|
|
af72010e9f | ||
|
|
50883cc1e9 | ||
|
|
f2858a42ba | ||
|
|
3c21735b35 | ||
|
|
56dda4c98c | ||
|
|
6b8902e8b9 | ||
|
|
08a2d002b8 | ||
|
|
02a9495063 | ||
|
|
0fcba68d4c | ||
|
|
338393c016 | ||
|
|
8ebdbe0ea2 | ||
|
|
38f7071da9 | ||
|
|
d2d5f3c04b | ||
|
|
885d71636f | ||
|
|
d07f09925f | ||
|
|
c7b35342dd | ||
|
|
308e500832 | ||
|
|
4b878f6aeb | ||
|
|
1893473148 | ||
|
|
3a416f6f33 | ||
|
|
11a37834c2 | ||
|
|
d620455531 | ||
|
|
568eccb4c6 | ||
|
|
3a07dd8d96 | ||
|
|
802ccd3788 | ||
|
|
6042785c57 | ||
|
|
5d8664c13e | ||
|
|
3d0f24067c | ||
|
|
44049540b0 | ||
|
|
40f00ccc1c | ||
|
|
a301051263 | ||
|
|
fd61be4078 | ||
|
|
4a8bdc3c75 | ||
|
|
9c7629ce61 | ||
|
|
ba53c56a21 | ||
|
|
14c0989411 | ||
|
|
36bc07a5af | ||
|
|
270b807cdf | ||
|
|
bd52ce5640 | ||
|
|
a624871ccd | ||
|
|
819d09e64e | ||
|
|
9e6cb89101 | ||
|
|
a8347c3762 | ||
|
|
57b63ea83d | ||
|
|
c162074888 | ||
|
|
088eac9d4e | ||
|
|
5fe237a3fd | ||
|
|
ae398539c5 | ||
|
|
359360ad86 | ||
|
|
5d12eb9528 | ||
|
|
6fb4f2a7a5 | ||
|
|
48dfa45a9a | ||
|
|
97520c827e | ||
|
|
b75a89776d | ||
|
|
b909679367 | ||
|
|
639d1dd8fe | ||
|
|
7033b4d0a8 | ||
|
|
87c16374aa | ||
|
|
d366a1430f | ||
|
|
cfea5c73de | ||
|
|
2589eb207f | ||
|
|
ec7c72da3f | ||
|
|
a4b36a72ad | ||
|
|
e37a9081a6 | ||
|
|
a2469d933e | ||
|
|
3cde93bf2d | ||
|
|
898bcdec87 | ||
|
|
d5971e2da5 | ||
|
|
c71f4d4847 | ||
|
|
dec7827548 | ||
|
|
7faa8cb110 | ||
|
|
d8a4a125c0 | ||
|
|
50923f06f1 | ||
|
|
ba919fb619 | ||
|
|
47b4de3531 | ||
|
|
bb6d1d502f | ||
|
|
31e964e7cf | ||
|
|
06b2304a5f | ||
|
|
1b67339e4d | ||
|
|
1571246ba8 | ||
|
|
d730d8be01 | ||
|
|
e42cc85112 | ||
|
|
c7a79f1877 | ||
|
|
431f5347af | ||
|
|
1ed4a98233 | ||
|
|
db4ff89579 | ||
|
|
2f56761060 | ||
|
|
09286ccae0 | ||
|
|
4e959849f6 | ||
|
|
3690cafeb8 | ||
|
|
bcca253dec | ||
|
|
6d69ad5574 | ||
|
|
1f9be63e96 | ||
|
|
0873908030 | ||
|
|
4db2d94854 | ||
|
|
e5d52e4eb5 | ||
|
|
f20c0bffd3 | ||
|
|
9110e6a2a7 | ||
|
|
0888c02379 | ||
|
|
24ce49d9d7 | ||
|
|
5d69f00282 | ||
|
|
12016c8eb4 | ||
|
|
d6331cf792 | ||
|
|
2d7c9c9692 | ||
|
|
1aa18c6cd6 | ||
|
|
de25703e9d | ||
|
|
1133d87be0 | ||
|
|
42aa28d512 | ||
|
|
c6bd320003 | ||
|
|
24a9841322 | ||
|
|
8bf06cbcc1 | ||
|
|
be2e6f1926 | ||
|
|
72c12d59af | ||
|
|
8408e4702e | ||
|
|
ef14f64f9e | ||
|
|
3f60a6c2a4 | ||
|
|
d447b7694a | ||
|
|
5638b782c5 | ||
|
|
eb3f337695 | ||
|
|
c88ff3c08b | ||
|
|
e0e8b94384 | ||
|
|
8f4a72c57a | ||
|
|
ef155f3766 | ||
|
|
82a323ef70 | ||
|
|
a500eaa2d4 | ||
|
|
b714bb21d2 | ||
|
|
472d01fbaf | ||
|
|
a5c15a23e4 | ||
|
|
3d189b42a3 | ||
|
|
91a3ee642d | ||
|
|
37b24f4870 | ||
|
|
38572b8175 | ||
|
|
fc1addb8f4 | ||
|
|
83b7d8e04c | ||
|
|
d27dbfe062 | ||
|
|
f8904e3972 | ||
|
|
4a5823562c | ||
|
|
3aaf29b693 | ||
|
|
6b29896a35 | ||
|
|
1bb8574179 | ||
|
|
2611c35acc | ||
|
|
00c238777a | ||
|
|
e4b548fa76 | ||
|
|
e132dd2c70 | ||
|
|
fbe9669c57 | ||
|
|
c34ad7223a | ||
|
|
cc86a64bb5 | ||
|
|
3394402aef | ||
|
|
6cd3a59022 | ||
|
|
5aeb305344 | ||
|
|
6eb043aedb | ||
|
|
e96f6385c2 | ||
|
|
1109a282e0 | ||
|
|
25f3eef957 | ||
|
|
0ca75544ab | ||
|
|
572a037e5d | ||
|
|
ad92181fa7 | ||
|
|
c56f4aa5d8 | ||
|
|
a344a766fd | ||
|
|
bca793d064 | ||
|
|
ad3c192837 | ||
|
|
5512231ca8 | ||
|
|
bad394cd49 | ||
|
|
3b97580621 | ||
|
|
cb88fe26aa | ||
|
|
e345b89ce5 | ||
|
|
26c7b240ba | ||
|
|
d327a2b1cf | ||
|
|
c1b03b728a | ||
|
|
2a2437bf22 | ||
|
|
4ccb82e81a | ||
|
|
92912219df | ||
|
|
bab3124e8b | ||
|
|
7a66ec6bc9 | ||
|
|
3a505b2691 | ||
|
|
20f43372f6 | ||
|
|
fb79dd7bf8 | ||
|
|
4025b655a4 | ||
|
|
7379903568 | ||
|
|
a685e7a805 | ||
|
|
ce7484b4f5 | ||
|
|
0bc1dcbe1b | ||
|
|
a69b339baf | ||
|
|
26f835cdd2 | ||
|
|
bd3d1413fd | ||
|
|
2c17a980ff | ||
|
|
b784c923a8 | ||
|
|
ea96f898c0 | ||
|
|
47435f6e17 | ||
|
|
df59d1412b | ||
|
|
46739ca7cd | ||
|
|
d055c1cad6 | ||
|
|
adfbfe350d | ||
|
|
652a776554 | ||
|
|
1d78100f63 | ||
|
|
57a5d5fd34 | ||
|
|
14684d8e75 | ||
|
|
2cac848823 | ||
|
|
5a3e0ef13a | ||
|
|
7ed4499748 | ||
|
|
4d5e86d8a5 | ||
|
|
222b6cda96 | ||
|
|
8e243c6500 | ||
|
|
98f3ff6273 | ||
|
|
ce08442732 | ||
|
|
8fcfbd697a | ||
|
|
a8669aba8f | ||
|
|
d31e9cff6a | ||
|
|
0cb11c2412 | ||
|
|
9b1d7047d4 | ||
|
|
703d634744 | ||
|
|
e273a31e70 | ||
|
|
277c68d8e5 | ||
|
|
10985671ad | ||
|
|
3dfbb70593 | ||
|
|
07947bab7d | ||
|
|
4eed55973f | ||
|
|
6e984378d7 | ||
|
|
4fd3141ab5 | ||
|
|
8d0a303af4 | ||
|
|
0186a85063 | ||
|
|
ed4e4843c2 | ||
|
|
a93a1b93e1 | ||
|
|
ace63b3ddb | ||
|
|
d338bd528c | ||
|
|
ea2d089db0 | ||
|
|
4226097228 | ||
|
|
e35a4131d0 | ||
|
|
0e669b6016 | ||
|
|
9163611989 | ||
|
|
d93cefd47a | ||
|
|
a580fb47d2 | ||
|
|
9d3c81a683 | ||
|
|
86e545a23e | ||
|
|
b0afdf6ea4 | ||
|
|
d8c25bfeb4 | ||
|
|
160ba295a8 | ||
|
|
16332a8583 | ||
|
|
ae6e85b2a4 | ||
|
|
fdad823edc | ||
|
|
5cc1d6097e | ||
|
|
8c1af9b445 | ||
|
|
ef979ccfa8 | ||
|
|
bb30e06855 | ||
|
|
b055f973df | ||
|
|
45fa5e7199 | ||
|
|
3ebf27aab9 | ||
|
|
1d041c8861 | ||
|
|
089ab9defa | ||
|
|
f7708efa5b | ||
|
|
60807846a9 | ||
|
|
afd0716cbd | ||
|
|
920255e8c6 | ||
|
|
21e0778002 | ||
|
|
d9363da9ee | ||
|
|
9b23130ac4 | ||
|
|
62a24c2dda | ||
|
|
3a3aa300bb | ||
|
|
cf50a289db | ||
|
|
3c85cf4fac | ||
|
|
878ddc6a0a | ||
|
|
3761121728 | ||
|
|
3aaa34be1e | ||
|
|
985c2a3d15 | ||
|
|
eb553f53ac | ||
|
|
d1482e1483 | ||
|
|
45f0050372 | ||
|
|
b5c8bd3421 | ||
|
|
2bab5e8c39 | ||
|
|
85b5f5b705 | ||
|
|
460a87f359 | ||
|
|
c190f5f611 | ||
|
|
7911cb62ab | ||
|
|
839c5cda12 | ||
|
|
67c985ce82 | ||
|
|
575f2cf2a5 | ||
|
|
933a491ade | ||
|
|
3b6b3e6fc8 | ||
|
|
8631d6c01d | ||
|
|
68bb8ce1da | ||
|
|
306fc77076 | ||
|
|
759ec104b6 | ||
|
|
ef205c3660 | ||
|
|
df3203d2dd | ||
|
|
ed439b2057 | ||
|
|
cd775a2862 | ||
|
|
b020758446 | ||
|
|
179c40749d | ||
|
|
1e25df21a2 | ||
|
|
ffc000de8e | ||
|
|
0b9e929f68 | ||
|
|
d0dcffefa7 | ||
|
|
7d46872775 | ||
|
|
afb04ed5d4 | ||
|
|
6d95f0d14c | ||
|
|
d018903887 | ||
|
|
d30e917385 | ||
|
|
72c09e1dcc | ||
|
|
bc1fd0633d | ||
|
|
88e2eb5416 | ||
|
|
b8848cfae1 | ||
|
|
4f51c0912d | ||
|
|
1c71604e0a | ||
|
|
e242fe19e4 | ||
|
|
f991a6c0b6 | ||
|
|
b1764b2ffd | ||
|
|
ebe5a2b74a | ||
|
|
9f20e0d14b | ||
|
|
ebb907d646 | ||
|
|
b8ee882126 | ||
|
|
693127d382 | ||
|
|
0d90a22f90 | ||
|
|
34ebe814dd | ||
|
|
1fb6c0b5b3 | ||
|
|
98aeb60a7f | ||
|
|
1608565c80 | ||
|
|
b06afd657d | ||
|
|
dd296f7033 | ||
|
|
fb7b2f6b4d | ||
|
|
e0f1c3c20e | ||
|
|
dec304a273 | ||
|
|
c9719dff72 | ||
|
|
7f95cc64c5 | ||
|
|
b525c03d20 | ||
|
|
8da5fd0a66 | ||
|
|
0303c29e3f | ||
|
|
adb0c4d4f9 | ||
|
|
991496a753 | ||
|
|
76db218674 | ||
|
|
29671c1397 | ||
|
|
f66624fe6e | ||
|
|
d475fd6137 | ||
|
|
93eee0daf4 | ||
|
|
445e0d7676 | ||
|
|
4018c863e3 | ||
|
|
a8f2884521 | ||
|
|
c0814da785 | ||
|
|
20dcff1e2e | ||
|
|
11dd281c92 | ||
|
|
548608b7ad | ||
|
|
4e0f509e7b | ||
|
|
ff3b174c42 | ||
|
|
70303d0b42 | ||
|
|
7ccf223c84 | ||
|
|
e9b9a62fe4 | ||
|
|
81c623f26e | ||
|
|
3696d1ded1 | ||
|
|
50f208d69f | ||
|
|
958320f9c1 | ||
|
|
d1ee4c8dca | ||
|
|
ac018e3a35 | ||
|
|
e6e9c15d34 | ||
|
|
aaee5fb680 | ||
|
|
ff0abacf4b | ||
|
|
0771e3a8be | ||
|
|
da952135ca | ||
|
|
789705ea96 | ||
|
|
ba54cee55e | ||
|
|
847e06f9e1 | ||
|
|
2db618dea3 | ||
|
|
ecab692ca1 | ||
|
|
59a323e9a8 | ||
|
|
658bf6fa58 | ||
|
|
a82ca86008 | ||
|
|
ed472d8a67 | ||
|
|
ff4414bb15 | ||
|
|
56ad2db020 | ||
|
|
ae811ad8d2 | ||
|
|
85df106713 | ||
|
|
892bb75265 | ||
|
|
a115565054 | ||
|
|
d82d22b2d7 | ||
|
|
d723147083 | ||
|
|
9f9f0fb8eb | ||
|
|
ecb274273a | ||
|
|
5f421883a8 | ||
|
|
fa97475ee8 | ||
|
|
0eaeb4588e | ||
|
|
1413d77b1f | ||
|
|
624dd94b5d | ||
|
|
d86f24b6b3 | ||
|
|
03de51bd3c | ||
|
|
8f9742d988 | ||
|
|
f6e7aefa72 | ||
|
|
e269788a8f | ||
|
|
66780195dc |
@@ -0,0 +1,15 @@
|
|||||||
|
adamdotdevin
|
||||||
|
Brendonovich
|
||||||
|
fwang
|
||||||
|
Hona
|
||||||
|
iamdavidhill
|
||||||
|
jayair
|
||||||
|
jlongster
|
||||||
|
kitlangton
|
||||||
|
kommander
|
||||||
|
MrMushrooooom
|
||||||
|
nexxeln
|
||||||
|
R44VC0RP
|
||||||
|
rekram1-node
|
||||||
|
RhysSullivan
|
||||||
|
thdxr
|
||||||
@@ -3,11 +3,13 @@ description: "Setup Bun with caching and install dependencies"
|
|||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Mount Bun Cache
|
- name: Cache Bun dependencies
|
||||||
uses: useblacksmith/stickydisk@v1
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
|
path: ~/.bun/install/cache
|
||||||
path: ~/.bun
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|||||||
@@ -1,7 +1,29 @@
|
|||||||
|
### Issue for this PR
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
### Type of change
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Refactor / code improvement
|
||||||
|
- [ ] Documentation
|
||||||
|
|
||||||
### What does this PR do?
|
### What does this PR do?
|
||||||
|
|
||||||
Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
|
Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
|
||||||
|
|
||||||
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
|
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
|
||||||
|
|
||||||
### How did you verify your code works?
|
### How did you verify your code works?
|
||||||
|
|
||||||
|
### Screenshots / recordings
|
||||||
|
|
||||||
|
_If this is a UI change, please include a screenshot or recording._
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] I have tested my changes locally
|
||||||
|
- [ ] I have not included unrelated changes in this PR
|
||||||
|
|
||||||
|
_If you do not follow this template your PR will be automatically rejected._
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ name: duplicate-issues
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [opened]
|
types: [opened, edited]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-duplicates:
|
check-duplicates:
|
||||||
|
if: github.event.action == 'opened'
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -34,7 +35,7 @@ jobs:
|
|||||||
"webfetch": "deny"
|
"webfetch": "deny"
|
||||||
}
|
}
|
||||||
run: |
|
run: |
|
||||||
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:
|
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
|
||||||
|
|
||||||
Issue number: ${{ github.event.issue.number }}
|
Issue number: ${{ github.event.issue.number }}
|
||||||
|
|
||||||
@@ -115,3 +116,62 @@ jobs:
|
|||||||
If you believe this was flagged incorrectly, please let a maintainer know.
|
If you believe this was flagged incorrectly, please let a maintainer know.
|
||||||
|
|
||||||
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
|
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
|
||||||
|
|
||||||
|
recheck-compliance:
|
||||||
|
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Install opencode
|
||||||
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
- name: Recheck compliance
|
||||||
|
env:
|
||||||
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
OPENCODE_PERMISSION: |
|
||||||
|
{
|
||||||
|
"bash": {
|
||||||
|
"*": "deny",
|
||||||
|
"gh issue*": "allow"
|
||||||
|
},
|
||||||
|
"webfetch": "deny"
|
||||||
|
}
|
||||||
|
run: |
|
||||||
|
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
|
||||||
|
|
||||||
|
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||||
|
|
||||||
|
Re-check whether the issue now follows our contributing guidelines and issue templates.
|
||||||
|
|
||||||
|
This project has three issue templates that every issue MUST use one of:
|
||||||
|
|
||||||
|
1. Bug Report - requires a Description field with real content
|
||||||
|
2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]:
|
||||||
|
3. Question - requires the Question field with real content
|
||||||
|
|
||||||
|
Additionally check:
|
||||||
|
- No AI-generated walls of text (long, AI-generated descriptions are not acceptable)
|
||||||
|
- The issue has real content, not just template placeholder text left unchanged
|
||||||
|
- Bug reports should include some context about how to reproduce
|
||||||
|
- Feature requests should explain the problem or need
|
||||||
|
- We want to push for having the user provide system description & information
|
||||||
|
|
||||||
|
Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content.
|
||||||
|
|
||||||
|
If the issue is NOW compliant:
|
||||||
|
1. Remove the needs:compliance label: gh issue edit ${{ github.event.issue.number }} --remove-label needs:compliance
|
||||||
|
2. Find and delete the previous compliance comment (the one containing <!-- issue-compliance -->) using: gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments --jq '.[] | select(.body | contains(\"<!-- issue-compliance -->\")) | .id' then delete it with: gh api -X DELETE repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments/{id}
|
||||||
|
3. Post a short comment thanking them for updating the issue.
|
||||||
|
|
||||||
|
If the issue is STILL not compliant:
|
||||||
|
Post a comment explaining what still needs to be fixed. Keep the needs:compliance label."
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
name: nix-desktop
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev]
|
|
||||||
paths:
|
|
||||||
- "flake.nix"
|
|
||||||
- "flake.lock"
|
|
||||||
- "nix/**"
|
|
||||||
- "packages/app/**"
|
|
||||||
- "packages/desktop/**"
|
|
||||||
- ".github/workflows/nix-desktop.yml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "flake.nix"
|
|
||||||
- "flake.lock"
|
|
||||||
- "nix/**"
|
|
||||||
- "packages/app/**"
|
|
||||||
- "packages/desktop/**"
|
|
||||||
- ".github/workflows/nix-desktop.yml"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
nix-desktop:
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os:
|
|
||||||
- blacksmith-4vcpu-ubuntu-2404
|
|
||||||
- blacksmith-4vcpu-ubuntu-2404-arm
|
|
||||||
- macos-15-intel
|
|
||||||
- macos-latest
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup Nix
|
|
||||||
uses: nixbuild/nix-quick-install-action@v34
|
|
||||||
|
|
||||||
- name: Build desktop via flake
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
nix --version
|
|
||||||
nix build .#desktop -L
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
name: nix-eval
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
nix-eval:
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Nix
|
||||||
|
uses: nixbuild/nix-quick-install-action@v34
|
||||||
|
|
||||||
|
- name: Evaluate flake outputs (all systems)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
nix --version
|
||||||
|
|
||||||
|
echo "=== Flake metadata ==="
|
||||||
|
nix flake metadata
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Flake structure ==="
|
||||||
|
nix flake show --all-systems
|
||||||
|
|
||||||
|
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
|
||||||
|
PACKAGES="opencode"
|
||||||
|
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
|
||||||
|
OPTIONAL_PACKAGES="desktop"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Evaluating packages for all systems ==="
|
||||||
|
for system in $SYSTEMS; do
|
||||||
|
echo ""
|
||||||
|
echo "--- $system ---"
|
||||||
|
for pkg in $PACKAGES; do
|
||||||
|
printf " %s: " "$pkg"
|
||||||
|
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
|
||||||
|
echo "✓"
|
||||||
|
else
|
||||||
|
echo "✗"
|
||||||
|
echo "::error::Evaluation failed for packages.$system.$pkg"
|
||||||
|
echo "$output"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Evaluating optional packages ==="
|
||||||
|
for system in $SYSTEMS; do
|
||||||
|
echo ""
|
||||||
|
echo "--- $system ---"
|
||||||
|
for pkg in $OPTIONAL_PACKAGES; do
|
||||||
|
printf " %s: " "$pkg"
|
||||||
|
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
|
||||||
|
echo "✓"
|
||||||
|
else
|
||||||
|
echo "✗"
|
||||||
|
echo "::warning::Evaluation failed for packages.$system.$pkg"
|
||||||
|
echo "$output"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Evaluating devShells for all systems ==="
|
||||||
|
for system in $SYSTEMS; do
|
||||||
|
printf "%s: " "$system"
|
||||||
|
if output=$(nix eval ".#devShells.$system.default.drvPath" --raw 2>&1); then
|
||||||
|
echo "✓"
|
||||||
|
else
|
||||||
|
echo "✗"
|
||||||
|
echo "::error::Evaluation failed for devShells.$system.default"
|
||||||
|
echo "$output"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== All evaluations passed ==="
|
||||||
@@ -6,7 +6,7 @@ permissions:
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [dev]
|
branches: [dev, beta]
|
||||||
paths:
|
paths:
|
||||||
- "bun.lock"
|
- "bun.lock"
|
||||||
- "package.json"
|
- "package.json"
|
||||||
|
|||||||
@@ -6,17 +6,6 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-duplicates:
|
check-duplicates:
|
||||||
if: |
|
|
||||||
github.event.pull_request.user.login != 'actions-user' &&
|
|
||||||
github.event.pull_request.user.login != 'opencode' &&
|
|
||||||
github.event.pull_request.user.login != 'rekram1-node' &&
|
|
||||||
github.event.pull_request.user.login != 'thdxr' &&
|
|
||||||
github.event.pull_request.user.login != 'kommander' &&
|
|
||||||
github.event.pull_request.user.login != 'jayair' &&
|
|
||||||
github.event.pull_request.user.login != 'fwang' &&
|
|
||||||
github.event.pull_request.user.login != 'adamdotdevin' &&
|
|
||||||
github.event.pull_request.user.login != 'iamdavidhill' &&
|
|
||||||
github.event.pull_request.user.login != 'opencode-agent[bot]'
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -27,16 +16,31 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Check team membership
|
||||||
|
id: team-check
|
||||||
|
run: |
|
||||||
|
LOGIN="${{ github.event.pull_request.user.login }}"
|
||||||
|
if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then
|
||||||
|
echo "is_team=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Skipping: $LOGIN is a team member or bot"
|
||||||
|
else
|
||||||
|
echo "is_team=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
|
if: steps.team-check.outputs.is_team != 'true'
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: steps.team-check.outputs.is_team != 'true'
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
- name: Install opencode
|
- name: Install opencode
|
||||||
|
if: steps.team-check.outputs.is_team != 'true'
|
||||||
run: curl -fsSL https://opencode.ai/install | bash
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
- name: Build prompt
|
- name: Build prompt
|
||||||
|
if: steps.team-check.outputs.is_team != 'true'
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
@@ -53,6 +57,7 @@ jobs:
|
|||||||
} > pr_info.txt
|
} > pr_info.txt
|
||||||
|
|
||||||
- name: Check for duplicate PRs
|
- name: Check for duplicate PRs
|
||||||
|
if: steps.team-check.outputs.is_team != 'true'
|
||||||
env:
|
env:
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -60,9 +65,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
|
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
|
||||||
|
|
||||||
|
if [ "$COMMENT" != "No duplicate PRs found" ]; then
|
||||||
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
|
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
|
||||||
|
|
||||||
$COMMENT"
|
$COMMENT"
|
||||||
|
fi
|
||||||
|
|
||||||
add-contributor-label:
|
add-contributor-label:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -6,19 +6,9 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-standards:
|
check-standards:
|
||||||
if: |
|
|
||||||
github.event.pull_request.user.login != 'actions-user' &&
|
|
||||||
github.event.pull_request.user.login != 'opencode' &&
|
|
||||||
github.event.pull_request.user.login != 'rekram1-node' &&
|
|
||||||
github.event.pull_request.user.login != 'thdxr' &&
|
|
||||||
github.event.pull_request.user.login != 'kommander' &&
|
|
||||||
github.event.pull_request.user.login != 'jayair' &&
|
|
||||||
github.event.pull_request.user.login != 'fwang' &&
|
|
||||||
github.event.pull_request.user.login != 'adamdotdevin' &&
|
|
||||||
github.event.pull_request.user.login != 'iamdavidhill' &&
|
|
||||||
github.event.pull_request.user.login != 'opencode-agent[bot]'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check PR standards
|
- name: Check PR standards
|
||||||
@@ -26,6 +16,30 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
|
const login = pr.user.login;
|
||||||
|
|
||||||
|
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
|
||||||
|
const cutoff = new Date('2026-02-19T00:00:00Z');
|
||||||
|
const prCreated = new Date(pr.created_at);
|
||||||
|
if (prCreated < cutoff) {
|
||||||
|
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if author is a team member or bot
|
||||||
|
if (login === 'opencode-agent[bot]') return;
|
||||||
|
const { data: file } = await github.rest.repos.getContent({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
path: '.github/TEAM_MEMBERS',
|
||||||
|
ref: 'dev'
|
||||||
|
});
|
||||||
|
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
if (members.includes(login)) {
|
||||||
|
console.log(`Skipping: ${login} is a team member`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const title = pr.title;
|
const title = pr.title;
|
||||||
|
|
||||||
async function addLabel(label) {
|
async function addLabel(label) {
|
||||||
@@ -137,3 +151,201 @@ jobs:
|
|||||||
|
|
||||||
await removeLabel('needs:issue');
|
await removeLabel('needs:issue');
|
||||||
console.log('PR meets all standards');
|
console.log('PR meets all standards');
|
||||||
|
|
||||||
|
check-compliance:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Check PR template compliance
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const pr = context.payload.pull_request;
|
||||||
|
const login = pr.user.login;
|
||||||
|
|
||||||
|
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
|
||||||
|
const cutoff = new Date('2026-02-19T00:00:00Z');
|
||||||
|
const prCreated = new Date(pr.created_at);
|
||||||
|
if (prCreated < cutoff) {
|
||||||
|
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if author is a team member or bot
|
||||||
|
if (login === 'opencode-agent[bot]') return;
|
||||||
|
const { data: file } = await github.rest.repos.getContent({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
path: '.github/TEAM_MEMBERS',
|
||||||
|
ref: 'dev'
|
||||||
|
});
|
||||||
|
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
if (members.includes(login)) {
|
||||||
|
console.log(`Skipping: ${login} is a team member`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = pr.body || '';
|
||||||
|
const title = pr.title;
|
||||||
|
const isDocsOrRefactor = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
||||||
|
|
||||||
|
const issues = [];
|
||||||
|
|
||||||
|
// Check: template sections exist
|
||||||
|
const hasWhatSection = /### What does this PR do\?/.test(body);
|
||||||
|
const hasTypeSection = /### Type of change/.test(body);
|
||||||
|
const hasVerifySection = /### How did you verify your code works\?/.test(body);
|
||||||
|
const hasChecklistSection = /### Checklist/.test(body);
|
||||||
|
const hasIssueSection = /### Issue for this PR/.test(body);
|
||||||
|
|
||||||
|
if (!hasWhatSection || !hasTypeSection || !hasVerifySection || !hasChecklistSection || !hasIssueSection) {
|
||||||
|
issues.push('PR description is missing required template sections. Please use the [PR template](../blob/dev/.github/pull_request_template.md).');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: "What does this PR do?" has real content (not just placeholder text)
|
||||||
|
if (hasWhatSection) {
|
||||||
|
const whatMatch = body.match(/### What does this PR do\?\s*\n([\s\S]*?)(?=###|$)/);
|
||||||
|
const whatContent = whatMatch ? whatMatch[1].trim() : '';
|
||||||
|
const placeholder = 'Please provide a description of the issue';
|
||||||
|
const onlyPlaceholder = whatContent.includes(placeholder) && whatContent.replace(placeholder, '').replace(/[*\s]/g, '').length < 20;
|
||||||
|
if (!whatContent || onlyPlaceholder) {
|
||||||
|
issues.push('"What does this PR do?" section is empty or only contains placeholder text. Please describe your changes.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: at least one "Type of change" checkbox is checked
|
||||||
|
if (hasTypeSection) {
|
||||||
|
const typeMatch = body.match(/### Type of change\s*\n([\s\S]*?)(?=###|$)/);
|
||||||
|
const typeContent = typeMatch ? typeMatch[1] : '';
|
||||||
|
const hasCheckedBox = /- \[x\]/i.test(typeContent);
|
||||||
|
if (!hasCheckedBox) {
|
||||||
|
issues.push('No "Type of change" checkbox is checked. Please select at least one.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: issue reference (skip for docs/refactor)
|
||||||
|
if (!isDocsOrRefactor && hasIssueSection) {
|
||||||
|
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
|
||||||
|
const issueContent = issueMatch ? issueMatch[1].trim() : '';
|
||||||
|
const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
|
||||||
|
if (!hasIssueRef) {
|
||||||
|
issues.push('No issue referenced. Please add `Closes #<number>` linking to the relevant issue.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: "How did you verify" has content
|
||||||
|
if (hasVerifySection) {
|
||||||
|
const verifyMatch = body.match(/### How did you verify your code works\?\s*\n([\s\S]*?)(?=###|$)/);
|
||||||
|
const verifyContent = verifyMatch ? verifyMatch[1].trim() : '';
|
||||||
|
if (!verifyContent) {
|
||||||
|
issues.push('"How did you verify your code works?" section is empty. Please explain how you tested.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: checklist boxes are checked
|
||||||
|
if (hasChecklistSection) {
|
||||||
|
const checklistMatch = body.match(/### Checklist\s*\n([\s\S]*?)(?=###|$)/);
|
||||||
|
const checklistContent = checklistMatch ? checklistMatch[1] : '';
|
||||||
|
const unchecked = (checklistContent.match(/- \[ \]/g) || []).length;
|
||||||
|
const checked = (checklistContent.match(/- \[x\]/gi) || []).length;
|
||||||
|
if (checked < 2) {
|
||||||
|
issues.push('Not all checklist items are checked. Please confirm you have tested locally and have not included unrelated changes.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
async function addLabel(label) {
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
labels: [label]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLabel(label) {
|
||||||
|
try {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
name: label
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasComplianceLabel = pr.labels.some(l => l.name === 'needs:compliance');
|
||||||
|
|
||||||
|
if (issues.length > 0) {
|
||||||
|
// Non-compliant
|
||||||
|
if (!hasComplianceLabel) {
|
||||||
|
await addLabel('needs:compliance');
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = '<!-- issue-compliance -->';
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number
|
||||||
|
});
|
||||||
|
const existing = comments.find(c => c.body.includes(marker));
|
||||||
|
|
||||||
|
const body_text = `${marker}
|
||||||
|
This PR doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) and [PR template](../blob/dev/.github/pull_request_template.md).
|
||||||
|
|
||||||
|
**What needs to be fixed:**
|
||||||
|
${issues.map(i => `- ${i}`).join('\n')}
|
||||||
|
|
||||||
|
Please edit this PR description to address the above within **2 hours**, or it will be automatically closed.
|
||||||
|
|
||||||
|
If you believe this was flagged incorrectly, please let a maintainer know.`;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body: body_text
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
body: body_text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`PR #${pr.number} is non-compliant: ${issues.join(', ')}`);
|
||||||
|
} else if (hasComplianceLabel) {
|
||||||
|
// Was non-compliant, now fixed
|
||||||
|
await removeLabel('needs:compliance');
|
||||||
|
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number
|
||||||
|
});
|
||||||
|
const marker = '<!-- issue-compliance -->';
|
||||||
|
const existing = comments.find(c => c.body.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.deleteComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
body: 'Thanks for updating your PR! It now meets our contributing guidelines. :+1:'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`PR #${pr.number} is now compliant, label removed`);
|
||||||
|
} else {
|
||||||
|
console.log(`PR #${pr.number} is compliant`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: /var/cache/apt/archives
|
path: ~/apt-cache
|
||||||
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
|
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ matrix.settings.target }}-apt-
|
${{ runner.os }}-${{ matrix.settings.target }}-apt-
|
||||||
@@ -145,8 +145,10 @@ jobs:
|
|||||||
- name: install dependencies (ubuntu only)
|
- name: install dependencies (ubuntu only)
|
||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
sudo chmod -R a+rw ~/apt-cache
|
||||||
|
|
||||||
- name: install Rust stable
|
- name: install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -169,13 +171,23 @@ jobs:
|
|||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Resolve tauri portable SHA
|
||||||
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
|
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
||||||
- name: Install tauri-cli from portable appimage branch
|
- name: Install tauri-cli from portable appimage branch
|
||||||
|
uses: taiki-e/cache-cargo-install-action@v3
|
||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
run: |
|
with:
|
||||||
cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
|
tool: tauri-cli
|
||||||
echo "Installed tauri-cli version:"
|
git: https://github.com/tauri-apps/tauri
|
||||||
cargo tauri --version
|
# branch: feat/truly-portable-appimage
|
||||||
|
rev: ${{ env.TAURI_PORTABLE_SHA }}
|
||||||
|
|
||||||
|
- name: Show tauri-cli version
|
||||||
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
|
run: cargo tauri --version
|
||||||
|
|
||||||
- name: Build and upload artifacts
|
- name: Build and upload artifacts
|
||||||
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: sign-cli
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- brendan/desktop-signpath
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sign-cli:
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
if: github.repository == 'anomalyco/opencode'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
./packages/opencode/script/build.ts
|
||||||
|
|
||||||
|
- name: Upload unsigned Windows CLI
|
||||||
|
id: upload_unsigned_windows_cli
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: unsigned-opencode-windows-cli
|
||||||
|
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Submit SignPath signing request
|
||||||
|
id: submit_signpath_signing_request
|
||||||
|
uses: signpath/github-action-submit-signing-request@v1
|
||||||
|
with:
|
||||||
|
api-token: ${{ secrets.SIGNPATH_API_KEY }}
|
||||||
|
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||||
|
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
|
||||||
|
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
|
||||||
|
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||||
|
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
|
||||||
|
wait-for-completion: true
|
||||||
|
output-artifact-directory: signed-opencode-cli
|
||||||
|
|
||||||
|
- name: Upload signed Windows CLI
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: signed-opencode-windows-cli
|
||||||
|
path: signed-opencode-cli/*.exe
|
||||||
|
if-no-files-found: error
|
||||||
@@ -359,6 +359,7 @@ opencode serve --hostname 0.0.0.0 --port 4096
|
|||||||
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
||||||
opencode session [command]
|
opencode session [command]
|
||||||
opencode session list
|
opencode session list
|
||||||
|
opencode session delete <sessionID>
|
||||||
opencode stats
|
opencode stats
|
||||||
opencode uninstall
|
opencode uninstall
|
||||||
opencode upgrade
|
opencode upgrade
|
||||||
@@ -598,6 +599,7 @@ OPENCODE_EXPERIMENTAL_MARKDOWN
|
|||||||
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|
||||||
OPENCODE_EXPERIMENTAL_OXFMT
|
OPENCODE_EXPERIMENTAL_OXFMT
|
||||||
OPENCODE_EXPERIMENTAL_PLAN_MODE
|
OPENCODE_EXPERIMENTAL_PLAN_MODE
|
||||||
|
OPENCODE_ENABLE_QUESTION_TOOL
|
||||||
OPENCODE_FAKE_VCS
|
OPENCODE_FAKE_VCS
|
||||||
OPENCODE_GIT_BASH_PATH
|
OPENCODE_GIT_BASH_PATH
|
||||||
OPENCODE_MODEL
|
OPENCODE_MODEL
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
mode: primary
|
mode: primary
|
||||||
hidden: true
|
hidden: true
|
||||||
model: opencode/claude-haiku-4-5
|
model: opencode/minimax-m2.5
|
||||||
color: "#44BA81"
|
color: "#44BA81"
|
||||||
tools:
|
tools:
|
||||||
"*": false
|
"*": false
|
||||||
@@ -12,6 +12,8 @@ You are a triage agent responsible for triaging github issues.
|
|||||||
|
|
||||||
Use your github-triage tool to triage issues.
|
Use your github-triage tool to triage issues.
|
||||||
|
|
||||||
|
This file is the source of truth for ownership/routing rules.
|
||||||
|
|
||||||
## Labels
|
## Labels
|
||||||
|
|
||||||
### windows
|
### windows
|
||||||
@@ -43,12 +45,34 @@ Desktop app issues:
|
|||||||
|
|
||||||
**Only** add if the issue explicitly mentions nix.
|
**Only** add if the issue explicitly mentions nix.
|
||||||
|
|
||||||
|
If the issue does not mention nix, do not add nix.
|
||||||
|
|
||||||
|
If the issue mentions nix, assign to `rekram1-node`.
|
||||||
|
|
||||||
#### zen
|
#### zen
|
||||||
|
|
||||||
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
|
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
|
||||||
|
|
||||||
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
|
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
|
||||||
|
|
||||||
|
#### core
|
||||||
|
|
||||||
|
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- LSP server behavior
|
||||||
|
- Harness behavior (agent + tools)
|
||||||
|
- Feature requests for server behavior
|
||||||
|
- Agent context construction
|
||||||
|
- API endpoints
|
||||||
|
- Provider integration issues
|
||||||
|
- New, broken, or poor-quality models
|
||||||
|
|
||||||
|
#### acp
|
||||||
|
|
||||||
|
If the issue mentions acp support, assign acp label.
|
||||||
|
|
||||||
#### docs
|
#### docs
|
||||||
|
|
||||||
Add if the issue requests better documentation or docs updates.
|
Add if the issue requests better documentation or docs updates.
|
||||||
@@ -66,13 +90,51 @@ TUI issues potentially caused by our underlying TUI library:
|
|||||||
|
|
||||||
When assigning to people here are the following rules:
|
When assigning to people here are the following rules:
|
||||||
|
|
||||||
adamdotdev:
|
Desktop / Web:
|
||||||
ONLY assign adam if the issue will have the "desktop" label.
|
Use for desktop-labeled issues only.
|
||||||
|
|
||||||
fwang:
|
- adamdotdevin
|
||||||
ONLY assign fwang if the issue will have the "zen" label.
|
- iamdavidhill
|
||||||
|
- Brendonovich
|
||||||
|
- nexxeln
|
||||||
|
|
||||||
jayair:
|
Zen:
|
||||||
ONLY assign jayair if the issue will have the "docs" label.
|
ONLY assign if the issue will have the "zen" label.
|
||||||
|
|
||||||
In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node.
|
- fwang
|
||||||
|
- MrMushrooooom
|
||||||
|
|
||||||
|
TUI (`packages/opencode/src/cli/cmd/tui/...`):
|
||||||
|
|
||||||
|
- thdxr for TUI UX/UI product decisions and interaction flow
|
||||||
|
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
|
||||||
|
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
|
||||||
|
|
||||||
|
Core (`packages/opencode/...`, excluding TUI subtree):
|
||||||
|
|
||||||
|
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
|
||||||
|
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
|
||||||
|
- rekram1-node for harness issues, provider issues, and other bug-squashing
|
||||||
|
|
||||||
|
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
|
||||||
|
|
||||||
|
Docs:
|
||||||
|
|
||||||
|
- R44VC0RP
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
|
||||||
|
|
||||||
|
Determinism rules:
|
||||||
|
|
||||||
|
- If title + body does not contain "zen", do not add the "zen" label
|
||||||
|
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
|
||||||
|
- If title + body mentions nix/nixos, assign to `rekram1-node`
|
||||||
|
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
|
||||||
|
|
||||||
|
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||||
|
|
||||||
|
ACP:
|
||||||
|
|
||||||
|
- rekram1-node (assign any acp issues to rekram1-node)
|
||||||
|
|||||||
@@ -16,15 +16,12 @@ wip:
|
|||||||
|
|
||||||
For anything in the packages/web use the docs: prefix.
|
For anything in the packages/web use the docs: prefix.
|
||||||
|
|
||||||
For anything in the packages/app use the ignore: prefix.
|
|
||||||
|
|
||||||
prefer to explain WHY something was done from an end user perspective instead of
|
prefer to explain WHY something was done from an end user perspective instead of
|
||||||
WHAT was done.
|
WHAT was done.
|
||||||
|
|
||||||
do not do generic messages like "improved agent experience" be very specific
|
do not do generic messages like "improved agent experience" be very specific
|
||||||
about what user facing changes were made
|
about what user facing changes were made
|
||||||
|
|
||||||
if there are changes do a git pull --rebase
|
|
||||||
if there are conflicts DO NOT FIX THEM. notify me and I will fix them
|
if there are conflicts DO NOT FIX THEM. notify me and I will fix them
|
||||||
|
|
||||||
## GIT DIFF
|
## GIT DIFF
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
// "enterprise": {
|
|
||||||
// "url": "https://enterprise.dev.opencode.ai",
|
|
||||||
// },
|
|
||||||
"provider": {
|
"provider": {
|
||||||
"opencode": {
|
"opencode": {
|
||||||
"options": {},
|
"options": {},
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
name: bun-file-io
|
|
||||||
description: Use this when you are working on file operations like reading, writing, scanning, or deleting files. It summarizes the preferred file APIs and patterns used in this repo. It also notes when to use filesystem helpers for directories.
|
|
||||||
---
|
|
||||||
|
|
||||||
## Use this when
|
|
||||||
|
|
||||||
- Editing file I/O or scans in `packages/opencode`
|
|
||||||
- Handling directory operations or external tools
|
|
||||||
|
|
||||||
## Bun file APIs (from Bun docs)
|
|
||||||
|
|
||||||
- `Bun.file(path)` is lazy; call `text`, `json`, `stream`, `arrayBuffer`, `bytes`, `exists` to read.
|
|
||||||
- Metadata: `file.size`, `file.type`, `file.name`.
|
|
||||||
- `Bun.write(dest, input)` writes strings, buffers, Blobs, Responses, or files.
|
|
||||||
- `Bun.file(...).delete()` deletes a file.
|
|
||||||
- `file.writer()` returns a FileSink for incremental writes.
|
|
||||||
- `Bun.Glob` + `Array.fromAsync(glob.scan({ cwd, absolute, onlyFiles, dot }))` for scans.
|
|
||||||
- Use `Bun.which` to find a binary, then `Bun.spawn` to run it.
|
|
||||||
- `Bun.readableStreamToText/Bytes/JSON` for stream output.
|
|
||||||
|
|
||||||
## When to use node:fs
|
|
||||||
|
|
||||||
- Use `node:fs/promises` for directories (`mkdir`, `readdir`, recursive operations).
|
|
||||||
|
|
||||||
## Repo patterns
|
|
||||||
|
|
||||||
- Prefer Bun APIs over Node `fs` for file access.
|
|
||||||
- Check `Bun.file(...).exists()` before reading.
|
|
||||||
- For binary/large files use `arrayBuffer()` and MIME checks via `file.type`.
|
|
||||||
- Use `Bun.Glob` + `Array.fromAsync` for scans.
|
|
||||||
- Decode tool stderr with `Bun.readableStreamToText`.
|
|
||||||
- For large writes, use `Bun.write(Bun.file(path), text)`.
|
|
||||||
|
|
||||||
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
|
|
||||||
Use Filesystem.exists(...) instead if path can be file or directory
|
|
||||||
|
|
||||||
## Quick checklist
|
|
||||||
|
|
||||||
- Use Bun APIs first.
|
|
||||||
- Use `path.join`/`path.resolve` for paths.
|
|
||||||
- Prefer promise `.catch(...)` over `try/catch` when possible.
|
|
||||||
@@ -1,8 +1,22 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
// import { Octokit } from "@octokit/rest"
|
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-triage.txt"
|
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"],
|
||||||
|
docs: ["R44VC0RP"],
|
||||||
|
windows: ["Hona"],
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
|
||||||
|
|
||||||
|
function pick<T>(items: readonly T[]) {
|
||||||
|
return items[Math.floor(Math.random() * items.length)]!
|
||||||
|
}
|
||||||
|
|
||||||
function getIssueNumber(): number {
|
function getIssueNumber(): number {
|
||||||
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
|
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
|
||||||
if (!issue) throw new Error("ISSUE_NUMBER env var not set")
|
if (!issue) throw new Error("ISSUE_NUMBER env var not set")
|
||||||
@@ -29,60 +43,69 @@ export default tool({
|
|||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
args: {
|
args: {
|
||||||
assignee: tool.schema
|
assignee: tool.schema
|
||||||
.enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"])
|
.enum(ASSIGNEES as [string, ...string[]])
|
||||||
.describe("The username of the assignee")
|
.describe("The username of the assignee")
|
||||||
.default("rekram1-node"),
|
.default("rekram1-node"),
|
||||||
labels: tool.schema
|
labels: tool.schema
|
||||||
.array(tool.schema.enum(["nix", "opentui", "perf", "desktop", "zen", "docs", "windows"]))
|
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
||||||
.describe("The labels(s) to add to the issue")
|
.describe("The labels(s) to add to the issue")
|
||||||
.default([]),
|
.default([]),
|
||||||
},
|
},
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
const issue = getIssueNumber()
|
const issue = getIssueNumber()
|
||||||
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
|
|
||||||
const owner = "anomalyco"
|
const owner = "anomalyco"
|
||||||
const repo = "opencode"
|
const repo = "opencode"
|
||||||
|
|
||||||
const results: string[] = []
|
const results: string[] = []
|
||||||
|
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
|
||||||
|
const web = labels.includes("web")
|
||||||
|
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
|
||||||
|
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
|
||||||
|
const nix = /\bnix(os)?\b/.test(text)
|
||||||
|
|
||||||
if (args.assignee === "adamdotdevin" && !args.labels.includes("desktop")) {
|
if (labels.includes("nix") && !nix) {
|
||||||
throw new Error("Only desktop issues should be assigned to adamdotdevin")
|
labels = labels.filter((x) => x !== "nix")
|
||||||
|
results.push("Dropped label: nix (issue does not mention nix)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.assignee === "fwang" && !args.labels.includes("zen")) {
|
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||||
throw new Error("Only zen issues should be assigned to fwang")
|
|
||||||
|
if (labels.includes("zen") && !zen) {
|
||||||
|
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.assignee === "kommander" && !args.labels.includes("opentui")) {
|
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
|
||||||
|
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
|
||||||
|
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignee === "Hona" && !labels.includes("windows")) {
|
||||||
|
throw new Error("Only windows issues should be assigned to Hona")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
|
||||||
|
throw new Error("Only docs issues should be assigned to R44VC0RP")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignee === "kommander" && !labels.includes("opentui")) {
|
||||||
throw new Error("Only opentui issues should be assigned to kommander")
|
throw new Error("Only opentui issues should be assigned to kommander")
|
||||||
}
|
}
|
||||||
|
|
||||||
// await octokit.rest.issues.addAssignees({
|
|
||||||
// owner,
|
|
||||||
// repo,
|
|
||||||
// issue_number: issue,
|
|
||||||
// assignees: [args.assignee],
|
|
||||||
// })
|
|
||||||
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
|
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ assignees: [args.assignee] }),
|
body: JSON.stringify({ assignees: [assignee] }),
|
||||||
})
|
})
|
||||||
results.push(`Assigned @${args.assignee} to issue #${issue}`)
|
results.push(`Assigned @${assignee} to issue #${issue}`)
|
||||||
|
|
||||||
const labels: string[] = args.labels.map((label) => (label === "desktop" ? "web" : label))
|
|
||||||
|
|
||||||
if (labels.length > 0) {
|
if (labels.length > 0) {
|
||||||
// await octokit.rest.issues.addLabels({
|
|
||||||
// owner,
|
|
||||||
// repo,
|
|
||||||
// issue_number: issue,
|
|
||||||
// labels,
|
|
||||||
// })
|
|
||||||
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
|
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ labels }),
|
body: JSON.stringify({ labels }),
|
||||||
})
|
})
|
||||||
results.push(`Added labels: ${args.labels.join(", ")}`)
|
results.push(`Added labels: ${labels.join(", ")}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return results.join("\n")
|
return results.join("\n")
|
||||||
|
|||||||
@@ -1,88 +1,6 @@
|
|||||||
Use this tool to assign and/or label a GitHub issue.
|
Use this tool to assign and/or label a GitHub issue.
|
||||||
|
|
||||||
You can assign the following users:
|
Choose labels and assignee using the current triage policy and ownership rules.
|
||||||
- thdxr
|
Pick the most fitting labels for the issue and assign one owner.
|
||||||
- adamdotdevin
|
|
||||||
- fwang
|
|
||||||
- jayair
|
|
||||||
- kommander
|
|
||||||
- rekram1-node
|
|
||||||
|
|
||||||
|
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||||
You can use the following labels:
|
|
||||||
- nix
|
|
||||||
- opentui
|
|
||||||
- perf
|
|
||||||
- web
|
|
||||||
- zen
|
|
||||||
- docs
|
|
||||||
|
|
||||||
Always try to assign an issue, if in doubt, assign rekram1-node to it.
|
|
||||||
|
|
||||||
## Breakdown of responsibilities:
|
|
||||||
|
|
||||||
### thdxr
|
|
||||||
|
|
||||||
Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him.
|
|
||||||
|
|
||||||
This relates to OpenCode server primarily but has overlap with just about anything
|
|
||||||
|
|
||||||
### adamdotdevin
|
|
||||||
|
|
||||||
Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him.
|
|
||||||
|
|
||||||
|
|
||||||
### fwang
|
|
||||||
|
|
||||||
Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue.
|
|
||||||
|
|
||||||
### jayair
|
|
||||||
|
|
||||||
Jay is responsible for documentation. If there is an issue relating to documentation assign him.
|
|
||||||
|
|
||||||
### kommander
|
|
||||||
|
|
||||||
Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about:
|
|
||||||
- random characters on screen
|
|
||||||
- keybinds not working on different terminals
|
|
||||||
- general terminal stuff
|
|
||||||
Then assign the issue to Him.
|
|
||||||
|
|
||||||
### rekram1-node
|
|
||||||
|
|
||||||
ALL BUGS SHOULD BE assigned to rekram1-node unless they have the `opentui` label.
|
|
||||||
|
|
||||||
Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things.
|
|
||||||
If no one else makes sense to assign, assign rekram1-node to it.
|
|
||||||
|
|
||||||
Always assign to aiden if the issue mentions "acp", "zed", or model performance issues
|
|
||||||
|
|
||||||
## Breakdown of Labels:
|
|
||||||
|
|
||||||
### nix
|
|
||||||
|
|
||||||
Any issue that mentions nix, or nixos should have a nix label
|
|
||||||
|
|
||||||
### opentui
|
|
||||||
|
|
||||||
Anything relating to the TUI itself should have an opentui label
|
|
||||||
|
|
||||||
### perf
|
|
||||||
|
|
||||||
Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label
|
|
||||||
|
|
||||||
### desktop
|
|
||||||
|
|
||||||
Anything related to `opencode web` command or the desktop app should have a desktop label. Never add this label for anything terminal/tui related
|
|
||||||
|
|
||||||
### zen
|
|
||||||
|
|
||||||
Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label
|
|
||||||
|
|
||||||
### docs
|
|
||||||
|
|
||||||
Anything related to the documentation should have a docs label
|
|
||||||
|
|
||||||
### windows
|
|
||||||
|
|
||||||
Use for any issue that involves the windows OS
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
github-policies:
|
||||||
|
runners:
|
||||||
|
allowed_groups:
|
||||||
|
- "GitHub Actions"
|
||||||
|
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"format_on_save": "on",
|
||||||
|
"formatter": {
|
||||||
|
"external": {
|
||||||
|
"command": "bunx",
|
||||||
|
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,3 +110,4 @@ const table = sqliteTable("session", {
|
|||||||
|
|
||||||
- Avoid mocks as much as possible
|
- Avoid mocks as much as possible
|
||||||
- Test actual implementation, do not duplicate logic into tests
|
- Test actual implementation, do not duplicate logic into tests
|
||||||
|
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ If you are unsure if a PR would be accepted, feel free to ask a maintainer or lo
|
|||||||
|
|
||||||
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
|
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
|
||||||
|
|
||||||
|
## Adding New Providers
|
||||||
|
|
||||||
|
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to:
|
||||||
|
https://github.com/anomalyco/models.dev
|
||||||
|
|
||||||
## Developing OpenCode
|
## Developing OpenCode
|
||||||
|
|
||||||
- Requirements: Bun 1.3+
|
- Requirements: Bun 1.3+
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
|
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
|
||||||
brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل)
|
brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # اي نظام
|
mise use -g opencode # اي نظام
|
||||||
nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev
|
nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
|
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
|
||||||
brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos)
|
brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # qualquer sistema
|
mise use -g opencode # qualquer sistema
|
||||||
nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente
|
nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -32,7 +32,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,7 +52,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
|
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
|
||||||
brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
|
brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # Bilo koji OS
|
mise use -g opencode # Bilo koji OS
|
||||||
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
|
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date)
|
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date)
|
||||||
brew install opencode # macOS og Linux (officiel brew formula, opdateres sjældnere)
|
brew install opencode # macOS og Linux (officiel brew formula, opdateres sjældnere)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # alle OS
|
mise use -g opencode # alle OS
|
||||||
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
|
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
|
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
|
||||||
brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert)
|
brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # jedes Betriebssystem
|
mise use -g opencode # jedes Betriebssystem
|
||||||
nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch
|
nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día)
|
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día)
|
||||||
brew install opencode # macOS y Linux (fórmula oficial de brew, se actualiza menos)
|
brew install opencode # macOS y Linux (fórmula oficial de brew, se actualiza menos)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # cualquier sistema
|
mise use -g opencode # cualquier sistema
|
||||||
nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente
|
nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour)
|
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour)
|
||||||
brew install opencode # macOS et Linux (formule officielle brew, mise à jour moins fréquente)
|
brew install opencode # macOS et Linux (formule officielle brew, mise à jour moins fréquente)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # n'importe quel OS
|
mise use -g opencode # n'importe quel OS
|
||||||
nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente
|
nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
|
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
|
||||||
brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso)
|
brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # Qualsiasi OS
|
mise use -g opencode # Qualsiasi OS
|
||||||
nix run nixpkgs#opencode # oppure github:anomalyco/opencode per l’ultima branch di sviluppo
|
nix run nixpkgs#opencode # oppure github:anomalyco/opencode per l’ultima branch di sviluppo
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS と Linux(推奨。常に最新)
|
brew install anomalyco/tap/opencode # macOS と Linux(推奨。常に最新)
|
||||||
brew install opencode # macOS と Linux(公式 brew formula。更新頻度は低め)
|
brew install opencode # macOS と Linux(公式 brew formula。更新頻度は低め)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # どのOSでも
|
mise use -g opencode # どのOSでも
|
||||||
nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ
|
nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
|
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
|
||||||
brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음)
|
brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # 어떤 OS든
|
mise use -g opencode # 어떤 OS든
|
||||||
nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치
|
nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,7 +52,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
||||||
brew install opencode # macOS and Linux (official brew formula, updated less)
|
brew install opencode # macOS and Linux (official brew formula, updated less)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # Any OS
|
mise use -g opencode # Any OS
|
||||||
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
|
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
|
||||||
brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere)
|
brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # alle OS
|
mise use -g opencode # alle OS
|
||||||
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
|
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
|
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
|
||||||
brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana)
|
brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # dowolny system
|
mise use -g opencode # dowolny system
|
||||||
nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev
|
nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
|
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
|
||||||
brew install opencode # macOS и Linux (официальная формула brew, обновляется реже)
|
brew install opencode # macOS и Linux (официальная формула brew, обновляется реже)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # любая ОС
|
mise use -g opencode # любая ОС
|
||||||
nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev
|
nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
|
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
|
||||||
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
|
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # ระบบปฏิบัติการใดก็ได้
|
mise use -g opencode # ระบบปฏิบัติการใดก็ได้
|
||||||
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
|
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
|
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
|
||||||
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
|
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # Tüm işletim sistemleri
|
mise use -g opencode # Tüm işletim sistemleri
|
||||||
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
|
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
|
||||||
```
|
```
|
||||||
|
|||||||
+139
@@ -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">AI-агент для програмування з відкритим кодом.</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>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[](https://opencode.ai)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Встановлення
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# YOLO
|
||||||
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
# Менеджери пакетів
|
||||||
|
npm i -g opencode-ai@latest # або bun/pnpm/yarn
|
||||||
|
scoop install opencode # Windows
|
||||||
|
choco install opencode # Windows
|
||||||
|
brew install anomalyco/tap/opencode # macOS і Linux (рекомендовано, завжди актуально)
|
||||||
|
brew install opencode # macOS і Linux (офіційна формула Homebrew, оновлюється рідше)
|
||||||
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
|
mise use -g opencode # Будь-яка ОС
|
||||||
|
nix run nixpkgs#opencode # або github:anomalyco/opencode для найновішої dev-гілки
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Перед встановленням видаліть версії старші за 0.1.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` або AppImage |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS (Homebrew)
|
||||||
|
brew install --cask opencode-desktop
|
||||||
|
# Windows (Scoop)
|
||||||
|
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Каталог встановлення
|
||||||
|
|
||||||
|
Скрипт встановлення дотримується такого порядку пріоритету для шляху встановлення:
|
||||||
|
|
||||||
|
1. `$OPENCODE_INSTALL_DIR` - Користувацький каталог встановлення
|
||||||
|
2. `$XDG_BIN_DIR` - Шлях, сумісний зі специфікацією XDG Base Directory
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Агенти
|
||||||
|
|
||||||
|
OpenCode містить два вбудовані агенти, між якими можна перемикатися клавішею `Tab`.
|
||||||
|
|
||||||
|
- **build** - Агент за замовчуванням із повним доступом для завдань розробки
|
||||||
|
- **plan** - Агент лише для читання для аналізу та дослідження коду
|
||||||
|
- За замовчуванням забороняє редагування файлів
|
||||||
|
- Запитує дозвіл перед запуском bash-команд
|
||||||
|
- Ідеально підходить для дослідження незнайомих кодових баз або планування змін
|
||||||
|
|
||||||
|
Також доступний допоміжний агент **general** для складного пошуку та багатокрокових завдань.
|
||||||
|
Він використовується всередині системи й може бути викликаний у повідомленнях через `@general`.
|
||||||
|
|
||||||
|
Дізнайтеся більше про [agents](https://opencode.ai/docs/agents).
|
||||||
|
|
||||||
|
### Документація
|
||||||
|
|
||||||
|
Щоб дізнатися більше про налаштування OpenCode, [**перейдіть до нашої документації**](https://opencode.ai/docs).
|
||||||
|
|
||||||
|
### Внесок
|
||||||
|
|
||||||
|
Якщо ви хочете зробити внесок в OpenCode, будь ласка, прочитайте нашу [документацію для контриб'юторів](./CONTRIBUTING.md) перед надсиланням pull request.
|
||||||
|
|
||||||
|
### Проєкти на базі OpenCode
|
||||||
|
|
||||||
|
Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README.
|
||||||
|
Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами.
|
||||||
|
|
||||||
|
### FAQ
|
||||||
|
|
||||||
|
#### Чим це відрізняється від Claude Code?
|
||||||
|
|
||||||
|
За можливостями це дуже схоже на Claude Code. Ось ключові відмінності:
|
||||||
|
|
||||||
|
- 100% open source
|
||||||
|
- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [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)
|
||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
|
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
|
||||||
brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低)
|
brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # 任意系统
|
mise use -g opencode # 任意系统
|
||||||
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
|
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-2
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -50,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
|
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
|
||||||
brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低)
|
brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # 任何作業系統
|
mise use -g opencode # 任何作業系統
|
||||||
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
|
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Security
|
# Security
|
||||||
|
|
||||||
|
## IMPORTANT
|
||||||
|
|
||||||
|
We do not accept AI generated security reports. We receive a large number of
|
||||||
|
these and we absolutely do not have the resources to review them all. If you
|
||||||
|
submit one that will be an automatic ban from the project.
|
||||||
|
|
||||||
## Threat Model
|
## Threat Model
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|||||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770073757,
|
"lastModified": 1770812194,
|
||||||
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
|
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
|
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
Vendored
+1
@@ -2,6 +2,7 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/* deno-fmt-ignore-file */
|
/* deno-fmt-ignore-file */
|
||||||
|
/* biome-ignore-all lint: auto-generated */
|
||||||
|
|
||||||
/// <reference path="../sst-env.d.ts" />
|
/// <reference path="../sst-env.d.ts" />
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -145,6 +145,16 @@ const ZEN_MODELS = [
|
|||||||
new sst.Secret("ZEN_MODELS18"),
|
new sst.Secret("ZEN_MODELS18"),
|
||||||
new sst.Secret("ZEN_MODELS19"),
|
new sst.Secret("ZEN_MODELS19"),
|
||||||
new sst.Secret("ZEN_MODELS20"),
|
new sst.Secret("ZEN_MODELS20"),
|
||||||
|
new sst.Secret("ZEN_MODELS21"),
|
||||||
|
new sst.Secret("ZEN_MODELS22"),
|
||||||
|
new sst.Secret("ZEN_MODELS23"),
|
||||||
|
new sst.Secret("ZEN_MODELS24"),
|
||||||
|
new sst.Secret("ZEN_MODELS25"),
|
||||||
|
new sst.Secret("ZEN_MODELS26"),
|
||||||
|
new sst.Secret("ZEN_MODELS27"),
|
||||||
|
new sst.Secret("ZEN_MODELS28"),
|
||||||
|
new sst.Secret("ZEN_MODELS29"),
|
||||||
|
new sst.Secret("ZEN_MODELS30"),
|
||||||
]
|
]
|
||||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||||
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
||||||
@@ -204,9 +214,9 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
},
|
},
|
||||||
transform: {
|
transform: {
|
||||||
server: {
|
server: {
|
||||||
|
placement: { region: "aws:us-east-1" },
|
||||||
transform: {
|
transform: {
|
||||||
worker: {
|
worker: {
|
||||||
placement: { mode: "smart" },
|
|
||||||
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ else
|
|||||||
needs_baseline=false
|
needs_baseline=false
|
||||||
if [ "$arch" = "x64" ]; then
|
if [ "$arch" = "x64" ]; then
|
||||||
if [ "$os" = "linux" ]; then
|
if [ "$os" = "linux" ]; then
|
||||||
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
|
if ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then
|
||||||
needs_baseline=true
|
needs_baseline=true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -141,6 +141,20 @@ else
|
|||||||
needs_baseline=true
|
needs_baseline=true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$os" = "windows" ]; then
|
||||||
|
ps="(Add-Type -MemberDefinition \"[DllImport(\"\"kernel32.dll\"\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);\" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)"
|
||||||
|
out=""
|
||||||
|
if command -v powershell.exe >/dev/null 2>&1; then
|
||||||
|
out=$(powershell.exe -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
|
||||||
|
elif command -v pwsh >/dev/null 2>&1; then
|
||||||
|
out=$(pwsh -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
out=$(echo "$out" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
||||||
|
if [ "$out" != "true" ] && [ "$out" != "1" ]; then
|
||||||
|
needs_baseline=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
target="$os-$arch"
|
target="$os-$arch"
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"keep": {
|
|
||||||
"days": true,
|
|
||||||
"amount": 14
|
|
||||||
},
|
|
||||||
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"date": 1759827172859,
|
|
||||||
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
|
|
||||||
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"hashType": "sha256"
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.879"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.880"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.191"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.192"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.267"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.268"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.276"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.277"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.838"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.839"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.499"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.500"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
|
||||||
{"arguments":{"url":"https://google.com"},"level":"debug","message":"Tool call received","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150","tool":"puppeteer_navigate"}
|
|
||||||
{"0":"n","1":"p","2":"x","level":"info","message":"Launching browser with config:","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.488"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.489"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.815"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.816"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.934"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.935"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.154"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.155"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.426"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.427"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.715"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.716"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.063"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.064"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.567"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.568"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.937"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.938"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.120"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.121"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.490"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.491"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.524"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.525"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.126"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.127"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.175"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.176"}
|
|
||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-pp2gb4nxiIT3VltB6Xli2wZPH32JfnMsI+BbihyU1+E=",
|
"x86_64-linux": "sha256-fjrvCgQ2PHYxzw8NsiEHOcor46qN95/cfilFHFqCp/k=",
|
||||||
"aarch64-linux": "sha256-hJwxhBICZz/pbIxQsF/sIpZTlFIgLpcAyF44O8wxMdU=",
|
"aarch64-linux": "sha256-xWp4LLJrbrCPFL1F6SSbProq/t/az4CqhTcymPvjOBQ=",
|
||||||
"aarch64-darwin": "sha256-DPONXP52XOg/ApdSnLp32a+K5XCOnDGhbTUto2Rme0g=",
|
"aarch64-darwin": "sha256-Wbfyy/bruFHKUWsyJ2aiPXAzLkk5MNBfN6QdGPQwZS0=",
|
||||||
"x86_64-darwin": "sha256-KX1h5LRJSgthpbOPqWlbM/sPf8cvQrdRJvxtrz/FzBQ="
|
"x86_64-darwin": "sha256-wDnMbiaBCRj5STkaLoVCZTdXVde+/YKfwWzwJZ1AJXQ="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -35,11 +35,13 @@
|
|||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/diffs": "1.0.2",
|
"@pierre/diffs": "1.1.0-beta.13",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
|
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||||
|
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||||
"ai": "5.0.124",
|
"ai": "5.0.124",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
@@ -67,10 +69,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/artifact": "5.0.1",
|
"@actions/artifact": "5.0.1",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
|
"@types/mime-types": "3.0.1",
|
||||||
|
"glob": "13.0.5",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
"sst": "3.17.23",
|
"sst": "3.18.10",
|
||||||
"turbo": "2.5.6"
|
"turbo": "2.5.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -101,6 +105,7 @@
|
|||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
|
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,163 @@ export async function withSession<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seedSystem = [
|
||||||
|
"You are seeding deterministic e2e UI state.",
|
||||||
|
"Follow the user's instruction exactly.",
|
||||||
|
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
|
||||||
|
"Do not call any extra tools.",
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
|
||||||
|
const timeout = input.timeout ?? 30_000
|
||||||
|
const end = Date.now() + timeout
|
||||||
|
while (Date.now() < end) {
|
||||||
|
const value = await input.probe()
|
||||||
|
if (value !== undefined) return value
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seed = async <T>(input: {
|
||||||
|
sessionID: string
|
||||||
|
prompt: string
|
||||||
|
sdk: ReturnType<typeof createSdk>
|
||||||
|
probe: () => Promise<T | undefined>
|
||||||
|
timeout?: number
|
||||||
|
attempts?: number
|
||||||
|
}) => {
|
||||||
|
for (let i = 0; i < (input.attempts ?? 2); i++) {
|
||||||
|
await input.sdk.session.promptAsync({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: "build",
|
||||||
|
system: seedSystem,
|
||||||
|
parts: [{ type: "text", text: input.prompt }],
|
||||||
|
})
|
||||||
|
const value = await wait({ probe: input.probe, timeout: input.timeout })
|
||||||
|
if (value !== undefined) return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedSessionQuestion(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
questions: Array<{
|
||||||
|
header: string
|
||||||
|
question: string
|
||||||
|
options: Array<{ label: string; description: string }>
|
||||||
|
multiple?: boolean
|
||||||
|
custom?: boolean
|
||||||
|
}>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const first = input.questions[0]
|
||||||
|
if (!first) throw new Error("Question seed requires at least one question")
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one question tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
"After calling the tool, wait for the user response.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 30_000,
|
||||||
|
probe: async () => {
|
||||||
|
const list = await sdk.question.list().then((x) => x.data ?? [])
|
||||||
|
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding question request")
|
||||||
|
return { id: result.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedSessionPermission(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
permission: string
|
||||||
|
patterns: string[]
|
||||||
|
description?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one bash tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({
|
||||||
|
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
|
||||||
|
workdir: "/",
|
||||||
|
description: input.description ?? `seed ${input.permission} permission request`,
|
||||||
|
})}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 30_000,
|
||||||
|
probe: async () => {
|
||||||
|
const list = await sdk.permission.list().then((x) => x.data ?? [])
|
||||||
|
return list.find((item) => item.sessionID === input.sessionID)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding permission request")
|
||||||
|
return { id: result.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedSessionTodos(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
todos: Array<{ content: string; status: string; priority: string }>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one todowrite tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n")
|
||||||
|
const target = JSON.stringify(input.todos)
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 30_000,
|
||||||
|
probe: async () => {
|
||||||
|
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
|
||||||
|
if (JSON.stringify(todos) !== target) return
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding todos")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
|
const [questions, permissions] = await Promise.all([
|
||||||
|
sdk.question.list().then((x) => x.data ?? []),
|
||||||
|
sdk.permission.list().then((x) => x.data ?? []),
|
||||||
|
])
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...questions
|
||||||
|
.filter((item) => item.sessionID === sessionID)
|
||||||
|
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
|
||||||
|
...permissions
|
||||||
|
.filter((item) => item.sessionID === sessionID)
|
||||||
|
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
|
||||||
|
])
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export async function openStatusPopover(page: Page) {
|
export async function openStatusPopover(page: Page) {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
await expect(prompt).not.toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+L")
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
const expanded = async (el: { getAttribute: (name: string) => Promise<string | null> }) => {
|
||||||
|
const value = await el.getAttribute("aria-expanded")
|
||||||
|
if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`)
|
||||||
|
return value === "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
||||||
|
await expect(treeToggle).toBeVisible()
|
||||||
|
if (await expanded(treeToggle)) await treeToggle.click()
|
||||||
|
await expect(treeToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
|
const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first()
|
||||||
|
await expect(reviewToggle).toBeVisible()
|
||||||
|
if (await expanded(reviewToggle)) await reviewToggle.click()
|
||||||
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
||||||
|
await expect(page.locator("#review-panel")).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
test("mod+w closes the active file tab", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await dialog.getByRole("textbox").first().fill("package.json")
|
||||||
|
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||||
|
await expect(item).toBeVisible({ timeout: 30_000 })
|
||||||
|
await item.click()
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: "package.json" }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+W`)
|
||||||
|
await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -1,15 +1,28 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openPalette, clickListItem } from "../actions"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const dialog = await openPalette(page)
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
const input = dialog.getByRole("textbox").first()
|
||||||
await input.fill("package.json")
|
await input.fill("package.json")
|
||||||
|
|
||||||
await clickListItem(dialog, { keyStartsWith: "file:" })
|
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||||
|
await expect(item).toBeVisible({ timeout: 30_000 })
|
||||||
|
await item.click()
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openPalette, clickListItem } from "../actions"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const sep = process.platform === "win32" ? "\\" : "/"
|
await page.locator(promptSelector).click()
|
||||||
const file = ["packages", "app", "package.json"].join(sep)
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
const dialog = await openPalette(page)
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
const input = dialog.getByRole("textbox").first()
|
||||||
await input.fill(file)
|
await input.fill("package.json")
|
||||||
|
|
||||||
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
|
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
||||||
|
let index = -1
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
||||||
|
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
||||||
|
return index >= 0
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const item = items.nth(index)
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
@@ -22,5 +45,5 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
|||||||
|
|
||||||
const code = page.locator('[data-component="code"]').first()
|
const code = page.locator('[data-component="code"]').first()
|
||||||
await expect(code).toBeVisible()
|
await expect(code).toBeVisible()
|
||||||
await expect(code.getByText("@opencode-ai/app")).toBeVisible()
|
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
|||||||
const key = await target.getAttribute("data-key")
|
const key = await target.getAttribute("data-key")
|
||||||
if (!key) throw new Error("Failed to resolve model key from list item")
|
if (!key) throw new Error("Failed to resolve model key from list item")
|
||||||
|
|
||||||
const name = (await target.locator("span").first().innerText()).trim()
|
|
||||||
const model = key.split(":").slice(1).join(":")
|
const model = key.split(":").slice(1).join(":")
|
||||||
|
|
||||||
await input.fill(model)
|
await input.fill(model)
|
||||||
@@ -37,6 +36,13 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
|||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
const form = page.locator(promptSelector).locator("xpath=ancestor::form[1]")
|
await page.locator(promptSelector).click()
|
||||||
await expect(form.locator('[data-component="button"]').filter({ hasText: name }).first()).toBeVisible()
|
await page.keyboard.type("/model")
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await command.hover()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialogAgain = page.getByRole("dialog")
|
||||||
|
await expect(dialogAgain).toBeVisible()
|
||||||
|
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { defocus, createTestProject, cleanupTestProject } from "../actions"
|
import {
|
||||||
import { projectSwitchSelector } from "../selectors"
|
defocus,
|
||||||
import { dirSlug } from "../utils"
|
createTestProject,
|
||||||
|
cleanupTestProject,
|
||||||
|
openSidebar,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
} from "../actions"
|
||||||
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
|
import { createSdk, dirSlug } from "../utils"
|
||||||
|
|
||||||
|
function slugFromUrl(url: string) {
|
||||||
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
@@ -33,3 +45,94 @@ test("can switch between projects from sidebar", async ({ page, withProject }) =
|
|||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const otherSlug = dirSlug(other)
|
||||||
|
const stamp = Date.now()
|
||||||
|
let rootDir: string | undefined
|
||||||
|
let workspaceDir: string | undefined
|
||||||
|
let sessionID: string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withProject(
|
||||||
|
async ({ directory, slug }) => {
|
||||||
|
rootDir = directory
|
||||||
|
await defocus(page)
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, slug, true)
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const next = slugFromUrl(page.url())
|
||||||
|
if (!next) return ""
|
||||||
|
if (next === slug) return ""
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
|
||||||
|
const workspaceSlug = slugFromUrl(page.url())
|
||||||
|
workspaceDir = base64Decode(workspaceSlug)
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||||
|
await expect(workspace).toBeVisible()
|
||||||
|
await workspace.hover()
|
||||||
|
|
||||||
|
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
|
||||||
|
await expect(newSession).toBeVisible()
|
||||||
|
await newSession.click({ force: true })
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await prompt.fill(`project switch remembers workspace ${stamp}`)
|
||||||
|
await prompt.press("Enter")
|
||||||
|
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
const created = sessionIDFromUrl(page.url())
|
||||||
|
if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`)
|
||||||
|
sessionID = created
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.click()
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
|
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
|
await expect(rootButton).toBeVisible()
|
||||||
|
await rootButton.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (sessionID) {
|
||||||
|
const id = sessionID
|
||||||
|
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
|
||||||
|
await Promise.all(
|
||||||
|
dirs.map((directory) =>
|
||||||
|
createSdk(directory)
|
||||||
|
.session.delete({ sessionID: id })
|
||||||
|
.catch(() => undefined),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (workspaceDir) {
|
||||||
|
await cleanupTestProject(workspaceDir)
|
||||||
|
}
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -69,15 +69,19 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
|
|||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
|
await expect(prompt).toBeEditable()
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type(text)
|
await expect(prompt).toBeFocused()
|
||||||
await page.keyboard.press("Enter")
|
await prompt.fill(text)
|
||||||
|
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||||
|
await prompt.press("Enter")
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
const sessionID = sessionIDFromUrl(page.url())
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
|
||||||
return sessionID
|
return sessionID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,18 +11,12 @@ import {
|
|||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
clickMenuItem,
|
clickMenuItem,
|
||||||
confirmDialog,
|
confirmDialog,
|
||||||
openProjectMenu,
|
|
||||||
openSidebar,
|
openSidebar,
|
||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import {
|
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
inlineInputSelector,
|
import { createSdk, dirSlug } from "../utils"
|
||||||
projectSwitchSelector,
|
|
||||||
projectWorkspacesToggleSelector,
|
|
||||||
workspaceItemSelector,
|
|
||||||
} from "../selectors"
|
|
||||||
import { dirSlug } from "../utils"
|
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
function slugFromUrl(url: string) {
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
@@ -143,26 +137,35 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
|
|||||||
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await withProject(async () => {
|
||||||
async () => {
|
await page.goto(`/${nonGitSlug}/session`)
|
||||||
|
|
||||||
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
|
const activeDir = base64Decode(slugFromUrl(page.url()))
|
||||||
|
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
|
||||||
const nonGitButton = page.locator(projectSwitchSelector(nonGitSlug)).first()
|
const trigger = page.locator('[data-action="project-menu"]').first()
|
||||||
await expect(nonGitButton).toBeVisible()
|
const hasMenu = await trigger
|
||||||
await nonGitButton.click()
|
.isVisible()
|
||||||
await expect(page).toHaveURL(new RegExp(`/${nonGitSlug}/session`))
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!hasMenu) return
|
||||||
|
|
||||||
const menu = await openProjectMenu(page, nonGitSlug)
|
await trigger.click({ force: true })
|
||||||
const toggle = menu.locator(projectWorkspacesToggleSelector(nonGitSlug)).first()
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
|
||||||
|
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
|
||||||
|
|
||||||
await expect(toggle).toBeVisible()
|
await expect(toggle).toBeVisible()
|
||||||
await expect(toggle).toBeDisabled()
|
await expect(toggle).toBeDisabled()
|
||||||
|
|
||||||
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
})
|
||||||
},
|
|
||||||
{ extra: [nonGit] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(nonGit)
|
await cleanupTestProject(nonGit)
|
||||||
}
|
}
|
||||||
@@ -256,14 +259,45 @@ test("can delete a workspace", async ({ page, withProject }) => {
|
|||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await withProject(async (project) => {
|
||||||
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
|
const sdk = createSdk(project.directory)
|
||||||
|
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
|
await project.gotoSession()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
||||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { withSession } from "../actions"
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
function contextButton(page: Page) {
|
||||||
const title = `e2e smoke context ${Date.now()}`
|
return page
|
||||||
|
.locator('[data-component="button"]')
|
||||||
|
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
|
||||||
await sdk.session.promptAsync({
|
await input.sdk.session.promptAsync({
|
||||||
sessionID: session.id,
|
sessionID: input.sessionID,
|
||||||
noReply: true,
|
noReply: true,
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
@@ -19,22 +24,72 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
|
|||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
|
const messages = await input.sdk.session
|
||||||
|
.messages({ sessionID: input.sessionID, limit: 1 })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
return messages.length
|
return messages.length
|
||||||
})
|
})
|
||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
||||||
|
const title = `e2e smoke context ${Date.now()}`
|
||||||
|
|
||||||
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
|
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const contextButton = page
|
const trigger = contextButton(page)
|
||||||
.locator('[data-component="button"]')
|
await expect(trigger).toBeVisible()
|
||||||
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
await trigger.click()
|
||||||
.first()
|
|
||||||
|
|
||||||
await expect(contextButton).toBeVisible()
|
|
||||||
await contextButton.click()
|
|
||||||
|
|
||||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||||
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
|
||||||
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
|
||||||
|
const trigger = contextButton(page)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
|
||||||
|
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||||
|
const context = tabs.getByRole("tab", { name: "Context" })
|
||||||
|
await expect(context).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Close tab" }).first().click()
|
||||||
|
await expect(context).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
|
||||||
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
|
||||||
|
const trigger = contextButton(page)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
|
||||||
|
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||||
|
await page.getByRole("button", { name: "Open file" }).first().click()
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { sessionIDFromUrl } from "../actions"
|
||||||
|
|
||||||
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
|
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||||
|
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||||
|
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const token = `E2E_ASYNC_${Date.now()}`
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
|
const sessionID = sessionIDFromUrl(page.url())!
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Agent response arrives via SSE despite sync endpoint being dead
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((m) => m.info.role === "assistant")
|
||||||
|
.flatMap((m) => m.parts)
|
||||||
|
.filter((p) => p.type === "text")
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toContain(token)
|
||||||
|
} finally {
|
||||||
|
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
|
||||||
|
const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt"
|
||||||
|
const dt = await page.evaluateHandle((text) => {
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
dt.setData("text/plain", text)
|
||||||
|
return dt
|
||||||
|
}, `file:${path}`)
|
||||||
|
|
||||||
|
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
|
||||||
|
|
||||||
|
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
|
||||||
|
await expect(pill).toBeVisible()
|
||||||
|
await expect(pill).toHaveAttribute("data-path", path)
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("dropping an image file adds an attachment", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
|
||||||
|
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII="
|
||||||
|
const dt = await page.evaluateHandle((b64) => {
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
|
||||||
|
const file = new File([bytes], "drop.png", { type: "image/png" })
|
||||||
|
dt.items.add(file)
|
||||||
|
return dt
|
||||||
|
}, png)
|
||||||
|
|
||||||
|
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
|
||||||
|
|
||||||
|
const img = page.locator('img[alt="drop.png"]').first()
|
||||||
|
await expect(img).toBeVisible()
|
||||||
|
|
||||||
|
const remove = page.getByRole("button", { name: "Remove attachment" }).first()
|
||||||
|
await expect(remove).toBeVisible()
|
||||||
|
|
||||||
|
await img.hover()
|
||||||
|
await remove.click()
|
||||||
|
await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("line one")
|
||||||
|
await page.keyboard.press("Shift+Enter")
|
||||||
|
await page.keyboard.type("line two")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
await expect(prompt).toContainText("line one")
|
||||||
|
await expect(prompt).toContainText("line two")
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const terminal = page.locator(terminalSelector)
|
||||||
|
|
||||||
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("/terminal")
|
||||||
|
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("/terminal")
|
||||||
|
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await expect(terminal).not.toBeVisible()
|
||||||
|
})
|
||||||
@@ -44,9 +44,6 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
|||||||
)
|
)
|
||||||
|
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
|
|
||||||
const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first()
|
|
||||||
await expect(reply).toBeVisible({ timeout: 90_000 })
|
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
export const promptSelector = '[data-component="prompt-input"]'
|
export const promptSelector = '[data-component="prompt-input"]'
|
||||||
export const terminalSelector = '[data-component="terminal"]'
|
export const terminalSelector = '[data-component="terminal"]'
|
||||||
|
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||||
|
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||||
|
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||||
|
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
|
||||||
|
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
|
||||||
|
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
|
||||||
|
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
|
||||||
|
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
|
||||||
|
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
||||||
|
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
||||||
|
|
||||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||||
@@ -10,8 +20,11 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi
|
|||||||
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
||||||
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
||||||
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
|
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
|
||||||
|
export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]'
|
||||||
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
|
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
|
||||||
|
export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]'
|
||||||
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
|
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
|
||||||
|
export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]'
|
||||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||||
|
|
||||||
@@ -27,6 +40,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
|
|||||||
|
|
||||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectClearNotificationsSelector = (slug: string) =>
|
||||||
|
`[data-action="project-clear-notifications"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||||
|
import {
|
||||||
|
permissionDockSelector,
|
||||||
|
promptSelector,
|
||||||
|
questionDockSelector,
|
||||||
|
sessionComposerDockSelector,
|
||||||
|
sessionTodoDockSelector,
|
||||||
|
sessionTodoListSelector,
|
||||||
|
sessionTodoToggleButtonSelector,
|
||||||
|
} from "../selectors"
|
||||||
|
|
||||||
|
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||||
|
|
||||||
|
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
|
||||||
|
const session = await sdk.session.create({ title }).then((r) => r.data)
|
||||||
|
if (!session?.id) throw new Error("Session create did not return an id")
|
||||||
|
return fn(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await expect(page.locator(promptSelector)).toBeFocused()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionQuestion(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [
|
||||||
|
{ label: "Continue", description: "Continue now" },
|
||||||
|
{ label: "Stop", description: "Stop here" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const dock = page.locator(questionDockSelector)
|
||||||
|
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionPermission(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["README.md"],
|
||||||
|
description: "Need permission for command",
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator(permissionDockSelector)
|
||||||
|
.getByRole("button", { name: /allow once/i })
|
||||||
|
.click()
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionPermission(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["REJECT.md"],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionPermission(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["README.md"],
|
||||||
|
description: "Need permission for command",
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator(permissionDockSelector)
|
||||||
|
.getByRole("button", { name: /allow always/i })
|
||||||
|
.click()
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionTodos(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
todos: [
|
||||||
|
{ content: "first task", status: "pending", priority: "high" },
|
||||||
|
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||||
|
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
|
||||||
|
|
||||||
|
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||||
|
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await seedSessionTodos(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
todos: [
|
||||||
|
{ content: "first task", status: "completed", priority: "high" },
|
||||||
|
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionQuestion(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [{ label: "Continue", description: "Continue now" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
await page.keyboard.type("abc")
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,21 +10,26 @@ async function seedConversation(input: {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
token: string
|
token: string
|
||||||
}) {
|
}) {
|
||||||
|
const messages = async () =>
|
||||||
|
await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
|
||||||
|
const seeded = await messages()
|
||||||
|
const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
|
||||||
|
|
||||||
const prompt = input.page.locator(promptSelector)
|
const prompt = input.page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
await prompt.click()
|
await input.sdk.session.promptAsync({
|
||||||
await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
|
sessionID: input.sessionID,
|
||||||
await input.page.keyboard.press("Enter")
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text: input.token }],
|
||||||
|
})
|
||||||
|
|
||||||
let userMessageID: string | undefined
|
let userMessageID: string | undefined
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const messages = await input.sdk.session
|
const users = (await messages()).filter(
|
||||||
.messages({ sessionID: input.sessionID, limit: 50 })
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
const users = messages.filter(
|
|
||||||
(m) =>
|
(m) =>
|
||||||
|
!userIDs.has(m.info.id) &&
|
||||||
m.info.role === "user" &&
|
m.info.role === "user" &&
|
||||||
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
|
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
|
||||||
)
|
)
|
||||||
@@ -33,21 +38,14 @@ async function seedConversation(input: {
|
|||||||
const user = users[users.length - 1]
|
const user = users[users.length - 1]
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
userMessageID = user.info.id
|
userMessageID = user.info.id
|
||||||
|
return true
|
||||||
const assistantText = messages
|
|
||||||
.filter((m) => m.info.role === "assistant")
|
|
||||||
.flatMap((m) => m.parts)
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
return assistantText.includes(input.token)
|
|
||||||
},
|
},
|
||||||
{ timeout: 90_000 },
|
{ timeout: 90_000, intervals: [250, 500, 1_000] },
|
||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
if (!userMessageID) throw new Error("Expected a user message id")
|
if (!userMessageID) throw new Error("Expected a user message id")
|
||||||
|
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
|
||||||
return { prompt, userMessageID }
|
return { prompt, userMessageID }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,21 +34,34 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
|||||||
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
const newTitle = `e2e renamed ${stamp}`
|
const renamedTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, originalTitle, async (session) => {
|
await withSession(sdk, originalTitle, async (session) => {
|
||||||
await seedMessage(sdk, session.id)
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /rename/i)
|
await clickMenuItem(menu, /rename/i)
|
||||||
|
|
||||||
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await input.fill(newTitle)
|
await expect(input).toBeFocused()
|
||||||
|
await input.fill(renamedTitle)
|
||||||
|
await expect(input).toHaveValue(renamedTitle)
|
||||||
await input.press("Enter")
|
await input.press("Enter")
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.title
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(renamedTitle)
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,8 +129,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
await seedMessage(sdk, session.id)
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const { rightSection, popoverBody } = await openSharePopover(page)
|
const shared = await openSharePopover(page)
|
||||||
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
|
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||||
|
await expect(publish).toBeVisible({ timeout: 30_000 })
|
||||||
|
await publish.click()
|
||||||
|
|
||||||
|
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -129,14 +148,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
)
|
)
|
||||||
.not.toBeUndefined()
|
.not.toBeUndefined()
|
||||||
|
|
||||||
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
|
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||||
await expect(copyButton).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const sharedPopover = await openSharePopover(page)
|
|
||||||
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
|
||||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||||
await unpublish.click()
|
await unpublish.click()
|
||||||
|
|
||||||
|
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -147,10 +166,8 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
)
|
)
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
|
const unshared = await openSharePopover(page)
|
||||||
|
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
const unsharedPopover = await openSharePopover(page)
|
|
||||||
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
settingsNotificationsPermissionsSelector,
|
settingsNotificationsPermissionsSelector,
|
||||||
settingsReleaseNotesSelector,
|
settingsReleaseNotesSelector,
|
||||||
settingsSoundsAgentSelector,
|
settingsSoundsAgentSelector,
|
||||||
|
settingsSoundsAgentEnabledSelector,
|
||||||
settingsSoundsErrorsSelector,
|
settingsSoundsErrorsSelector,
|
||||||
settingsSoundsPermissionsSelector,
|
settingsSoundsPermissionsSelector,
|
||||||
settingsThemeSelector,
|
settingsThemeSelector,
|
||||||
@@ -335,6 +336,30 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
|
|||||||
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
|
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const select = dialog.locator(settingsSoundsAgentSelector)
|
||||||
|
const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector)
|
||||||
|
const trigger = select.locator('[data-slot="select-select-trigger"]')
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
await expect(switchContainer).toBeVisible()
|
||||||
|
await expect(trigger).toBeEnabled()
|
||||||
|
|
||||||
|
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
await expect(trigger).toBeDisabled()
|
||||||
|
|
||||||
|
const stored = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
expect(stored?.sounds?.agentEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
|
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.59",
|
"version": "1.2.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
+97
-96
@@ -1,41 +1,56 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
|
|
||||||
import { Router, Route, Navigate } from "@solidjs/router"
|
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
|
||||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
|
||||||
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
|
||||||
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
|
||||||
import { Diff } from "@opencode-ai/ui/diff"
|
|
||||||
import { Code } from "@opencode-ai/ui/code"
|
import { Code } from "@opencode-ai/ui/code"
|
||||||
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
|
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
||||||
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
||||||
|
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
|
import { Diff } from "@opencode-ai/ui/diff"
|
||||||
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
import { Navigate, Route, Router } from "@solidjs/router"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
||||||
|
import { CommandProvider } from "@/context/command"
|
||||||
|
import { CommentsProvider } from "@/context/comments"
|
||||||
|
import { FileProvider } from "@/context/file"
|
||||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||||
import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server"
|
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||||
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
|
import { LanguageProvider, useLanguage } from "@/context/language"
|
||||||
|
import { LayoutProvider } from "@/context/layout"
|
||||||
|
import { ModelsProvider } from "@/context/models"
|
||||||
|
import { NotificationProvider } from "@/context/notification"
|
||||||
|
import { PermissionProvider } from "@/context/permission"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { PromptProvider } from "@/context/prompt"
|
||||||
|
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TerminalProvider } from "@/context/terminal"
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
|
||||||
import { FileProvider } from "@/context/file"
|
|
||||||
import { CommentsProvider } from "@/context/comments"
|
|
||||||
import { NotificationProvider } from "@/context/notification"
|
|
||||||
import { ModelsProvider } from "@/context/models"
|
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { CommandProvider } from "@/context/command"
|
|
||||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
|
||||||
import Layout from "@/pages/layout"
|
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { Suspense, JSX } from "solid-js"
|
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const Home = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
const Loading = () => <div class="size-full" />
|
const Loading = () => <div class="size-full" />
|
||||||
|
|
||||||
|
const HomeRoute = () => (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Home />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SessionRoute = () => (
|
||||||
|
<SessionProviders>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Session />
|
||||||
|
</Suspense>
|
||||||
|
</SessionProviders>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SessionIndexRoute = () => <Navigate href="session" />
|
||||||
|
|
||||||
function UiI18nBridge(props: ParentProps) {
|
function UiI18nBridge(props: ParentProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
|
||||||
@@ -43,7 +58,11 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
|
__OPENCODE__?: {
|
||||||
|
updaterEnabled?: boolean
|
||||||
|
deepLinks?: string[]
|
||||||
|
wsl?: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +71,47 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
|
|||||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppShellProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<SettingsProvider>
|
||||||
|
<PermissionProvider>
|
||||||
|
<LayoutProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<ModelsProvider>
|
||||||
|
<CommandProvider>
|
||||||
|
<HighlightsProvider>
|
||||||
|
<Layout>{props.children}</Layout>
|
||||||
|
</HighlightsProvider>
|
||||||
|
</CommandProvider>
|
||||||
|
</ModelsProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</PermissionProvider>
|
||||||
|
</SettingsProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<TerminalProvider>
|
||||||
|
<FileProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<CommentsProvider>{props.children}</CommentsProvider>
|
||||||
|
</PromptProvider>
|
||||||
|
</FileProvider>
|
||||||
|
</TerminalProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||||
|
return (
|
||||||
|
<AppShellProviders>
|
||||||
|
{props.appChildren}
|
||||||
|
{props.children}
|
||||||
|
</AppShellProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps) {
|
export function AppBaseProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
@@ -78,88 +138,29 @@ export function AppBaseProviders(props: ParentProps) {
|
|||||||
function ServerKey(props: ParentProps) {
|
function ServerKey(props: ParentProps) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
return (
|
return (
|
||||||
<Show when={server.url} keyed>
|
<Show when={server.key} keyed>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
export function AppInterface(props: {
|
||||||
const platform = usePlatform()
|
children?: JSX.Element
|
||||||
|
defaultServer: ServerConnection.Key
|
||||||
const stored = (() => {
|
servers?: Array<ServerConnection.Any>
|
||||||
if (platform.platform !== "web") return
|
}) {
|
||||||
const result = platform.getDefaultServerUrl?.()
|
|
||||||
if (result instanceof Promise) return
|
|
||||||
if (!result) return
|
|
||||||
return normalizeServerUrl(result)
|
|
||||||
})()
|
|
||||||
|
|
||||||
const defaultServerUrl = () => {
|
|
||||||
if (props.defaultUrl) return props.defaultUrl
|
|
||||||
if (stored) return stored
|
|
||||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
|
||||||
if (import.meta.env.DEV)
|
|
||||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
|
||||||
|
|
||||||
return window.location.origin
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}>
|
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
<Router
|
<Router
|
||||||
root={(routerProps) => (
|
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||||
<SettingsProvider>
|
|
||||||
<PermissionProvider>
|
|
||||||
<LayoutProvider>
|
|
||||||
<NotificationProvider>
|
|
||||||
<ModelsProvider>
|
|
||||||
<CommandProvider>
|
|
||||||
<HighlightsProvider>
|
|
||||||
<Layout>
|
|
||||||
{props.children}
|
|
||||||
{routerProps.children}
|
|
||||||
</Layout>
|
|
||||||
</HighlightsProvider>
|
|
||||||
</CommandProvider>
|
|
||||||
</ModelsProvider>
|
|
||||||
</NotificationProvider>
|
|
||||||
</LayoutProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
</SettingsProvider>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Route
|
<Route path="/" component={HomeRoute} />
|
||||||
path="/"
|
|
||||||
component={() => (
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<Home />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/" component={SessionIndexRoute} />
|
||||||
<Route
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
path="/session/:id?"
|
|
||||||
component={(p) => (
|
|
||||||
<Show when={p.params.id ?? "new"}>
|
|
||||||
<TerminalProvider>
|
|
||||||
<FileProvider>
|
|
||||||
<PromptProvider>
|
|
||||||
<CommentsProvider>
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<Session />
|
|
||||||
</Suspense>
|
|
||||||
</CommentsProvider>
|
|
||||||
</PromptProvider>
|
|
||||||
</FileProvider>
|
|
||||||
</TerminalProvider>
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Router>
|
</Router>
|
||||||
</GlobalSyncProvider>
|
</GlobalSyncProvider>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
|
||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
@@ -55,6 +54,47 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "method.select"; index: number }
|
||||||
|
| { type: "method.reset" }
|
||||||
|
| { type: "auth.pending" }
|
||||||
|
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||||
|
| { type: "auth.error"; error: string }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
if (action.type === "method.select") {
|
||||||
|
draft.methodIndex = action.index
|
||||||
|
draft.authorization = undefined
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "method.reset") {
|
||||||
|
draft.methodIndex = undefined
|
||||||
|
draft.authorization = undefined
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "auth.pending") {
|
||||||
|
draft.state = "pending"
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "auth.complete") {
|
||||||
|
draft.state = "complete"
|
||||||
|
draft.authorization = action.authorization
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.state = "error"
|
||||||
|
draft.error = action.error
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
||||||
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
@@ -63,6 +103,24 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
return value.label ?? ""
|
return value.label ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatError(value: unknown, fallback: string): string {
|
||||||
|
if (value && typeof value === "object" && "data" in value) {
|
||||||
|
const data = (value as { data?: { message?: unknown } }).data
|
||||||
|
if (typeof data?.message === "string" && data.message) return data.message
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object" && "error" in value) {
|
||||||
|
const nested = formatError((value as { error?: unknown }).error, "")
|
||||||
|
if (nested) return nested
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object" && "message" in value) {
|
||||||
|
const message = (value as { message?: unknown }).message
|
||||||
|
if (typeof message === "string" && message) return message
|
||||||
|
}
|
||||||
|
if (value instanceof Error && value.message) return value.message
|
||||||
|
if (typeof value === "string" && value) return value
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
async function selectMethod(index: number) {
|
async function selectMethod(index: number) {
|
||||||
if (timer.current !== undefined) {
|
if (timer.current !== undefined) {
|
||||||
clearTimeout(timer.current)
|
clearTimeout(timer.current)
|
||||||
@@ -70,17 +128,10 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const method = methods()[index]
|
const method = methods()[index]
|
||||||
setStore(
|
dispatch({ type: "method.select", index })
|
||||||
produce((draft) => {
|
|
||||||
draft.methodIndex = index
|
|
||||||
draft.authorization = undefined
|
|
||||||
draft.state = undefined
|
|
||||||
draft.error = undefined
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
setStore("state", "pending")
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await globalSDK.client.provider.oauth
|
await globalSDK.client.provider.oauth
|
||||||
.authorize(
|
.authorize(
|
||||||
@@ -100,18 +151,15 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
timer.current = setTimeout(() => {
|
timer.current = setTimeout(() => {
|
||||||
timer.current = undefined
|
timer.current = undefined
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
setStore("state", "complete")
|
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||||
setStore("authorization", x.data!)
|
|
||||||
}, delay)
|
}, delay)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("state", "complete")
|
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||||
setStore("authorization", x.data!)
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
setStore("state", "error")
|
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
|
||||||
setStore("error", String(e))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,10 +177,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
if (methods().length === 1) {
|
if (methods().length === 1) {
|
||||||
selectMethod(0)
|
selectMethod(0)
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", handleKey)
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("keydown", handleKey)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
@@ -152,56 +196,31 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.authorization) {
|
if (store.authorization) {
|
||||||
setStore("authorization", undefined)
|
dispatch({ type: "method.reset" })
|
||||||
setStore("methodIndex", undefined)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.methodIndex) {
|
if (store.methodIndex !== undefined) {
|
||||||
setStore("methodIndex", undefined)
|
dispatch({ type: "method.reset" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MethodSelection() {
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<>
|
||||||
title={
|
|
||||||
<IconButton
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="arrow-left"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={goBack}
|
|
||||||
aria-label={language.t("common.goBack")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
|
||||||
<div class="px-2.5 flex gap-4 items-center">
|
|
||||||
<ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
|
|
||||||
<div class="text-16-medium text-text-strong">
|
|
||||||
<Switch>
|
|
||||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
|
||||||
{language.t("provider.connect.title.anthropicProMax")}
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
|
||||||
<Switch>
|
|
||||||
<Match when={store.methodIndex === undefined}>
|
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div>
|
||||||
<List
|
<List
|
||||||
ref={(ref) => {
|
ref={(ref) => {
|
||||||
listRef = ref
|
listRef = ref
|
||||||
}}
|
}}
|
||||||
items={methods}
|
items={methods}
|
||||||
key={(m) => m?.label}
|
key={(m) => m?.label}
|
||||||
onSelect={async (method, index) => {
|
onSelect={async (selected, index) => {
|
||||||
if (!method) return
|
if (!selected) return
|
||||||
selectMethod(index)
|
selectMethod(index)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -215,25 +234,11 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</>
|
||||||
<Match when={store.state === "pending"}>
|
)
|
||||||
<div class="text-14-regular text-text-base">
|
}
|
||||||
<div class="flex items-center gap-x-2">
|
|
||||||
<Spinner />
|
function ApiAuthView() {
|
||||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={store.state === "error"}>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
<div class="flex items-center gap-x-2">
|
|
||||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
|
||||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={method()?.type === "api"}>
|
|
||||||
{iife(() => {
|
|
||||||
const [formStore, setFormStore] = createStore({
|
const [formStore, setFormStore] = createStore({
|
||||||
value: "",
|
value: "",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
@@ -267,12 +272,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Match when={provider().id === "opencode"}>
|
<Match when={provider().id === "opencode"}>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div>
|
||||||
{language.t("provider.connect.opencodeZen.line1")}
|
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||||
</div>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.opencodeZen.line2")}
|
|
||||||
</div>
|
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||||
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
||||||
@@ -296,7 +297,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||||
name="apiKey"
|
name="apiKey"
|
||||||
value={formStore.value}
|
value={formStore.value}
|
||||||
onChange={setFormStore.bind(null, "value")}
|
onChange={(v) => setFormStore("value", v)}
|
||||||
validationState={formStore.error ? "invalid" : undefined}
|
validationState={formStore.error ? "invalid" : undefined}
|
||||||
error={formStore.error}
|
error={formStore.error}
|
||||||
/>
|
/>
|
||||||
@@ -306,12 +307,9 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
}
|
||||||
</Match>
|
|
||||||
<Match when={method()?.type === "oauth"}>
|
function OAuthCodeView() {
|
||||||
<Switch>
|
|
||||||
<Match when={store.authorization?.method === "code"}>
|
|
||||||
{iife(() => {
|
|
||||||
const [formStore, setFormStore] = createStore({
|
const [formStore, setFormStore] = createStore({
|
||||||
value: "",
|
value: "",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
@@ -342,25 +340,20 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
method: store.methodIndex,
|
method: store.methodIndex,
|
||||||
code,
|
code,
|
||||||
})
|
})
|
||||||
.then((value) =>
|
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||||
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
|
|
||||||
)
|
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await complete()
|
await complete()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
||||||
setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
<Link href={store.authorization!.url}>
|
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
||||||
{language.t("provider.connect.oauth.code.visit.link")}
|
|
||||||
</Link>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
@@ -371,7 +364,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||||
name="code"
|
name="code"
|
||||||
value={formStore.value}
|
value={formStore.value}
|
||||||
onChange={setFormStore.bind(null, "value")}
|
onChange={(v) => setFormStore("value", v)}
|
||||||
validationState={formStore.error ? "invalid" : undefined}
|
validationState={formStore.error ? "invalid" : undefined}
|
||||||
error={formStore.error}
|
error={formStore.error}
|
||||||
/>
|
/>
|
||||||
@@ -381,14 +374,13 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
}
|
||||||
</Match>
|
|
||||||
<Match when={store.authorization?.method === "auto"}>
|
function OAuthAutoView() {
|
||||||
{iife(() => {
|
|
||||||
const code = createMemo(() => {
|
const code = createMemo(() => {
|
||||||
const instructions = store.authorization?.instructions
|
const instructions = store.authorization?.instructions
|
||||||
if (instructions?.includes(":")) {
|
if (instructions?.includes(":")) {
|
||||||
return instructions?.split(":")[1]?.trim()
|
return instructions.split(":")[1]?.trim()
|
||||||
}
|
}
|
||||||
return instructions
|
return instructions
|
||||||
})
|
})
|
||||||
@@ -404,17 +396,14 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
method: store.methodIndex,
|
method: store.methodIndex,
|
||||||
})
|
})
|
||||||
.then((value) =>
|
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||||
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
|
|
||||||
)
|
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
const message = formatError(result.error, language.t("common.requestFailed"))
|
||||||
setStore("state", "error")
|
dispatch({ type: "auth.error", error: message })
|
||||||
setStore("error", message)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,9 +415,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||||
<Link href={store.authorization!.url}>
|
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
||||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
|
||||||
</Link>
|
|
||||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -444,13 +431,71 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
icon="arrow-left"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={goBack}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
||||||
|
<div class="px-2.5 flex gap-4 items-center">
|
||||||
|
<ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
|
||||||
|
<div class="text-16-medium text-text-strong">
|
||||||
|
<Switch>
|
||||||
|
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||||
|
{language.t("provider.connect.title.anthropicProMax")}
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||||
|
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||||
|
<Switch>
|
||||||
|
<Match when={store.methodIndex === undefined}>
|
||||||
|
<MethodSelection />
|
||||||
|
</Match>
|
||||||
|
<Match when={store.state === "pending"}>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
<div class="flex items-center gap-x-2">
|
||||||
|
<Spinner />
|
||||||
|
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={store.state === "error"}>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
<div class="flex items-center gap-x-2">
|
||||||
|
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||||
|
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={method()?.type === "api"}>
|
||||||
|
<ApiAuthView />
|
||||||
|
</Match>
|
||||||
|
<Match when={method()?.type === "oauth"}>
|
||||||
|
<Switch>
|
||||||
|
<Match when={store.authorization?.method === "code"}>
|
||||||
|
<OAuthCodeView />
|
||||||
|
</Match>
|
||||||
|
<Match when={store.authorization?.method === "auto"}>
|
||||||
|
<OAuthAutoView />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { For } from "solid-js"
|
import { For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
@@ -16,6 +16,147 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
|||||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||||
|
|
||||||
|
type Translator = ReturnType<typeof useLanguage>["t"]
|
||||||
|
|
||||||
|
type ModelRow = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderRow = {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
providerID: string
|
||||||
|
name: string
|
||||||
|
baseURL: string
|
||||||
|
apiKey: string
|
||||||
|
models: ModelRow[]
|
||||||
|
headers: HeaderRow[]
|
||||||
|
saving: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormErrors = {
|
||||||
|
providerID: string | undefined
|
||||||
|
name: string | undefined
|
||||||
|
baseURL: string | undefined
|
||||||
|
models: Array<{ id?: string; name?: string }>
|
||||||
|
headers: Array<{ key?: string; value?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidateArgs = {
|
||||||
|
form: FormState
|
||||||
|
t: Translator
|
||||||
|
disabledProviders: string[]
|
||||||
|
existingProviderIDs: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCustomProvider(input: ValidateArgs) {
|
||||||
|
const providerID = input.form.providerID.trim()
|
||||||
|
const name = input.form.name.trim()
|
||||||
|
const baseURL = input.form.baseURL.trim()
|
||||||
|
const apiKey = input.form.apiKey.trim()
|
||||||
|
|
||||||
|
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||||
|
const key = apiKey && !env ? apiKey : undefined
|
||||||
|
|
||||||
|
const idError = !providerID
|
||||||
|
? input.t("provider.custom.error.providerID.required")
|
||||||
|
: !PROVIDER_ID.test(providerID)
|
||||||
|
? input.t("provider.custom.error.providerID.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||||
|
const urlError = !baseURL
|
||||||
|
? input.t("provider.custom.error.baseURL.required")
|
||||||
|
: !/^https?:\/\//.test(baseURL)
|
||||||
|
? input.t("provider.custom.error.baseURL.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const disabled = input.disabledProviders.includes(providerID)
|
||||||
|
const existsError = idError
|
||||||
|
? undefined
|
||||||
|
: input.existingProviderIDs.has(providerID) && !disabled
|
||||||
|
? input.t("provider.custom.error.providerID.exists")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const seenModels = new Set<string>()
|
||||||
|
const modelErrors = input.form.models.map((m) => {
|
||||||
|
const id = m.id.trim()
|
||||||
|
const modelIdError = !id
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenModels.has(id)
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenModels.add(id)
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { id: modelIdError, name: modelNameError }
|
||||||
|
})
|
||||||
|
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||||
|
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||||
|
|
||||||
|
const seenHeaders = new Set<string>()
|
||||||
|
const headerErrors = input.form.headers.map((h) => {
|
||||||
|
const key = h.key.trim()
|
||||||
|
const value = h.value.trim()
|
||||||
|
|
||||||
|
if (!key && !value) return {}
|
||||||
|
const keyError = !key
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenHeaders.has(key.toLowerCase())
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenHeaders.add(key.toLowerCase())
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { key: keyError, value: valueError }
|
||||||
|
})
|
||||||
|
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||||
|
const headers = Object.fromEntries(
|
||||||
|
input.form.headers
|
||||||
|
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||||
|
.filter((h) => !!h.key && !!h.value)
|
||||||
|
.map((h) => [h.key, h.value]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const errors: FormErrors = {
|
||||||
|
providerID: idError ?? existsError,
|
||||||
|
name: nameError,
|
||||||
|
baseURL: urlError,
|
||||||
|
models: modelErrors,
|
||||||
|
headers: headerErrors,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||||
|
if (!ok) return { errors }
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
baseURL,
|
||||||
|
...(Object.keys(headers).length ? { headers } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
result: {
|
||||||
|
providerID,
|
||||||
|
name,
|
||||||
|
key,
|
||||||
|
config: {
|
||||||
|
npm: OPENAI_COMPATIBLE,
|
||||||
|
name,
|
||||||
|
...(env ? { env: [env] } : {}),
|
||||||
|
options,
|
||||||
|
models,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
back?: "providers" | "close"
|
back?: "providers" | "close"
|
||||||
}
|
}
|
||||||
@@ -26,7 +167,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const [form, setForm] = createStore({
|
const [form, setForm] = createStore<FormState>({
|
||||||
providerID: "",
|
providerID: "",
|
||||||
name: "",
|
name: "",
|
||||||
baseURL: "",
|
baseURL: "",
|
||||||
@@ -36,12 +177,12 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
saving: false,
|
saving: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [errors, setErrors] = createStore({
|
const [errors, setErrors] = createStore<FormErrors>({
|
||||||
providerID: undefined as string | undefined,
|
providerID: undefined,
|
||||||
name: undefined as string | undefined,
|
name: undefined,
|
||||||
baseURL: undefined as string | undefined,
|
baseURL: undefined,
|
||||||
models: [{} as { id?: string; name?: string }],
|
models: [{}],
|
||||||
headers: [{} as { key?: string; value?: string }],
|
headers: [{}],
|
||||||
})
|
})
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
@@ -53,169 +194,36 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addModel = () => {
|
const addModel = () => {
|
||||||
setForm(
|
setForm("models", (v) => [...v, { id: "", name: "" }])
|
||||||
"models",
|
setErrors("models", (v) => [...v, {}])
|
||||||
produce((draft) => {
|
|
||||||
draft.push({ id: "", name: "" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"models",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.push({})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeModel = (index: number) => {
|
const removeModel = (index: number) => {
|
||||||
if (form.models.length <= 1) return
|
if (form.models.length <= 1) return
|
||||||
setForm(
|
setForm("models", (v) => v.filter((_, i) => i !== index))
|
||||||
"models",
|
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"models",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addHeader = () => {
|
const addHeader = () => {
|
||||||
setForm(
|
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
||||||
"headers",
|
setErrors("headers", (v) => [...v, {}])
|
||||||
produce((draft) => {
|
|
||||||
draft.push({ key: "", value: "" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"headers",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.push({})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeHeader = (index: number) => {
|
const removeHeader = (index: number) => {
|
||||||
if (form.headers.length <= 1) return
|
if (form.headers.length <= 1) return
|
||||||
setForm(
|
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
||||||
"headers",
|
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"headers",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const providerID = form.providerID.trim()
|
const output = validateCustomProvider({
|
||||||
const name = form.name.trim()
|
form,
|
||||||
const baseURL = form.baseURL.trim()
|
t: language.t,
|
||||||
const apiKey = form.apiKey.trim()
|
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||||
|
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
|
||||||
const key = apiKey && !env ? apiKey : undefined
|
|
||||||
|
|
||||||
const idError = !providerID
|
|
||||||
? language.t("provider.custom.error.providerID.required")
|
|
||||||
: !PROVIDER_ID.test(providerID)
|
|
||||||
? language.t("provider.custom.error.providerID.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
|
|
||||||
const urlError = !baseURL
|
|
||||||
? language.t("provider.custom.error.baseURL.required")
|
|
||||||
: !/^https?:\/\//.test(baseURL)
|
|
||||||
? language.t("provider.custom.error.baseURL.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
|
|
||||||
const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID)
|
|
||||||
const existsError = idError
|
|
||||||
? undefined
|
|
||||||
: existingProvider && !disabled
|
|
||||||
? language.t("provider.custom.error.providerID.exists")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const seenModels = new Set<string>()
|
|
||||||
const modelErrors = form.models.map((m) => {
|
|
||||||
const id = m.id.trim()
|
|
||||||
const modelIdError = !id
|
|
||||||
? language.t("provider.custom.error.required")
|
|
||||||
: seenModels.has(id)
|
|
||||||
? language.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenModels.add(id)
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
|
|
||||||
return { id: modelIdError, name: modelNameError }
|
|
||||||
})
|
})
|
||||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
setErrors(output.errors)
|
||||||
const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
return output.result
|
||||||
|
|
||||||
const seenHeaders = new Set<string>()
|
|
||||||
const headerErrors = form.headers.map((h) => {
|
|
||||||
const key = h.key.trim()
|
|
||||||
const value = h.value.trim()
|
|
||||||
|
|
||||||
if (!key && !value) return {}
|
|
||||||
const keyError = !key
|
|
||||||
? language.t("provider.custom.error.required")
|
|
||||||
: seenHeaders.has(key.toLowerCase())
|
|
||||||
? language.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenHeaders.add(key.toLowerCase())
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const valueError = !value ? language.t("provider.custom.error.required") : undefined
|
|
||||||
return { key: keyError, value: valueError }
|
|
||||||
})
|
|
||||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
|
||||||
const headers = Object.fromEntries(
|
|
||||||
form.headers
|
|
||||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
|
||||||
.filter((h) => !!h.key && !!h.value)
|
|
||||||
.map((h) => [h.key, h.value]),
|
|
||||||
)
|
|
||||||
|
|
||||||
setErrors(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.providerID = idError ?? existsError
|
|
||||||
draft.name = nameError
|
|
||||||
draft.baseURL = urlError
|
|
||||||
draft.models = modelErrors
|
|
||||||
draft.headers = headerErrors
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
|
||||||
if (!ok) return
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
baseURL,
|
|
||||||
...(Object.keys(headers).length ? { headers } : {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerID,
|
|
||||||
name,
|
|
||||||
key,
|
|
||||||
config: {
|
|
||||||
npm: OPENAI_COMPATIBLE,
|
|
||||||
name,
|
|
||||||
...(env ? { env: [env] } : {}),
|
|
||||||
options,
|
|
||||||
models,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async (e: SubmitEvent) => {
|
const save = async (e: SubmitEvent) => {
|
||||||
@@ -297,7 +305,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||||
description={language.t("provider.custom.field.providerID.description")}
|
description={language.t("provider.custom.field.providerID.description")}
|
||||||
value={form.providerID}
|
value={form.providerID}
|
||||||
onChange={setForm.bind(null, "providerID")}
|
onChange={(v) => setForm("providerID", v)}
|
||||||
validationState={errors.providerID ? "invalid" : undefined}
|
validationState={errors.providerID ? "invalid" : undefined}
|
||||||
error={errors.providerID}
|
error={errors.providerID}
|
||||||
/>
|
/>
|
||||||
@@ -305,7 +313,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
label={language.t("provider.custom.field.name.label")}
|
label={language.t("provider.custom.field.name.label")}
|
||||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={setForm.bind(null, "name")}
|
onChange={(v) => setForm("name", v)}
|
||||||
validationState={errors.name ? "invalid" : undefined}
|
validationState={errors.name ? "invalid" : undefined}
|
||||||
error={errors.name}
|
error={errors.name}
|
||||||
/>
|
/>
|
||||||
@@ -313,7 +321,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
label={language.t("provider.custom.field.baseURL.label")}
|
label={language.t("provider.custom.field.baseURL.label")}
|
||||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||||
value={form.baseURL}
|
value={form.baseURL}
|
||||||
onChange={setForm.bind(null, "baseURL")}
|
onChange={(v) => setForm("baseURL", v)}
|
||||||
validationState={errors.baseURL ? "invalid" : undefined}
|
validationState={errors.baseURL ? "invalid" : undefined}
|
||||||
error={errors.baseURL}
|
error={errors.baseURL}
|
||||||
/>
|
/>
|
||||||
@@ -322,7 +330,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||||
description={language.t("provider.custom.field.apiKey.description")}
|
description={language.t("provider.custom.field.apiKey.description")}
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={setForm.bind(null, "apiKey")}
|
onChange={(v) => setForm("apiKey", v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
iconHover: false,
|
iconHover: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let iconInput: HTMLInputElement | undefined
|
||||||
|
|
||||||
function handleFileSelect(file: File) {
|
function handleFileSelect(file: File) {
|
||||||
if (!file.type.startsWith("image/")) return
|
if (!file.type.startsWith("image/")) return
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
@@ -72,6 +74,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
|
await Promise.resolve()
|
||||||
|
.then(async () => {
|
||||||
setStore("saving", true)
|
setStore("saving", true)
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
const start = store.startup.trim()
|
const start = store.startup.trim()
|
||||||
@@ -85,7 +89,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
commands: { start },
|
commands: { start },
|
||||||
})
|
})
|
||||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||||
setStore("saving", false)
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -95,8 +98,11 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||||
commands: { start: start || undefined },
|
commands: { start: start || undefined },
|
||||||
})
|
})
|
||||||
setStore("saving", false)
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setStore("saving", false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
if (store.iconUrl && store.iconHover) {
|
if (store.iconUrl && store.iconHover) {
|
||||||
clearIcon()
|
clearIcon()
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("icon-upload")?.click()
|
iconInput?.click()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -176,7 +182,16 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
<input
|
||||||
|
id="icon-upload"
|
||||||
|
ref={(el) => {
|
||||||
|
iconInput = el
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { usePrompt } from "@/context/prompt"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
@@ -66,15 +67,23 @@ export const DialogFork: Component = () => {
|
|||||||
attachmentName: language.t("common.attachment"),
|
attachmentName: language.t("common.attachment"),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
sdk.client.session
|
||||||
|
.fork({ sessionID, messageID: item.id })
|
||||||
|
.then((forked) => {
|
||||||
|
if (!forked.data) {
|
||||||
|
showToast({ title: language.t("common.requestFailed") })
|
||||||
|
return
|
||||||
|
}
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
|
|
||||||
if (!forked.data) return
|
|
||||||
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
prompt.set(restored)
|
prompt.set(restored)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import type { Component } from "solid-js"
|
import type { Component } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -17,6 +18,15 @@ export const DialogManageModels: Component = () => {
|
|||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
}
|
}
|
||||||
|
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||||
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
|
const providerVisible = (providerID: string) =>
|
||||||
|
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
|
||||||
|
const setProviderVisibility = (providerID: string, checked: boolean) => {
|
||||||
|
providerList(providerID).forEach((x) => {
|
||||||
|
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -35,21 +45,41 @@ export const DialogManageModels: Component = () => {
|
|||||||
items={local.model.list()}
|
items={local.model.list()}
|
||||||
filterKeys={["provider.name", "name", "id"]}
|
filterKeys={["provider.name", "name", "id"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
groupBy={(x) => x.provider.name}
|
groupBy={(x) => x.provider.id}
|
||||||
|
groupHeader={(group) => {
|
||||||
|
const provider = group.items[0].provider
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span>{provider.name}</span>
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
class="-mr-1"
|
||||||
|
checked={providerVisible(provider.id)}
|
||||||
|
onChange={(checked) => setProviderVisibility(provider.id, checked)}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{provider.name}
|
||||||
|
</Switch>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
sortGroupsBy={(a, b) => {
|
sortGroupsBy={(a, b) => {
|
||||||
const aProvider = a.items[0].provider.id
|
const aRank = providerRank(a.items[0].provider.id)
|
||||||
const bProvider = b.items[0].provider.id
|
const bRank = providerRank(b.items[0].provider.id)
|
||||||
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
const aPopular = aRank >= 0
|
||||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
const bPopular = bRank >= 0
|
||||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
if (aPopular && !bPopular) return -1
|
||||||
|
if (!aPopular && bPopular) return 1
|
||||||
|
return aRank - bRank
|
||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
const visible = local.model.visible({
|
const key = { modelID: x.id, providerID: x.provider.id }
|
||||||
modelID: x.id,
|
local.model.setVisibility(key, !local.model.visible(key))
|
||||||
providerID: x.provider.id,
|
|
||||||
})
|
|
||||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
@@ -57,12 +87,7 @@ export const DialogManageModels: Component = () => {
|
|||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={
|
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
|
||||||
!!local.model.visible({
|
|
||||||
modelID: i.id,
|
|
||||||
providerID: i.provider.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -40,8 +40,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
let focusTrap: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -60,27 +58,13 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
focusTrap?.focus()
|
|
||||||
document.addEventListener("keydown", handleKeyDown)
|
|
||||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Refocus the trap when index changes to ensure escape always works
|
|
||||||
createEffect(() => {
|
|
||||||
index() // track index
|
|
||||||
focusTrap?.focus()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
size="large"
|
size="large"
|
||||||
fit
|
fit
|
||||||
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
|
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Hidden element to capture initial focus and handle escape */}
|
<div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}>
|
||||||
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
|
|
||||||
<div class="flex flex-1 min-w-0 min-h-0">
|
|
||||||
{/* Left side - Text content */}
|
{/* Left side - Text content */}
|
||||||
<div class="flex flex-col flex-1 min-w-0 p-8">
|
<div class="flex flex-col flex-1 min-w-0 p-8">
|
||||||
{/* Top section - feature content (fixed position from top) */}
|
{/* Top section - feature content (fixed position from top) */}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import type { ListRef } from "@opencode-ai/ui/list"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { createMemo, createResource, createSignal } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { ListRef } from "@opencode-ai/ui/list"
|
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -21,80 +21,49 @@ type Row = {
|
|||||||
search: string
|
search: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
function cleanInput(value: string) {
|
||||||
const sync = useGlobalSync()
|
|
||||||
const sdk = useGlobalSDK()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
const [filter, setFilter] = createSignal("")
|
|
||||||
|
|
||||||
let list: ListRef | undefined
|
|
||||||
|
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
|
||||||
|
|
||||||
const [fallbackPath] = createResource(
|
|
||||||
() => (missingBase() ? true : undefined),
|
|
||||||
async () => {
|
|
||||||
return sdk.client.path
|
|
||||||
.get()
|
|
||||||
.then((x) => x.data)
|
|
||||||
.catch(() => undefined)
|
|
||||||
},
|
|
||||||
{ initialValue: undefined },
|
|
||||||
)
|
|
||||||
|
|
||||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
|
||||||
|
|
||||||
const start = createMemo(
|
|
||||||
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
|
||||||
)
|
|
||||||
|
|
||||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
|
||||||
|
|
||||||
const clean = (value: string) => {
|
|
||||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize(input: string) {
|
function normalizePath(input: string) {
|
||||||
const v = input.replaceAll("\\", "/")
|
const v = input.replaceAll("\\", "/")
|
||||||
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
||||||
return v.replace(/\/+/g, "/")
|
return v.replace(/\/+/g, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDriveRoot(input: string) {
|
function normalizeDriveRoot(input: string) {
|
||||||
const v = normalize(input)
|
const v = normalizePath(input)
|
||||||
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimTrailing(input: string) {
|
function trimTrailing(input: string) {
|
||||||
const v = normalizeDriveRoot(input)
|
const v = normalizeDriveRoot(input)
|
||||||
if (v === "/") return v
|
if (v === "/") return v
|
||||||
if (v === "//") return v
|
if (v === "//") return v
|
||||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||||
return v.replace(/\/+$/, "")
|
return v.replace(/\/+$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
function join(base: string | undefined, rel: string) {
|
function joinPath(base: string | undefined, rel: string) {
|
||||||
const b = trimTrailing(base ?? "")
|
const b = trimTrailing(base ?? "")
|
||||||
const r = trimTrailing(rel).replace(/^\/+/, "")
|
const r = trimTrailing(rel).replace(/^\/+/, "")
|
||||||
if (!b) return r
|
if (!b) return r
|
||||||
if (!r) return b
|
if (!r) return b
|
||||||
if (b.endsWith("/")) return b + r
|
if (b.endsWith("/")) return b + r
|
||||||
return b + "/" + r
|
return b + "/" + r
|
||||||
}
|
}
|
||||||
|
|
||||||
function rootOf(input: string) {
|
function rootOf(input: string) {
|
||||||
const v = normalizeDriveRoot(input)
|
const v = normalizeDriveRoot(input)
|
||||||
if (v.startsWith("//")) return "//"
|
if (v.startsWith("//")) return "//"
|
||||||
if (v.startsWith("/")) return "/"
|
if (v.startsWith("/")) return "/"
|
||||||
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function parentOf(input: string) {
|
function parentOf(input: string) {
|
||||||
const v = trimTrailing(input)
|
const v = trimTrailing(input)
|
||||||
if (v === "/") return v
|
if (v === "/") return v
|
||||||
if (v === "//") return v
|
if (v === "//") return v
|
||||||
@@ -104,40 +73,37 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
if (i <= 0) return "/"
|
if (i <= 0) return "/"
|
||||||
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
||||||
return v.slice(0, i)
|
return v.slice(0, i)
|
||||||
}
|
}
|
||||||
|
|
||||||
function modeOf(input: string) {
|
function modeOf(input: string) {
|
||||||
const raw = normalizeDriveRoot(input.trim())
|
const raw = normalizeDriveRoot(input.trim())
|
||||||
if (!raw) return "relative" as const
|
if (!raw) return "relative" as const
|
||||||
if (raw.startsWith("~")) return "tilde" as const
|
if (raw.startsWith("~")) return "tilde" as const
|
||||||
if (rootOf(raw)) return "absolute" as const
|
if (rootOf(raw)) return "absolute" as const
|
||||||
return "relative" as const
|
return "relative" as const
|
||||||
}
|
}
|
||||||
|
|
||||||
function display(path: string, input: string) {
|
function tildeOf(absolute: string, home: string) {
|
||||||
const full = trimTrailing(path)
|
|
||||||
if (modeOf(input) === "absolute") return full
|
|
||||||
|
|
||||||
return tildeOf(full) || full
|
|
||||||
}
|
|
||||||
|
|
||||||
function tildeOf(absolute: string) {
|
|
||||||
const full = trimTrailing(absolute)
|
const full = trimTrailing(absolute)
|
||||||
const h = home()
|
if (!home) return ""
|
||||||
if (!h) return ""
|
|
||||||
|
|
||||||
const hn = trimTrailing(h)
|
const hn = trimTrailing(home)
|
||||||
const lc = full.toLowerCase()
|
const lc = full.toLowerCase()
|
||||||
const hc = hn.toLowerCase()
|
const hc = hn.toLowerCase()
|
||||||
if (lc === hc) return "~"
|
if (lc === hc) return "~"
|
||||||
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function row(absolute: string): Row {
|
function displayPath(path: string, input: string, home: string) {
|
||||||
|
const full = trimTrailing(path)
|
||||||
|
if (modeOf(input) === "absolute") return full
|
||||||
|
return tildeOf(full, home) || full
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRow(absolute: string, home: string): Row {
|
||||||
const full = trimTrailing(absolute)
|
const full = trimTrailing(absolute)
|
||||||
const tilde = tildeOf(full)
|
const tilde = tildeOf(full, home)
|
||||||
|
|
||||||
const withSlash = (value: string) => {
|
const withSlash = (value: string) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
if (value.endsWith("/")) return value
|
if (value.endsWith("/")) return value
|
||||||
@@ -148,30 +114,38 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
||||||
).join("\n")
|
).join("\n")
|
||||||
return { absolute: full, search }
|
return { absolute: full, search }
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoped(value: string) {
|
function useDirectorySearch(args: {
|
||||||
const base = start()
|
sdk: ReturnType<typeof useGlobalSDK>
|
||||||
|
start: () => string | undefined
|
||||||
|
home: () => string
|
||||||
|
}) {
|
||||||
|
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||||
|
let current = 0
|
||||||
|
|
||||||
|
const scoped = (value: string) => {
|
||||||
|
const base = args.start()
|
||||||
if (!base) return
|
if (!base) return
|
||||||
|
|
||||||
const raw = normalizeDriveRoot(value)
|
const raw = normalizeDriveRoot(value)
|
||||||
if (!raw) return { directory: trimTrailing(base), path: "" }
|
if (!raw) return { directory: trimTrailing(base), path: "" }
|
||||||
|
|
||||||
const h = home()
|
const h = args.home()
|
||||||
if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" }
|
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
|
||||||
if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) }
|
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
|
||||||
|
|
||||||
const root = rootOf(raw)
|
const root = rootOf(raw)
|
||||||
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
|
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
|
||||||
return { directory: trimTrailing(base), path: raw }
|
return { directory: trimTrailing(base), path: raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dirs(dir: string) {
|
const dirs = async (dir: string) => {
|
||||||
const key = trimTrailing(dir)
|
const key = trimTrailing(dir)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const request = sdk.client.file
|
const request = args.sdk.client.file
|
||||||
.list({ directory: key, path: "" })
|
.list({ directory: key, path: "" })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
@@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
async function match(dir: string, query: string, limit: number) {
|
const match = async (dir: string, query: string, limit: number) => {
|
||||||
const items = await dirs(dir)
|
const items = await dirs(dir)
|
||||||
if (!query) return items.slice(0, limit).map((x) => x.absolute)
|
if (!query) return items.slice(0, limit).map((x) => x.absolute)
|
||||||
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
|
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
|
||||||
}
|
}
|
||||||
|
|
||||||
const directories = async (filter: string) => {
|
return async (filter: string) => {
|
||||||
const value = clean(filter)
|
const token = ++current
|
||||||
|
const active = () => token === current
|
||||||
|
|
||||||
|
const value = cleanInput(filter)
|
||||||
const scopedInput = scoped(value)
|
const scopedInput = scoped(value)
|
||||||
if (!scopedInput) return [] as string[]
|
if (!scopedInput) return [] as string[]
|
||||||
|
|
||||||
const raw = normalizeDriveRoot(value)
|
const raw = normalizeDriveRoot(value)
|
||||||
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
||||||
|
|
||||||
const query = normalizeDriveRoot(scopedInput.path)
|
const query = normalizeDriveRoot(scopedInput.path)
|
||||||
|
|
||||||
const find = () =>
|
const find = () =>
|
||||||
sdk.client.find
|
args.sdk.client.find
|
||||||
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
|
|
||||||
if (!isPath) {
|
if (!isPath) {
|
||||||
const results = await find()
|
const results = await find()
|
||||||
|
if (!active()) return []
|
||||||
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
|
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = query.replace(/^\/+/, "").split("/")
|
const segments = query.replace(/^\/+/, "").split("/")
|
||||||
@@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const branch = 4
|
const branch = 4
|
||||||
let paths = [scopedInput.directory]
|
let paths = [scopedInput.directory]
|
||||||
for (const part of head) {
|
for (const part of head) {
|
||||||
|
if (!active()) return []
|
||||||
if (part === "..") {
|
if (part === "..") {
|
||||||
paths = paths.map(parentOf)
|
paths = paths.map(parentOf)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
|
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
|
||||||
|
if (!active()) return []
|
||||||
paths = Array.from(new Set(next)).slice(0, cap)
|
paths = Array.from(new Set(next)).slice(0, cap)
|
||||||
if (paths.length === 0) return [] as string[]
|
if (paths.length === 0) return [] as string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
||||||
|
if (!active()) return []
|
||||||
const deduped = Array.from(new Set(out))
|
const deduped = Array.from(new Set(out))
|
||||||
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
||||||
const expand = !raw.endsWith("/")
|
const expand = !raw.endsWith("/")
|
||||||
@@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
if (!target) return deduped.slice(0, 50)
|
if (!target) return deduped.slice(0, 50)
|
||||||
|
|
||||||
const children = await match(target, "", 30)
|
const children = await match(target, "", 30)
|
||||||
|
if (!active()) return []
|
||||||
const items = Array.from(new Set([...deduped, ...children]))
|
const items = Array.from(new Set([...deduped, ...children]))
|
||||||
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
|
const sync = useGlobalSync()
|
||||||
|
const sdk = useGlobalSDK()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const [filter, setFilter] = createSignal("")
|
||||||
|
let list: ListRef | undefined
|
||||||
|
|
||||||
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
|
const [fallbackPath] = createResource(
|
||||||
|
() => (missingBase() ? true : undefined),
|
||||||
|
async () => {
|
||||||
|
return sdk.client.path
|
||||||
|
.get()
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
},
|
||||||
|
{ initialValue: undefined },
|
||||||
|
)
|
||||||
|
|
||||||
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
|
const start = createMemo(
|
||||||
|
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
const directories = useDirectorySearch({
|
||||||
|
sdk,
|
||||||
|
home,
|
||||||
|
start,
|
||||||
|
})
|
||||||
|
|
||||||
const items = async (value: string) => {
|
const items = async (value: string) => {
|
||||||
const results = await directories(value)
|
const results = await directories(value)
|
||||||
return results.map(row)
|
return results.map((absolute) => toRow(absolute, home()))
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(absolute: string) {
|
function resolve(absolute: string) {
|
||||||
@@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
key={(x) => x.absolute}
|
key={(x) => x.absolute}
|
||||||
filterKeys={["search"]}
|
filterKeys={["search"]}
|
||||||
ref={(r) => (list = r)}
|
ref={(r) => (list = r)}
|
||||||
onFilter={(value) => setFilter(clean(value))}
|
onFilter={(value) => setFilter(cleanInput(value))}
|
||||||
onKeyEvent={(e, item) => {
|
onKeyEvent={(e, item) => {
|
||||||
if (e.key !== "Tab") return
|
if (e.key !== "Tab") return
|
||||||
if (e.shiftKey) return
|
if (e.shiftKey) return
|
||||||
@@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const value = display(item.absolute, filter())
|
const value = displayPath(item.absolute, filter(), home())
|
||||||
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
||||||
}}
|
}}
|
||||||
onSelect={(path) => {
|
onSelect={(path) => {
|
||||||
@@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const path = display(item.absolute, filter())
|
const path = displayPath(item.absolute, filter(), home())
|
||||||
if (path === "~") {
|
if (path === "~") {
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div class="w-full flex items-center justify-between rounded-md">
|
||||||
|
|||||||
@@ -36,6 +36,223 @@ type Entry = {
|
|||||||
|
|
||||||
type DialogSelectFileMode = "all" | "files"
|
type DialogSelectFileMode = "all" | "files"
|
||||||
|
|
||||||
|
const ENTRY_LIMIT = 5
|
||||||
|
const COMMON_COMMAND_IDS = [
|
||||||
|
"session.new",
|
||||||
|
"workspace.new",
|
||||||
|
"session.previous",
|
||||||
|
"session.next",
|
||||||
|
"terminal.toggle",
|
||||||
|
"review.toggle",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const uniqueEntries = (items: Entry[]) => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: Entry[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (seen.has(item.id)) continue
|
||||||
|
seen.add(item.id)
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
|
||||||
|
id: "command:" + option.id,
|
||||||
|
type: "command",
|
||||||
|
title: option.title,
|
||||||
|
description: option.description,
|
||||||
|
keybind: option.keybind,
|
||||||
|
category,
|
||||||
|
option,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createFileEntry = (path: string, category: string): Entry => ({
|
||||||
|
id: "file:" + path,
|
||||||
|
type: "file",
|
||||||
|
title: path,
|
||||||
|
category,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createSessionEntry = (
|
||||||
|
input: {
|
||||||
|
directory: string
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
},
|
||||||
|
category: string,
|
||||||
|
): Entry => ({
|
||||||
|
id: `session:${input.directory}:${input.id}`,
|
||||||
|
type: "session",
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
category,
|
||||||
|
directory: input.directory,
|
||||||
|
sessionID: input.id,
|
||||||
|
archived: input.archived,
|
||||||
|
updated: input.updated,
|
||||||
|
})
|
||||||
|
|
||||||
|
function createCommandEntries(props: {
|
||||||
|
filesOnly: () => boolean
|
||||||
|
command: ReturnType<typeof useCommand>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const allowed = createMemo(() => {
|
||||||
|
if (props.filesOnly()) return []
|
||||||
|
return props.command.options.filter(
|
||||||
|
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = createMemo(() => {
|
||||||
|
const category = props.language.t("palette.group.commands")
|
||||||
|
return allowed().map((option) => createCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
const picks = createMemo(() => {
|
||||||
|
const all = allowed()
|
||||||
|
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
|
||||||
|
const picked = all.filter((option) => order.has(option.id))
|
||||||
|
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||||
|
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||||
|
const category = props.language.t("palette.group.commands")
|
||||||
|
return sorted.map((option) => createCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
return { allowed, list, picks }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFileEntries(props: {
|
||||||
|
file: ReturnType<typeof useFile>
|
||||||
|
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const recent = createMemo(() => {
|
||||||
|
const all = props.tabs().all()
|
||||||
|
const active = props.tabs().active()
|
||||||
|
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const category = props.language.t("palette.group.files")
|
||||||
|
const items: Entry[] = []
|
||||||
|
|
||||||
|
for (const item of order) {
|
||||||
|
const path = props.file.pathFromTab(item)
|
||||||
|
if (!path) continue
|
||||||
|
if (seen.has(path)) continue
|
||||||
|
seen.add(path)
|
||||||
|
items.push(createFileEntry(path, category))
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.slice(0, ENTRY_LIMIT)
|
||||||
|
})
|
||||||
|
|
||||||
|
const root = createMemo(() => {
|
||||||
|
const category = props.language.t("palette.group.files")
|
||||||
|
const nodes = props.file.tree.children("")
|
||||||
|
const paths = nodes
|
||||||
|
.filter((node) => node.type === "file")
|
||||||
|
.map((node) => node.path)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
return { recent, root }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionEntries(props: {
|
||||||
|
workspaces: () => string[]
|
||||||
|
label: (directory: string) => string
|
||||||
|
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const state: {
|
||||||
|
token: number
|
||||||
|
inflight: Promise<Entry[]> | undefined
|
||||||
|
cached: Entry[] | undefined
|
||||||
|
} = {
|
||||||
|
token: 0,
|
||||||
|
inflight: undefined,
|
||||||
|
cached: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = (text: string) => {
|
||||||
|
const query = text.trim()
|
||||||
|
if (!query) {
|
||||||
|
state.token += 1
|
||||||
|
state.inflight = undefined
|
||||||
|
state.cached = undefined
|
||||||
|
return [] as Entry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.cached) return state.cached
|
||||||
|
if (state.inflight) return state.inflight
|
||||||
|
|
||||||
|
const current = state.token
|
||||||
|
const dirs = props.workspaces()
|
||||||
|
if (dirs.length === 0) return [] as Entry[]
|
||||||
|
|
||||||
|
state.inflight = Promise.all(
|
||||||
|
dirs.map((directory) => {
|
||||||
|
const description = props.label(directory)
|
||||||
|
return props.globalSDK.client.session
|
||||||
|
.list({ directory, roots: true })
|
||||||
|
.then((x) =>
|
||||||
|
(x.data ?? [])
|
||||||
|
.filter((s) => !!s?.id)
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title ?? props.language.t("command.session.new"),
|
||||||
|
description,
|
||||||
|
directory,
|
||||||
|
archived: s.time?.archived,
|
||||||
|
updated: s.time?.updated,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.catch(
|
||||||
|
() =>
|
||||||
|
[] as {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
directory: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
}[],
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((results) => {
|
||||||
|
if (state.token !== current) return [] as Entry[]
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const category = props.language.t("command.category.session")
|
||||||
|
const next = results
|
||||||
|
.flat()
|
||||||
|
.filter((item) => {
|
||||||
|
const key = `${item.directory}:${item.id}`
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map((item) => createSessionEntry(item, category))
|
||||||
|
state.cached = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
.catch(() => [] as Entry[])
|
||||||
|
.finally(() => {
|
||||||
|
state.inflight = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return state.inflight
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessions }
|
||||||
|
}
|
||||||
|
|
||||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -52,40 +269,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const view = createMemo(() => layout.view(sessionKey))
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
const [grouped, setGrouped] = createSignal(false)
|
const [grouped, setGrouped] = createSignal(false)
|
||||||
const common = [
|
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||||
"session.new",
|
const fileEntries = createFileEntries({ file, tabs, language })
|
||||||
"workspace.new",
|
|
||||||
"session.previous",
|
|
||||||
"session.next",
|
|
||||||
"terminal.toggle",
|
|
||||||
"review.toggle",
|
|
||||||
]
|
|
||||||
const limit = 5
|
|
||||||
|
|
||||||
const allowed = createMemo(() => {
|
|
||||||
if (filesOnly()) return []
|
|
||||||
return command.options.filter(
|
|
||||||
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const commandItem = (option: CommandOption): Entry => ({
|
|
||||||
id: "command:" + option.id,
|
|
||||||
type: "command",
|
|
||||||
title: option.title,
|
|
||||||
description: option.description,
|
|
||||||
keybind: option.keybind,
|
|
||||||
category: language.t("palette.group.commands"),
|
|
||||||
option,
|
|
||||||
})
|
|
||||||
|
|
||||||
const fileItem = (path: string): Entry => ({
|
|
||||||
id: "file:" + path,
|
|
||||||
type: "file",
|
|
||||||
title: path,
|
|
||||||
category: language.t("palette.group.files"),
|
|
||||||
path,
|
|
||||||
})
|
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
@@ -116,136 +301,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
return `${kind} : ${name || path}`
|
return `${kind} : ${name || path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionItem = (input: {
|
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
|
||||||
directory: string
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
archived?: number
|
|
||||||
updated?: number
|
|
||||||
}): Entry => ({
|
|
||||||
id: `session:${input.directory}:${input.id}`,
|
|
||||||
type: "session",
|
|
||||||
title: input.title,
|
|
||||||
description: input.description,
|
|
||||||
category: language.t("command.category.session"),
|
|
||||||
directory: input.directory,
|
|
||||||
sessionID: input.id,
|
|
||||||
archived: input.archived,
|
|
||||||
updated: input.updated,
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = createMemo(() => allowed().map(commandItem))
|
|
||||||
|
|
||||||
const picks = createMemo(() => {
|
|
||||||
const all = allowed()
|
|
||||||
const order = new Map(common.map((id, index) => [id, index]))
|
|
||||||
const picked = all.filter((option) => order.has(option.id))
|
|
||||||
const base = picked.length ? picked : all.slice(0, limit)
|
|
||||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
|
||||||
return sorted.map(commandItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
const recent = createMemo(() => {
|
|
||||||
const all = tabs().all()
|
|
||||||
const active = tabs().active()
|
|
||||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const items: Entry[] = []
|
|
||||||
|
|
||||||
for (const item of order) {
|
|
||||||
const path = file.pathFromTab(item)
|
|
||||||
if (!path) continue
|
|
||||||
if (seen.has(path)) continue
|
|
||||||
seen.add(path)
|
|
||||||
items.push(fileItem(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.slice(0, limit)
|
|
||||||
})
|
|
||||||
|
|
||||||
const root = createMemo(() => {
|
|
||||||
const nodes = file.tree.children("")
|
|
||||||
const paths = nodes
|
|
||||||
.filter((node) => node.type === "file")
|
|
||||||
.map((node) => node.path)
|
|
||||||
.sort((a, b) => a.localeCompare(b))
|
|
||||||
return paths.slice(0, limit).map(fileItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
const unique = (items: Entry[]) => {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const out: Entry[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (seen.has(item.id)) continue
|
|
||||||
seen.add(item.id)
|
|
||||||
out.push(item)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionToken = { value: 0 }
|
|
||||||
let sessionInflight: Promise<Entry[]> | undefined
|
|
||||||
let sessionAll: Entry[] | undefined
|
|
||||||
|
|
||||||
const sessions = (text: string) => {
|
|
||||||
const query = text.trim()
|
|
||||||
if (!query) {
|
|
||||||
sessionToken.value += 1
|
|
||||||
sessionInflight = undefined
|
|
||||||
sessionAll = undefined
|
|
||||||
return [] as Entry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionAll) return sessionAll
|
|
||||||
if (sessionInflight) return sessionInflight
|
|
||||||
|
|
||||||
const current = sessionToken.value
|
|
||||||
const dirs = workspaces()
|
|
||||||
if (dirs.length === 0) return [] as Entry[]
|
|
||||||
|
|
||||||
sessionInflight = Promise.all(
|
|
||||||
dirs.map((directory) => {
|
|
||||||
const description = label(directory)
|
|
||||||
return globalSDK.client.session
|
|
||||||
.list({ directory, roots: true })
|
|
||||||
.then((x) =>
|
|
||||||
(x.data ?? [])
|
|
||||||
.filter((s) => !!s?.id)
|
|
||||||
.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title ?? language.t("command.session.new"),
|
|
||||||
description,
|
|
||||||
directory,
|
|
||||||
archived: s.time?.archived,
|
|
||||||
updated: s.time?.updated,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then((results) => {
|
|
||||||
if (sessionToken.value !== current) return [] as Entry[]
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const next = results
|
|
||||||
.flat()
|
|
||||||
.filter((item) => {
|
|
||||||
const key = `${item.directory}:${item.id}`
|
|
||||||
if (seen.has(key)) return false
|
|
||||||
seen.add(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map(sessionItem)
|
|
||||||
sessionAll = next
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
.catch(() => [] as Entry[])
|
|
||||||
.finally(() => {
|
|
||||||
sessionInflight = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
return sessionInflight
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = async (text: string) => {
|
const items = async (text: string) => {
|
||||||
const query = text.trim()
|
const query = text.trim()
|
||||||
@@ -254,7 +310,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
if (!query && filesOnly()) {
|
if (!query && filesOnly()) {
|
||||||
const loaded = file.tree.state("")?.loaded
|
const loaded = file.tree.state("")?.loaded
|
||||||
const pending = loaded ? Promise.resolve() : file.tree.list("")
|
const pending = loaded ? Promise.resolve() : file.tree.list("")
|
||||||
const next = unique([...recent(), ...root()])
|
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
||||||
|
|
||||||
if (loaded || next.length > 0) {
|
if (loaded || next.length > 0) {
|
||||||
void pending
|
void pending
|
||||||
@@ -262,19 +318,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pending
|
await pending
|
||||||
return unique([...recent(), ...root()])
|
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query) return [...picks(), ...recent()]
|
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
|
||||||
|
|
||||||
if (filesOnly()) {
|
if (filesOnly()) {
|
||||||
const files = await file.searchFiles(query)
|
const files = await file.searchFiles(query)
|
||||||
return files.map(fileItem)
|
const category = language.t("palette.group.files")
|
||||||
|
return files.map((path) => createFileEntry(path, category))
|
||||||
}
|
}
|
||||||
|
|
||||||
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
||||||
const entries = files.map(fileItem)
|
const category = language.t("palette.group.files")
|
||||||
return [...list(), ...nextSessions, ...entries]
|
const entries = files.map((path) => createFileEntry(path, category))
|
||||||
|
return [...commandEntries.list(), ...nextSessions, ...entries]
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMove = (item: Entry | undefined) => {
|
const handleMove = (item: Entry | undefined) => {
|
||||||
@@ -289,9 +347,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
tabs().open(value)
|
tabs().open(value)
|
||||||
file.load(path)
|
file.load(path)
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
layout.fileTree.open()
|
|
||||||
layout.fileTree.setTab("all")
|
layout.fileTree.setTab("all")
|
||||||
props.onOpenFile?.(path)
|
props.onOpenFile?.(path)
|
||||||
|
tabs().setActive(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelect = (item: Entry | undefined) => {
|
const handleSelect = (item: Entry | undefined) => {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { List } from "@opencode-ai/ui/list"
|
|||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
connected: "mcp.status.connected",
|
||||||
|
failed: "mcp.status.failed",
|
||||||
|
needs_auth: "mcp.status.needs_auth",
|
||||||
|
disabled: "mcp.status.disabled",
|
||||||
|
} as const
|
||||||
|
|
||||||
export const DialogSelectMcp: Component = () => {
|
export const DialogSelectMcp: Component = () => {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -21,16 +28,20 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const toggle = async (name: string) => {
|
const toggle = async (name: string) => {
|
||||||
if (loading()) return
|
if (loading()) return
|
||||||
setLoading(name)
|
setLoading(name)
|
||||||
|
try {
|
||||||
const status = sync.data.mcp[name]
|
const status = sync.data.mcp[name]
|
||||||
if (status?.status === "connected") {
|
if (status?.status === "connected") {
|
||||||
await sdk.client.mcp.disconnect({ name })
|
await sdk.client.mcp.disconnect({ name })
|
||||||
} else {
|
} else {
|
||||||
await sdk.client.mcp.connect({ name })
|
await sdk.client.mcp.connect({ name })
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await sdk.client.mcp.status()
|
const result = await sdk.client.mcp.status()
|
||||||
if (result.data) sync.set("mcp", result.data)
|
if (result.data) sync.set("mcp", result.data)
|
||||||
|
} finally {
|
||||||
setLoading(null)
|
setLoading(null)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||||
const totalCount = createMemo(() => items().length)
|
const totalCount = createMemo(() => items().length)
|
||||||
@@ -54,6 +65,11 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
{(i) => {
|
{(i) => {
|
||||||
const mcpStatus = () => sync.data.mcp[i.name]
|
const mcpStatus = () => sync.data.mcp[i.name]
|
||||||
const status = () => mcpStatus()?.status
|
const status = () => mcpStatus()?.status
|
||||||
|
const statusLabel = () => {
|
||||||
|
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||||
|
if (!key) return
|
||||||
|
return language.t(key)
|
||||||
|
}
|
||||||
const error = () => {
|
const error = () => {
|
||||||
const s = mcpStatus()
|
const s = mcpStatus()
|
||||||
return s?.status === "failed" ? s.error : undefined
|
return s?.status === "failed" ? s.error : undefined
|
||||||
@@ -64,17 +80,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
<div class="flex flex-col gap-0.5 min-w-0">
|
<div class="flex flex-col gap-0.5 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="truncate">{i.name}</span>
|
<span class="truncate">{i.name}</span>
|
||||||
<Show when={status() === "connected"}>
|
<Show when={statusLabel()}>
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span>
|
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||||
</Show>
|
|
||||||
<Show when={status() === "failed"}>
|
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.failed")}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={status() === "needs_auth"}>
|
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.needs_auth")}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={status() === "disabled"}>
|
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.disabled")}</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={loading() === i.name}>
|
<Show when={loading() === i.name}>
|
||||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
|
|||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { type Component, onCleanup, onMount, Show } from "solid-js"
|
import { type Component, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
@@ -21,24 +21,17 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
let listRef: ListRef | undefined
|
let listRef: ListRef | undefined
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") return
|
if (e.key === "Escape") return
|
||||||
listRef?.onKeyDown(e)
|
listRef?.onKeyDown(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
document.addEventListener("keydown", handleKey)
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("keydown", handleKey)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={language.t("dialog.model.select.title")}
|
title={language.t("dialog.model.select.title")}
|
||||||
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
|
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3 px-2.5">
|
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
|
||||||
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
||||||
<List
|
<List
|
||||||
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
|
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -15,6 +15,9 @@ import { DialogManageModels } from "./dialog-manage-models"
|
|||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||||
|
provider === "opencode" && (!cost || cost.input === 0)
|
||||||
|
|
||||||
const ModelList: Component<{
|
const ModelList: Component<{
|
||||||
provider?: string
|
provider?: string
|
||||||
class?: string
|
class?: string
|
||||||
@@ -54,13 +57,7 @@ const ModelList: Component<{
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={12}
|
gutter={12}
|
||||||
value={
|
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||||
<ModelTooltip
|
|
||||||
model={item}
|
|
||||||
latest={item.latest}
|
|
||||||
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{node}
|
{node}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -75,7 +72,7 @@ const ModelList: Component<{
|
|||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||||
<span class="truncate">{i.name}</span>
|
<span class="truncate">{i.name}</span>
|
||||||
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
<Show when={isFree(i.provider.id, i.cost)}>
|
||||||
<Tag>{language.t("model.tag.free")}</Tag>
|
<Tag>{language.t("model.tag.free")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={i.latest}>
|
<Show when={i.latest}>
|
||||||
@@ -98,13 +95,9 @@ export function ModelSelectorPopover(props: {
|
|||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
open: boolean
|
open: boolean
|
||||||
dismiss: "escape" | "outside" | null
|
dismiss: "escape" | "outside" | null
|
||||||
trigger?: HTMLElement
|
|
||||||
content?: HTMLElement
|
|
||||||
}>({
|
}>({
|
||||||
open: false,
|
open: false,
|
||||||
dismiss: null,
|
dismiss: null,
|
||||||
trigger: undefined,
|
|
||||||
content: undefined,
|
|
||||||
})
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
@@ -119,54 +112,6 @@ export function ModelSelectorPopover(props: {
|
|||||||
}
|
}
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!store.open) return
|
|
||||||
|
|
||||||
const inside = (node: Node | null | undefined) => {
|
|
||||||
if (!node) return false
|
|
||||||
const el = store.content
|
|
||||||
if (el && el.contains(node)) return true
|
|
||||||
const anchor = store.trigger
|
|
||||||
if (anchor && anchor.contains(node)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key !== "Escape") return
|
|
||||||
setStore("dismiss", "escape")
|
|
||||||
setStore("open", false)
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
|
||||||
const target = event.target
|
|
||||||
if (!(target instanceof Node)) return
|
|
||||||
if (inside(target)) return
|
|
||||||
setStore("dismiss", "outside")
|
|
||||||
setStore("open", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFocusIn = (event: FocusEvent) => {
|
|
||||||
if (!store.content) return
|
|
||||||
const target = event.target
|
|
||||||
if (!(target instanceof Node)) return
|
|
||||||
if (inside(target)) return
|
|
||||||
setStore("dismiss", "outside")
|
|
||||||
setStore("open", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown, true)
|
|
||||||
window.addEventListener("pointerdown", onPointerDown, true)
|
|
||||||
window.addEventListener("focusin", onFocusIn, true)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown, true)
|
|
||||||
window.removeEventListener("pointerdown", onPointerDown, true)
|
|
||||||
window.removeEventListener("focusin", onFocusIn, true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Kobalte
|
<Kobalte
|
||||||
open={store.open}
|
open={store.open}
|
||||||
@@ -176,14 +121,13 @@ export function ModelSelectorPopover(props: {
|
|||||||
}}
|
}}
|
||||||
modal={false}
|
modal={false}
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={8}
|
gutter={4}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Kobalte.Trigger>
|
</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
ref={(el) => setStore("content", el)}
|
|
||||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
onEscapeKeyDown={(event) => {
|
onEscapeKeyDown={(event) => {
|
||||||
setStore("dismiss", "escape")
|
setStore("dismiss", "escape")
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
|
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||||
|
const customLabel = () => language.t("settings.providers.tag.custom")
|
||||||
|
const note = (id: string) => {
|
||||||
|
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
||||||
|
if (id === "openai") return language.t("dialog.provider.openai.note")
|
||||||
|
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("command.provider.connect")} transition>
|
<Dialog title={language.t("command.provider.connect")} transition>
|
||||||
@@ -34,7 +40,7 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
items={() => {
|
items={() => {
|
||||||
language.locale()
|
language.locale()
|
||||||
return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()]
|
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
|
||||||
}}
|
}}
|
||||||
filterKeys={["id", "name"]}
|
filterKeys={["id", "name"]}
|
||||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||||
@@ -70,15 +76,7 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={i.id === "anthropic"}>
|
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.id === "openai"}>
|
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.id.startsWith("github-copilot")}>
|
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { normalizeServerUrl, useServer } from "@/context/server"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||||
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { ServerRow } from "@/components/server/server-row"
|
import { ServerRow } from "@/components/server/server-row"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
interface AddRowProps {
|
interface AddRowProps {
|
||||||
@@ -38,6 +37,64 @@ interface EditRowProps {
|
|||||||
onBlur: () => void
|
onBlur: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
||||||
|
const [defaultUrl, defaultUrlActions] = createResource(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const url = await platform.getDefaultServerUrl?.()
|
||||||
|
if (!url) return null
|
||||||
|
return normalizeServerUrl(url) ?? null
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ initialValue: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||||
|
const setDefault = async (url: string | null) => {
|
||||||
|
try {
|
||||||
|
await platform.setDefaultServerUrl?.(url)
|
||||||
|
defaultUrlActions.mutate(url)
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaultUrl, canDefault, setDefault }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useServerPreview(fetcher: typeof fetch) {
|
||||||
|
const looksComplete = (value: string) => {
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) return false
|
||||||
|
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||||
|
if (!host) return false
|
||||||
|
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||||
|
return host.includes(".") || host.includes(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
||||||
|
setStatus(undefined)
|
||||||
|
if (!looksComplete(value)) return
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) return
|
||||||
|
const result = await checkServerHealth({ url: normalized }, fetcher)
|
||||||
|
setStatus(result.healthy)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previewStatus }
|
||||||
|
}
|
||||||
|
|
||||||
function AddRow(props: AddRowProps) {
|
function AddRow(props: AddRowProps) {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
||||||
@@ -113,10 +170,13 @@ export function DialogSelectServer() {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const globalSDK = useGlobalSDK()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
|
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||||
|
const { previewStatus } = useServerPreview(fetcher)
|
||||||
|
let listRoot: HTMLDivElement | undefined
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<string, ServerHealth | undefined>,
|
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
url: "",
|
url: "",
|
||||||
adding: false,
|
adding: false,
|
||||||
@@ -132,43 +192,6 @@ export function DialogSelectServer() {
|
|||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [defaultUrl, defaultUrlActions] = createResource(
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const url = await platform.getDefaultServerUrl?.()
|
|
||||||
if (!url) return null
|
|
||||||
return normalizeServerUrl(url) ?? null
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ initialValue: null },
|
|
||||||
)
|
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
|
||||||
|
|
||||||
const looksComplete = (value: string) => {
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) return false
|
|
||||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
|
||||||
if (!host) return false
|
|
||||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
|
||||||
return host.includes(".") || host.includes(":")
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
|
||||||
setStatus(undefined)
|
|
||||||
if (!looksComplete(value)) return
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) return
|
|
||||||
const result = await checkServerHealth(normalized, fetcher)
|
|
||||||
setStatus(result.healthy)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetAdd = () => {
|
const resetAdd = () => {
|
||||||
setStore("addServer", {
|
setStore("addServer", {
|
||||||
@@ -189,24 +212,25 @@ export function DialogSelectServer() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceServer = (original: string, next: string) => {
|
const replaceServer = (original: ServerConnection.Http, next: string) => {
|
||||||
const active = server.url
|
const active = server.key
|
||||||
const nextActive = active === original ? next : active
|
const newConn = server.add(next)
|
||||||
|
if (!newConn) return
|
||||||
|
|
||||||
server.add(next)
|
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
|
||||||
if (nextActive) server.setActive(nextActive)
|
if (nextActive) server.setActive(nextActive)
|
||||||
server.remove(original)
|
server.remove(ServerConnection.key(original))
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = createMemo(() => {
|
const items = createMemo(() => {
|
||||||
const current = server.url
|
const current = server.current
|
||||||
const list = server.list
|
const list = server.list
|
||||||
if (!current) return list
|
if (!current) return list
|
||||||
if (!list.includes(current)) return [current, ...list]
|
if (!list.includes(current)) return [current, ...list]
|
||||||
return [current, ...list.filter((x) => x !== current)]
|
return [current, ...list.filter((x) => x !== current)]
|
||||||
})
|
})
|
||||||
|
|
||||||
const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
|
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
|
||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const list = items()
|
const list = items()
|
||||||
@@ -221,17 +245,17 @@ export function DialogSelectServer() {
|
|||||||
return list.slice().sort((a, b) => {
|
return list.slice().sort((a, b) => {
|
||||||
if (a === active) return -1
|
if (a === active) return -1
|
||||||
if (b === active) return 1
|
if (b === active) return 1
|
||||||
const diff = rank(store.status[a]) - rank(store.status[b])
|
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
|
||||||
if (diff !== 0) return diff
|
if (diff !== 0) return diff
|
||||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function refreshHealth() {
|
async function refreshHealth() {
|
||||||
const results: Record<string, ServerHealth> = {}
|
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
items().map(async (url) => {
|
items().map(async (conn) => {
|
||||||
results[url] = await checkServerHealth(url, fetcher)
|
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setStore("status", reconcile(results))
|
setStore("status", reconcile(results))
|
||||||
@@ -244,15 +268,15 @@ export function DialogSelectServer() {
|
|||||||
onCleanup(() => clearInterval(interval))
|
onCleanup(() => clearInterval(interval))
|
||||||
})
|
})
|
||||||
|
|
||||||
async function select(value: string, persist?: boolean) {
|
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||||
if (!persist && store.status[value]?.healthy === false) return
|
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
||||||
dialog.close()
|
dialog.close()
|
||||||
if (persist) {
|
if (persist) {
|
||||||
server.add(value)
|
server.add(conn.http.url)
|
||||||
navigate("/")
|
navigate("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
server.setActive(value)
|
server.setActive(ServerConnection.key(conn))
|
||||||
navigate("/")
|
navigate("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +287,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollListToBottom = () => {
|
const scrollListToBottom = () => {
|
||||||
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
|
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
|
||||||
if (!scroll) return
|
if (!scroll) return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scroll.scrollTop = scroll.scrollHeight
|
scroll.scrollTop = scroll.scrollHeight
|
||||||
@@ -286,7 +310,7 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
setStore("addServer", { adding: true, error: "" })
|
setStore("addServer", { adding: true, error: "" })
|
||||||
|
|
||||||
const result = await checkServerHealth(normalized, fetcher)
|
const result = await checkServerHealth({ url: normalized }, fetcher)
|
||||||
setStore("addServer", { adding: false })
|
setStore("addServer", { adding: false })
|
||||||
|
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
@@ -295,25 +319,25 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetAdd()
|
resetAdd()
|
||||||
await select(normalized, true)
|
await select({ type: "http", http: { url: normalized } }, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEdit(original: string, value: string) {
|
async function handleEdit(original: ServerConnection.Any, value: string) {
|
||||||
if (store.editServer.busy) return
|
if (store.editServer.busy || original.type !== "http") return
|
||||||
const normalized = normalizeServerUrl(value)
|
const normalized = normalizeServerUrl(value)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
resetEdit()
|
resetEdit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized === original) {
|
if (normalized === original.http.url) {
|
||||||
resetEdit()
|
resetEdit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore("editServer", { busy: true, error: "" })
|
setStore("editServer", { busy: true, error: "" })
|
||||||
|
|
||||||
const result = await checkServerHealth(normalized, fetcher)
|
const result = await checkServerHealth({ url: normalized }, fetcher)
|
||||||
setStore("editServer", { busy: false })
|
setStore("editServer", { busy: false })
|
||||||
|
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
@@ -341,7 +365,7 @@ export function DialogSelectServer() {
|
|||||||
handleAdd(store.addServer.url)
|
handleAdd(store.addServer.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditKey = (event: KeyboardEvent, original: string) => {
|
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -353,7 +377,7 @@ export function DialogSelectServer() {
|
|||||||
handleEdit(original, store.editServer.value)
|
handleEdit(original, store.editServer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemove(url: string) {
|
async function handleRemove(url: ServerConnection.Key) {
|
||||||
server.remove(url)
|
server.remove(url)
|
||||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
if ((await platform.getDefaultServerUrl?.()) === url) {
|
||||||
platform.setDefaultServerUrl?.(null)
|
platform.setDefaultServerUrl?.(null)
|
||||||
@@ -363,12 +387,16 @@ export function DialogSelectServer() {
|
|||||||
return (
|
return (
|
||||||
<Dialog title={language.t("dialog.server.title")}>
|
<Dialog title={language.t("dialog.server.title")}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
<div ref={(el) => (listRoot = el)}>
|
||||||
<List
|
<List
|
||||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
search={{
|
||||||
|
placeholder: language.t("dialog.server.search.placeholder"),
|
||||||
|
autofocus: false,
|
||||||
|
}}
|
||||||
noInitialSelection
|
noInitialSelection
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
items={sortedItems}
|
items={sortedItems}
|
||||||
key={(x) => x}
|
key={(x) => x.http.url}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x) select(x)
|
if (x) select(x)
|
||||||
}}
|
}}
|
||||||
@@ -399,10 +427,11 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
|
const key = ServerConnection.key(i)
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||||
<Show
|
<Show
|
||||||
when={store.editServer.id !== i}
|
when={store.editServer.id !== i.http.url}
|
||||||
fallback={
|
fallback={
|
||||||
<EditRow
|
<EditRow
|
||||||
value={store.editServer.value}
|
value={store.editServer.value}
|
||||||
@@ -417,12 +446,12 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ServerRow
|
<ServerRow
|
||||||
url={i}
|
conn={i}
|
||||||
status={store.status[i]}
|
status={store.status[key]}
|
||||||
dimmed={store.status[i]?.healthy === false}
|
dimmed={store.status[key]?.healthy === false}
|
||||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={defaultUrl() === i}>
|
<Show when={defaultUrl() === i.http.url}>
|
||||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
{language.t("dialog.server.status.default")}
|
{language.t("dialog.server.status.default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -430,12 +459,13 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={store.editServer.id !== i}>
|
<Show when={store.editServer.id !== i.http.url}>
|
||||||
<div class="flex items-center justify-center gap-5 pl-4">
|
<div class="flex items-center justify-center gap-5 pl-4">
|
||||||
<Show when={current() === i}>
|
<Show when={ServerConnection.key(current()) === key}>
|
||||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={i.type === "http"}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
@@ -450,50 +480,24 @@ export function DialogSelectServer() {
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setStore("editServer", {
|
setStore("editServer", {
|
||||||
id: i,
|
id: i.http.url,
|
||||||
value: i,
|
value: i.http.url,
|
||||||
error: "",
|
error: "",
|
||||||
status: store.status[i]?.healthy,
|
status: store.status[ServerConnection.key(i)]?.healthy,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<Show when={canDefault() && defaultUrl() !== i}>
|
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
||||||
onSelect={async () => {
|
|
||||||
try {
|
|
||||||
await platform.setDefaultServerUrl?.(i)
|
|
||||||
defaultUrlActions.mutate(i)
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.default")}
|
{language.t("dialog.server.menu.default")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={canDefault() && defaultUrl() === i}>
|
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||||
onSelect={async () => {
|
|
||||||
try {
|
|
||||||
await platform.setDefaultServerUrl?.(null)
|
|
||||||
defaultUrlActions.mutate(null)
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
@@ -501,20 +505,24 @@ export function DialogSelectServer() {
|
|||||||
</Show>
|
</Show>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => handleRemove(i)}
|
onSelect={() => handleRemove(ServerConnection.key(i))}
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.delete")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</List>
|
</List>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="px-5 pb-5">
|
<div class="px-5 pb-5">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -67,15 +67,6 @@ export const DialogSettings: Component = () => {
|
|||||||
<Tabs.Content value="models" class="no-scrollbar">
|
<Tabs.Content value="models" class="no-scrollbar">
|
||||||
<SettingsModels />
|
<SettingsModels />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
|
|
||||||
{/* <SettingsAgents /> */}
|
|
||||||
{/* </Tabs.Content> */}
|
|
||||||
{/* <Tabs.Content value="commands" class="no-scrollbar"> */}
|
|
||||||
{/* <SettingsCommands /> */}
|
|
||||||
{/* </Tabs.Content> */}
|
|
||||||
{/* <Tabs.Content value="mcp" class="no-scrollbar"> */}
|
|
||||||
{/* <SettingsMcp /> */}
|
|
||||||
{/* </Tabs.Content> */}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
untrack,
|
untrack,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
|
type JSXElement,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
const MAX_DEPTH = 128
|
||||||
|
|
||||||
function pathToFileUrl(filepath: string): string {
|
function pathToFileUrl(filepath: string): string {
|
||||||
return `file://${encodeFilePath(filepath)}`
|
return `file://${encodeFilePath(filepath)}`
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,189 @@ export function dirsToExpand(input: {
|
|||||||
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kindLabel = (kind: Kind) => {
|
||||||
|
if (kind === "add") return "A"
|
||||||
|
if (kind === "del") return "D"
|
||||||
|
return "M"
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindTextColor = (kind: Kind) => {
|
||||||
|
if (kind === "add") return "color: var(--icon-diff-add-base)"
|
||||||
|
if (kind === "del") return "color: var(--icon-diff-delete-base)"
|
||||||
|
return "color: var(--icon-diff-modified-base)"
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindDotColor = (kind: Kind) => {
|
||||||
|
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
|
||||||
|
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
|
||||||
|
return "background-color: var(--icon-diff-modified-base)"
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||||
|
const kind = kinds?.get(node.path)
|
||||||
|
if (!kind) return
|
||||||
|
if (!marks?.has(node.path)) return
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDragImage = (target: HTMLElement) => {
|
||||||
|
const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
|
||||||
|
const text = target.querySelector("span")
|
||||||
|
if (!icon || !text) return
|
||||||
|
|
||||||
|
const image = document.createElement("div")
|
||||||
|
image.className =
|
||||||
|
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
|
||||||
|
image.style.position = "absolute"
|
||||||
|
image.style.top = "-1000px"
|
||||||
|
image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
const withFileDragImage = (event: DragEvent) => {
|
||||||
|
const image = buildDragImage(event.currentTarget as HTMLElement)
|
||||||
|
if (!image) return
|
||||||
|
document.body.appendChild(image)
|
||||||
|
event.dataTransfer?.setDragImage(image, 0, 12)
|
||||||
|
setTimeout(() => document.body.removeChild(image), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileTreeNode = (
|
||||||
|
p: ParentProps &
|
||||||
|
ComponentProps<"div"> &
|
||||||
|
ComponentProps<"button"> & {
|
||||||
|
node: FileNode
|
||||||
|
level: number
|
||||||
|
active?: string
|
||||||
|
nodeClass?: string
|
||||||
|
draggable: boolean
|
||||||
|
kinds?: ReadonlyMap<string, Kind>
|
||||||
|
marks?: Set<string>
|
||||||
|
as?: "div" | "button"
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const [local, rest] = splitProps(p, [
|
||||||
|
"node",
|
||||||
|
"level",
|
||||||
|
"active",
|
||||||
|
"nodeClass",
|
||||||
|
"draggable",
|
||||||
|
"kinds",
|
||||||
|
"marks",
|
||||||
|
"as",
|
||||||
|
"children",
|
||||||
|
"class",
|
||||||
|
"classList",
|
||||||
|
])
|
||||||
|
const kind = () => visibleKind(local.node, local.kinds, local.marks)
|
||||||
|
const active = () => !!kind() && !local.node.ignored
|
||||||
|
const color = () => {
|
||||||
|
const value = kind()
|
||||||
|
if (!value) return
|
||||||
|
return kindTextColor(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dynamic
|
||||||
|
component={local.as ?? "div"}
|
||||||
|
classList={{
|
||||||
|
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||||
|
"bg-surface-base-active": local.node.path === local.active,
|
||||||
|
...(local.classList ?? {}),
|
||||||
|
[local.class ?? ""]: !!local.class,
|
||||||
|
[local.nodeClass ?? ""]: !!local.nodeClass,
|
||||||
|
}}
|
||||||
|
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
||||||
|
draggable={local.draggable}
|
||||||
|
onDragStart={(event: DragEvent) => {
|
||||||
|
if (!local.draggable) return
|
||||||
|
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||||
|
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
|
||||||
|
withFileDragImage(event)
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
<span
|
||||||
|
classList={{
|
||||||
|
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
||||||
|
"text-text-weaker": local.node.ignored,
|
||||||
|
"text-text-weak": !local.node.ignored && !active(),
|
||||||
|
}}
|
||||||
|
style={active() ? color() : undefined}
|
||||||
|
>
|
||||||
|
{local.node.name}
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const value = kind()
|
||||||
|
if (!value) return null
|
||||||
|
if (local.node.type === "file") {
|
||||||
|
return (
|
||||||
|
<span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
|
||||||
|
{kindLabel(value)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
|
||||||
|
})()}
|
||||||
|
</Dynamic>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
|
||||||
|
if (!props.enabled) return props.children
|
||||||
|
|
||||||
|
const parts = props.node.path.split("/")
|
||||||
|
const leaf = parts[parts.length - 1] ?? props.node.path
|
||||||
|
const head = parts.slice(0, -1).join("/")
|
||||||
|
const prefix = head ? `${head}/` : ""
|
||||||
|
const label =
|
||||||
|
props.kind === "add"
|
||||||
|
? "Additions"
|
||||||
|
: props.kind === "del"
|
||||||
|
? "Deletions"
|
||||||
|
: props.kind === "mix"
|
||||||
|
? "Modifications"
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
openDelay={2000}
|
||||||
|
placement="bottom-start"
|
||||||
|
class="w-full"
|
||||||
|
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
||||||
|
value={
|
||||||
|
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
||||||
|
<span
|
||||||
|
class="min-w-0 truncate text-text-invert-base"
|
||||||
|
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
||||||
|
>
|
||||||
|
{prefix}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
||||||
|
<Show when={label}>
|
||||||
|
{(text) => (
|
||||||
|
<>
|
||||||
|
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||||
|
<span class="shrink-0 text-text-invert-strong">{text()}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={props.node.type === "directory" && props.node.ignored}>
|
||||||
|
<>
|
||||||
|
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||||
|
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function FileTree(props: {
|
export default function FileTree(props: {
|
||||||
path: string
|
path: string
|
||||||
class?: string
|
class?: string
|
||||||
@@ -76,12 +262,20 @@ export default function FileTree(props: {
|
|||||||
_marks?: Set<string>
|
_marks?: Set<string>
|
||||||
_deeps?: Map<string, number>
|
_deeps?: Map<string, number>
|
||||||
_kinds?: ReadonlyMap<string, Kind>
|
_kinds?: ReadonlyMap<string, Kind>
|
||||||
|
_chain?: readonly string[]
|
||||||
}) {
|
}) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const level = props.level ?? 0
|
const level = props.level ?? 0
|
||||||
const draggable = () => props.draggable ?? true
|
const draggable = () => props.draggable ?? true
|
||||||
const tooltip = () => props.tooltip ?? true
|
const tooltip = () => props.tooltip ?? true
|
||||||
|
|
||||||
|
const key = (p: string) =>
|
||||||
|
file
|
||||||
|
.normalize(p)
|
||||||
|
.replace(/[\\/]+$/, "")
|
||||||
|
.replaceAll("\\", "/")
|
||||||
|
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
|
||||||
|
|
||||||
const filter = createMemo(() => {
|
const filter = createMemo(() => {
|
||||||
if (props._filter) return props._filter
|
if (props._filter) return props._filter
|
||||||
|
|
||||||
@@ -123,23 +317,45 @@ export default function FileTree(props: {
|
|||||||
|
|
||||||
const out = new Map<string, number>()
|
const out = new Map<string, number>()
|
||||||
|
|
||||||
const visit = (dir: string, lvl: number): number => {
|
const root = props.path
|
||||||
const expanded = file.tree.state(dir)?.expanded ?? false
|
if (!(file.tree.state(root)?.expanded ?? false)) return out
|
||||||
if (!expanded) return -1
|
|
||||||
|
|
||||||
const nodes = file.tree.children(dir)
|
const seen = new Set<string>()
|
||||||
const max = nodes.reduce((max, node) => {
|
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
|
||||||
if (node.type !== "directory") return max
|
|
||||||
const open = file.tree.state(node.path)?.expanded ?? false
|
|
||||||
if (!open) return max
|
|
||||||
return Math.max(max, visit(node.path, lvl + 1))
|
|
||||||
}, lvl)
|
|
||||||
|
|
||||||
out.set(dir, max)
|
const push = (dir: string, lvl: number) => {
|
||||||
return max
|
const id = key(dir)
|
||||||
|
if (seen.has(id)) return
|
||||||
|
seen.add(id)
|
||||||
|
|
||||||
|
const kids = file.tree
|
||||||
|
.children(dir)
|
||||||
|
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
|
||||||
|
.map((node) => node.path)
|
||||||
|
|
||||||
|
stack.push({ dir, lvl, i: 0, kids, max: lvl })
|
||||||
|
}
|
||||||
|
|
||||||
|
push(root, level - 1)
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const top = stack[stack.length - 1]!
|
||||||
|
|
||||||
|
if (top.i < top.kids.length) {
|
||||||
|
const next = top.kids[top.i]!
|
||||||
|
top.i++
|
||||||
|
push(next, top.lvl + 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out.set(top.dir, top.max)
|
||||||
|
stack.pop()
|
||||||
|
|
||||||
|
const parent = stack[stack.length - 1]
|
||||||
|
if (!parent) continue
|
||||||
|
parent.max = Math.max(parent.max, top.max)
|
||||||
}
|
}
|
||||||
|
|
||||||
visit(props.path, level - 1)
|
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -230,178 +446,14 @@ export default function FileTree(props: {
|
|||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
const Node = (
|
|
||||||
p: ParentProps &
|
|
||||||
ComponentProps<"div"> &
|
|
||||||
ComponentProps<"button"> & {
|
|
||||||
node: FileNode
|
|
||||||
as?: "div" | "button"
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
|
|
||||||
return (
|
return (
|
||||||
<Dynamic
|
<div data-component="filetree" class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
||||||
component={local.as ?? "div"}
|
|
||||||
classList={{
|
|
||||||
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
|
||||||
"bg-surface-base-active": local.node.path === props.active,
|
|
||||||
...(local.classList ?? {}),
|
|
||||||
[local.class ?? ""]: !!local.class,
|
|
||||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
|
||||||
}}
|
|
||||||
style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
|
||||||
draggable={draggable()}
|
|
||||||
onDragStart={(e: DragEvent) => {
|
|
||||||
if (!draggable()) return
|
|
||||||
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
|
||||||
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
|
||||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
|
|
||||||
|
|
||||||
const dragImage = document.createElement("div")
|
|
||||||
dragImage.className =
|
|
||||||
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
|
|
||||||
dragImage.style.position = "absolute"
|
|
||||||
dragImage.style.top = "-1000px"
|
|
||||||
|
|
||||||
const icon =
|
|
||||||
(e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ??
|
|
||||||
(e.currentTarget as HTMLElement).querySelector("svg")
|
|
||||||
const text = (e.currentTarget as HTMLElement).querySelector("span")
|
|
||||||
if (icon && text) {
|
|
||||||
dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.appendChild(dragImage)
|
|
||||||
e.dataTransfer?.setDragImage(dragImage, 0, 12)
|
|
||||||
setTimeout(() => document.body.removeChild(dragImage), 0)
|
|
||||||
}}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
{(() => {
|
|
||||||
const kind = kinds()?.get(local.node.path)
|
|
||||||
const marked = marks()?.has(local.node.path) ?? false
|
|
||||||
const active = !!kind && marked && !local.node.ignored
|
|
||||||
const color =
|
|
||||||
kind === "add"
|
|
||||||
? "color: var(--icon-diff-add-base)"
|
|
||||||
: kind === "del"
|
|
||||||
? "color: var(--icon-diff-delete-base)"
|
|
||||||
: kind === "mix"
|
|
||||||
? "color: var(--icon-warning-active)"
|
|
||||||
: undefined
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
classList={{
|
|
||||||
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
|
||||||
"text-text-weaker": local.node.ignored,
|
|
||||||
"text-text-weak": !local.node.ignored && !active,
|
|
||||||
}}
|
|
||||||
style={active ? color : undefined}
|
|
||||||
>
|
|
||||||
{local.node.name}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{(() => {
|
|
||||||
const kind = kinds()?.get(local.node.path)
|
|
||||||
if (!kind) return null
|
|
||||||
if (!marks()?.has(local.node.path)) return null
|
|
||||||
|
|
||||||
if (local.node.type === "file") {
|
|
||||||
const text = kind === "add" ? "A" : kind === "del" ? "D" : "M"
|
|
||||||
const color =
|
|
||||||
kind === "add"
|
|
||||||
? "color: var(--icon-diff-add-base)"
|
|
||||||
: kind === "del"
|
|
||||||
? "color: var(--icon-diff-delete-base)"
|
|
||||||
: "color: var(--icon-warning-active)"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span class="shrink-0 w-4 text-center text-12-medium" style={color}>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (local.node.type === "directory") {
|
|
||||||
const color =
|
|
||||||
kind === "add"
|
|
||||||
? "background-color: var(--icon-diff-add-base)"
|
|
||||||
: kind === "del"
|
|
||||||
? "background-color: var(--icon-diff-delete-base)"
|
|
||||||
: "background-color: var(--icon-warning-active)"
|
|
||||||
|
|
||||||
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
})()}
|
|
||||||
</Dynamic>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
|
||||||
<For each={nodes()}>
|
<For each={nodes()}>
|
||||||
{(node) => {
|
{(node) => {
|
||||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||||
const deep = () => deeps().get(node.path) ?? -1
|
const deep = () => deeps().get(node.path) ?? -1
|
||||||
const Wrapper = (p: ParentProps) => {
|
const kind = () => visibleKind(node, kinds(), marks())
|
||||||
if (!tooltip()) return p.children
|
const active = () => !!kind() && !node.ignored
|
||||||
|
|
||||||
const parts = node.path.split("/")
|
|
||||||
const leaf = parts[parts.length - 1] ?? node.path
|
|
||||||
const head = parts.slice(0, -1).join("/")
|
|
||||||
const prefix = head ? `${head}/` : ""
|
|
||||||
|
|
||||||
const kind = () => kinds()?.get(node.path)
|
|
||||||
const label = () => {
|
|
||||||
const k = kind()
|
|
||||||
if (!k) return
|
|
||||||
if (k === "add") return "Additions"
|
|
||||||
if (k === "del") return "Deletions"
|
|
||||||
return "Modifications"
|
|
||||||
}
|
|
||||||
|
|
||||||
const ignored = () => node.type === "directory" && node.ignored
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
openDelay={2000}
|
|
||||||
placement="bottom-start"
|
|
||||||
class="w-full"
|
|
||||||
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
|
||||||
value={
|
|
||||||
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
|
||||||
<span
|
|
||||||
class="min-w-0 truncate text-text-invert-base"
|
|
||||||
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
|
||||||
>
|
|
||||||
{prefix}
|
|
||||||
</span>
|
|
||||||
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
|
||||||
<Show when={label()}>
|
|
||||||
{(t: () => string) => (
|
|
||||||
<>
|
|
||||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
|
||||||
<span class="shrink-0 text-text-invert-strong">{t()}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={ignored()}>
|
|
||||||
<>
|
|
||||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
|
||||||
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
|
||||||
</>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{p.children}
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -415,13 +467,21 @@ export default function FileTree(props: {
|
|||||||
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<Wrapper>
|
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||||
<Node node={node}>
|
<FileTreeNode
|
||||||
|
node={node}
|
||||||
|
level={level}
|
||||||
|
active={props.active}
|
||||||
|
nodeClass={props.nodeClass}
|
||||||
|
draggable={draggable()}
|
||||||
|
kinds={kinds()}
|
||||||
|
marks={marks()}
|
||||||
|
>
|
||||||
<div class="size-4 flex items-center justify-center text-icon-weak">
|
<div class="size-4 flex items-center justify-center text-icon-weak">
|
||||||
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
||||||
</div>
|
</div>
|
||||||
</Node>
|
</FileTreeNode>
|
||||||
</Wrapper>
|
</FileTreeNodeTooltip>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Collapsible.Content class="relative pt-0.5">
|
<Collapsible.Content class="relative pt-0.5">
|
||||||
<div
|
<div
|
||||||
@@ -432,6 +492,10 @@ export default function FileTree(props: {
|
|||||||
}}
|
}}
|
||||||
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
|
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
|
||||||
/>
|
/>
|
||||||
|
<Show
|
||||||
|
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
|
||||||
|
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
|
||||||
|
>
|
||||||
<FileTree
|
<FileTree
|
||||||
path={node.path}
|
path={node.path}
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
@@ -446,17 +510,60 @@ export default function FileTree(props: {
|
|||||||
_marks={marks()}
|
_marks={marks()}
|
||||||
_deeps={deeps()}
|
_deeps={deeps()}
|
||||||
_kinds={kinds()}
|
_kinds={kinds()}
|
||||||
|
_chain={chain}
|
||||||
/>
|
/>
|
||||||
|
</Show>
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={node.type === "file"}>
|
<Match when={node.type === "file"}>
|
||||||
<Wrapper>
|
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||||
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
|
<FileTreeNode
|
||||||
|
node={node}
|
||||||
|
level={level}
|
||||||
|
active={props.active}
|
||||||
|
nodeClass={props.nodeClass}
|
||||||
|
draggable={draggable()}
|
||||||
|
kinds={kinds()}
|
||||||
|
marks={marks()}
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onFileClick?.(node)}
|
||||||
|
>
|
||||||
<div class="w-4 shrink-0" />
|
<div class="w-4 shrink-0" />
|
||||||
<FileIcon node={node} class="text-icon-weak size-4" />
|
<Switch>
|
||||||
</Node>
|
<Match when={node.ignored}>
|
||||||
</Wrapper>
|
<FileIcon
|
||||||
|
node={node}
|
||||||
|
class="size-4 filetree-icon filetree-icon--mono"
|
||||||
|
style="color: var(--icon-weak-base)"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={active()}>
|
||||||
|
<FileIcon
|
||||||
|
node={node}
|
||||||
|
class="size-4 filetree-icon filetree-icon--mono"
|
||||||
|
style={kindTextColor(kind()!)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={!node.ignored}>
|
||||||
|
<span class="filetree-iconpair size-4">
|
||||||
|
<FileIcon
|
||||||
|
node={node}
|
||||||
|
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
|
||||||
|
/>
|
||||||
|
<FileIcon
|
||||||
|
node={node}
|
||||||
|
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</FileTreeNode>
|
||||||
|
</FileTreeNodeTooltip>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { ComponentProps, splitProps } from "solid-js"
|
import { ComponentProps, splitProps } from "solid-js"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
||||||
export interface LinkProps extends ComponentProps<"button"> {
|
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
|
||||||
href: string
|
href: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Link(props: LinkProps) {
|
export function Link(props: LinkProps) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const [local, rest] = splitProps(props, ["href", "children"])
|
const [local, rest] = splitProps(props, ["href", "children", "class"])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
|
<a
|
||||||
|
href={local.href}
|
||||||
|
class={`text-text-strong underline ${local.class ?? ""}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!local.href) return
|
||||||
|
event.preventDefault()
|
||||||
|
platform.openLink(local.href)
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
{local.children}
|
{local.children}
|
||||||
</button>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -20,25 +20,31 @@ import { useParams } from "@solidjs/router"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
|
import { RadioGroup } from "@opencode-ai/ui/radio-group"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||||
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
||||||
import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
|
import {
|
||||||
|
canNavigateHistoryAtCursor,
|
||||||
|
navigatePromptHistory,
|
||||||
|
prependHistoryEntry,
|
||||||
|
promptLength,
|
||||||
|
} from "./prompt-input/history"
|
||||||
import { createPromptSubmit } from "./prompt-input/submit"
|
import { createPromptSubmit } from "./prompt-input/submit"
|
||||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||||
import { PromptContextItems } from "./prompt-input/context-items"
|
import { PromptContextItems } from "./prompt-input/context-items"
|
||||||
@@ -89,7 +95,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const files = useFile()
|
const files = useFile()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length)
|
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -100,11 +105,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
let editorRef!: HTMLDivElement
|
let editorRef!: HTMLDivElement
|
||||||
let fileInputRef!: HTMLInputElement
|
let fileInputRef: HTMLInputElement | undefined
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
let slashPopoverRef!: HTMLDivElement
|
let slashPopoverRef!: HTMLDivElement
|
||||||
|
|
||||||
const mirror = { input: false }
|
const mirror = { input: false }
|
||||||
|
const inset = 44
|
||||||
|
|
||||||
const scrollCursorIntoView = () => {
|
const scrollCursorIntoView = () => {
|
||||||
const container = scrollRef
|
const container = scrollRef
|
||||||
@@ -114,7 +120,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
if (!editorRef.contains(range.startContainer)) return
|
if (!editorRef.contains(range.startContainer)) return
|
||||||
|
|
||||||
const rect = range.getBoundingClientRect()
|
const cursor = getCursorPosition(editorRef)
|
||||||
|
const length = promptLength(prompt.current().filter((part) => part.type !== "image"))
|
||||||
|
if (cursor >= length) {
|
||||||
|
container.scrollTop = container.scrollHeight
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect()
|
||||||
if (!rect.height) return
|
if (!rect.height) return
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect()
|
const containerRect = container.getBoundingClientRect()
|
||||||
@@ -127,8 +140,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bottom > container.scrollTop + container.clientHeight - padding) {
|
if (bottom > container.scrollTop + container.clientHeight - inset) {
|
||||||
container.scrollTop = bottom - container.clientHeight + padding
|
container.scrollTop = bottom - container.clientHeight + inset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,14 +171,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
||||||
if (wantsReview) {
|
if (wantsReview) {
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
layout.fileTree.open()
|
|
||||||
layout.fileTree.setTab("changes")
|
layout.fileTree.setTab("changes")
|
||||||
|
tabs().setActive("review")
|
||||||
requestAnimationFrame(() => comments.setFocus(focus))
|
requestAnimationFrame(() => comments.setFocus(focus))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
layout.fileTree.open()
|
|
||||||
layout.fileTree.setTab("all")
|
layout.fileTree.setTab("all")
|
||||||
const tab = files.tab(item.path)
|
const tab = files.tab(item.path)
|
||||||
tabs().open(tab)
|
tabs().open(tab)
|
||||||
@@ -219,16 +231,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
})
|
})
|
||||||
const placeholder = createMemo(() =>
|
|
||||||
promptPlaceholder({
|
|
||||||
mode: store.mode,
|
|
||||||
commentCount: commentCount(),
|
|
||||||
example: language.t(EXAMPLES[store.placeholder]),
|
|
||||||
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const MAX_HISTORY = 100
|
const commentCount = createMemo(() => {
|
||||||
|
if (store.mode === "shell") return 0
|
||||||
|
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const contextItems = createMemo(() => {
|
||||||
|
const items = prompt.context.items()
|
||||||
|
if (store.mode !== "shell") return items
|
||||||
|
return items.filter((item) => !item.comment?.trim())
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasUserPrompt = createMemo(() => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return false
|
||||||
|
const messages = sync.data.message[sessionID]
|
||||||
|
if (!messages) return false
|
||||||
|
return messages.some((m) => m.role === "user")
|
||||||
|
})
|
||||||
|
|
||||||
const [history, setHistory] = persisted(
|
const [history, setHistory] = persisted(
|
||||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||||
createStore<{
|
createStore<{
|
||||||
@@ -246,6 +268,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const suggest = createMemo(() => !hasUserPrompt())
|
||||||
|
|
||||||
|
const placeholder = createMemo(() =>
|
||||||
|
promptPlaceholder({
|
||||||
|
mode: store.mode,
|
||||||
|
commentCount: commentCount(),
|
||||||
|
example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "",
|
||||||
|
suggest: suggest(),
|
||||||
|
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
||||||
const length = position === "start" ? 0 : promptLength(p)
|
const length = position === "start" ? 0 : promptLength(p)
|
||||||
setStore("applyingHistory", true)
|
setStore("applyingHistory", true)
|
||||||
@@ -276,10 +310,91 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isFocused = createFocusSignal(() => editorRef)
|
const isFocused = createFocusSignal(() => editorRef)
|
||||||
|
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
||||||
|
|
||||||
|
const pick = () => fileInputRef?.click()
|
||||||
|
|
||||||
|
const setMode = (mode: "normal" | "shell") => {
|
||||||
|
setStore("mode", mode)
|
||||||
|
setStore("popover", null)
|
||||||
|
requestAnimationFrame(() => editorRef?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellModeKey = "mod+shift+x"
|
||||||
|
const normalModeKey = "mod+shift+e"
|
||||||
|
|
||||||
|
command.register("prompt-input", () => [
|
||||||
|
{
|
||||||
|
id: "file.attach",
|
||||||
|
title: language.t("prompt.action.attachFile"),
|
||||||
|
category: language.t("command.category.file"),
|
||||||
|
keybind: "mod+u",
|
||||||
|
disabled: store.mode !== "normal",
|
||||||
|
onSelect: pick,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.shell",
|
||||||
|
title: language.t("command.prompt.mode.shell"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: shellModeKey,
|
||||||
|
disabled: store.mode === "shell",
|
||||||
|
onSelect: () => setMode("shell"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.normal",
|
||||||
|
title: language.t("command.prompt.mode.normal"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: normalModeKey,
|
||||||
|
disabled: store.mode === "normal",
|
||||||
|
onSelect: () => setMode("normal"),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const closePopover = () => setStore("popover", null)
|
||||||
|
|
||||||
|
const resetHistoryNavigation = (force = false) => {
|
||||||
|
if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
|
||||||
|
setStore("historyIndex", -1)
|
||||||
|
setStore("savedPrompt", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearEditor = () => {
|
||||||
|
editorRef.innerHTML = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const setEditorText = (text: string) => {
|
||||||
|
clearEditor()
|
||||||
|
editorRef.textContent = text
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusEditorEnd = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editorRef.focus()
|
||||||
|
const range = document.createRange()
|
||||||
|
const selection = window.getSelection()
|
||||||
|
range.selectNodeContents(editorRef)
|
||||||
|
range.collapse(false)
|
||||||
|
selection?.removeAllRanges()
|
||||||
|
selection?.addRange(range)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCursor = () => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null
|
||||||
|
return getCursorPosition(editorRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderEditorWithCursor = (parts: Prompt) => {
|
||||||
|
const cursor = currentCursor()
|
||||||
|
renderEditor(parts)
|
||||||
|
if (cursor !== null) setCursorPosition(editorRef, cursor)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
params.id
|
params.id
|
||||||
if (params.id) return
|
if (params.id) return
|
||||||
|
if (!suggest()) return
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
|
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
|
||||||
}, 6500)
|
}, 6500)
|
||||||
@@ -289,21 +404,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const [composing, setComposing] = createSignal(false)
|
const [composing, setComposing] = createSignal(false)
|
||||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||||
|
|
||||||
createEffect(() => {
|
const handleBlur = () => {
|
||||||
if (!isFocused()) setStore("popover", null)
|
closePopover()
|
||||||
})
|
setComposing(false)
|
||||||
|
}
|
||||||
// Safety: reset composing state on focus change to prevent stuck state
|
|
||||||
// This handles edge cases where compositionend event may not fire
|
|
||||||
createEffect(() => {
|
|
||||||
if (!isFocused()) setComposing(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
const agentList = createMemo(() =>
|
const agentList = createMemo(() =>
|
||||||
sync.data.agent
|
sync.data.agent
|
||||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
||||||
)
|
)
|
||||||
|
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
|
||||||
|
|
||||||
const handleAtSelect = (option: AtOption | undefined) => {
|
const handleAtSelect = (option: AtOption | undefined) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
@@ -381,26 +492,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||||
if (!cmd) return
|
if (!cmd) return
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
|
|
||||||
if (cmd.type === "custom") {
|
if (cmd.type === "custom") {
|
||||||
const text = `/${cmd.trigger} `
|
const text = `/${cmd.trigger} `
|
||||||
editorRef.innerHTML = ""
|
setEditorText(text)
|
||||||
editorRef.textContent = text
|
|
||||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||||
requestAnimationFrame(() => {
|
focusEditorEnd()
|
||||||
editorRef.focus()
|
|
||||||
const range = document.createRange()
|
|
||||||
const sel = window.getSelection()
|
|
||||||
range.selectNodeContents(editorRef)
|
|
||||||
range.collapse(false)
|
|
||||||
sel?.removeAllRanges()
|
|
||||||
sel?.addRange(range)
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
editorRef.innerHTML = ""
|
clearEditor()
|
||||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||||
command.trigger(cmd.id, "slash")
|
command.trigger(cmd.id, "slash")
|
||||||
}
|
}
|
||||||
@@ -441,10 +543,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const prev = node.previousSibling
|
const prev = node.previousSibling
|
||||||
const next = node.nextSibling
|
const next = node.nextSibling
|
||||||
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
|
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
|
||||||
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
|
return !!prevIsBr && !next
|
||||||
if (!prevIsBr && !nextIsBr) return false
|
|
||||||
if (nextIsBr && !prevIsBr && prev) return false
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return false
|
if (node.nodeType !== Node.ELEMENT_NODE) return false
|
||||||
const el = node as HTMLElement
|
const el = node as HTMLElement
|
||||||
@@ -454,7 +553,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const renderEditor = (parts: Prompt) => {
|
const renderEditor = (parts: Prompt) => {
|
||||||
editorRef.innerHTML = ""
|
clearEditor()
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
editorRef.appendChild(createTextFragment(part.content))
|
editorRef.appendChild(createTextFragment(part.content))
|
||||||
@@ -464,6 +563,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
editorRef.appendChild(createPill(part))
|
editorRef.appendChild(createPill(part))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const last = editorRef.lastChild
|
||||||
|
if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") {
|
||||||
|
editorRef.appendChild(document.createTextNode("\u200B"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -514,34 +618,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
mirror.input = false
|
mirror.input = false
|
||||||
if (isNormalizedEditor()) return
|
if (isNormalizedEditor()) return
|
||||||
|
|
||||||
const selection = window.getSelection()
|
renderEditorWithCursor(inputParts)
|
||||||
let cursorPosition: number | null = null
|
|
||||||
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
|
|
||||||
cursorPosition = getCursorPosition(editorRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderEditor(inputParts)
|
|
||||||
|
|
||||||
if (cursorPosition !== null) {
|
|
||||||
setCursorPosition(editorRef, cursorPosition)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const domParts = parseFromDOM()
|
const domParts = parseFromDOM()
|
||||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
||||||
|
|
||||||
const selection = window.getSelection()
|
renderEditorWithCursor(inputParts)
|
||||||
let cursorPosition: number | null = null
|
|
||||||
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
|
|
||||||
cursorPosition = getCursorPosition(editorRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderEditor(inputParts)
|
|
||||||
|
|
||||||
if (cursorPosition !== null) {
|
|
||||||
setCursorPosition(editorRef, cursorPosition)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -636,11 +720,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
|
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
|
||||||
|
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
resetHistoryNavigation()
|
||||||
setStore("historyIndex", -1)
|
|
||||||
setStore("savedPrompt", null)
|
|
||||||
}
|
|
||||||
if (prompt.dirty()) {
|
if (prompt.dirty()) {
|
||||||
mirror.input = true
|
mirror.input = true
|
||||||
prompt.set(DEFAULT_PROMPT, 0)
|
prompt.set(DEFAULT_PROMPT, 0)
|
||||||
@@ -662,16 +743,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
slashOnInput(slashMatch[1])
|
slashOnInput(slashMatch[1])
|
||||||
setStore("popover", "slash")
|
setStore("popover", "slash")
|
||||||
} else {
|
} else {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
resetHistoryNavigation()
|
||||||
setStore("historyIndex", -1)
|
|
||||||
setStore("savedPrompt", null)
|
|
||||||
}
|
|
||||||
|
|
||||||
mirror.input = true
|
mirror.input = true
|
||||||
prompt.set([...rawParts, ...images], cursorPosition)
|
prompt.set([...rawParts, ...images], cursorPosition)
|
||||||
@@ -723,16 +801,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (last.nodeType !== Node.TEXT_NODE) {
|
if (last.nodeType !== Node.TEXT_NODE) {
|
||||||
|
const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR"
|
||||||
|
const next = last.nextSibling
|
||||||
|
const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === ""
|
||||||
|
if (isBreak && (!next || emptyText)) {
|
||||||
|
const placeholder = next && emptyText ? next : document.createTextNode("\u200B")
|
||||||
|
if (!next) last.parentNode?.insertBefore(placeholder, null)
|
||||||
|
placeholder.textContent = "\u200B"
|
||||||
|
range.setStart(placeholder, 0)
|
||||||
|
} else {
|
||||||
range.setStartAfter(last)
|
range.setStartAfter(last)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
range.collapse(true)
|
range.collapse(true)
|
||||||
selection.removeAllRanges()
|
selection.removeAllRanges()
|
||||||
selection.addRange(range)
|
selection.addRange(range)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInput()
|
handleInput()
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
}
|
}
|
||||||
|
|
||||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||||
@@ -782,8 +870,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
promptLength,
|
promptLength,
|
||||||
addToHistory,
|
addToHistory,
|
||||||
resetHistoryNavigation: () => {
|
resetHistoryNavigation: () => {
|
||||||
setStore("historyIndex", -1)
|
resetHistoryNavigation(true)
|
||||||
setStore("savedPrompt", null)
|
|
||||||
},
|
},
|
||||||
setMode: (mode) => setStore("mode", mode),
|
setMode: (mode) => setStore("mode", mode),
|
||||||
setPopover: (popover) => setStore("popover", popover),
|
setPopover: (popover) => setStore("popover", popover),
|
||||||
@@ -793,6 +880,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
||||||
|
event.preventDefault()
|
||||||
|
if (store.mode !== "normal") return
|
||||||
|
pick()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === "Backspace") {
|
if (event.key === "Backspace") {
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (selection && selection.isCollapsed) {
|
if (selection && selection.isCollapsed) {
|
||||||
@@ -820,13 +914,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (store.mode === "shell") {
|
|
||||||
const { collapsed, cursorPosition, textLength } = getCaretState()
|
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
setStore("mode", "normal")
|
if (store.popover) {
|
||||||
|
closePopover()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (store.mode === "shell") {
|
||||||
|
setStore("mode", "normal")
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (working()) {
|
||||||
|
abort()
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (escBlur()) {
|
||||||
|
editorRef.blur()
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.mode === "shell") {
|
||||||
|
const { collapsed, cursorPosition, textLength } = getCaretState()
|
||||||
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
|
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
|
||||||
setStore("mode", "normal")
|
setStore("mode", "normal")
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -872,7 +992,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
if (ctrl && event.code === "KeyG") {
|
if (ctrl && event.code === "KeyG") {
|
||||||
if (store.popover) {
|
if (store.popover) {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -889,29 +1009,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (!collapsed) return
|
if (!collapsed) return
|
||||||
|
|
||||||
const cursorPosition = getCursorPosition(editorRef)
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
const textLength = promptLength(prompt.current())
|
|
||||||
const textContent = prompt
|
const textContent = prompt
|
||||||
.current()
|
.current()
|
||||||
.map((part) => ("content" in part ? part.content : ""))
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
.join("")
|
.join("")
|
||||||
const isEmpty = textContent.trim() === "" || textLength <= 1
|
const direction = event.key === "ArrowUp" ? "up" : "down"
|
||||||
const hasNewlines = textContent.includes("\n")
|
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return
|
||||||
const inHistory = store.historyIndex >= 0
|
if (navigateHistory(direction)) {
|
||||||
const atStart = cursorPosition <= (isEmpty ? 1 : 0)
|
|
||||||
const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength)
|
|
||||||
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
|
|
||||||
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
|
|
||||||
|
|
||||||
if (event.key === "ArrowUp") {
|
|
||||||
if (!allowUp) return
|
|
||||||
if (navigateHistory("up")) {
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowDown) return
|
|
||||||
if (navigateHistory("down")) {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -921,17 +1025,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
handleSubmit(event)
|
handleSubmit(event)
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
|
||||||
if (store.popover) {
|
|
||||||
setStore("popover", null)
|
|
||||||
} else if (working()) {
|
|
||||||
abort()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
||||||
<PromptPopover
|
<PromptPopover
|
||||||
popover={store.popover}
|
popover={store.popover}
|
||||||
setSlashPopoverRef={(el) => (slashPopoverRef = el)}
|
setSlashPopoverRef={(el) => (slashPopoverRef = el)}
|
||||||
@@ -947,12 +1046,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
commandKeybind={command.keybind}
|
commandKeybind={command.keybind}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<form
|
<DockShellForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
classList={{
|
classList={{
|
||||||
"group/prompt-input": true,
|
"group/prompt-input": true,
|
||||||
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
|
"focus-within:shadow-xs-border": true,
|
||||||
"rounded-[14px] overflow-clip focus-within:shadow-xs-border": true,
|
|
||||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||||
[props.class ?? ""]: !!props.class,
|
[props.class ?? ""]: !!props.class,
|
||||||
}}
|
}}
|
||||||
@@ -962,7 +1060,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
|
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
|
||||||
/>
|
/>
|
||||||
<PromptContextItems
|
<PromptContextItems
|
||||||
items={prompt.context.items()}
|
items={contextItems()}
|
||||||
active={(item) => {
|
active={(item) => {
|
||||||
const active = comments.active()
|
const active = comments.active()
|
||||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||||
@@ -982,7 +1080,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onRemove={removeImageAttachment}
|
onRemove={removeImageAttachment}
|
||||||
removeLabel={language.t("prompt.attachment.remove")}
|
removeLabel={language.t("prompt.attachment.remove")}
|
||||||
/>
|
/>
|
||||||
<div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
|
<div
|
||||||
|
class="relative"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
const target = e.target
|
||||||
|
if (!(target instanceof HTMLElement)) return
|
||||||
|
if (
|
||||||
|
target.closest(
|
||||||
|
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editorRef?.focus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-input"
|
data-component="prompt-input"
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -1000,144 +1113,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={() => setComposing(true)}
|
onCompositionStart={() => setComposing(true)}
|
||||||
onCompositionEnd={() => setComposing(false)}
|
onCompositionEnd={() => setComposing(false)}
|
||||||
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
classList={{
|
classList={{
|
||||||
"select-text": true,
|
"select-text": true,
|
||||||
"w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||||
"[&_[data-type=file]]:text-syntax-property": true,
|
"[&_[data-type=file]]:text-syntax-property": true,
|
||||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||||
"font-mono!": store.mode === "shell",
|
"font-mono!": store.mode === "shell",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Show when={!prompt.dirty()}>
|
<Show when={!prompt.dirty()}>
|
||||||
<div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
<div
|
||||||
|
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||||
|
classList={{ "font-mono!": store.mode === "shell" }}
|
||||||
|
>
|
||||||
{placeholder()}
|
{placeholder()}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative p-3 flex items-center justify-between gap-2">
|
|
||||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
||||||
<Switch>
|
|
||||||
<Match when={store.mode === "shell"}>
|
|
||||||
<div class="flex items-center gap-2 px-2 h-6">
|
|
||||||
<Icon name="console" size="small" class="text-icon-primary" />
|
|
||||||
<span class="text-12-regular text-text-primary">{language.t("prompt.mode.shell")}</span>
|
|
||||||
<span class="text-12-regular text-text-weak">{language.t("prompt.mode.shell.exit")}</span>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={store.mode === "normal"}>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.agent.cycle")}
|
|
||||||
keybind={command.keybind("agent.cycle")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={local.agent.list().map((agent) => agent.name)}
|
|
||||||
current={local.agent.current()?.name ?? ""}
|
|
||||||
onSelect={local.agent.set}
|
|
||||||
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
|
|
||||||
valueClass="truncate"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
<Show
|
|
||||||
when={providers.paid().length > 0}
|
|
||||||
fallback={
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.model.choose")}
|
|
||||||
keybind={command.keybind("model.choose")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
as="div"
|
|
||||||
variant="ghost"
|
|
||||||
class="px-2 min-w-0 max-w-[240px]"
|
|
||||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
|
||||||
>
|
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
|
||||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.model.choose")}
|
|
||||||
keybind={command.keybind("model.choose")}
|
|
||||||
>
|
|
||||||
<ModelSelectorPopover
|
|
||||||
triggerAs={Button}
|
|
||||||
triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[240px]" }}
|
|
||||||
>
|
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
|
||||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</ModelSelectorPopover>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</Show>
|
|
||||||
<Show when={local.model.variant.list().length > 0}>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.model.variant.cycle")}
|
|
||||||
keybind={command.keybind("model.variant.cycle")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
data-action="model-variant-cycle"
|
|
||||||
variant="ghost"
|
|
||||||
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
|
|
||||||
onClick={() => local.model.variant.cycle()}
|
|
||||||
>
|
|
||||||
{local.model.variant.current() ?? language.t("common.default")}
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</Show>
|
|
||||||
<Show when={permission.permissionsEnabled() && params.id}>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.permissions.autoaccept.enable")}
|
|
||||||
keybind={command.keybind("permissions.autoaccept")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
|
||||||
classList={{
|
|
||||||
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
|
||||||
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
|
||||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
|
||||||
}}
|
|
||||||
aria-label={
|
|
||||||
permission.isAutoAccepting(params.id!, sdk.directory)
|
|
||||||
? language.t("command.permissions.autoaccept.disable")
|
|
||||||
: language.t("command.permissions.autoaccept.enable")
|
|
||||||
}
|
|
||||||
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name="chevron-double-right"
|
|
||||||
size="small"
|
|
||||||
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</Show>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -1149,22 +1145,34 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
e.currentTarget.value = ""
|
e.currentTarget.value = ""
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-1 mr-1">
|
|
||||||
<SessionContextUsage />
|
<div
|
||||||
<Show when={store.mode === "normal"}>
|
aria-hidden={store.mode !== "normal"}
|
||||||
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
|
class="flex items-center gap-1 transition-all duration-200 ease-out"
|
||||||
|
classList={{
|
||||||
|
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
|
||||||
|
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
title={language.t("prompt.action.attachFile")}
|
||||||
|
keybind={command.keybind("file.attach")}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
|
data-action="prompt-attach"
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="size-6 px-1"
|
class="size-8 p-0"
|
||||||
onClick={() => fileInputRef.click()}
|
onClick={pick}
|
||||||
|
disabled={store.mode !== "normal"}
|
||||||
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
aria-label={language.t("prompt.action.attachFile")}
|
aria-label={language.t("prompt.action.attachFile")}
|
||||||
>
|
>
|
||||||
<Icon name="photo" class="size-4.5" />
|
<Icon name="plus" class="size-4.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</TooltipKeybind>
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement="top"
|
placement="top"
|
||||||
inactive={!prompt.dirty() && !working()}
|
inactive={!prompt.dirty() && !working()}
|
||||||
@@ -1186,17 +1194,198 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
data-action="prompt-submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!prompt.dirty() && !working() && commentCount() === 0}
|
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
||||||
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
icon={working() ? "stop" : "arrow-up"}
|
icon={working() ? "stop" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="h-6 w-4.5"
|
class="size-8"
|
||||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
|
||||||
|
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||||
|
<div class="pointer-events-auto">
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={8}
|
||||||
|
title={language.t("command.permissions.autoaccept.enable")}
|
||||||
|
keybind={command.keybind("permissions.autoaccept")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
data-action="prompt-permissions"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||||
|
classList={{
|
||||||
|
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||||
|
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
||||||
|
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
||||||
|
}}
|
||||||
|
aria-label={
|
||||||
|
permission.isAutoAccepting(params.id!, sdk.directory)
|
||||||
|
? language.t("command.permissions.autoaccept.disable")
|
||||||
|
: language.t("command.permissions.autoaccept.enable")
|
||||||
|
}
|
||||||
|
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="chevron-double-right"
|
||||||
|
size="small"
|
||||||
|
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</DockShellForm>
|
||||||
|
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||||
|
<DockTray attach="top">
|
||||||
|
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
|
<Show when={store.mode === "shell"}>
|
||||||
|
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
|
||||||
|
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||||
|
<div class="size-4 shrink-0" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={store.mode === "normal"}>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.agent.cycle")}
|
||||||
|
keybind={command.keybind("agent.cycle")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
size="normal"
|
||||||
|
options={agentNames()}
|
||||||
|
current={local.agent.current()?.name ?? ""}
|
||||||
|
onSelect={local.agent.set}
|
||||||
|
class="capitalize max-w-[160px]"
|
||||||
|
valueClass="truncate text-13-regular"
|
||||||
|
triggerStyle={{ height: "28px" }}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
<Show
|
||||||
|
when={providers.paid().length > 0}
|
||||||
|
fallback={
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybind("model.choose")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
as="div"
|
||||||
|
variant="ghost"
|
||||||
|
size="normal"
|
||||||
|
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||||
|
style={{ height: "28px" }}
|
||||||
|
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||||
|
>
|
||||||
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={local.model.current()!.provider.id as IconName}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybind("model.choose")}
|
||||||
|
>
|
||||||
|
<ModelSelectorPopover
|
||||||
|
triggerAs={Button}
|
||||||
|
triggerProps={{
|
||||||
|
variant: "ghost",
|
||||||
|
size: "normal",
|
||||||
|
style: { height: "28px" },
|
||||||
|
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={local.model.current()!.provider.id as IconName}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</ModelSelectorPopover>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.variant.cycle")}
|
||||||
|
keybind={command.keybind("model.variant.cycle")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
size="normal"
|
||||||
|
options={variants()}
|
||||||
|
current={local.model.variant.current() ?? "default"}
|
||||||
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
|
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||||
|
class="capitalize max-w-[160px]"
|
||||||
|
valueClass="truncate text-13-regular"
|
||||||
|
triggerStyle={{ height: "28px" }}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0">
|
||||||
|
<RadioGroup
|
||||||
|
options={["shell", "normal"] as const}
|
||||||
|
current={store.mode}
|
||||||
|
value={(mode) => mode}
|
||||||
|
label={(mode) => (
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
openDelay={2000}
|
||||||
|
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
||||||
|
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
||||||
|
class="size-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={mode === "shell" ? "console" : "prompt"}
|
||||||
|
class="size-[18px]"
|
||||||
|
classList={{
|
||||||
|
"text-icon-strong-base": store.mode === mode,
|
||||||
|
"text-icon-weak": store.mode !== mode,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
)}
|
||||||
|
onSelect={(mode) => mode && setMode(mode)}
|
||||||
|
fill
|
||||||
|
pad="none"
|
||||||
|
class="w-[68px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DockTray>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!plainText) return
|
if (!plainText) return
|
||||||
|
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
|
||||||
|
if (inserted) return
|
||||||
|
|
||||||
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,14 +20,20 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
<Show when={props.items.length > 0}>
|
<Show when={props.items.length > 0}>
|
||||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||||
<For each={props.items}>
|
<For each={props.items}>
|
||||||
{(item) => (
|
{(item) => {
|
||||||
|
const directory = getDirectory(item.path)
|
||||||
|
const filename = getFilename(item.path)
|
||||||
|
const label = getFilenameTruncated(item.path, 14)
|
||||||
|
const selected = props.active(item)
|
||||||
|
|
||||||
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
value={
|
value={
|
||||||
<span class="flex max-w-[300px]">
|
<span class="flex max-w-[300px]">
|
||||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||||
{getDirectory(item.path)}
|
{directory}
|
||||||
</span>
|
</span>
|
||||||
<span class="shrink-0">{getFilename(item.path)}</span>
|
<span class="shrink-0">{filename}</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -35,18 +41,17 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||||
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
|
"hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
||||||
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
|
||||||
props.active(item),
|
"bg-background-stronger": !selected,
|
||||||
"bg-background-stronger": !props.active(item),
|
|
||||||
}}
|
}}
|
||||||
onClick={() => props.openComment(item)}
|
onClick={() => props.openComment(item)}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||||
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
||||||
<Show when={item.selection}>
|
<Show when={item.selection}>
|
||||||
{(sel) => (
|
{(sel) => (
|
||||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||||
@@ -74,7 +79,8 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ type PromptDragOverlayProps = {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kindToIcon = {
|
||||||
|
image: "photo",
|
||||||
|
"@mention": "link",
|
||||||
|
} as const
|
||||||
|
|
||||||
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={props.type !== null}>
|
<Show when={props.type !== null}>
|
||||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
||||||
<div class="flex flex-col items-center gap-2 text-text-weak">
|
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||||
<Icon name={props.type === "@mention" ? "link" : "photo"} class="size-8" />
|
<Icon name={props.type ? kindToIcon[props.type] : kindToIcon.image} class="size-8" />
|
||||||
<span class="text-14-regular">{props.label}</span>
|
<span class="text-14-regular">{props.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,17 +2,26 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
|
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
|
||||||
|
|
||||||
describe("prompt-input editor dom", () => {
|
describe("prompt-input editor dom", () => {
|
||||||
test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
|
test("createTextFragment preserves newlines with consecutive br nodes", () => {
|
||||||
const fragment = createTextFragment("foo\n\nbar")
|
const fragment = createTextFragment("foo\n\nbar")
|
||||||
const container = document.createElement("div")
|
const container = document.createElement("div")
|
||||||
container.appendChild(fragment)
|
container.appendChild(fragment)
|
||||||
|
|
||||||
expect(container.childNodes.length).toBe(5)
|
expect(container.childNodes.length).toBe(4)
|
||||||
|
expect(container.childNodes[0]?.textContent).toBe("foo")
|
||||||
|
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||||
|
expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR")
|
||||||
|
expect(container.childNodes[3]?.textContent).toBe("bar")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("createTextFragment keeps trailing newline as terminal break", () => {
|
||||||
|
const fragment = createTextFragment("foo\n")
|
||||||
|
const container = document.createElement("div")
|
||||||
|
container.appendChild(fragment)
|
||||||
|
|
||||||
|
expect(container.childNodes.length).toBe(2)
|
||||||
expect(container.childNodes[0]?.textContent).toBe("foo")
|
expect(container.childNodes[0]?.textContent).toBe("foo")
|
||||||
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||||
expect(container.childNodes[2]?.textContent).toBe("\u200B")
|
|
||||||
expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
|
|
||||||
expect(container.childNodes[4]?.textContent).toBe("bar")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
||||||
@@ -48,4 +57,21 @@ describe("prompt-input editor dom", () => {
|
|||||||
|
|
||||||
container.remove()
|
container.remove()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
|
||||||
|
const container = document.createElement("div")
|
||||||
|
container.appendChild(document.createTextNode("a"))
|
||||||
|
container.appendChild(document.createElement("br"))
|
||||||
|
container.appendChild(document.createElement("br"))
|
||||||
|
container.appendChild(document.createTextNode("b"))
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
setCursorPosition(container, 2)
|
||||||
|
expect(getCursorPosition(container)).toBe(2)
|
||||||
|
|
||||||
|
setCursorPosition(container, 3)
|
||||||
|
expect(getCursorPosition(container)).toBe(3)
|
||||||
|
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ export function createTextFragment(content: string): DocumentFragment {
|
|||||||
segments.forEach((segment, index) => {
|
segments.forEach((segment, index) => {
|
||||||
if (segment) {
|
if (segment) {
|
||||||
fragment.appendChild(document.createTextNode(segment))
|
fragment.appendChild(document.createTextNode(segment))
|
||||||
} else if (segments.length > 1) {
|
|
||||||
fragment.appendChild(document.createTextNode("\u200B"))
|
|
||||||
}
|
}
|
||||||
if (index < segments.length - 1) {
|
if (index < segments.length - 1) {
|
||||||
fragment.appendChild(document.createElement("br"))
|
fragment.appendChild(document.createElement("br"))
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Prompt } from "@/context/prompt"
|
import type { Prompt } from "@/context/prompt"
|
||||||
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
|
import {
|
||||||
|
canNavigateHistoryAtCursor,
|
||||||
|
clonePromptParts,
|
||||||
|
navigatePromptHistory,
|
||||||
|
prependHistoryEntry,
|
||||||
|
promptLength,
|
||||||
|
} from "./history"
|
||||||
|
|
||||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
@@ -66,4 +72,29 @@ describe("prompt-input history", () => {
|
|||||||
if (original[1]?.type !== "file") throw new Error("expected file")
|
if (original[1]?.type !== "file") throw new Error("expected file")
|
||||||
expect(original[1].selection?.startLine).toBe(1)
|
expect(original[1].selection?.startLine).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
||||||
|
const value = "a\nb\nc"
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 3, true)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 1, true)).toBe(false)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 1, true)).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
|||||||
|
|
||||||
export const MAX_HISTORY = 100
|
export const MAX_HISTORY = 100
|
||||||
|
|
||||||
|
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
||||||
|
const position = Math.max(0, Math.min(cursor, text.length))
|
||||||
|
const atStart = position === 0
|
||||||
|
const atEnd = position === text.length
|
||||||
|
if (inHistory) return atStart || atEnd
|
||||||
|
if (direction === "up") return position === 0
|
||||||
|
return position === text.length
|
||||||
|
}
|
||||||
|
|
||||||
export function clonePromptParts(prompt: Prompt): Prompt {
|
export function clonePromptParts(prompt: Prompt): Prompt {
|
||||||
return prompt.map((part) => {
|
return prompt.map((part) => {
|
||||||
if (part.type === "text") return { ...part }
|
if (part.type === "text") return { ...part }
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ type PromptImageAttachmentsProps = {
|
|||||||
removeLabel: string
|
removeLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
|
||||||
|
const imageClass =
|
||||||
|
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||||
|
const removeClass =
|
||||||
|
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||||
|
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
|
||||||
|
|
||||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={props.attachments.length > 0}>
|
<Show when={props.attachments.length > 0}>
|
||||||
@@ -19,7 +26,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<Show
|
<Show
|
||||||
when={attachment.mime.startsWith("image/")}
|
when={attachment.mime.startsWith("image/")}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
<div class={fallbackClass}>
|
||||||
<Icon name="folder" class="size-6 text-text-weak" />
|
<Icon name="folder" class="size-6 text-text-weak" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -27,19 +34,19 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<img
|
<img
|
||||||
src={attachment.dataUrl}
|
src={attachment.dataUrl}
|
||||||
alt={attachment.filename}
|
alt={attachment.filename}
|
||||||
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
class={imageClass}
|
||||||
onClick={() => props.onOpen(attachment)}
|
onClick={() => props.onOpen(attachment)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.onRemove(attachment.id)}
|
onClick={() => props.onRemove(attachment.id)}
|
||||||
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
class={removeClass}
|
||||||
aria-label={props.removeLabel}
|
aria-label={props.removeLabel}
|
||||||
>
|
>
|
||||||
<Icon name="close" class="size-3 text-text-weak" />
|
<Icon name="close" class="size-3 text-text-weak" />
|
||||||
</button>
|
</button>
|
||||||
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
|
<div class={nameClass}>
|
||||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user