Compare commits
687 Commits
v0.57.0
...
v0.62.3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf4331e8cf | ||
|
|
8e7f711371 | ||
|
|
df837283e8 | ||
|
|
4e788b1818 | ||
|
|
b8733feeff | ||
|
|
e8b917e3f0 | ||
|
|
5c0083d4dd | ||
|
|
85cb68c3f5 | ||
|
|
7aec6b5531 | ||
|
|
4dab0f89f4 | ||
|
|
d2f6b315a3 | ||
|
|
62d4473c3f | ||
|
|
6e2d3aae68 | ||
|
|
e5959483ed | ||
|
|
e13012c48e | ||
|
|
df708465d1 | ||
|
|
aa9ccf3411 | ||
|
|
6410fdc474 | ||
|
|
499d947e69 | ||
|
|
c093516351 | ||
|
|
41699224ff | ||
|
|
8886cb5786 | ||
|
|
f56f0b7bbb | ||
|
|
ae79b50101 | ||
|
|
fcfc4a4298 | ||
|
|
d355bd3372 | ||
|
|
2bfd46d48c | ||
|
|
f1b41389b3 | ||
|
|
92a4998ddc | ||
|
|
23d7209f82 | ||
|
|
cf3c610eba | ||
|
|
6a010f58be | ||
|
|
0f1b0a4a78 | ||
|
|
a4a8596a29 | ||
|
|
1cdd3c0e28 | ||
|
|
22db5bffe8 | ||
|
|
a61f3b715b | ||
|
|
949a28d49c | ||
|
|
88be4fe77e | ||
|
|
625a62626e | ||
|
|
ee440cf300 | ||
|
|
cf2ec99a4d | ||
|
|
bb0f6e85a8 | ||
|
|
4412217f51 | ||
|
|
1e85361914 | ||
|
|
f611b443c0 | ||
|
|
5984be3d84 | ||
|
|
5a8061ac7b | ||
|
|
509c327b3b | ||
|
|
56a66b348d | ||
|
|
a7d86a164c | ||
|
|
383334633f | ||
|
|
6a2dc444c6 | ||
|
|
e9073310c4 | ||
|
|
3b67602b13 | ||
|
|
04477e9f97 | ||
|
|
990c83eabd | ||
|
|
ddc71653ad | ||
|
|
e5e5cf1314 | ||
|
|
f364a15d89 | ||
|
|
2b4fd53202 | ||
|
|
dfe2fd0386 | ||
|
|
2055f05b09 | ||
|
|
33ebfc3f10 | ||
|
|
6a4f3aaa56 | ||
|
|
c1f7ac0d8c | ||
|
|
19adfdf8bb | ||
|
|
af74d5409a | ||
|
|
2a3773240d | ||
|
|
782676dc67 | ||
|
|
68717d0fe8 | ||
|
|
8bd9577318 | ||
|
|
2ac537393d | ||
|
|
82956b618a | ||
|
|
a725ded95e | ||
|
|
113b7f6f97 | ||
|
|
aed085b168 | ||
|
|
345544646a | ||
|
|
4520227e98 | ||
|
|
f5795ffc6f | ||
|
|
8cde64d3f6 | ||
|
|
d7b8a189e4 | ||
|
|
cfde3e348c | ||
|
|
70e2951e35 | ||
|
|
ba35536664 | ||
|
|
b9f9819637 | ||
|
|
076d353e84 | ||
|
|
64e9b9f893 | ||
|
|
21ad375b42 | ||
|
|
cb9534eae0 | ||
|
|
8b43368bf9 | ||
|
|
c96c8fd782 | ||
|
|
c295f943ba | ||
|
|
e527474dd9 | ||
|
|
73f267167f | ||
|
|
40290a9a42 | ||
|
|
bd35468d18 | ||
|
|
c80395fc18 | ||
|
|
95be2c6070 | ||
|
|
fb7a92242b | ||
|
|
8c2ff69515 | ||
|
|
011085a93f | ||
|
|
dce21900a7 | ||
|
|
2b5ac535b9 | ||
|
|
484c8f7cbe | ||
|
|
7e4d582d1e | ||
|
|
50c4783333 | ||
|
|
9860dbbbea | ||
|
|
874a3605f8 | ||
|
|
088c5bac1f | ||
|
|
e135b982c1 | ||
|
|
a8bd234aa4 | ||
|
|
f99d70500c | ||
|
|
476020ae84 | ||
|
|
2f1ddc0d0f | ||
|
|
ef5844bc79 | ||
|
|
0c9ceb51e6 | ||
|
|
cedc0f64d5 | ||
|
|
9952f08cce | ||
|
|
efa6745035 | ||
|
|
4816a587c3 | ||
|
|
6514eb5209 | ||
|
|
2a38c4938d | ||
|
|
b015761131 | ||
|
|
99e6ecc466 | ||
|
|
7e411ae098 | ||
|
|
1bbb7dd126 | ||
|
|
78969d0938 | ||
|
|
bac3dc1ccd | ||
|
|
ae44a38285 | ||
|
|
77b13b1356 | ||
|
|
2e97e2dbfd | ||
|
|
75ec5c3b1b | ||
|
|
3a456b09cb | ||
|
|
022f70b1de | ||
|
|
c1e23fc6d9 | ||
|
|
a6e9d0d061 | ||
|
|
b700ea84a5 | ||
|
|
0ef62fc334 | ||
|
|
c3900565b9 | ||
|
|
a86756ed20 | ||
|
|
e3ef6d35ab | ||
|
|
038670cc6f | ||
|
|
5d87a04dc3 | ||
|
|
fbfe8a2311 | ||
|
|
bd8509990a | ||
|
|
6bdb08ab9c | ||
|
|
db8b8ef66b | ||
|
|
ac5d5e2451 | ||
|
|
fad6cfef05 | ||
|
|
c83cae60f6 | ||
|
|
9b8e6cce02 | ||
|
|
9858906463 | ||
|
|
be1dc01d9e | ||
|
|
de24b4b4e8 | ||
|
|
629d3d473c | ||
|
|
5dc82d3df8 | ||
|
|
76a1b81e45 | ||
|
|
99aa1219d2 | ||
|
|
69472f7823 | ||
|
|
723fa83909 | ||
|
|
2f064d5ccc | ||
|
|
ae9a0a99ea | ||
|
|
c2b9b08944 | ||
|
|
2aa2e5af7a | ||
|
|
b7c439f4c4 | ||
|
|
e6b29086a9 | ||
|
|
83e4e26989 | ||
|
|
caec9c1f45 | ||
|
|
e3809c267d | ||
|
|
0d9eecd2ed | ||
|
|
d7915840d0 | ||
|
|
8098697847 | ||
|
|
4c2f8406c7 | ||
|
|
e0a477265d | ||
|
|
364c3f2f00 | ||
|
|
75c79d60fe | ||
|
|
5b2dd8e4d0 | ||
|
|
9e8e227b46 | ||
|
|
adf7578007 | ||
|
|
b6e5aa3bb0 | ||
|
|
288c039929 | ||
|
|
fb5c6493cf | ||
|
|
3160d07b9c | ||
|
|
e49fc9f4b1 | ||
|
|
ed6f482e68 | ||
|
|
773f569385 | ||
|
|
219793afcc | ||
|
|
571636c526 | ||
|
|
cbc15b6b58 | ||
|
|
c410935c9c | ||
|
|
79cf5dbd4b | ||
|
|
da5203011c | ||
|
|
84c7aa9cad | ||
|
|
f8e5a08324 | ||
|
|
5e57a33df7 | ||
|
|
38bdf7ad92 | ||
|
|
5447f63e9d | ||
|
|
50ba8bdc9b | ||
|
|
6f279c0239 | ||
|
|
26ccd70e77 | ||
|
|
b0ddbeb0ad | ||
|
|
826eb113e7 | ||
|
|
2661a9cc98 | ||
|
|
b06366ebb7 | ||
|
|
c7a629ba6b | ||
|
|
d155c11729 | ||
|
|
0c3c1e1f68 | ||
|
|
6c322dc835 | ||
|
|
6019e4c37b | ||
|
|
9c8dd66b20 | ||
|
|
0c0e8688ed | ||
|
|
6146923dbb | ||
|
|
2c4f003897 | ||
|
|
0491747eed | ||
|
|
29b9651ebd | ||
|
|
48a1dd1588 | ||
|
|
9cf39b1da6 | ||
|
|
47be340cac | ||
|
|
bf98300547 | ||
|
|
46635956f4 | ||
|
|
8c6de99159 | ||
|
|
a42a703b35 | ||
|
|
59fab0bb2d | ||
|
|
c73e2c2d0f | ||
|
|
8c1c98a0bf | ||
|
|
d99a074bc0 | ||
|
|
05b4b443d9 | ||
|
|
4b09f77950 | ||
|
|
dbea3cf20c | ||
|
|
aa8fa4a6d5 | ||
|
|
dbc03e2668 | ||
|
|
4ef69c8361 | ||
|
|
895aeb033f | ||
|
|
e15cc376b0 | ||
|
|
54428ca6f6 | ||
|
|
54cf6fa838 | ||
|
|
09a0b3eb55 | ||
|
|
40c3e925ad | ||
|
|
5ef5147780 | ||
|
|
318b923bac | ||
|
|
93a30ea940 | ||
|
|
5bb2edca8b | ||
|
|
1789dfb8b1 | ||
|
|
f473eadf2d | ||
|
|
1f161b9aa1 | ||
|
|
354fefe61b | ||
|
|
19c98bb5ad | ||
|
|
2149c17a0a | ||
|
|
1716aff969 | ||
|
|
2a5d7ea2de | ||
|
|
be34c50c72 | ||
|
|
50ae3e03f7 | ||
|
|
499b8f5f55 | ||
|
|
81d83841ab | ||
|
|
cce00526b9 | ||
|
|
c9225bb87c | ||
|
|
75c339851f | ||
|
|
e39c7c62e4 | ||
|
|
b6bb2985f5 | ||
|
|
6bdbab2faf | ||
|
|
f09d6b7b95 | ||
|
|
19a2752674 | ||
|
|
5d433b1666 | ||
|
|
caeae38e3a | ||
|
|
c25acc155d | ||
|
|
4222f86537 | ||
|
|
9569323f93 | ||
|
|
0bbba90f30 | ||
|
|
f1ff557a25 | ||
|
|
23d7143298 | ||
|
|
12eab6551f | ||
|
|
d25c6b15a6 | ||
|
|
b9308ad80d | ||
|
|
6e363e464c | ||
|
|
6e53deb1b2 | ||
|
|
0717c168d9 | ||
|
|
6d020a3ee9 | ||
|
|
9a381c1803 | ||
|
|
3e23d1f48d | ||
|
|
1750fcf833 | ||
|
|
646d344a11 | ||
|
|
bc03592912 | ||
|
|
a4b518ec72 | ||
|
|
b541ac313c | ||
|
|
934474f87e | ||
|
|
3a4e802093 | ||
|
|
b3eb5f7cdf | ||
|
|
c21e0e916c | ||
|
|
d301a215f7 | ||
|
|
8044beffc7 | ||
|
|
8df84e0341 | ||
|
|
137a9cefbd | ||
|
|
55576f879b | ||
|
|
78aee53411 | ||
|
|
864020463f | ||
|
|
2d3d07d4d7 | ||
|
|
ad6f9b2499 | ||
|
|
330968434f | ||
|
|
4b12fb6b3b | ||
|
|
eef086f60f | ||
|
|
6ac0b81778 | ||
|
|
8d82702da2 | ||
|
|
dde3dfdbf6 | ||
|
|
8d609959f1 | ||
|
|
16f854b636 | ||
|
|
9c47325c25 | ||
|
|
cf499abf31 | ||
|
|
86ddbc6d26 | ||
|
|
b8bc5a282e | ||
|
|
f5db02a605 | ||
|
|
9ebd586350 | ||
|
|
1bec8087ee | ||
|
|
a5a60eb854 | ||
|
|
edb61a9c8f | ||
|
|
06dfb74663 | ||
|
|
26b03afa60 | ||
|
|
c4680e66ff | ||
|
|
06e9b8276f | ||
|
|
ad975da8bd | ||
|
|
37a0fd33c5 | ||
|
|
f28cc5ca0c | ||
|
|
0a1aea6cb8 | ||
|
|
a6a7e85894 | ||
|
|
e75dcc853b | ||
|
|
b5786cbf30 | ||
|
|
513c02e67f | ||
|
|
51c0a140c6 | ||
|
|
e73270085b | ||
|
|
dd1320e6d1 | ||
|
|
d42bf8eebe | ||
|
|
2a1dbd6fb5 | ||
|
|
9760eb0081 | ||
|
|
6cdf4e98fc | ||
|
|
2ff6ffff58 | ||
|
|
27a87c3d9e | ||
|
|
1d8717f4de | ||
|
|
fedec68d39 | ||
|
|
490a608663 | ||
|
|
94a5bbc0ab | ||
|
|
89f05ada0b | ||
|
|
3bb1f0097f | ||
|
|
69dcfbb423 | ||
|
|
e744520d90 | ||
|
|
3c3671a193 | ||
|
|
cbf31e6d27 | ||
|
|
b3567a7240 | ||
|
|
296656570e | ||
|
|
aac24938f5 | ||
|
|
47332f97c7 | ||
|
|
1179f8f7be | ||
|
|
bd146306c6 | ||
|
|
c4dde0f4e2 | ||
|
|
ec19f0f8e9 | ||
|
|
cc56fa9ea6 | ||
|
|
a19783919c | ||
|
|
83d3fad80d | ||
|
|
202950aa98 | ||
|
|
9adbab5d99 | ||
|
|
a6910584b6 | ||
|
|
e24a69b838 | ||
|
|
b1f64d9550 | ||
|
|
41590ef64b | ||
|
|
e7b6d1befe | ||
|
|
76a86b7e5e | ||
|
|
7eceff1d7b | ||
|
|
81a3a22379 | ||
|
|
d1f1eb9a29 | ||
|
|
5487f99ac7 | ||
|
|
bc2a6e429c | ||
|
|
0beb97547e | ||
|
|
941f4097fe | ||
|
|
673041d1f5 | ||
|
|
6dfa34fcf8 | ||
|
|
b626ec3bf9 | ||
|
|
5708879b5a | ||
|
|
638e9f9477 | ||
|
|
acc85ad03c | ||
|
|
0a8e2f6bb0 | ||
|
|
9bdcd37f60 | ||
|
|
a833652077 | ||
|
|
7ce758b343 | ||
|
|
cc8ae45012 | ||
|
|
65b8c512fe | ||
|
|
0e695eaae8 | ||
|
|
1f0a9ce418 | ||
|
|
a656047c15 | ||
|
|
f26695ea8c | ||
|
|
f4306d977f | ||
|
|
d93e75bf5f | ||
|
|
67a32de7d4 | ||
|
|
ba6c5441c0 | ||
|
|
e2700ff8c6 | ||
|
|
f83de0a91c | ||
|
|
4c07a0782b | ||
|
|
ee2587d3e5 | ||
|
|
45d118f96f | ||
|
|
eb711cde53 | ||
|
|
4504b36c8f | ||
|
|
29c3b81a0a | ||
|
|
feb17c29ec | ||
|
|
8e7f96cebc | ||
|
|
0a306808da | ||
|
|
1d4bdfc4a1 | ||
|
|
9ec62d4c1f | ||
|
|
bf0a04ab50 | ||
|
|
bf488f2027 | ||
|
|
b229bc69b9 | ||
|
|
7b084199be | ||
|
|
e0b6b0df2a | ||
|
|
6dcf638322 | ||
|
|
b8c2acf0f2 | ||
|
|
eedcc585af | ||
|
|
7528bf8f32 | ||
|
|
0d31ea7cf2 | ||
|
|
6a237deb21 | ||
|
|
95bc18a995 | ||
|
|
d2494822b0 | ||
|
|
61dc703a58 | ||
|
|
a87d9d3578 | ||
|
|
425e540c9a | ||
|
|
3ae96f2c6e | ||
|
|
fc770c6ea5 | ||
|
|
0c68abbe17 | ||
|
|
576581c20d | ||
|
|
1d2495d57b | ||
|
|
7d6690335f | ||
|
|
2f96a09c46 | ||
|
|
94c68d246e | ||
|
|
8dc99d42ff | ||
|
|
04fcd18c75 | ||
|
|
d9d99e5e04 | ||
|
|
5f9cedad23 | ||
|
|
afaacba41f | ||
|
|
3396a98978 | ||
|
|
7cfe435e62 | ||
|
|
9d990ae329 | ||
|
|
25ff5959fb | ||
|
|
d7bac3cea6 | ||
|
|
79748803a9 | ||
|
|
6f4edf6df5 | ||
|
|
1af4b263b2 | ||
|
|
2d25e25ec3 | ||
|
|
c4028ef116 | ||
|
|
393d728769 | ||
|
|
5fec8c8bfd | ||
|
|
f90b693ed5 | ||
|
|
515c1ea123 | ||
|
|
b82db3a254 | ||
|
|
34cb742db1 | ||
|
|
59aaf4ce1b | ||
|
|
d14744d02f | ||
|
|
e96abf1429 | ||
|
|
2758234e03 | ||
|
|
00188511cb | ||
|
|
4456e81163 | ||
|
|
6ecf870c66 | ||
|
|
95cb9ceac9 | ||
|
|
fcf13b44fb | ||
|
|
070c4bc503 | ||
|
|
e15f27106d | ||
|
|
15595a67fa | ||
|
|
bf50a8ad8e | ||
|
|
188b775fa6 | ||
|
|
ec76146a23 | ||
|
|
f9fb3f78b2 | ||
|
|
96c5bb8c39 | ||
|
|
560d8a8004 | ||
|
|
251e06c50f | ||
|
|
6fb5901d69 | ||
|
|
d3cddfdced | ||
|
|
386de03f46 | ||
|
|
4aaf3df8c7 | ||
|
|
d7cea646fc | ||
|
|
e82320cde8 | ||
|
|
669406d5af | ||
|
|
b479c8c8ba | ||
|
|
3d467a9491 | ||
|
|
8fb8fff61b | ||
|
|
d67fad8dca | ||
|
|
431ac1267a | ||
|
|
47a8e4222a | ||
|
|
4508d94a3e | ||
|
|
8411d886ac | ||
|
|
17ed80f74d | ||
|
|
63e1c839fe | ||
|
|
b6525e9164 | ||
|
|
c0ee8dc007 | ||
|
|
fe7a39ba5c | ||
|
|
51fa06cc8d | ||
|
|
771215d254 | ||
|
|
9f81699e01 | ||
|
|
95e08edbb8 | ||
|
|
baf6097b49 | ||
|
|
4cb306fbf3 | ||
|
|
2e84fc6737 | ||
|
|
c43956d70a | ||
|
|
40163da679 | ||
|
|
7763acbdd5 | ||
|
|
55cc142319 | ||
|
|
edf4c3ec00 | ||
|
|
b7e115a6a1 | ||
|
|
7fb5fe036a | ||
|
|
8b86781ad1 | ||
|
|
3f4be5521c | ||
|
|
aa86806408 | ||
|
|
5bc074005c | ||
|
|
fa31c9659b | ||
|
|
5ef342f8c4 | ||
|
|
5b811e4304 | ||
|
|
183ca5da6f | ||
|
|
8f8843711f | ||
|
|
383c21046f | ||
|
|
78e3370c1e | ||
|
|
84eebbe24a | ||
|
|
087760dba0 | ||
|
|
d9fb8c90d8 | ||
|
|
836b536a90 | ||
|
|
2bd947d4d0 | ||
|
|
4a61b1011e | ||
|
|
84847ff181 | ||
|
|
b5d941b10c | ||
|
|
0bbc02e10d | ||
|
|
fceba6814f | ||
|
|
0ed811b81b | ||
|
|
ce2112df43 | ||
|
|
678b013da6 | ||
|
|
ebee2168fc | ||
|
|
41240351d3 | ||
|
|
debedaf004 | ||
|
|
57930cb88a | ||
|
|
de917c4678 | ||
|
|
456dde200c | ||
|
|
218ba81013 | ||
|
|
499e95d16a | ||
|
|
6f7547d28f | ||
|
|
c354b9b959 | ||
|
|
841ba405f0 | ||
|
|
6f6d72890a | ||
|
|
f3d83631ef | ||
|
|
e6487de069 | ||
|
|
a5c2f22bf7 | ||
|
|
7080dc9c23 | ||
|
|
06813be5c8 | ||
|
|
8f4b3c3493 | ||
|
|
4477f95ee6 | ||
|
|
9427bb7553 | ||
|
|
1e45198b9f | ||
|
|
ad323d6e3b | ||
|
|
da6106db8e | ||
|
|
bec6b41448 | ||
|
|
6426037653 | ||
|
|
01176e04b7 | ||
|
|
c237075102 | ||
|
|
0f1d71c38f | ||
|
|
56b4162023 | ||
|
|
fd42811ef1 | ||
|
|
34926abe83 | ||
|
|
1aa554f4c9 | ||
|
|
52dbf2f9b8 | ||
|
|
5769cdc354 | ||
|
|
7f84abaf13 | ||
|
|
512f817e2f | ||
|
|
8c24c858c9 | ||
|
|
a1299d9b68 | ||
|
|
af0974264c | ||
|
|
c95646a298 | ||
|
|
42b7820dbb | ||
|
|
ce7f6dd082 | ||
|
|
6540936970 | ||
|
|
1c5d15b85e | ||
|
|
964a5d2db7 | ||
|
|
bce25918a0 | ||
|
|
074b8f18d1 | ||
|
|
be8990ea78 | ||
|
|
761ae3ae6f | ||
|
|
25bba396ef | ||
|
|
3c62de34f7 | ||
|
|
b35e8f0164 | ||
|
|
a6cccf82f7 | ||
|
|
fcf11b1181 | ||
|
|
e865b85d9c | ||
|
|
9fe6a5e83e | ||
|
|
b395fbb3f2 | ||
|
|
8a2430090b | ||
|
|
113d3b88d0 | ||
|
|
f7714a25d1 | ||
|
|
71b2126eca | ||
|
|
d5fd531743 | ||
|
|
bf3b3da6ed | ||
|
|
7e5d49487b | ||
|
|
759b7f1e07 | ||
|
|
d2b18790a0 | ||
|
|
4251e0f5f1 | ||
|
|
c8e63d76a4 | ||
|
|
6ac9308a03 | ||
|
|
0d1b2a7e46 | ||
|
|
bb8798a844 | ||
|
|
8d2de1074b | ||
|
|
632f47930f | ||
|
|
a679557e40 | ||
|
|
b18dd8fcff | ||
|
|
8edee9b2a8 | ||
|
|
6633c0b328 | ||
|
|
6825b6077a | ||
|
|
9c82954877 | ||
|
|
c4da8c46f7 | ||
|
|
b9d84df127 | ||
|
|
446bf88655 | ||
|
|
03b6f3e0bf | ||
|
|
e72e132ce2 | ||
|
|
c1249a3d84 | ||
|
|
96917a8007 | ||
|
|
2f7283fd13 | ||
|
|
e0ea932fa7 | ||
|
|
4b2040a7ca | ||
|
|
a2e8fc79d9 | ||
|
|
61ff24edc8 | ||
|
|
a86e93d46f | ||
|
|
883d5b7a08 | ||
|
|
5157c71fa9 | ||
|
|
fdda2abb78 | ||
|
|
641daf0a6e | ||
|
|
55ca02351c | ||
|
|
6fa2e62fa4 | ||
|
|
2a14af4cde | ||
|
|
1898e813f5 | ||
|
|
e0db62173a | ||
|
|
1158911560 | ||
|
|
634f9de7e6 | ||
|
|
f8da5ab2e7 | ||
|
|
fbe5f9225c | ||
|
|
4f44375abd | ||
|
|
773423fcf4 | ||
|
|
a62e2a38d7 | ||
|
|
48dcc465f2 | ||
|
|
d0c50b4fbf | ||
|
|
2da32af340 | ||
|
|
2b0794f5ae | ||
|
|
67e188a015 | ||
|
|
a2e57e8d71 | ||
|
|
21fb2b9bf1 | ||
|
|
e4f5e85c3c | ||
|
|
a48995c782 | ||
|
|
04d194924e | ||
|
|
46b61feb9a | ||
|
|
aa3cb8e35e | ||
|
|
8ff4f044b7 | ||
|
|
ab3a6f775e | ||
|
|
815cf44647 | ||
|
|
f5b2d56efd | ||
|
|
1d1bd3975a | ||
|
|
4b73239972 | ||
|
|
0a29e13d4a | ||
|
|
0db6eb2fb8 | ||
|
|
782309f369 | ||
|
|
5a3a85b2c8 | ||
|
|
c8a48e8990 | ||
|
|
80ab144bf3 | ||
|
|
6aa0f0b200 | ||
|
|
f0c45cbceb | ||
|
|
e55e7e4844 | ||
|
|
573086eed2 | ||
|
|
df285def59 | ||
|
|
bb9ce86a29 | ||
|
|
f4697ff4d1 | ||
|
|
55b095cbd3 | ||
|
|
4a9bf8f4fe | ||
|
|
ebb5ffcedc | ||
|
|
0b1e372d11 | ||
|
|
8fec7da799 | ||
|
|
46019f8537 | ||
|
|
0674ca14d9 | ||
|
|
d0b35b5e19 | ||
|
|
01570504ad | ||
|
|
506c28d2b6 | ||
|
|
53f58f72f2 | ||
|
|
c9786fe464 | ||
|
|
c2ffc7086c | ||
|
|
96f9ee784d | ||
|
|
962f087ac2 | ||
|
|
ebe8c952e4 | ||
|
|
eabd687cbc | ||
|
|
593c7a8cd1 | ||
|
|
79b9420017 | ||
|
|
db5c83eb36 | ||
|
|
56f9543a95 |
@@ -1,3 +1,11 @@
|
||||
/target
|
||||
/manifest.yml
|
||||
/migrate.yml
|
||||
**/target
|
||||
zed.xcworkspace
|
||||
.DS_Store
|
||||
plugins/bin
|
||||
script/node_modules
|
||||
styles/node_modules
|
||||
crates/collab/static/styles.css
|
||||
vendor/bin
|
||||
assets/themes/*.json
|
||||
assets/themes/internal/*.json
|
||||
assets/themes/experiments/*.json
|
||||
|
||||
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
@@ -39,6 +39,7 @@ jobs:
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
clean: false
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --no-fail-fast
|
||||
@@ -57,6 +58,7 @@ jobs:
|
||||
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
|
||||
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
|
||||
ZED_AMPLITUDE_API_KEY: ${{ secrets.ZED_AMPLITUDE_API_KEY }}
|
||||
ZED_MIXPANEL_TOKEN: ${{ secrets.ZED_MIXPANEL_TOKEN }}
|
||||
steps:
|
||||
- name: Install Rust
|
||||
run: |
|
||||
@@ -75,10 +77,32 @@ jobs:
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
clean: false
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Validate version
|
||||
- name: Determine version and release channel
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: script/validate-version
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
version=$(script/get-crate-version zed)
|
||||
channel=$(cat crates/zed/RELEASE_CHANNEL)
|
||||
echo "Publishing version: ${version} on release channel ${channel}"
|
||||
echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV
|
||||
|
||||
expected_tag_name=""
|
||||
case ${channel} in
|
||||
stable)
|
||||
expected_tag_name="v${version}";;
|
||||
preview)
|
||||
expected_tag_name="v${version}-pre";;
|
||||
*)
|
||||
echo "can't publish a release on channel ${channel}"
|
||||
exit 1;;
|
||||
esac
|
||||
if [[ $GITHUB_REF_NAME != $expected_tag_name ]]; then
|
||||
echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create app bundle
|
||||
run: script/bundle
|
||||
@@ -91,12 +115,12 @@ jobs:
|
||||
path: target/release/Zed.dmg
|
||||
|
||||
- uses: softprops/action-gh-release@v1
|
||||
name: Upload app bundle to release if release tag
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
name: Upload app bundle to release
|
||||
if: ${{ env.RELEASE_CHANNEL }}
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
|
||||
files: target/release/Zed.dmg
|
||||
overwrite: true
|
||||
body: ""
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
46
.github/workflows/publish_collab_image.yml
vendored
Normal file
46
.github/workflows/publish_collab_image.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Publish Collab Server Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- collab-v*
|
||||
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish collab server image
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- deploy
|
||||
steps:
|
||||
- name: Add Rust to the PATH
|
||||
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Sign into DigitalOcean docker registry
|
||||
run: doctl registry login
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
clean: false
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Determine version
|
||||
run: |
|
||||
set -eu
|
||||
version=$(script/get-crate-version collab)
|
||||
if [[ $GITHUB_REF_NAME != "collab-v${version}" ]]; then
|
||||
echo "release tag ${GITHUB_REF_NAME} does not match version ${version}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Publishing collab version: ${version}"
|
||||
echo "COLLAB_VERSION=${version}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build docker image
|
||||
run: docker build . --tag registry.digitalocean.com/zed/collab:v${COLLAB_VERSION}
|
||||
|
||||
- name: Publish docker image
|
||||
run: docker push registry.digitalocean.com/zed/collab:v${COLLAB_VERSION}
|
||||
34
.github/workflows/release_actions.yml
vendored
Normal file
34
.github/workflows/release_actions.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
discord_release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Discord Webhook Action
|
||||
uses: tsickert/discord-webhook@v5.3.0
|
||||
if: ${{ ! github.event.release.prerelease }}
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
content: |
|
||||
📣 Zed ${{ github.event.release.tag_name }} was just released!
|
||||
|
||||
Restart your Zed or head to https://zed.dev/releases to grab it.
|
||||
|
||||
```md
|
||||
### Changelog
|
||||
|
||||
${{ github.event.release.body }}
|
||||
```
|
||||
amplitude_release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10.5"
|
||||
architecture: "x64"
|
||||
cache: "pip"
|
||||
- run: pip install -r script/amplitude_release/requirements.txt
|
||||
- run: python script/amplitude_release/main.py ${{ github.event.release.tag_name }} ${{ secrets.ZED_AMPLITUDE_API_KEY }} ${{ secrets.ZED_AMPLITUDE_SECRET_KEY }}
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -7,5 +7,14 @@
|
||||
/crates/collab/static/styles.css
|
||||
/vendor/bin
|
||||
/assets/themes/*.json
|
||||
/assets/themes/internal/*.json
|
||||
/assets/themes/experiments/*.json
|
||||
/assets/themes/Internal/*.json
|
||||
/assets/themes/Experiments/*.json
|
||||
**/venv
|
||||
.build
|
||||
Packages
|
||||
*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "crates/live_kit_server/protocol"]
|
||||
path = crates/live_kit_server/protocol
|
||||
url = https://github.com/livekit/protocol
|
||||
1722
Cargo.lock
generated
1722
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
66
Cargo.toml
66
Cargo.toml
@@ -1,8 +1,69 @@
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
members = [
|
||||
"crates/activity_indicator",
|
||||
"crates/assets",
|
||||
"crates/auto_update",
|
||||
"crates/breadcrumbs",
|
||||
"crates/call",
|
||||
"crates/cli",
|
||||
"crates/client",
|
||||
"crates/clock",
|
||||
"crates/collab",
|
||||
"crates/collab_ui",
|
||||
"crates/collections",
|
||||
"crates/command_palette",
|
||||
"crates/context_menu",
|
||||
"crates/db",
|
||||
"crates/diagnostics",
|
||||
"crates/drag_and_drop",
|
||||
"crates/editor",
|
||||
"crates/file_finder",
|
||||
"crates/fs",
|
||||
"crates/fsevent",
|
||||
"crates/fuzzy",
|
||||
"crates/git",
|
||||
"crates/go_to_line",
|
||||
"crates/gpui",
|
||||
"crates/gpui_macros",
|
||||
"crates/journal",
|
||||
"crates/language",
|
||||
"crates/live_kit_client",
|
||||
"crates/live_kit_server",
|
||||
"crates/lsp",
|
||||
"crates/media",
|
||||
"crates/menu",
|
||||
"crates/outline",
|
||||
"crates/picker",
|
||||
"crates/plugin",
|
||||
"crates/plugin_macros",
|
||||
"crates/plugin_runtime",
|
||||
"crates/project",
|
||||
"crates/project_panel",
|
||||
"crates/project_symbols",
|
||||
"crates/rope",
|
||||
"crates/rpc",
|
||||
"crates/search",
|
||||
"crates/settings",
|
||||
"crates/snippet",
|
||||
"crates/sum_tree",
|
||||
"crates/terminal",
|
||||
"crates/text",
|
||||
"crates/theme",
|
||||
"crates/theme_selector",
|
||||
"crates/theme_testbench",
|
||||
"crates/util",
|
||||
"crates/vim",
|
||||
"crates/workspace",
|
||||
"crates/zed",
|
||||
]
|
||||
default-members = ["crates/zed"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] }
|
||||
rand = { version = "0.8" }
|
||||
|
||||
[patch.crates-io]
|
||||
tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "366210ae925d7ea0891bc7a0c738f60c77c04d7b" }
|
||||
async-task = { git = "https://github.com/zed-industries/async-task", rev = "341b57d6de98cdfd7b418567b8de2022ca993a6e" }
|
||||
@@ -13,11 +74,10 @@ cocoa-foundation = { git = "https://github.com/servo/core-foundation-rs", rev =
|
||||
core-foundation = { git = "https://github.com/servo/core-foundation-rs", rev = "079665882507dd5e2ff77db3de5070c1f6c0fb85" }
|
||||
core-foundation-sys = { git = "https://github.com/servo/core-foundation-rs", rev = "079665882507dd5e2ff77db3de5070c1f6c0fb85" }
|
||||
core-graphics = { git = "https://github.com/servo/core-foundation-rs", rev = "079665882507dd5e2ff77db3de5070c1f6c0fb85" }
|
||||
# TODO - Remove when a new version of RustRocksDB is released
|
||||
rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "39dc822dde743b2a26eb160b660e8fbdab079d49" }
|
||||
|
||||
[profile.dev]
|
||||
split-debuginfo = "unpacked"
|
||||
|
||||
[profile.release]
|
||||
debug = true
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax = docker/dockerfile:1.2
|
||||
|
||||
FROM rust:1.62-bullseye as builder
|
||||
FROM rust:1.64-bullseye as builder
|
||||
WORKDIR app
|
||||
COPY . .
|
||||
|
||||
@@ -19,5 +19,7 @@ FROM debian:bullseye-slim as runtime
|
||||
RUN apt-get update; \
|
||||
apt-get install -y --no-install-recommends libcurl4-openssl-dev ca-certificates
|
||||
WORKDIR app
|
||||
COPY --from=builder /app/collab /app
|
||||
COPY --from=builder /app/collab /app/collab
|
||||
COPY --from=builder /app/crates/collab/migrations /app/migrations
|
||||
ENV MIGRATIONS_PATH=/app/migrations
|
||||
ENTRYPOINT ["/app/collab"]
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# syntax = docker/dockerfile:1.2
|
||||
|
||||
FROM rust:1.62-bullseye as builder
|
||||
WORKDIR app
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=./target \
|
||||
cargo install sqlx-cli --root=/app --target-dir=/app/target --version 0.5.7
|
||||
|
||||
FROM debian:bullseye-slim as runtime
|
||||
RUN apt-get update; \
|
||||
apt-get install -y --no-install-recommends libssl1.1
|
||||
WORKDIR app
|
||||
COPY --from=builder /app/bin/sqlx /app
|
||||
COPY ./crates/collab/migrations /app/migrations
|
||||
ENTRYPOINT ["/app/sqlx", "migrate", "run"]
|
||||
4
Procfile
4
Procfile
@@ -1,2 +1,2 @@
|
||||
web: cd ../zed.dev && PORT=3000 npx next dev
|
||||
collab: cd crates/collab && cargo run
|
||||
web: cd ../zed.dev && PORT=3000 npx vercel dev
|
||||
collab: cd crates/collab && cargo run serve
|
||||
|
||||
3
assets/icons/disable_screen_sharing_12.svg
Normal file
3
assets/icons/disable_screen_sharing_12.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 0.666656H1C0.447917 0.666656 0 1.11457 0 1.66666V8.33332C0 8.88541 0.447917 9.33332 1 9.33332H5L4.66667 10.3333H3.16667C2.89167 10.3333 2.66667 10.5583 2.66667 10.8333C2.66667 11.1083 2.89167 11.3333 3.16667 11.3333H8.83333C9.10938 11.3333 9.33333 11.1094 9.33333 10.8333C9.33333 10.5573 9.10938 10.3333 8.83333 10.3333H7.33333L7 9.33332H11C11.5521 9.33332 12 8.88541 12 8.33332V1.66666C12 1.11457 11.5521 0.666656 11 0.666656ZM10.6667 7.99999H1.33333V1.99999H10.6667V7.99999Z" fill="#979DB4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 611 B |
3
assets/icons/enable_screen_sharing_12.svg
Normal file
3
assets/icons/enable_screen_sharing_12.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.53324 9.90014H7.18324L6.88324 9.00014H7.63305L6.10211 7.80014H1.78324V4.41577L0.583236 3.47452V8.10014C0.583217 8.59702 0.986361 9.00014 1.46636 9.00014H5.04949L4.74949 9.90014H3.43324C3.1848 9.90014 2.98324 10.1017 2.98324 10.3501C2.98324 10.5986 3.1848 10.8001 3.43324 10.8001H8.51637C8.7648 10.8001 8.96637 10.5986 8.96637 10.3501C8.96637 10.1017 8.79762 9.90014 8.53324 9.90014ZM11.8276 9.99577L10.5507 8.99489C11.0234 8.96789 11.3999 8.57939 11.3999 8.09996V2.09995C11.3999 1.60308 10.9968 1.19995 10.4999 1.19995H1.5168C1.28617 1.19995 1.07786 1.28939 0.918674 1.43208L0.727799 1.29595C0.645299 1.23145 0.547423 1.19995 0.450673 1.19995C0.316986 1.19995 0.184611 1.2592 0.0961106 1.37226C-0.057452 1.56801 -0.023327 1.85095 0.172236 2.00414L11.2724 10.7041C11.4693 10.8579 11.7519 10.8226 11.9041 10.6276C12.0581 10.4321 12.0224 10.149 11.8274 9.99521L11.8276 9.99577ZM10.1832 7.80014H9.00968L2.11905 2.40014H10.1816L10.1832 7.80014Z" fill="#93A1A1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 11C5 14.3137 7.68629 17 11 17C14.3137 17 17 14.3137 17 11C17 7.68629 14.3137 5 11 5C7.68629 5 5 7.68629 5 11ZM11 3C6.58172 3 3 6.58172 3 11C3 15.4183 6.58172 19 11 19C15.4183 19 19 15.4183 19 11C19 6.58172 15.4183 3 11 3Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.09092 8.09088H14.6364L10.5511 12.4545H12.4546L13.9091 13.9091H7.36365L11.7273 9.54543H9.54547L8.09092 8.09088Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 571 B |
@@ -3,8 +3,12 @@
|
||||
{
|
||||
"bindings": {
|
||||
"up": "menu::SelectPrev",
|
||||
"pageup": "menu::SelectFirst",
|
||||
"shift-pageup": "menu::SelectFirst",
|
||||
"ctrl-p": "menu::SelectPrev",
|
||||
"down": "menu::SelectNext",
|
||||
"pagedown": "menu::SelectLast",
|
||||
"shift-pagedown": "menu::SelectFirst",
|
||||
"ctrl-n": "menu::SelectNext",
|
||||
"cmd-up": "menu::SelectFirst",
|
||||
"cmd-down": "menu::SelectLast",
|
||||
@@ -60,13 +64,18 @@
|
||||
"cmd-z": "editor::Undo",
|
||||
"cmd-shift-z": "editor::Redo",
|
||||
"up": "editor::MoveUp",
|
||||
"pageup": "editor::PageUp",
|
||||
"shift-pageup": "editor::MovePageUp",
|
||||
"down": "editor::MoveDown",
|
||||
"pagedown": "editor::PageDown",
|
||||
"shift-pagedown": "editor::MovePageDown",
|
||||
"left": "editor::MoveLeft",
|
||||
"right": "editor::MoveRight",
|
||||
"ctrl-p": "editor::MoveUp",
|
||||
"ctrl-n": "editor::MoveDown",
|
||||
"ctrl-b": "editor::MoveLeft",
|
||||
"ctrl-f": "editor::MoveRight",
|
||||
"ctrl-l": "editor::CenterScreen",
|
||||
"alt-left": "editor::MoveToPreviousWordStart",
|
||||
"alt-b": "editor::MoveToPreviousWordStart",
|
||||
"alt-right": "editor::MoveToNextWordEnd",
|
||||
@@ -118,8 +127,18 @@
|
||||
"stop_at_soft_wraps": true
|
||||
}
|
||||
],
|
||||
"pageup": "editor::PageUp",
|
||||
"pagedown": "editor::PageDown",
|
||||
"ctrl-v": [
|
||||
"editor::MovePageDown",
|
||||
{
|
||||
"center_cursor": true
|
||||
}
|
||||
],
|
||||
"alt-v": [
|
||||
"editor::MovePageUp",
|
||||
{
|
||||
"center_cursor": true
|
||||
}
|
||||
],
|
||||
"ctrl-cmd-space": "editor::ShowCharacterPalette"
|
||||
}
|
||||
},
|
||||
@@ -376,6 +395,7 @@
|
||||
{
|
||||
"bindings": {
|
||||
"ctrl-alt-cmd-f": "workspace::FollowNextCollaborator",
|
||||
"cmd-shift-c": "collab::ToggleCollaborationMenu",
|
||||
"cmd-alt-i": "zed::DebugElements"
|
||||
}
|
||||
},
|
||||
@@ -395,7 +415,6 @@
|
||||
"context": "Workspace",
|
||||
"bindings": {
|
||||
"shift-escape": "dock::FocusDock",
|
||||
"cmd-shift-c": "contacts_panel::ToggleFocus",
|
||||
"cmd-shift-b": "workspace::ToggleRightSidebar"
|
||||
}
|
||||
},
|
||||
@@ -412,6 +431,12 @@
|
||||
"shift-escape": "dock::HideDock"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Pane",
|
||||
"bindings": {
|
||||
"cmd-escape": "dock::MoveActiveItemToDock"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "ProjectPanel",
|
||||
"bindings": {
|
||||
@@ -451,10 +476,18 @@
|
||||
"terminal::SendKeystroke",
|
||||
"up"
|
||||
],
|
||||
"pageup": [
|
||||
"terminal::SendKeystroke",
|
||||
"pageup"
|
||||
],
|
||||
"down": [
|
||||
"terminal::SendKeystroke",
|
||||
"down"
|
||||
],
|
||||
"pagedown": [
|
||||
"terminal::SendKeystroke",
|
||||
"pagedown"
|
||||
],
|
||||
"escape": [
|
||||
"terminal::SendKeystroke",
|
||||
"escape"
|
||||
|
||||
@@ -9,11 +9,10 @@
|
||||
}
|
||||
],
|
||||
"h": "vim::Left",
|
||||
"backspace": "vim::Left",
|
||||
"backspace": "vim::Backspace",
|
||||
"j": "vim::Down",
|
||||
"k": "vim::Up",
|
||||
"l": "vim::Right",
|
||||
"0": "vim::StartOfLine",
|
||||
"$": "vim::EndOfLine",
|
||||
"shift-g": "vim::EndOfDocument",
|
||||
"w": "vim::NextWordStart",
|
||||
@@ -38,7 +37,60 @@
|
||||
}
|
||||
],
|
||||
"%": "vim::Matching",
|
||||
"escape": "editor::Cancel"
|
||||
"escape": "editor::Cancel",
|
||||
"i": [
|
||||
"vim::PushOperator",
|
||||
{
|
||||
"Object": {
|
||||
"around": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"a": [
|
||||
"vim::PushOperator",
|
||||
{
|
||||
"Object": {
|
||||
"around": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"0": "vim::StartOfLine", // When no number operator present, use start of line motion
|
||||
"1": [
|
||||
"vim::Number",
|
||||
1
|
||||
],
|
||||
"2": [
|
||||
"vim::Number",
|
||||
2
|
||||
],
|
||||
"3": [
|
||||
"vim::Number",
|
||||
3
|
||||
],
|
||||
"4": [
|
||||
"vim::Number",
|
||||
4
|
||||
],
|
||||
"5": [
|
||||
"vim::Number",
|
||||
5
|
||||
],
|
||||
"6": [
|
||||
"vim::Number",
|
||||
6
|
||||
],
|
||||
"7": [
|
||||
"vim::Number",
|
||||
7
|
||||
],
|
||||
"8": [
|
||||
"vim::Number",
|
||||
8
|
||||
],
|
||||
"9": [
|
||||
"vim::Number",
|
||||
9
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -98,6 +150,15 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Editor && vim_operator == n",
|
||||
"bindings": {
|
||||
"0": [
|
||||
"vim::Number",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Editor && vim_operator == g",
|
||||
"bindings": {
|
||||
@@ -112,13 +173,6 @@
|
||||
{
|
||||
"context": "Editor && vim_operator == c",
|
||||
"bindings": {
|
||||
"w": "vim::ChangeWord",
|
||||
"shift-w": [
|
||||
"vim::ChangeWord",
|
||||
{
|
||||
"ignorePunctuation": true
|
||||
}
|
||||
],
|
||||
"c": "vim::CurrentLine"
|
||||
}
|
||||
},
|
||||
@@ -134,9 +188,34 @@
|
||||
"y": "vim::CurrentLine"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Editor && VimObject",
|
||||
"bindings": {
|
||||
"w": "vim::Word",
|
||||
"shift-w": [
|
||||
"vim::Word",
|
||||
{
|
||||
"ignorePunctuation": true
|
||||
}
|
||||
],
|
||||
"s": "vim::Sentence",
|
||||
"'": "vim::Quotes",
|
||||
"`": "vim::BackQuotes",
|
||||
"\"": "vim::DoubleQuotes",
|
||||
"(": "vim::Parentheses",
|
||||
")": "vim::Parentheses",
|
||||
"[": "vim::SquareBrackets",
|
||||
"]": "vim::SquareBrackets",
|
||||
"{": "vim::CurlyBrackets",
|
||||
"}": "vim::CurlyBrackets",
|
||||
"<": "vim::AngleBrackets",
|
||||
">": "vim::AngleBrackets"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Editor && vim_mode == visual",
|
||||
"bindings": {
|
||||
"u": "editor::Undo",
|
||||
"c": "vim::VisualChange",
|
||||
"d": "vim::VisualDelete",
|
||||
"x": "vim::VisualDelete",
|
||||
|
||||
@@ -1,206 +1,230 @@
|
||||
{
|
||||
// The name of the Zed theme to use for the UI
|
||||
"theme": "one-dark",
|
||||
// The name of a font to use for rendering text in the editor
|
||||
"buffer_font_family": "Zed Mono",
|
||||
// The default font size for text in the editor
|
||||
"buffer_font_size": 15,
|
||||
// Whether to enable vim modes and key bindings
|
||||
"vim_mode": false,
|
||||
// Whether to show the informational hover box when moving the mouse
|
||||
// over symbols in the editor.
|
||||
"hover_popover_enabled": true,
|
||||
// Whether to pop the completions menu while typing in an editor without
|
||||
// explicitly requesting it.
|
||||
"show_completions_on_input": true,
|
||||
// Whether new projects should start out 'online'. Online projects
|
||||
// appear in the contacts panel under your name, so that your contacts
|
||||
// can see which projects you are working on. Regardless of this
|
||||
// setting, projects keep their last online status when you reopen them.
|
||||
"projects_online_by_default": true,
|
||||
// Whether to use language servers to provide code intelligence.
|
||||
"enable_language_server": true,
|
||||
// When to automatically save edited buffers. This setting can
|
||||
// take four values.
|
||||
//
|
||||
// 1. Never automatically save:
|
||||
// "autosave": "off",
|
||||
// 2. Save when changing focus away from the Zed window:
|
||||
// "autosave": "on_window_change",
|
||||
// 3. Save when changing focus away from a specific buffer:
|
||||
// "autosave": "on_focus_change",
|
||||
// 4. Save when idle for a certain amount of time:
|
||||
// "autosave": { "after_delay": {"milliseconds": 500} },
|
||||
"autosave": "off",
|
||||
// Where to place the dock by default. This setting can take three
|
||||
// values:
|
||||
//
|
||||
// 1. Position the dock attached to the bottom of the workspace
|
||||
// "default_dock_anchor": "bottom"
|
||||
// 2. Position the dock to the right of the workspace like a side panel
|
||||
// "default_dock_anchor": "right"
|
||||
// 3. Position the dock full screen over the entire workspace"
|
||||
// "default_dock_anchor": "expanded"
|
||||
"default_dock_anchor": "right",
|
||||
// Whether or not to perform a buffer format before saving
|
||||
"format_on_save": "on",
|
||||
// How to perform a buffer format. This setting can take two values:
|
||||
//
|
||||
// 1. Format code using the current language server:
|
||||
// "format_on_save": "language_server"
|
||||
// 2. Format code using an external command:
|
||||
// "format_on_save": {
|
||||
// "external": {
|
||||
// "command": "prettier",
|
||||
// "arguments": ["--stdin-filepath", "{buffer_path}"]
|
||||
// }
|
||||
// The name of the Zed theme to use for the UI
|
||||
"theme": "One Dark",
|
||||
// The name of a font to use for rendering text in the editor
|
||||
"buffer_font_family": "Zed Mono",
|
||||
// The default font size for text in the editor
|
||||
"buffer_font_size": 15,
|
||||
// Whether to enable vim modes and key bindings
|
||||
"vim_mode": false,
|
||||
// Whether to show the informational hover box when moving the mouse
|
||||
// over symbols in the editor.
|
||||
"hover_popover_enabled": true,
|
||||
// Whether the cursor blinks in the editor.
|
||||
"cursor_blink": true,
|
||||
// Whether to pop the completions menu while typing in an editor without
|
||||
// explicitly requesting it.
|
||||
"show_completions_on_input": true,
|
||||
// Whether new projects should start out 'online'. Online projects
|
||||
// appear in the contacts panel under your name, so that your contacts
|
||||
// can see which projects you are working on. Regardless of this
|
||||
// setting, projects keep their last online status when you reopen them.
|
||||
"projects_online_by_default": true,
|
||||
// Whether to use language servers to provide code intelligence.
|
||||
"enable_language_server": true,
|
||||
// When to automatically save edited buffers. This setting can
|
||||
// take four values.
|
||||
//
|
||||
// 1. Never automatically save:
|
||||
// "autosave": "off",
|
||||
// 2. Save when changing focus away from the Zed window:
|
||||
// "autosave": "on_window_change",
|
||||
// 3. Save when changing focus away from a specific buffer:
|
||||
// "autosave": "on_focus_change",
|
||||
// 4. Save when idle for a certain amount of time:
|
||||
// "autosave": { "after_delay": {"milliseconds": 500} },
|
||||
"autosave": "off",
|
||||
// Where to place the dock by default. This setting can take three
|
||||
// values:
|
||||
//
|
||||
// 1. Position the dock attached to the bottom of the workspace
|
||||
// "default_dock_anchor": "bottom"
|
||||
// 2. Position the dock to the right of the workspace like a side panel
|
||||
// "default_dock_anchor": "right"
|
||||
// 3. Position the dock full screen over the entire workspace"
|
||||
// "default_dock_anchor": "expanded"
|
||||
"default_dock_anchor": "right",
|
||||
// Whether or not to perform a buffer format before saving
|
||||
"format_on_save": "on",
|
||||
// How to perform a buffer format. This setting can take two values:
|
||||
//
|
||||
// 1. Format code using the current language server:
|
||||
// "format_on_save": "language_server"
|
||||
// 2. Format code using an external command:
|
||||
// "format_on_save": {
|
||||
// "external": {
|
||||
// "command": "prettier",
|
||||
// "arguments": ["--stdin-filepath", "{buffer_path}"]
|
||||
// }
|
||||
// }
|
||||
"formatter": "language_server",
|
||||
// How to soft-wrap long lines of text. This setting can take
|
||||
// three values:
|
||||
//
|
||||
// 1. Do not soft wrap.
|
||||
// "soft_wrap": "none",
|
||||
// 2. Soft wrap lines that overflow the editor:
|
||||
// "soft_wrap": "editor_width",
|
||||
// 3. Soft wrap lines at the preferred line length
|
||||
// "soft_wrap": "preferred_line_length",
|
||||
"soft_wrap": "none",
|
||||
// The column at which to soft-wrap lines, for buffers where soft-wrap
|
||||
// is enabled.
|
||||
"preferred_line_length": 80,
|
||||
// Whether to indent lines using tab characters, as opposed to multiple
|
||||
// spaces.
|
||||
"hard_tabs": false,
|
||||
// How many columns a tab should occupy.
|
||||
"tab_size": 4,
|
||||
// Git gutter behavior configuration.
|
||||
"git": {
|
||||
// Control whether the git gutter is shown. May take 2 values:
|
||||
// 1. Show the gutter
|
||||
// "git_gutter": "tracked_files"
|
||||
// 2. Hide the gutter
|
||||
// "git_gutter": "hide"
|
||||
"git_gutter": "tracked_files"
|
||||
},
|
||||
// Settings specific to journaling
|
||||
"journal": {
|
||||
// The path of the directory where journal entries are stored
|
||||
"path": "~",
|
||||
// What format to display the hours in
|
||||
// May take 2 values:
|
||||
// 1. hour12
|
||||
// 2. hour24
|
||||
"hour_format": "hour12"
|
||||
},
|
||||
// Settings specific to the terminal
|
||||
"terminal": {
|
||||
// What shell to use when opening a terminal. May take 3 values:
|
||||
// 1. Use the system's default terminal configuration (e.g. $TERM).
|
||||
// "shell": "system"
|
||||
// 2. A program:
|
||||
// "shell": {
|
||||
// "program": "sh"
|
||||
// }
|
||||
// 3. A program with arguments:
|
||||
// "shell": {
|
||||
// "with_arguments": {
|
||||
// "program": "/bin/bash",
|
||||
// "arguments": ["--login"]
|
||||
// }
|
||||
// }
|
||||
"formatter": "language_server",
|
||||
// How to soft-wrap long lines of text. This setting can take
|
||||
// three values:
|
||||
"shell": "system",
|
||||
// What working directory to use when launching the terminal.
|
||||
// May take 4 values:
|
||||
// 1. Use the current file's project directory. Will Fallback to the
|
||||
// first project directory strategy if unsuccessful
|
||||
// "working_directory": "current_project_directory"
|
||||
// 2. Use the first project in this workspace's directory
|
||||
// "working_directory": "first_project_directory"
|
||||
// 3. Always use this platform's home directory (if we can find it)
|
||||
// "working_directory": "always_home"
|
||||
// 4. Always use a specific directory. This value will be shell expanded.
|
||||
// If this path is not a valid directory the terminal will default to
|
||||
// this platform's home directory (if we can find it)
|
||||
// "working_directory": {
|
||||
// "always": {
|
||||
// "directory": "~/zed/projects/"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// 1. Do not soft wrap.
|
||||
// "soft_wrap": "none",
|
||||
// 2. Soft wrap lines that overflow the editor:
|
||||
// "soft_wrap": "editor_width",
|
||||
// 3. Soft wrap lines at the preferred line length
|
||||
// "soft_wrap": "preferred_line_length",
|
||||
"soft_wrap": "none",
|
||||
// The column at which to soft-wrap lines, for buffers where soft-wrap
|
||||
// is enabled.
|
||||
"preferred_line_length": 80,
|
||||
// Whether to indent lines using tab characters, as opposed to multiple
|
||||
// spaces.
|
||||
"hard_tabs": false,
|
||||
// How many columns a tab should occupy.
|
||||
"tab_size": 4,
|
||||
// Settings specific to the terminal
|
||||
"terminal": {
|
||||
// What shell to use when opening a terminal. May take 3 values:
|
||||
// 1. Use the system's default terminal configuration (e.g. $TERM).
|
||||
// "shell": "system"
|
||||
// 2. A program:
|
||||
// "shell": {
|
||||
// "program": "sh"
|
||||
// }
|
||||
// 3. A program with arguments:
|
||||
// "shell": {
|
||||
// "with_arguments": {
|
||||
// "program": "/bin/bash",
|
||||
// "arguments": ["--login"]
|
||||
// }
|
||||
// }
|
||||
"shell": "system",
|
||||
// What working directory to use when launching the terminal.
|
||||
// May take 4 values:
|
||||
// 1. Use the current file's project directory. Will Fallback to the
|
||||
// first project directory strategy if unsuccessful
|
||||
// "working_directory": "current_project_directory"
|
||||
// 2. Use the first project in this workspace's directory
|
||||
// "working_directory": "first_project_directory"
|
||||
// 3. Always use this platform's home directory (if we can find it)
|
||||
// "working_directory": "always_home"
|
||||
// 4. Always use a specific directory. This value will be shell expanded.
|
||||
// If this path is not a valid directory the terminal will default to
|
||||
// this platform's home directory (if we can find it)
|
||||
// "working_directory": {
|
||||
// "always": {
|
||||
// "directory": "~/zed/projects/"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//
|
||||
"working_directory": "current_project_directory",
|
||||
// Set the cursor blinking behavior in the terminal.
|
||||
// May take 4 values:
|
||||
// 1. Never blink the cursor, ignoring the terminal mode
|
||||
// "blinking": "off",
|
||||
// 2. Default the cursor blink to off, but allow the terminal to
|
||||
// set blinking
|
||||
// "blinking": "terminal_controlled",
|
||||
// 3. Always blink the cursor, ignoring the terminal mode
|
||||
// "blinking": "on",
|
||||
"blinking": "terminal_controlled",
|
||||
// Set whether Alternate Scroll mode (code: ?1007) is active by default.
|
||||
// Alternate Scroll mode converts mouse scroll events into up / down key
|
||||
// presses when in the alternate screen (e.g. when running applications
|
||||
// like vim or less). The terminal can still set and unset this mode.
|
||||
// May take 2 values:
|
||||
// 1. Default alternate scroll mode to on
|
||||
// "alternate_scroll": "on",
|
||||
// 2. Default alternate scroll mode to off
|
||||
// "alternate_scroll": "off",
|
||||
"alternate_scroll": "off",
|
||||
// Set whether the option key behaves as the meta key.
|
||||
// May take 2 values:
|
||||
// 1. Rely on default platform handling of option key, on macOS
|
||||
// this means generating certain unicode characters
|
||||
// "option_to_meta": false,
|
||||
// 2. Make the option keys behave as a 'meta' key, e.g. for emacs
|
||||
// "option_to_meta": true,
|
||||
"option_as_meta": false,
|
||||
// Any key-value pairs added to this list will be added to the terminal's
|
||||
// enviroment. Use `:` to seperate multiple values.
|
||||
"env": {
|
||||
// "KEY": "value1:value2"
|
||||
}
|
||||
// Set the terminal's font size. If this option is not included,
|
||||
// the terminal will default to matching the buffer's font size.
|
||||
// "font_size": "15"
|
||||
// Set the terminal's font family. If this option is not included,
|
||||
// the terminal will default to matching the buffer's font family.
|
||||
// "font_family": "Zed Mono"
|
||||
},
|
||||
// Different settings for specific languages.
|
||||
"languages": {
|
||||
"Plain Text": {
|
||||
"soft_wrap": "preferred_line_length"
|
||||
},
|
||||
"C": {
|
||||
"tab_size": 2
|
||||
},
|
||||
"C++": {
|
||||
"tab_size": 2
|
||||
},
|
||||
"Elixir": {
|
||||
"tab_size": 2
|
||||
},
|
||||
"Go": {
|
||||
"tab_size": 4,
|
||||
"hard_tabs": true
|
||||
},
|
||||
"Markdown": {
|
||||
"soft_wrap": "preferred_line_length"
|
||||
},
|
||||
"Rust": {
|
||||
"tab_size": 4
|
||||
},
|
||||
"JavaScript": {
|
||||
"tab_size": 2
|
||||
},
|
||||
"TypeScript": {
|
||||
"tab_size": 2
|
||||
},
|
||||
"TSX": {
|
||||
"tab_size": 2
|
||||
}
|
||||
},
|
||||
// LSP Specific settings.
|
||||
"lsp": {
|
||||
// Specify the LSP name as a key here.
|
||||
// As of 8/10/22, supported LSPs are:
|
||||
// pyright
|
||||
// gopls
|
||||
// rust-analyzer
|
||||
// typescript-language-server
|
||||
// vscode-json-languageserver
|
||||
// "rust_analyzer": {
|
||||
// //These initialization options are merged into Zed's defaults
|
||||
// "initialization_options": {
|
||||
// "checkOnSave": {
|
||||
// "command": "clippy"
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
"working_directory": "current_project_directory",
|
||||
// Set the cursor blinking behavior in the terminal.
|
||||
// May take 4 values:
|
||||
// 1. Never blink the cursor, ignoring the terminal mode
|
||||
// "blinking": "off",
|
||||
// 2. Default the cursor blink to off, but allow the terminal to
|
||||
// set blinking
|
||||
// "blinking": "terminal_controlled",
|
||||
// 3. Always blink the cursor, ignoring the terminal mode
|
||||
// "blinking": "on",
|
||||
"blinking": "terminal_controlled",
|
||||
// Set whether Alternate Scroll mode (code: ?1007) is active by default.
|
||||
// Alternate Scroll mode converts mouse scroll events into up / down key
|
||||
// presses when in the alternate screen (e.g. when running applications
|
||||
// like vim or less). The terminal can still set and unset this mode.
|
||||
// May take 2 values:
|
||||
// 1. Default alternate scroll mode to on
|
||||
// "alternate_scroll": "on",
|
||||
// 2. Default alternate scroll mode to off
|
||||
// "alternate_scroll": "off",
|
||||
"alternate_scroll": "off",
|
||||
// Set whether the option key behaves as the meta key.
|
||||
// May take 2 values:
|
||||
// 1. Rely on default platform handling of option key, on macOS
|
||||
// this means generating certain unicode characters
|
||||
// "option_to_meta": false,
|
||||
// 2. Make the option keys behave as a 'meta' key, e.g. for emacs
|
||||
// "option_to_meta": true,
|
||||
"option_as_meta": false,
|
||||
// Whether or not selecting text in the terminal will automatically
|
||||
// copy to the system clipboard.
|
||||
"copy_on_select": false,
|
||||
// Any key-value pairs added to this list will be added to the terminal's
|
||||
// enviroment. Use `:` to seperate multiple values.
|
||||
"env": {
|
||||
// "KEY": "value1:value2"
|
||||
}
|
||||
// Set the terminal's font size. If this option is not included,
|
||||
// the terminal will default to matching the buffer's font size.
|
||||
// "font_size": "15"
|
||||
// Set the terminal's font family. If this option is not included,
|
||||
// the terminal will default to matching the buffer's font family.
|
||||
// "font_family": "Zed Mono"
|
||||
},
|
||||
// Different settings for specific languages.
|
||||
"languages": {
|
||||
"Plain Text": {
|
||||
"soft_wrap": "preferred_line_length"
|
||||
},
|
||||
"C": {
|
||||
"tab_size": 2
|
||||
},
|
||||
"C++": {
|
||||
"tab_size": 2
|
||||
},
|
||||
"Elixir": {
|
||||
"tab_size": 2
|
||||
},
|
||||
"Go": {
|
||||
"tab_size": 4,
|
||||
"hard_tabs": true
|
||||
},
|
||||
"Markdown": {
|
||||
"soft_wrap": "preferred_line_length"
|
||||
},
|
||||
"Rust": {
|
||||
"tab_size": 4
|
||||
},
|
||||
"JavaScript": {
|
||||
"tab_size": 2
|
||||
},
|
||||
"TypeScript": {
|
||||
"tab_size": 2
|
||||
},
|
||||
"TSX": {
|
||||
"tab_size": 2
|
||||
}
|
||||
},
|
||||
// LSP Specific settings.
|
||||
"lsp": {
|
||||
// Specify the LSP name as a key here.
|
||||
// As of 8/10/22, supported LSPs are:
|
||||
// pyright
|
||||
// gopls
|
||||
// rust-analyzer
|
||||
// typescript-language-server
|
||||
// vscode-json-languageserver
|
||||
// "rust_analyzer": {
|
||||
// //These initialization options are merged into Zed's defaults
|
||||
// "initialization_options": {
|
||||
// "checkOnSave": {
|
||||
// "command": "clippy"
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ impl ActivityIndicator {
|
||||
cx: &mut ViewContext<Workspace>,
|
||||
) -> ViewHandle<ActivityIndicator> {
|
||||
let project = workspace.project().clone();
|
||||
let auto_updater = AutoUpdater::get(cx);
|
||||
let this = cx.add_view(|cx: &mut ViewContext<Self>| {
|
||||
let mut status_events = languages.language_server_binary_statuses();
|
||||
cx.spawn_weak(|this, mut cx| async move {
|
||||
@@ -66,11 +67,14 @@ impl ActivityIndicator {
|
||||
})
|
||||
.detach();
|
||||
cx.observe(&project, |_, _, cx| cx.notify()).detach();
|
||||
if let Some(auto_updater) = auto_updater.as_ref() {
|
||||
cx.observe(auto_updater, |_, _, cx| cx.notify()).detach();
|
||||
}
|
||||
|
||||
Self {
|
||||
statuses: Default::default(),
|
||||
project: project.clone(),
|
||||
auto_updater: AutoUpdater::get(cx),
|
||||
auto_updater,
|
||||
}
|
||||
});
|
||||
cx.subscribe(&this, move |workspace, _, event, cx| match event {
|
||||
@@ -285,7 +289,7 @@ impl View for ActivityIndicator {
|
||||
.workspace
|
||||
.status_bar
|
||||
.lsp_status;
|
||||
let style = if state.hovered && action.is_some() {
|
||||
let style = if state.hovered() && action.is_some() {
|
||||
theme.hover.as_ref().unwrap_or(&theme.default)
|
||||
} else {
|
||||
&theme.default
|
||||
|
||||
29
crates/assets/build.rs
Normal file
29
crates/assets/build.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let output = Command::new("npm")
|
||||
.current_dir("../../styles")
|
||||
.args(["install", "--no-save"])
|
||||
.output()
|
||||
.expect("failed to run npm");
|
||||
if !output.status.success() {
|
||||
panic!(
|
||||
"failed to install theme dependencies {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let output = Command::new("npm")
|
||||
.current_dir("../../styles")
|
||||
.args(["run", "build"])
|
||||
.output()
|
||||
.expect("failed to run npm");
|
||||
if !output.status.success() {
|
||||
panic!(
|
||||
"build script failed {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
println!("cargo:rerun-if-changed=../../styles/src");
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
mod update_notification;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use client::{http::HttpClient, ZED_SECRET_CLIENT_TOKEN};
|
||||
use client::{http::HttpClient, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL};
|
||||
use gpui::{
|
||||
actions, platform::AppVersion, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
|
||||
MutableAppContext, Task, WeakViewHandle,
|
||||
};
|
||||
use lazy_static::lazy_static;
|
||||
use serde::Deserialize;
|
||||
use settings::ReleaseChannel;
|
||||
use smol::{fs::File, io::AsyncReadExt, process::Command};
|
||||
use std::{env, ffi::OsString, path::PathBuf, sync::Arc, time::Duration};
|
||||
use update_notification::UpdateNotification;
|
||||
@@ -40,7 +41,7 @@ pub struct AutoUpdater {
|
||||
current_version: AppVersion,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
pending_poll: Option<Task<()>>,
|
||||
db: Arc<project::Db>,
|
||||
db: project::Db,
|
||||
server_url: String,
|
||||
}
|
||||
|
||||
@@ -54,13 +55,9 @@ impl Entity for AutoUpdater {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
pub fn init(
|
||||
db: Arc<project::Db>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
server_url: String,
|
||||
cx: &mut MutableAppContext,
|
||||
) {
|
||||
pub fn init(db: project::Db, http_client: Arc<dyn HttpClient>, cx: &mut MutableAppContext) {
|
||||
if let Some(version) = (*ZED_APP_VERSION).or_else(|| cx.platform().app_version().ok()) {
|
||||
let server_url = ZED_SERVER_URL.to_string();
|
||||
let auto_updater = cx.add_model(|cx| {
|
||||
let updater = AutoUpdater::new(version, db.clone(), http_client, server_url.clone());
|
||||
updater.start_polling(cx).detach();
|
||||
@@ -116,7 +113,7 @@ impl AutoUpdater {
|
||||
|
||||
fn new(
|
||||
current_version: AppVersion,
|
||||
db: Arc<project::Db>,
|
||||
db: project::Db,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
server_url: String,
|
||||
) -> Self {
|
||||
@@ -177,9 +174,19 @@ impl AutoUpdater {
|
||||
this.current_version,
|
||||
)
|
||||
});
|
||||
|
||||
let preview_param = cx.read(|cx| {
|
||||
if cx.has_global::<ReleaseChannel>() {
|
||||
if *cx.global::<ReleaseChannel>() == ReleaseChannel::Preview {
|
||||
return "&preview=1";
|
||||
}
|
||||
}
|
||||
""
|
||||
});
|
||||
|
||||
let mut response = client
|
||||
.get(
|
||||
&format!("{server_url}/api/releases/latest?token={ZED_SECRET_CLIENT_TOKEN}&asset=Zed.dmg"),
|
||||
&format!("{server_url}/api/releases/latest?token={ZED_SECRET_CLIENT_TOKEN}&asset=Zed.dmg{preview_param}"),
|
||||
Default::default(),
|
||||
true,
|
||||
)
|
||||
@@ -211,11 +218,14 @@ impl AutoUpdater {
|
||||
let temp_dir = tempdir::TempDir::new("zed-auto-update")?;
|
||||
let dmg_path = temp_dir.path().join("Zed.dmg");
|
||||
let mount_path = temp_dir.path().join("Zed");
|
||||
let mut mounted_app_path: OsString = mount_path.join("Zed.app").into();
|
||||
mounted_app_path.push("/");
|
||||
let running_app_path = ZED_APP_PATH
|
||||
.clone()
|
||||
.map_or_else(|| cx.platform().app_path(), Ok)?;
|
||||
let running_app_filename = running_app_path
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow!("invalid running app path"))?;
|
||||
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
|
||||
mounted_app_path.push("/");
|
||||
|
||||
let mut dmg_file = File::create(&dmg_path).await?;
|
||||
let mut response = client.get(&release.url, Default::default(), true).await?;
|
||||
@@ -283,9 +293,9 @@ impl AutoUpdater {
|
||||
let db = self.db.clone();
|
||||
cx.background().spawn(async move {
|
||||
if should_show {
|
||||
db.write([(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY, "")])?;
|
||||
db.write_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY, "")?;
|
||||
} else {
|
||||
db.delete([(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)])?;
|
||||
db.delete_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -293,8 +303,7 @@ impl AutoUpdater {
|
||||
|
||||
fn should_show_update_notification(&self, cx: &AppContext) -> Task<Result<bool>> {
|
||||
let db = self.db.clone();
|
||||
cx.background().spawn(async move {
|
||||
Ok(db.read([(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)])?[0].is_some())
|
||||
})
|
||||
cx.background()
|
||||
.spawn(async move { Ok(db.read_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)?.is_some()) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use gpui::{
|
||||
Element, Entity, MouseButton, View, ViewContext,
|
||||
};
|
||||
use menu::Cancel;
|
||||
use settings::Settings;
|
||||
use settings::{ReleaseChannel, Settings};
|
||||
use workspace::Notification;
|
||||
|
||||
pub struct UpdateNotification {
|
||||
@@ -29,13 +29,15 @@ impl View for UpdateNotification {
|
||||
let theme = cx.global::<Settings>().theme.clone();
|
||||
let theme = &theme.update_notification;
|
||||
|
||||
let app_name = cx.global::<ReleaseChannel>().name();
|
||||
|
||||
MouseEventHandler::<ViewReleaseNotes>::new(0, cx, |state, cx| {
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Text::new(
|
||||
format!("Updated to Zed {}", self.version),
|
||||
format!("Updated to {app_name} {}", self.version),
|
||||
theme.message.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
|
||||
40
crates/call/Cargo.toml
Normal file
40
crates/call/Cargo.toml
Normal file
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "call"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/call.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = [
|
||||
"client/test-support",
|
||||
"collections/test-support",
|
||||
"gpui/test-support",
|
||||
"live_kit_client/test-support",
|
||||
"project/test-support",
|
||||
"util/test-support"
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
client = { path = "../client" }
|
||||
collections = { path = "../collections" }
|
||||
gpui = { path = "../gpui" }
|
||||
live_kit_client = { path = "../live_kit_client" }
|
||||
media = { path = "../media" }
|
||||
project = { path = "../project" }
|
||||
util = { path = "../util" }
|
||||
|
||||
anyhow = "1.0.38"
|
||||
async-broadcast = "0.4"
|
||||
futures = "0.3"
|
||||
postage = { version = "0.4.1", features = ["futures-traits"] }
|
||||
|
||||
[dev-dependencies]
|
||||
client = { path = "../client", features = ["test-support"] }
|
||||
collections = { path = "../collections", features = ["test-support"] }
|
||||
gpui = { path = "../gpui", features = ["test-support"] }
|
||||
live_kit_client = { path = "../live_kit_client", features = ["test-support"] }
|
||||
project = { path = "../project", features = ["test-support"] }
|
||||
util = { path = "../util", features = ["test-support"] }
|
||||
300
crates/call/src/call.rs
Normal file
300
crates/call/src/call.rs
Normal file
@@ -0,0 +1,300 @@
|
||||
pub mod participant;
|
||||
pub mod room;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use client::{proto, Client, TypedEnvelope, User, UserStore};
|
||||
use collections::HashSet;
|
||||
use gpui::{
|
||||
AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext,
|
||||
Subscription, Task, WeakModelHandle,
|
||||
};
|
||||
pub use participant::ParticipantLocation;
|
||||
use postage::watch;
|
||||
use project::Project;
|
||||
pub use room::Room;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn init(client: Arc<Client>, user_store: ModelHandle<UserStore>, cx: &mut MutableAppContext) {
|
||||
let active_call = cx.add_model(|cx| ActiveCall::new(client, user_store, cx));
|
||||
cx.set_global(active_call);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IncomingCall {
|
||||
pub room_id: u64,
|
||||
pub caller: Arc<User>,
|
||||
pub participants: Vec<Arc<User>>,
|
||||
pub initial_project: Option<proto::ParticipantProject>,
|
||||
}
|
||||
|
||||
pub struct ActiveCall {
|
||||
room: Option<(ModelHandle<Room>, Vec<Subscription>)>,
|
||||
location: Option<WeakModelHandle<Project>>,
|
||||
pending_invites: HashSet<u64>,
|
||||
incoming_call: (
|
||||
watch::Sender<Option<IncomingCall>>,
|
||||
watch::Receiver<Option<IncomingCall>>,
|
||||
),
|
||||
client: Arc<Client>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
_subscriptions: Vec<client::Subscription>,
|
||||
}
|
||||
|
||||
impl Entity for ActiveCall {
|
||||
type Event = room::Event;
|
||||
}
|
||||
|
||||
impl ActiveCall {
|
||||
fn new(
|
||||
client: Arc<Client>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
room: None,
|
||||
location: None,
|
||||
pending_invites: Default::default(),
|
||||
incoming_call: watch::channel(),
|
||||
_subscriptions: vec![
|
||||
client.add_request_handler(cx.handle(), Self::handle_incoming_call),
|
||||
client.add_message_handler(cx.handle(), Self::handle_call_canceled),
|
||||
],
|
||||
client,
|
||||
user_store,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_incoming_call(
|
||||
this: ModelHandle<Self>,
|
||||
envelope: TypedEnvelope<proto::IncomingCall>,
|
||||
_: Arc<Client>,
|
||||
mut cx: AsyncAppContext,
|
||||
) -> Result<proto::Ack> {
|
||||
let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
|
||||
let call = IncomingCall {
|
||||
room_id: envelope.payload.room_id,
|
||||
participants: user_store
|
||||
.update(&mut cx, |user_store, cx| {
|
||||
user_store.get_users(envelope.payload.participant_user_ids, cx)
|
||||
})
|
||||
.await?,
|
||||
caller: user_store
|
||||
.update(&mut cx, |user_store, cx| {
|
||||
user_store.get_user(envelope.payload.caller_user_id, cx)
|
||||
})
|
||||
.await?,
|
||||
initial_project: envelope.payload.initial_project,
|
||||
};
|
||||
this.update(&mut cx, |this, _| {
|
||||
*this.incoming_call.0.borrow_mut() = Some(call);
|
||||
});
|
||||
|
||||
Ok(proto::Ack {})
|
||||
}
|
||||
|
||||
async fn handle_call_canceled(
|
||||
this: ModelHandle<Self>,
|
||||
_: TypedEnvelope<proto::CallCanceled>,
|
||||
_: Arc<Client>,
|
||||
mut cx: AsyncAppContext,
|
||||
) -> Result<()> {
|
||||
this.update(&mut cx, |this, _| {
|
||||
*this.incoming_call.0.borrow_mut() = None;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn global(cx: &AppContext) -> ModelHandle<Self> {
|
||||
cx.global::<ModelHandle<Self>>().clone()
|
||||
}
|
||||
|
||||
pub fn invite(
|
||||
&mut self,
|
||||
recipient_user_id: u64,
|
||||
initial_project: Option<ModelHandle<Project>>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let client = self.client.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
if !self.pending_invites.insert(recipient_user_id) {
|
||||
return Task::ready(Err(anyhow!("user was already invited")));
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let invite = async {
|
||||
if let Some(room) = this.read_with(&cx, |this, _| this.room().cloned()) {
|
||||
let initial_project_id = if let Some(initial_project) = initial_project {
|
||||
Some(
|
||||
room.update(&mut cx, |room, cx| {
|
||||
room.share_project(initial_project, cx)
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
room.update(&mut cx, |room, cx| {
|
||||
room.call(recipient_user_id, initial_project_id, cx)
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
let room = cx
|
||||
.update(|cx| {
|
||||
Room::create(recipient_user_id, initial_project, client, user_store, cx)
|
||||
})
|
||||
.await?;
|
||||
|
||||
this.update(&mut cx, |this, cx| this.set_room(Some(room), cx))
|
||||
.await?;
|
||||
};
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let result = invite.await;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.pending_invites.remove(&recipient_user_id);
|
||||
cx.notify();
|
||||
});
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cancel_invite(
|
||||
&mut self,
|
||||
recipient_user_id: u64,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let room_id = if let Some(room) = self.room() {
|
||||
room.read(cx).id()
|
||||
} else {
|
||||
return Task::ready(Err(anyhow!("no active call")));
|
||||
};
|
||||
|
||||
let client = self.client.clone();
|
||||
cx.foreground().spawn(async move {
|
||||
client
|
||||
.request(proto::CancelCall {
|
||||
room_id,
|
||||
recipient_user_id,
|
||||
})
|
||||
.await?;
|
||||
anyhow::Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn incoming(&self) -> watch::Receiver<Option<IncomingCall>> {
|
||||
self.incoming_call.1.clone()
|
||||
}
|
||||
|
||||
pub fn accept_incoming(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
|
||||
if self.room.is_some() {
|
||||
return Task::ready(Err(anyhow!("cannot join while on another call")));
|
||||
}
|
||||
|
||||
let call = if let Some(call) = self.incoming_call.1.borrow().clone() {
|
||||
call
|
||||
} else {
|
||||
return Task::ready(Err(anyhow!("no incoming call")));
|
||||
};
|
||||
|
||||
let join = Room::join(&call, self.client.clone(), self.user_store.clone(), cx);
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let room = join.await?;
|
||||
this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decline_incoming(&mut self) -> Result<()> {
|
||||
let call = self
|
||||
.incoming_call
|
||||
.0
|
||||
.borrow_mut()
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("no incoming call"))?;
|
||||
self.client.send(proto::DeclineCall {
|
||||
room_id: call.room_id,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn hang_up(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
|
||||
if let Some((room, _)) = self.room.take() {
|
||||
room.update(cx, |room, cx| room.leave(cx))?;
|
||||
cx.notify();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn share_project(
|
||||
&mut self,
|
||||
project: ModelHandle<Project>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<u64>> {
|
||||
if let Some((room, _)) = self.room.as_ref() {
|
||||
room.update(cx, |room, cx| room.share_project(project, cx))
|
||||
} else {
|
||||
Task::ready(Err(anyhow!("no active call")))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_location(
|
||||
&mut self,
|
||||
project: Option<&ModelHandle<Project>>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.location = project.map(|project| project.downgrade());
|
||||
if let Some((room, _)) = self.room.as_ref() {
|
||||
room.update(cx, |room, cx| room.set_location(project, cx))
|
||||
} else {
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
fn set_room(
|
||||
&mut self,
|
||||
room: Option<ModelHandle<Room>>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
if room.as_ref() != self.room.as_ref().map(|room| &room.0) {
|
||||
cx.notify();
|
||||
if let Some(room) = room {
|
||||
if room.read(cx).status().is_offline() {
|
||||
self.room = None;
|
||||
Task::ready(Ok(()))
|
||||
} else {
|
||||
let subscriptions = vec![
|
||||
cx.observe(&room, |this, room, cx| {
|
||||
if room.read(cx).status().is_offline() {
|
||||
this.set_room(None, cx).detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}),
|
||||
cx.subscribe(&room, |_, _, event, cx| cx.emit(event.clone())),
|
||||
];
|
||||
self.room = Some((room.clone(), subscriptions));
|
||||
let location = self.location.and_then(|location| location.upgrade(cx));
|
||||
room.update(cx, |room, cx| room.set_location(location.as_ref(), cx))
|
||||
}
|
||||
} else {
|
||||
self.room = None;
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
} else {
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn room(&self) -> Option<&ModelHandle<Room>> {
|
||||
self.room.as_ref().map(|(room, _)| room)
|
||||
}
|
||||
|
||||
pub fn pending_invites(&self) -> &HashSet<u64> {
|
||||
&self.pending_invites
|
||||
}
|
||||
}
|
||||
56
crates/call/src/participant.rs
Normal file
56
crates/call/src/participant.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use client::{proto, User};
|
||||
use collections::HashMap;
|
||||
use gpui::WeakModelHandle;
|
||||
pub use live_kit_client::Frame;
|
||||
use project::Project;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ParticipantLocation {
|
||||
SharedProject { project_id: u64 },
|
||||
UnsharedProject,
|
||||
External,
|
||||
}
|
||||
|
||||
impl ParticipantLocation {
|
||||
pub fn from_proto(location: Option<proto::ParticipantLocation>) -> Result<Self> {
|
||||
match location.and_then(|l| l.variant) {
|
||||
Some(proto::participant_location::Variant::SharedProject(project)) => {
|
||||
Ok(Self::SharedProject {
|
||||
project_id: project.id,
|
||||
})
|
||||
}
|
||||
Some(proto::participant_location::Variant::UnsharedProject(_)) => {
|
||||
Ok(Self::UnsharedProject)
|
||||
}
|
||||
Some(proto::participant_location::Variant::External(_)) => Ok(Self::External),
|
||||
None => Err(anyhow!("participant location was not provided")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LocalParticipant {
|
||||
pub projects: Vec<proto::ParticipantProject>,
|
||||
pub active_project: Option<WeakModelHandle<Project>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteParticipant {
|
||||
pub user: Arc<User>,
|
||||
pub projects: Vec<proto::ParticipantProject>,
|
||||
pub location: ParticipantLocation,
|
||||
pub tracks: HashMap<live_kit_client::Sid, Arc<RemoteVideoTrack>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteVideoTrack {
|
||||
pub(crate) live_kit_track: Arc<live_kit_client::RemoteVideoTrack>,
|
||||
}
|
||||
|
||||
impl RemoteVideoTrack {
|
||||
pub fn frames(&self) -> async_broadcast::Receiver<Frame> {
|
||||
self.live_kit_track.frames()
|
||||
}
|
||||
}
|
||||
760
crates/call/src/room.rs
Normal file
760
crates/call/src/room.rs
Normal file
@@ -0,0 +1,760 @@
|
||||
use crate::{
|
||||
participant::{LocalParticipant, ParticipantLocation, RemoteParticipant, RemoteVideoTrack},
|
||||
IncomingCall,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore};
|
||||
use collections::{BTreeMap, HashSet};
|
||||
use futures::StreamExt;
|
||||
use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task};
|
||||
use live_kit_client::{LocalTrackPublication, LocalVideoTrack, RemoteVideoTrackUpdate};
|
||||
use postage::stream::Stream;
|
||||
use project::Project;
|
||||
use std::{mem, os::unix::prelude::OsStrExt, sync::Arc};
|
||||
use util::{post_inc, ResultExt};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Event {
|
||||
ParticipantLocationChanged {
|
||||
participant_id: PeerId,
|
||||
},
|
||||
RemoteVideoTracksChanged {
|
||||
participant_id: PeerId,
|
||||
},
|
||||
RemoteProjectShared {
|
||||
owner: Arc<User>,
|
||||
project_id: u64,
|
||||
worktree_root_names: Vec<String>,
|
||||
},
|
||||
RemoteProjectUnshared {
|
||||
project_id: u64,
|
||||
},
|
||||
Left,
|
||||
}
|
||||
|
||||
pub struct Room {
|
||||
id: u64,
|
||||
live_kit: Option<LiveKitRoom>,
|
||||
status: RoomStatus,
|
||||
local_participant: LocalParticipant,
|
||||
remote_participants: BTreeMap<PeerId, RemoteParticipant>,
|
||||
pending_participants: Vec<Arc<User>>,
|
||||
participant_user_ids: HashSet<u64>,
|
||||
pending_call_count: usize,
|
||||
leave_when_empty: bool,
|
||||
client: Arc<Client>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
subscriptions: Vec<client::Subscription>,
|
||||
pending_room_update: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl Entity for Room {
|
||||
type Event = Event;
|
||||
|
||||
fn release(&mut self, _: &mut MutableAppContext) {
|
||||
if self.status.is_online() {
|
||||
self.client.send(proto::LeaveRoom { id: self.id }).log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Room {
|
||||
fn new(
|
||||
id: u64,
|
||||
live_kit_connection_info: Option<proto::LiveKitConnectionInfo>,
|
||||
client: Arc<Client>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let mut client_status = client.status();
|
||||
cx.spawn_weak(|this, mut cx| async move {
|
||||
let is_connected = client_status
|
||||
.next()
|
||||
.await
|
||||
.map_or(false, |s| s.is_connected());
|
||||
// Even if we're initially connected, any future change of the status means we momentarily disconnected.
|
||||
if !is_connected || client_status.next().await.is_some() {
|
||||
if let Some(this) = this.upgrade(&cx) {
|
||||
let _ = this.update(&mut cx, |this, cx| this.leave(cx));
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let live_kit_room = if let Some(connection_info) = live_kit_connection_info {
|
||||
let room = live_kit_client::Room::new();
|
||||
let mut status = room.status();
|
||||
// Consume the initial status of the room.
|
||||
let _ = status.try_recv();
|
||||
let _maintain_room = cx.spawn_weak(|this, mut cx| async move {
|
||||
while let Some(status) = status.next().await {
|
||||
let this = if let Some(this) = this.upgrade(&cx) {
|
||||
this
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
if status == live_kit_client::ConnectionState::Disconnected {
|
||||
this.update(&mut cx, |this, cx| this.leave(cx).log_err());
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut track_changes = room.remote_video_track_updates();
|
||||
let _maintain_tracks = cx.spawn_weak(|this, mut cx| async move {
|
||||
while let Some(track_change) = track_changes.next().await {
|
||||
let this = if let Some(this) = this.upgrade(&cx) {
|
||||
this
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.remote_video_track_updated(track_change, cx).log_err()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
cx.foreground()
|
||||
.spawn(room.connect(&connection_info.server_url, &connection_info.token))
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
Some(LiveKitRoom {
|
||||
room,
|
||||
screen_track: ScreenTrack::None,
|
||||
next_publish_id: 0,
|
||||
_maintain_room,
|
||||
_maintain_tracks,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
id,
|
||||
live_kit: live_kit_room,
|
||||
status: RoomStatus::Online,
|
||||
participant_user_ids: Default::default(),
|
||||
local_participant: Default::default(),
|
||||
remote_participants: Default::default(),
|
||||
pending_participants: Default::default(),
|
||||
pending_call_count: 0,
|
||||
subscriptions: vec![client.add_message_handler(cx.handle(), Self::handle_room_updated)],
|
||||
leave_when_empty: false,
|
||||
pending_room_update: None,
|
||||
client,
|
||||
user_store,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn create(
|
||||
recipient_user_id: u64,
|
||||
initial_project: Option<ModelHandle<Project>>,
|
||||
client: Arc<Client>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
cx: &mut MutableAppContext,
|
||||
) -> Task<Result<ModelHandle<Self>>> {
|
||||
cx.spawn(|mut cx| async move {
|
||||
let response = client.request(proto::CreateRoom {}).await?;
|
||||
let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
|
||||
let room = cx.add_model(|cx| {
|
||||
Self::new(
|
||||
room_proto.id,
|
||||
response.live_kit_connection_info,
|
||||
client,
|
||||
user_store,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let initial_project_id = if let Some(initial_project) = initial_project {
|
||||
let initial_project_id = room
|
||||
.update(&mut cx, |room, cx| {
|
||||
room.share_project(initial_project.clone(), cx)
|
||||
})
|
||||
.await?;
|
||||
Some(initial_project_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match room
|
||||
.update(&mut cx, |room, cx| {
|
||||
room.leave_when_empty = true;
|
||||
room.call(recipient_user_id, initial_project_id, cx)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(room),
|
||||
Err(error) => Err(anyhow!("room creation failed: {:?}", error)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn join(
|
||||
call: &IncomingCall,
|
||||
client: Arc<Client>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
cx: &mut MutableAppContext,
|
||||
) -> Task<Result<ModelHandle<Self>>> {
|
||||
let room_id = call.room_id;
|
||||
cx.spawn(|mut cx| async move {
|
||||
let response = client.request(proto::JoinRoom { id: room_id }).await?;
|
||||
let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
|
||||
let room = cx.add_model(|cx| {
|
||||
Self::new(
|
||||
room_id,
|
||||
response.live_kit_connection_info,
|
||||
client,
|
||||
user_store,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
room.update(&mut cx, |room, cx| {
|
||||
room.leave_when_empty = true;
|
||||
room.apply_room_update(room_proto, cx)?;
|
||||
anyhow::Ok(())
|
||||
})?;
|
||||
Ok(room)
|
||||
})
|
||||
}
|
||||
|
||||
fn should_leave(&self) -> bool {
|
||||
self.leave_when_empty
|
||||
&& self.pending_room_update.is_none()
|
||||
&& self.pending_participants.is_empty()
|
||||
&& self.remote_participants.is_empty()
|
||||
&& self.pending_call_count == 0
|
||||
}
|
||||
|
||||
pub(crate) fn leave(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
|
||||
if self.status.is_offline() {
|
||||
return Err(anyhow!("room is offline"));
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
cx.emit(Event::Left);
|
||||
self.status = RoomStatus::Offline;
|
||||
self.remote_participants.clear();
|
||||
self.pending_participants.clear();
|
||||
self.participant_user_ids.clear();
|
||||
self.subscriptions.clear();
|
||||
self.live_kit.take();
|
||||
self.client.send(proto::LeaveRoom { id: self.id })?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn status(&self) -> RoomStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
pub fn local_participant(&self) -> &LocalParticipant {
|
||||
&self.local_participant
|
||||
}
|
||||
|
||||
pub fn remote_participants(&self) -> &BTreeMap<PeerId, RemoteParticipant> {
|
||||
&self.remote_participants
|
||||
}
|
||||
|
||||
pub fn pending_participants(&self) -> &[Arc<User>] {
|
||||
&self.pending_participants
|
||||
}
|
||||
|
||||
pub fn contains_participant(&self, user_id: u64) -> bool {
|
||||
self.participant_user_ids.contains(&user_id)
|
||||
}
|
||||
|
||||
async fn handle_room_updated(
|
||||
this: ModelHandle<Self>,
|
||||
envelope: TypedEnvelope<proto::RoomUpdated>,
|
||||
_: Arc<Client>,
|
||||
mut cx: AsyncAppContext,
|
||||
) -> Result<()> {
|
||||
let room = envelope
|
||||
.payload
|
||||
.room
|
||||
.ok_or_else(|| anyhow!("invalid room"))?;
|
||||
this.update(&mut cx, |this, cx| this.apply_room_update(room, cx))
|
||||
}
|
||||
|
||||
fn apply_room_update(
|
||||
&mut self,
|
||||
mut room: proto::Room,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Result<()> {
|
||||
// Filter ourselves out from the room's participants.
|
||||
let local_participant_ix = room
|
||||
.participants
|
||||
.iter()
|
||||
.position(|participant| Some(participant.user_id) == self.client.user_id());
|
||||
let local_participant = local_participant_ix.map(|ix| room.participants.swap_remove(ix));
|
||||
|
||||
let remote_participant_user_ids = room
|
||||
.participants
|
||||
.iter()
|
||||
.map(|p| p.user_id)
|
||||
.collect::<Vec<_>>();
|
||||
let (remote_participants, pending_participants) =
|
||||
self.user_store.update(cx, move |user_store, cx| {
|
||||
(
|
||||
user_store.get_users(remote_participant_user_ids, cx),
|
||||
user_store.get_users(room.pending_participant_user_ids, cx),
|
||||
)
|
||||
});
|
||||
self.pending_room_update = Some(cx.spawn(|this, mut cx| async move {
|
||||
let (remote_participants, pending_participants) =
|
||||
futures::join!(remote_participants, pending_participants);
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.participant_user_ids.clear();
|
||||
|
||||
if let Some(participant) = local_participant {
|
||||
this.local_participant.projects = participant.projects;
|
||||
} else {
|
||||
this.local_participant.projects.clear();
|
||||
}
|
||||
|
||||
if let Some(participants) = remote_participants.log_err() {
|
||||
for (participant, user) in room.participants.into_iter().zip(participants) {
|
||||
let peer_id = PeerId(participant.peer_id);
|
||||
this.participant_user_ids.insert(participant.user_id);
|
||||
|
||||
let old_projects = this
|
||||
.remote_participants
|
||||
.get(&peer_id)
|
||||
.into_iter()
|
||||
.flat_map(|existing| &existing.projects)
|
||||
.map(|project| project.id)
|
||||
.collect::<HashSet<_>>();
|
||||
let new_projects = participant
|
||||
.projects
|
||||
.iter()
|
||||
.map(|project| project.id)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for project in &participant.projects {
|
||||
if !old_projects.contains(&project.id) {
|
||||
cx.emit(Event::RemoteProjectShared {
|
||||
owner: user.clone(),
|
||||
project_id: project.id,
|
||||
worktree_root_names: project.worktree_root_names.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for unshared_project_id in old_projects.difference(&new_projects) {
|
||||
cx.emit(Event::RemoteProjectUnshared {
|
||||
project_id: *unshared_project_id,
|
||||
});
|
||||
}
|
||||
|
||||
let location = ParticipantLocation::from_proto(participant.location)
|
||||
.unwrap_or(ParticipantLocation::External);
|
||||
if let Some(remote_participant) = this.remote_participants.get_mut(&peer_id)
|
||||
{
|
||||
remote_participant.projects = participant.projects;
|
||||
if location != remote_participant.location {
|
||||
remote_participant.location = location;
|
||||
cx.emit(Event::ParticipantLocationChanged {
|
||||
participant_id: peer_id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.remote_participants.insert(
|
||||
peer_id,
|
||||
RemoteParticipant {
|
||||
user: user.clone(),
|
||||
projects: participant.projects,
|
||||
location,
|
||||
tracks: Default::default(),
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(live_kit) = this.live_kit.as_ref() {
|
||||
let tracks =
|
||||
live_kit.room.remote_video_tracks(&peer_id.0.to_string());
|
||||
for track in tracks {
|
||||
this.remote_video_track_updated(
|
||||
RemoteVideoTrackUpdate::Subscribed(track),
|
||||
cx,
|
||||
)
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.remote_participants.retain(|_, participant| {
|
||||
if this.participant_user_ids.contains(&participant.user.id) {
|
||||
true
|
||||
} else {
|
||||
for project in &participant.projects {
|
||||
cx.emit(Event::RemoteProjectUnshared {
|
||||
project_id: project.id,
|
||||
});
|
||||
}
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(pending_participants) = pending_participants.log_err() {
|
||||
this.pending_participants = pending_participants;
|
||||
for participant in &this.pending_participants {
|
||||
this.participant_user_ids.insert(participant.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.pending_room_update.take();
|
||||
if this.should_leave() {
|
||||
let _ = this.leave(cx);
|
||||
}
|
||||
|
||||
this.check_invariants();
|
||||
cx.notify();
|
||||
});
|
||||
}));
|
||||
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remote_video_track_updated(
|
||||
&mut self,
|
||||
change: RemoteVideoTrackUpdate,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Result<()> {
|
||||
match change {
|
||||
RemoteVideoTrackUpdate::Subscribed(track) => {
|
||||
let peer_id = PeerId(track.publisher_id().parse()?);
|
||||
let track_id = track.sid().to_string();
|
||||
let participant = self
|
||||
.remote_participants
|
||||
.get_mut(&peer_id)
|
||||
.ok_or_else(|| anyhow!("subscribed to track by unknown participant"))?;
|
||||
participant.tracks.insert(
|
||||
track_id.clone(),
|
||||
Arc::new(RemoteVideoTrack {
|
||||
live_kit_track: track,
|
||||
}),
|
||||
);
|
||||
cx.emit(Event::RemoteVideoTracksChanged {
|
||||
participant_id: peer_id,
|
||||
});
|
||||
}
|
||||
RemoteVideoTrackUpdate::Unsubscribed {
|
||||
publisher_id,
|
||||
track_id,
|
||||
} => {
|
||||
let peer_id = PeerId(publisher_id.parse()?);
|
||||
let participant = self
|
||||
.remote_participants
|
||||
.get_mut(&peer_id)
|
||||
.ok_or_else(|| anyhow!("unsubscribed from track by unknown participant"))?;
|
||||
participant.tracks.remove(&track_id);
|
||||
cx.emit(Event::RemoteVideoTracksChanged {
|
||||
participant_id: peer_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_invariants(&self) {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
{
|
||||
for participant in self.remote_participants.values() {
|
||||
assert!(self.participant_user_ids.contains(&participant.user.id));
|
||||
}
|
||||
|
||||
for participant in &self.pending_participants {
|
||||
assert!(self.participant_user_ids.contains(&participant.id));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
self.participant_user_ids.len(),
|
||||
self.remote_participants.len() + self.pending_participants.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn call(
|
||||
&mut self,
|
||||
recipient_user_id: u64,
|
||||
initial_project_id: Option<u64>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
if self.status.is_offline() {
|
||||
return Task::ready(Err(anyhow!("room is offline")));
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
let client = self.client.clone();
|
||||
let room_id = self.id;
|
||||
self.pending_call_count += 1;
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let result = client
|
||||
.request(proto::Call {
|
||||
room_id,
|
||||
recipient_user_id,
|
||||
initial_project_id,
|
||||
})
|
||||
.await;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.pending_call_count -= 1;
|
||||
if this.should_leave() {
|
||||
this.leave(cx)?;
|
||||
}
|
||||
result
|
||||
})?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn share_project(
|
||||
&mut self,
|
||||
project: ModelHandle<Project>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<u64>> {
|
||||
if let Some(project_id) = project.read(cx).remote_id() {
|
||||
return Task::ready(Ok(project_id));
|
||||
}
|
||||
|
||||
let request = self.client.request(proto::ShareProject {
|
||||
room_id: self.id(),
|
||||
worktrees: project
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.map(|worktree| {
|
||||
let worktree = worktree.read(cx);
|
||||
proto::WorktreeMetadata {
|
||||
id: worktree.id().to_proto(),
|
||||
root_name: worktree.root_name().into(),
|
||||
visible: worktree.is_visible(),
|
||||
abs_path: worktree.abs_path().as_os_str().as_bytes().to_vec(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let response = request.await?;
|
||||
|
||||
project.update(&mut cx, |project, cx| {
|
||||
project
|
||||
.shared(response.project_id, cx)
|
||||
.detach_and_log_err(cx)
|
||||
});
|
||||
|
||||
// If the user's location is in this project, it changes from UnsharedProject to SharedProject.
|
||||
this.update(&mut cx, |this, cx| {
|
||||
let active_project = this.local_participant.active_project.as_ref();
|
||||
if active_project.map_or(false, |location| *location == project) {
|
||||
this.set_location(Some(&project), cx)
|
||||
} else {
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(response.project_id)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn set_location(
|
||||
&mut self,
|
||||
project: Option<&ModelHandle<Project>>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
if self.status.is_offline() {
|
||||
return Task::ready(Err(anyhow!("room is offline")));
|
||||
}
|
||||
|
||||
let client = self.client.clone();
|
||||
let room_id = self.id;
|
||||
let location = if let Some(project) = project {
|
||||
self.local_participant.active_project = Some(project.downgrade());
|
||||
if let Some(project_id) = project.read(cx).remote_id() {
|
||||
proto::participant_location::Variant::SharedProject(
|
||||
proto::participant_location::SharedProject { id: project_id },
|
||||
)
|
||||
} else {
|
||||
proto::participant_location::Variant::UnsharedProject(
|
||||
proto::participant_location::UnsharedProject {},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
self.local_participant.active_project = None;
|
||||
proto::participant_location::Variant::External(proto::participant_location::External {})
|
||||
};
|
||||
|
||||
cx.notify();
|
||||
cx.foreground().spawn(async move {
|
||||
client
|
||||
.request(proto::UpdateParticipantLocation {
|
||||
room_id,
|
||||
location: Some(proto::ParticipantLocation {
|
||||
variant: Some(location),
|
||||
}),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_screen_sharing(&self) -> bool {
|
||||
self.live_kit.as_ref().map_or(false, |live_kit| {
|
||||
!matches!(live_kit.screen_track, ScreenTrack::None)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn share_screen(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
|
||||
if self.status.is_offline() {
|
||||
return Task::ready(Err(anyhow!("room is offline")));
|
||||
} else if self.is_screen_sharing() {
|
||||
return Task::ready(Err(anyhow!("screen was already shared")));
|
||||
}
|
||||
|
||||
let (displays, publish_id) = if let Some(live_kit) = self.live_kit.as_mut() {
|
||||
let publish_id = post_inc(&mut live_kit.next_publish_id);
|
||||
live_kit.screen_track = ScreenTrack::Pending { publish_id };
|
||||
cx.notify();
|
||||
(live_kit.room.display_sources(), publish_id)
|
||||
} else {
|
||||
return Task::ready(Err(anyhow!("live-kit was not initialized")));
|
||||
};
|
||||
|
||||
cx.spawn_weak(|this, mut cx| async move {
|
||||
let publish_track = async {
|
||||
let displays = displays.await?;
|
||||
let display = displays
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("no display found"))?;
|
||||
let track = LocalVideoTrack::screen_share_for_display(&display);
|
||||
this.upgrade(&cx)
|
||||
.ok_or_else(|| anyhow!("room was dropped"))?
|
||||
.read_with(&cx, |this, _| {
|
||||
this.live_kit
|
||||
.as_ref()
|
||||
.map(|live_kit| live_kit.room.publish_video_track(&track))
|
||||
})
|
||||
.ok_or_else(|| anyhow!("live-kit was not initialized"))?
|
||||
.await
|
||||
};
|
||||
|
||||
let publication = publish_track.await;
|
||||
this.upgrade(&cx)
|
||||
.ok_or_else(|| anyhow!("room was dropped"))?
|
||||
.update(&mut cx, |this, cx| {
|
||||
let live_kit = this
|
||||
.live_kit
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow!("live-kit was not initialized"))?;
|
||||
|
||||
let canceled = if let ScreenTrack::Pending {
|
||||
publish_id: cur_publish_id,
|
||||
} = &live_kit.screen_track
|
||||
{
|
||||
*cur_publish_id != publish_id
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
match publication {
|
||||
Ok(publication) => {
|
||||
if canceled {
|
||||
live_kit.room.unpublish_track(publication);
|
||||
} else {
|
||||
live_kit.screen_track = ScreenTrack::Published(publication);
|
||||
cx.notify();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
if canceled {
|
||||
Ok(())
|
||||
} else {
|
||||
live_kit.screen_track = ScreenTrack::None;
|
||||
cx.notify();
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn unshare_screen(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
|
||||
if self.status.is_offline() {
|
||||
return Err(anyhow!("room is offline"));
|
||||
}
|
||||
|
||||
let live_kit = self
|
||||
.live_kit
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow!("live-kit was not initialized"))?;
|
||||
match mem::take(&mut live_kit.screen_track) {
|
||||
ScreenTrack::None => Err(anyhow!("screen was not shared")),
|
||||
ScreenTrack::Pending { .. } => {
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
ScreenTrack::Published(track) => {
|
||||
live_kit.room.unpublish_track(track);
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn set_display_sources(&self, sources: Vec<live_kit_client::MacOSDisplay>) {
|
||||
self.live_kit
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.room
|
||||
.set_display_sources(sources);
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveKitRoom {
|
||||
room: Arc<live_kit_client::Room>,
|
||||
screen_track: ScreenTrack,
|
||||
next_publish_id: usize,
|
||||
_maintain_room: Task<()>,
|
||||
_maintain_tracks: Task<()>,
|
||||
}
|
||||
|
||||
enum ScreenTrack {
|
||||
None,
|
||||
Pending { publish_id: usize },
|
||||
Published(LocalTrackPublication),
|
||||
}
|
||||
|
||||
impl Default for ScreenTrack {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
pub enum RoomStatus {
|
||||
Online,
|
||||
Offline,
|
||||
}
|
||||
|
||||
impl RoomStatus {
|
||||
pub fn is_offline(&self) -> bool {
|
||||
matches!(self, RoomStatus::Offline)
|
||||
}
|
||||
|
||||
pub fn is_online(&self) -> bool {
|
||||
matches!(self, RoomStatus::Online)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
[package]
|
||||
name = "capture"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "An example of screen capture"
|
||||
|
||||
[dependencies]
|
||||
gpui = { path = "../gpui" }
|
||||
live_kit = { path = "../live_kit" }
|
||||
media = { path = "../media" }
|
||||
|
||||
anyhow = "1.0.38"
|
||||
block = "0.1"
|
||||
bytes = "1.2"
|
||||
byteorder = "1.4"
|
||||
cocoa = "0.24"
|
||||
core-foundation = "0.9.3"
|
||||
core-graphics = "0.22.3"
|
||||
foreign-types = "0.3"
|
||||
futures = "0.3"
|
||||
hmac = "0.12"
|
||||
jwt = "0.16"
|
||||
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
|
||||
objc = "0.2"
|
||||
parking_lot = "0.11.1"
|
||||
postage = { version = "0.4.1", features = ["futures-traits"] }
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
sha2 = "0.10"
|
||||
simplelog = "0.9"
|
||||
|
||||
[build-dependencies]
|
||||
bindgen = "0.59.2"
|
||||
@@ -1,7 +0,0 @@
|
||||
fn main() {
|
||||
// Find WebRTC.framework as a sibling of the executable when running outside of an application bundle
|
||||
println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path");
|
||||
|
||||
// Register exported Objective-C selectors, protocols, etc
|
||||
println!("cargo:rustc-link-arg=-Wl,-ObjC");
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use hmac::{Hmac, Mac};
|
||||
use jwt::SignWithKey;
|
||||
use serde::Serialize;
|
||||
use sha2::Sha256;
|
||||
use std::{
|
||||
ops::Add,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
static DEFAULT_TTL: Duration = Duration::from_secs(6 * 60 * 60); // 6 hours
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClaimGrants<'a> {
|
||||
iss: &'a str,
|
||||
sub: &'a str,
|
||||
iat: u64,
|
||||
exp: u64,
|
||||
nbf: u64,
|
||||
jwtid: &'a str,
|
||||
video: VideoGrant<'a>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VideoGrant<'a> {
|
||||
room_create: Option<bool>,
|
||||
room_join: Option<bool>,
|
||||
room_list: Option<bool>,
|
||||
room_record: Option<bool>,
|
||||
room_admin: Option<bool>,
|
||||
room: Option<&'a str>,
|
||||
can_publish: Option<bool>,
|
||||
can_subscribe: Option<bool>,
|
||||
can_publish_data: Option<bool>,
|
||||
hidden: Option<bool>,
|
||||
recorder: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn create_token(
|
||||
api_key: &str,
|
||||
secret_key: &str,
|
||||
room_name: &str,
|
||||
participant_name: &str,
|
||||
) -> Result<String> {
|
||||
let secret_key: Hmac<Sha256> = Hmac::new_from_slice(secret_key.as_bytes())?;
|
||||
|
||||
let now = SystemTime::now();
|
||||
|
||||
let claims = ClaimGrants {
|
||||
iss: api_key,
|
||||
sub: participant_name,
|
||||
iat: now.duration_since(UNIX_EPOCH).unwrap().as_secs(),
|
||||
exp: now
|
||||
.add(DEFAULT_TTL)
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
nbf: 0,
|
||||
jwtid: participant_name,
|
||||
video: VideoGrant {
|
||||
room: Some(room_name),
|
||||
room_join: Some(true),
|
||||
can_publish: Some(true),
|
||||
can_subscribe: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
Ok(claims.sign_with_key(&secret_key)?)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
mod live_kit_token;
|
||||
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
actions,
|
||||
elements::{Canvas, *},
|
||||
keymap::Binding,
|
||||
platform::current::Surface,
|
||||
Menu, MenuItem, ViewContext,
|
||||
};
|
||||
use live_kit::{LocalVideoTrack, Room};
|
||||
use log::LevelFilter;
|
||||
use media::core_video::CVImageBuffer;
|
||||
use postage::watch;
|
||||
use simplelog::SimpleLogger;
|
||||
use std::sync::Arc;
|
||||
|
||||
actions!(capture, [Quit]);
|
||||
|
||||
fn main() {
|
||||
SimpleLogger::init(LevelFilter::Info, Default::default()).expect("could not initialize logger");
|
||||
|
||||
gpui::App::new(()).unwrap().run(|cx| {
|
||||
cx.platform().activate(true);
|
||||
cx.add_global_action(quit);
|
||||
|
||||
cx.add_bindings([Binding::new("cmd-q", Quit, None)]);
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Zed",
|
||||
items: vec![MenuItem::Action {
|
||||
name: "Quit",
|
||||
action: Box::new(Quit),
|
||||
}],
|
||||
}]);
|
||||
|
||||
let live_kit_url = std::env::var("LIVE_KIT_URL").unwrap();
|
||||
let live_kit_key = std::env::var("LIVE_KIT_KEY").unwrap();
|
||||
let live_kit_secret = std::env::var("LIVE_KIT_SECRET").unwrap();
|
||||
|
||||
cx.spawn(|mut cx| async move {
|
||||
let user1_token = live_kit_token::create_token(
|
||||
&live_kit_key,
|
||||
&live_kit_secret,
|
||||
"test-room",
|
||||
"test-participant-1",
|
||||
)
|
||||
.unwrap();
|
||||
let room1 = Room::new();
|
||||
room1.connect(&live_kit_url, &user1_token).await.unwrap();
|
||||
|
||||
let user2_token = live_kit_token::create_token(
|
||||
&live_kit_key,
|
||||
&live_kit_secret,
|
||||
"test-room",
|
||||
"test-participant-2",
|
||||
)
|
||||
.unwrap();
|
||||
let room2 = Room::new();
|
||||
room2.connect(&live_kit_url, &user2_token).await.unwrap();
|
||||
cx.add_window(Default::default(), |cx| ScreenCaptureView::new(room2, cx));
|
||||
|
||||
let windows = live_kit::list_windows();
|
||||
let window = windows
|
||||
.iter()
|
||||
.find(|w| w.owner_name.as_deref() == Some("Safari"))
|
||||
.unwrap();
|
||||
let track = LocalVideoTrack::screen_share_for_window(window.id);
|
||||
room1.publish_video_track(&track).await.unwrap();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
|
||||
struct ScreenCaptureView {
|
||||
image_buffer: Option<CVImageBuffer>,
|
||||
_room: Arc<Room>,
|
||||
}
|
||||
|
||||
impl gpui::Entity for ScreenCaptureView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl ScreenCaptureView {
|
||||
pub fn new(room: Arc<Room>, cx: &mut ViewContext<Self>) -> Self {
|
||||
let mut remote_video_tracks = room.remote_video_tracks();
|
||||
cx.spawn_weak(|this, mut cx| async move {
|
||||
if let Some(video_track) = remote_video_tracks.next().await {
|
||||
let (mut frames_tx, mut frames_rx) = watch::channel_with(None);
|
||||
video_track.add_renderer(move |frame| *frames_tx.borrow_mut() = Some(frame));
|
||||
|
||||
while let Some(frame) = frames_rx.next().await {
|
||||
if let Some(this) = this.upgrade(&cx) {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.image_buffer = frame;
|
||||
cx.notify();
|
||||
});
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
image_buffer: None,
|
||||
_room: room,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::View for ScreenCaptureView {
|
||||
fn ui_name() -> &'static str {
|
||||
"View"
|
||||
}
|
||||
|
||||
fn render(&mut self, _: &mut gpui::RenderContext<Self>) -> gpui::ElementBox {
|
||||
let image_buffer = self.image_buffer.clone();
|
||||
let canvas = Canvas::new(move |bounds, _, cx| {
|
||||
if let Some(image_buffer) = image_buffer.clone() {
|
||||
cx.scene.push_surface(Surface {
|
||||
bounds,
|
||||
image_buffer,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(image_buffer) = self.image_buffer.as_ref() {
|
||||
canvas
|
||||
.constrained()
|
||||
.with_width(image_buffer.width() as f32)
|
||||
.with_height(image_buffer.height() as f32)
|
||||
.aligned()
|
||||
.boxed()
|
||||
} else {
|
||||
canvas.boxed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn quit(_: &Quit, cx: &mut gpui::MutableAppContext) {
|
||||
cx.platform().quit();
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "chat_panel"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/chat_panel.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
client = { path = "../client" }
|
||||
editor = { path = "../editor" }
|
||||
gpui = { path = "../gpui" }
|
||||
menu = { path = "../menu" }
|
||||
settings = { path = "../settings" }
|
||||
theme = { path = "../theme" }
|
||||
util = { path = "../util" }
|
||||
workspace = { path = "../workspace" }
|
||||
postage = { version = "0.4.1", features = ["futures-traits"] }
|
||||
time = { version = "0.3", features = ["serde", "serde-well-known"] }
|
||||
@@ -1,433 +0,0 @@
|
||||
use client::{
|
||||
channel::{Channel, ChannelEvent, ChannelList, ChannelMessage},
|
||||
Client,
|
||||
};
|
||||
use editor::Editor;
|
||||
use gpui::{
|
||||
actions,
|
||||
elements::*,
|
||||
platform::CursorStyle,
|
||||
views::{ItemType, Select, SelectStyle},
|
||||
AnyViewHandle, AppContext, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext,
|
||||
Subscription, Task, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use menu::Confirm;
|
||||
use postage::prelude::Stream;
|
||||
use settings::{Settings, SoftWrap};
|
||||
use std::sync::Arc;
|
||||
use time::{OffsetDateTime, UtcOffset};
|
||||
use util::{ResultExt, TryFutureExt};
|
||||
|
||||
const MESSAGE_LOADING_THRESHOLD: usize = 50;
|
||||
|
||||
pub struct ChatPanel {
|
||||
rpc: Arc<Client>,
|
||||
channel_list: ModelHandle<ChannelList>,
|
||||
active_channel: Option<(ModelHandle<Channel>, Subscription)>,
|
||||
message_list: ListState,
|
||||
input_editor: ViewHandle<Editor>,
|
||||
channel_select: ViewHandle<Select>,
|
||||
local_timezone: UtcOffset,
|
||||
_observe_status: Task<()>,
|
||||
}
|
||||
|
||||
pub enum Event {}
|
||||
|
||||
actions!(chat_panel, [LoadMoreMessages]);
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
cx.add_action(ChatPanel::send);
|
||||
cx.add_action(ChatPanel::load_more_messages);
|
||||
}
|
||||
|
||||
impl ChatPanel {
|
||||
pub fn new(
|
||||
rpc: Arc<Client>,
|
||||
channel_list: ModelHandle<ChannelList>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let input_editor = cx.add_view(|cx| {
|
||||
let mut editor =
|
||||
Editor::auto_height(4, Some(|theme| theme.chat_panel.input_editor.clone()), cx);
|
||||
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
|
||||
editor
|
||||
});
|
||||
let channel_select = cx.add_view(|cx| {
|
||||
let channel_list = channel_list.clone();
|
||||
Select::new(0, cx, {
|
||||
move |ix, item_type, is_hovered, cx| {
|
||||
Self::render_channel_name(
|
||||
&channel_list,
|
||||
ix,
|
||||
item_type,
|
||||
is_hovered,
|
||||
&cx.global::<Settings>().theme.chat_panel.channel_select,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.with_style(move |cx| {
|
||||
let theme = &cx.global::<Settings>().theme.chat_panel.channel_select;
|
||||
SelectStyle {
|
||||
header: theme.header.container,
|
||||
menu: theme.menu,
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let mut message_list = ListState::new(0, Orientation::Bottom, 1000., cx, {
|
||||
let this = cx.weak_handle();
|
||||
move |_, ix, cx| {
|
||||
let this = this.upgrade(cx).unwrap().read(cx);
|
||||
let message = this.active_channel.as_ref().unwrap().0.read(cx).message(ix);
|
||||
this.render_message(message, cx)
|
||||
}
|
||||
});
|
||||
message_list.set_scroll_handler(|visible_range, cx| {
|
||||
if visible_range.start < MESSAGE_LOADING_THRESHOLD {
|
||||
cx.dispatch_action(LoadMoreMessages);
|
||||
}
|
||||
});
|
||||
let _observe_status = cx.spawn_weak(|this, mut cx| {
|
||||
let mut status = rpc.status();
|
||||
async move {
|
||||
while (status.recv().await).is_some() {
|
||||
if let Some(this) = this.upgrade(&cx) {
|
||||
this.update(&mut cx, |_, cx| cx.notify());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut this = Self {
|
||||
rpc,
|
||||
channel_list,
|
||||
active_channel: Default::default(),
|
||||
message_list,
|
||||
input_editor,
|
||||
channel_select,
|
||||
local_timezone: cx.platform().local_timezone(),
|
||||
_observe_status,
|
||||
};
|
||||
|
||||
this.init_active_channel(cx);
|
||||
cx.observe(&this.channel_list, |this, _, cx| {
|
||||
this.init_active_channel(cx);
|
||||
})
|
||||
.detach();
|
||||
cx.observe(&this.channel_select, |this, channel_select, cx| {
|
||||
let selected_ix = channel_select.read(cx).selected_index();
|
||||
let selected_channel = this.channel_list.update(cx, |channel_list, cx| {
|
||||
let available_channels = channel_list.available_channels()?;
|
||||
let channel_id = available_channels.get(selected_ix)?.id;
|
||||
channel_list.get_channel(channel_id, cx)
|
||||
});
|
||||
if let Some(selected_channel) = selected_channel {
|
||||
this.set_active_channel(selected_channel, cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
fn init_active_channel(&mut self, cx: &mut ViewContext<Self>) {
|
||||
let (active_channel, channel_count) = self.channel_list.update(cx, |list, cx| {
|
||||
let channel_count;
|
||||
let mut active_channel = None;
|
||||
|
||||
if let Some(available_channels) = list.available_channels() {
|
||||
channel_count = available_channels.len();
|
||||
if self.active_channel.is_none() {
|
||||
if let Some(channel_id) = available_channels.first().map(|channel| channel.id) {
|
||||
active_channel = list.get_channel(channel_id, cx);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel_count = 0;
|
||||
}
|
||||
|
||||
(active_channel, channel_count)
|
||||
});
|
||||
|
||||
if let Some(active_channel) = active_channel {
|
||||
self.set_active_channel(active_channel, cx);
|
||||
} else {
|
||||
self.message_list.reset(0);
|
||||
self.active_channel = None;
|
||||
}
|
||||
|
||||
self.channel_select.update(cx, |select, cx| {
|
||||
select.set_item_count(channel_count, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn set_active_channel(&mut self, channel: ModelHandle<Channel>, cx: &mut ViewContext<Self>) {
|
||||
if self.active_channel.as_ref().map(|e| &e.0) != Some(&channel) {
|
||||
{
|
||||
let channel = channel.read(cx);
|
||||
self.message_list.reset(channel.message_count());
|
||||
let placeholder = format!("Message #{}", channel.name());
|
||||
self.input_editor.update(cx, move |editor, cx| {
|
||||
editor.set_placeholder_text(placeholder, cx);
|
||||
});
|
||||
}
|
||||
let subscription = cx.subscribe(&channel, Self::channel_did_change);
|
||||
self.active_channel = Some((channel, subscription));
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_did_change(
|
||||
&mut self,
|
||||
_: ModelHandle<Channel>,
|
||||
event: &ChannelEvent,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
ChannelEvent::MessagesUpdated {
|
||||
old_range,
|
||||
new_count,
|
||||
} => {
|
||||
self.message_list.splice(old_range.clone(), *new_count);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_channel(&self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
let theme = &cx.global::<Settings>().theme;
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Container::new(ChildView::new(&self.channel_select).boxed())
|
||||
.with_style(theme.chat_panel.channel_select.container)
|
||||
.boxed(),
|
||||
)
|
||||
.with_child(self.render_active_channel_messages())
|
||||
.with_child(self.render_input_box(cx))
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_active_channel_messages(&self) -> ElementBox {
|
||||
let messages = if self.active_channel.is_some() {
|
||||
List::new(self.message_list.clone()).boxed()
|
||||
} else {
|
||||
Empty::new().boxed()
|
||||
};
|
||||
|
||||
FlexItem::new(messages).flex(1., true).boxed()
|
||||
}
|
||||
|
||||
fn render_message(&self, message: &ChannelMessage, cx: &AppContext) -> ElementBox {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let settings = cx.global::<Settings>();
|
||||
let theme = if message.is_pending() {
|
||||
&settings.theme.chat_panel.pending_message
|
||||
} else {
|
||||
&settings.theme.chat_panel.message
|
||||
};
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Container::new(
|
||||
Label::new(
|
||||
message.sender.github_login.clone(),
|
||||
theme.sender.text.clone(),
|
||||
)
|
||||
.boxed(),
|
||||
)
|
||||
.with_style(theme.sender.container)
|
||||
.boxed(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
Label::new(
|
||||
format_timestamp(message.timestamp, now, self.local_timezone),
|
||||
theme.timestamp.text.clone(),
|
||||
)
|
||||
.boxed(),
|
||||
)
|
||||
.with_style(theme.timestamp.container)
|
||||
.boxed(),
|
||||
)
|
||||
.boxed(),
|
||||
)
|
||||
.with_child(Text::new(message.body.clone(), theme.body.clone()).boxed())
|
||||
.boxed(),
|
||||
)
|
||||
.with_style(theme.container)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_input_box(&self, cx: &AppContext) -> ElementBox {
|
||||
let theme = &cx.global::<Settings>().theme;
|
||||
Container::new(ChildView::new(&self.input_editor).boxed())
|
||||
.with_style(theme.chat_panel.input_editor.container)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_channel_name(
|
||||
channel_list: &ModelHandle<ChannelList>,
|
||||
ix: usize,
|
||||
item_type: ItemType,
|
||||
is_hovered: bool,
|
||||
theme: &theme::ChannelSelect,
|
||||
cx: &AppContext,
|
||||
) -> ElementBox {
|
||||
let channel = &channel_list.read(cx).available_channels().unwrap()[ix];
|
||||
let theme = match (item_type, is_hovered) {
|
||||
(ItemType::Header, _) => &theme.header,
|
||||
(ItemType::Selected, false) => &theme.active_item,
|
||||
(ItemType::Selected, true) => &theme.hovered_active_item,
|
||||
(ItemType::Unselected, false) => &theme.item,
|
||||
(ItemType::Unselected, true) => &theme.hovered_item,
|
||||
};
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Container::new(Label::new("#".to_string(), theme.hash.text.clone()).boxed())
|
||||
.with_style(theme.hash.container)
|
||||
.boxed(),
|
||||
)
|
||||
.with_child(Label::new(channel.name.clone(), theme.name.clone()).boxed())
|
||||
.boxed(),
|
||||
)
|
||||
.with_style(theme.container)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_sign_in_prompt(&self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
let theme = cx.global::<Settings>().theme.clone();
|
||||
let rpc = self.rpc.clone();
|
||||
let this = cx.handle();
|
||||
|
||||
enum SignInPromptLabel {}
|
||||
|
||||
Align::new(
|
||||
MouseEventHandler::<SignInPromptLabel>::new(0, cx, |mouse_state, _| {
|
||||
Label::new(
|
||||
"Sign in to use chat".to_string(),
|
||||
if mouse_state.hovered {
|
||||
theme.chat_panel.hovered_sign_in_prompt.clone()
|
||||
} else {
|
||||
theme.chat_panel.sign_in_prompt.clone()
|
||||
},
|
||||
)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
let rpc = rpc.clone();
|
||||
let this = this.clone();
|
||||
cx.spawn(|mut cx| async move {
|
||||
if rpc
|
||||
.authenticate_and_connect(true, &cx)
|
||||
.log_err()
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
cx.update(|cx| {
|
||||
if let Some(this) = this.upgrade(cx) {
|
||||
if this.is_focused(cx) {
|
||||
this.update(cx, |this, cx| cx.focus(&this.input_editor));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.boxed(),
|
||||
)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
|
||||
if let Some((channel, _)) = self.active_channel.as_ref() {
|
||||
let body = self.input_editor.update(cx, |editor, cx| {
|
||||
let body = editor.text(cx);
|
||||
editor.clear(cx);
|
||||
body
|
||||
});
|
||||
|
||||
if let Some(task) = channel
|
||||
.update(cx, |channel, cx| channel.send_message(body, cx))
|
||||
.log_err()
|
||||
{
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
|
||||
if let Some((channel, _)) = self.active_channel.as_ref() {
|
||||
channel.update(cx, |channel, cx| {
|
||||
channel.load_more_messages(cx);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ChatPanel {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
impl View for ChatPanel {
|
||||
fn ui_name() -> &'static str {
|
||||
"ChatPanel"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
let element = if self.rpc.user_id().is_some() {
|
||||
self.render_channel(cx)
|
||||
} else {
|
||||
self.render_sign_in_prompt(cx)
|
||||
};
|
||||
let theme = &cx.global::<Settings>().theme;
|
||||
ConstrainedBox::new(
|
||||
Container::new(element)
|
||||
.with_style(theme.chat_panel.container)
|
||||
.boxed(),
|
||||
)
|
||||
.with_min_width(150.)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
|
||||
if matches!(
|
||||
*self.rpc.status().borrow(),
|
||||
client::Status::Connected { .. }
|
||||
) {
|
||||
cx.focus(&self.input_editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timestamp(
|
||||
mut timestamp: OffsetDateTime,
|
||||
mut now: OffsetDateTime,
|
||||
local_timezone: UtcOffset,
|
||||
) -> String {
|
||||
timestamp = timestamp.to_offset(local_timezone);
|
||||
now = now.to_offset(local_timezone);
|
||||
|
||||
let today = now.date();
|
||||
let date = timestamp.date();
|
||||
let mut hour = timestamp.hour();
|
||||
let mut part = "am";
|
||||
if hour > 12 {
|
||||
hour -= 12;
|
||||
part = "pm";
|
||||
}
|
||||
if date == today {
|
||||
format!("{:02}:{:02}{}", hour, timestamp.minute(), part)
|
||||
} else if date.next_day() == Some(today) {
|
||||
format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part)
|
||||
} else {
|
||||
format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,11 @@ tiny_http = "0.8"
|
||||
uuid = { version = "1.1.2", features = ["v4"] }
|
||||
url = "2.2"
|
||||
serde = { version = "*", features = ["derive"] }
|
||||
settings = { path = "../settings" }
|
||||
tempfile = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
collections = { path = "../collections", features = ["test-support"] }
|
||||
gpui = { path = "../gpui", features = ["test-support"] }
|
||||
rpc = { path = "../rpc", features = ["test-support"] }
|
||||
settings = { path = "../settings", features = ["test-support"] }
|
||||
|
||||
271
crates/client/src/amplitude_telemetry.rs
Normal file
271
crates/client/src/amplitude_telemetry.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use crate::http::HttpClient;
|
||||
use db::Db;
|
||||
use gpui::{
|
||||
executor::Background,
|
||||
serde_json::{self, value::Map, Value},
|
||||
AppContext, Task,
|
||||
};
|
||||
use isahc::Request;
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
io::Write,
|
||||
mem,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tempfile::NamedTempFile;
|
||||
use util::{post_inc, ResultExt, TryFutureExt};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct AmplitudeTelemetry {
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
executor: Arc<Background>,
|
||||
session_id: u128,
|
||||
state: Mutex<AmplitudeTelemetryState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AmplitudeTelemetryState {
|
||||
metrics_id: Option<Arc<str>>,
|
||||
device_id: Option<Arc<str>>,
|
||||
app_version: Option<Arc<str>>,
|
||||
os_version: Option<Arc<str>>,
|
||||
os_name: &'static str,
|
||||
queue: Vec<AmplitudeEvent>,
|
||||
next_event_id: usize,
|
||||
flush_task: Option<Task<()>>,
|
||||
log_file: Option<NamedTempFile>,
|
||||
}
|
||||
|
||||
const AMPLITUDE_EVENTS_URL: &'static str = "https://api2.amplitude.com/batch";
|
||||
|
||||
lazy_static! {
|
||||
static ref AMPLITUDE_API_KEY: Option<String> = std::env::var("ZED_AMPLITUDE_API_KEY")
|
||||
.ok()
|
||||
.or_else(|| option_env!("ZED_AMPLITUDE_API_KEY").map(|key| key.to_string()));
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AmplitudeEventBatch {
|
||||
api_key: &'static str,
|
||||
events: Vec<AmplitudeEvent>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AmplitudeEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user_id: Option<Arc<str>>,
|
||||
device_id: Option<Arc<str>>,
|
||||
event_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
event_properties: Option<Map<String, Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user_properties: Option<Map<String, Value>>,
|
||||
os_name: &'static str,
|
||||
os_version: Option<Arc<str>>,
|
||||
app_version: Option<Arc<str>>,
|
||||
platform: &'static str,
|
||||
event_id: usize,
|
||||
session_id: u128,
|
||||
time: u128,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
const MAX_QUEUE_LEN: usize = 1;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
const MAX_QUEUE_LEN: usize = 10;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
impl AmplitudeTelemetry {
|
||||
pub fn new(client: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
|
||||
let platform = cx.platform();
|
||||
let this = Arc::new(Self {
|
||||
http_client: client,
|
||||
executor: cx.background().clone(),
|
||||
session_id: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis(),
|
||||
state: Mutex::new(AmplitudeTelemetryState {
|
||||
os_version: platform.os_version().ok().map(|v| v.to_string().into()),
|
||||
os_name: platform.os_name().into(),
|
||||
app_version: platform.app_version().ok().map(|v| v.to_string().into()),
|
||||
device_id: None,
|
||||
queue: Default::default(),
|
||||
flush_task: Default::default(),
|
||||
next_event_id: 0,
|
||||
log_file: None,
|
||||
metrics_id: None,
|
||||
}),
|
||||
});
|
||||
|
||||
if AMPLITUDE_API_KEY.is_some() {
|
||||
this.executor
|
||||
.spawn({
|
||||
let this = this.clone();
|
||||
async move {
|
||||
if let Some(tempfile) = NamedTempFile::new().log_err() {
|
||||
this.state.lock().log_file = Some(tempfile);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
pub fn log_file_path(&self) -> Option<PathBuf> {
|
||||
Some(self.state.lock().log_file.as_ref()?.path().to_path_buf())
|
||||
}
|
||||
|
||||
pub fn start(self: &Arc<Self>, db: Db) {
|
||||
let this = self.clone();
|
||||
self.executor
|
||||
.spawn(
|
||||
async move {
|
||||
let device_id = if let Ok(Some(device_id)) = db.read_kvp("device_id") {
|
||||
device_id
|
||||
} else {
|
||||
let device_id = Uuid::new_v4().to_string();
|
||||
db.write_kvp("device_id", &device_id)?;
|
||||
device_id
|
||||
};
|
||||
|
||||
let device_id = Some(Arc::from(device_id));
|
||||
let mut state = this.state.lock();
|
||||
state.device_id = device_id.clone();
|
||||
for event in &mut state.queue {
|
||||
event.device_id = device_id.clone();
|
||||
}
|
||||
if !state.queue.is_empty() {
|
||||
drop(state);
|
||||
this.flush();
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.log_err(),
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_authenticated_user_info(
|
||||
self: &Arc<Self>,
|
||||
metrics_id: Option<String>,
|
||||
is_staff: bool,
|
||||
) {
|
||||
let is_signed_in = metrics_id.is_some();
|
||||
self.state.lock().metrics_id = metrics_id.map(|s| s.into());
|
||||
if is_signed_in {
|
||||
self.report_event_with_user_properties(
|
||||
"$identify",
|
||||
Default::default(),
|
||||
json!({ "$set": { "staff": is_staff } }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn report_event(self: &Arc<Self>, kind: &str, properties: Value) {
|
||||
self.report_event_with_user_properties(kind, properties, Default::default());
|
||||
}
|
||||
|
||||
fn report_event_with_user_properties(
|
||||
self: &Arc<Self>,
|
||||
kind: &str,
|
||||
properties: Value,
|
||||
user_properties: Value,
|
||||
) {
|
||||
if AMPLITUDE_API_KEY.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut state = self.state.lock();
|
||||
let event = AmplitudeEvent {
|
||||
event_type: kind.to_string(),
|
||||
time: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis(),
|
||||
session_id: self.session_id,
|
||||
event_properties: if let Value::Object(properties) = properties {
|
||||
Some(properties)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
user_properties: if let Value::Object(user_properties) = user_properties {
|
||||
Some(user_properties)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
user_id: state.metrics_id.clone(),
|
||||
device_id: state.device_id.clone(),
|
||||
os_name: state.os_name,
|
||||
platform: "Zed",
|
||||
os_version: state.os_version.clone(),
|
||||
app_version: state.app_version.clone(),
|
||||
event_id: post_inc(&mut state.next_event_id),
|
||||
};
|
||||
state.queue.push(event);
|
||||
if state.device_id.is_some() {
|
||||
if state.queue.len() >= MAX_QUEUE_LEN {
|
||||
drop(state);
|
||||
self.flush();
|
||||
} else {
|
||||
let this = self.clone();
|
||||
let executor = self.executor.clone();
|
||||
state.flush_task = Some(self.executor.spawn(async move {
|
||||
executor.timer(DEBOUNCE_INTERVAL).await;
|
||||
this.flush();
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(self: &Arc<Self>) {
|
||||
let mut state = self.state.lock();
|
||||
let events = mem::take(&mut state.queue);
|
||||
state.flush_task.take();
|
||||
drop(state);
|
||||
|
||||
if let Some(api_key) = AMPLITUDE_API_KEY.as_ref() {
|
||||
let this = self.clone();
|
||||
self.executor
|
||||
.spawn(
|
||||
async move {
|
||||
let mut json_bytes = Vec::new();
|
||||
|
||||
if let Some(file) = &mut this.state.lock().log_file {
|
||||
let file = file.as_file_mut();
|
||||
for event in &events {
|
||||
json_bytes.clear();
|
||||
serde_json::to_writer(&mut json_bytes, event)?;
|
||||
file.write_all(&json_bytes)?;
|
||||
file.write(b"\n")?;
|
||||
}
|
||||
}
|
||||
|
||||
let batch = AmplitudeEventBatch { api_key, events };
|
||||
json_bytes.clear();
|
||||
serde_json::to_writer(&mut json_bytes, &batch)?;
|
||||
let request =
|
||||
Request::post(AMPLITUDE_EVENTS_URL).body(json_bytes.into())?;
|
||||
this.http_client.send(request).await?;
|
||||
Ok(())
|
||||
}
|
||||
.log_err(),
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,7 +530,7 @@ impl ChannelMessage {
|
||||
) -> Result<Self> {
|
||||
let sender = user_store
|
||||
.update(cx, |user_store, cx| {
|
||||
user_store.fetch_user(message.sender_id, cx)
|
||||
user_store.get_user(message.sender_id, cx)
|
||||
})
|
||||
.await?;
|
||||
Ok(ChannelMessage {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test;
|
||||
|
||||
pub mod amplitude_telemetry;
|
||||
pub mod channel;
|
||||
pub mod http;
|
||||
pub mod telemetry;
|
||||
pub mod user;
|
||||
|
||||
use amplitude_telemetry::AmplitudeTelemetry;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_recursion::async_recursion;
|
||||
use async_tungstenite::tungstenite::{
|
||||
@@ -13,11 +15,13 @@ use async_tungstenite::tungstenite::{
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use db::Db;
|
||||
use futures::{future::LocalBoxFuture, FutureExt, SinkExt, StreamExt, TryStreamExt};
|
||||
use futures::{future::LocalBoxFuture, AsyncReadExt, FutureExt, SinkExt, StreamExt, TryStreamExt};
|
||||
use gpui::{
|
||||
actions, serde_json::Value, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle,
|
||||
AnyWeakViewHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
|
||||
MutableAppContext, Task, View, ViewContext, ViewHandle,
|
||||
actions,
|
||||
serde_json::{self, Value},
|
||||
AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AppContext,
|
||||
AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
use http::HttpClient;
|
||||
use lazy_static::lazy_static;
|
||||
@@ -25,6 +29,8 @@ use parking_lot::RwLock;
|
||||
use postage::watch;
|
||||
use rand::prelude::*;
|
||||
use rpc::proto::{AnyTypedEnvelope, EntityMessage, EnvelopedMessage, RequestMessage};
|
||||
use serde::Deserialize;
|
||||
use settings::ReleaseChannel;
|
||||
use std::{
|
||||
any::TypeId,
|
||||
collections::HashMap,
|
||||
@@ -50,9 +56,14 @@ lazy_static! {
|
||||
pub static ref IMPERSONATE_LOGIN: Option<String> = std::env::var("ZED_IMPERSONATE")
|
||||
.ok()
|
||||
.and_then(|s| if s.is_empty() { None } else { Some(s) });
|
||||
pub static ref ADMIN_API_TOKEN: Option<String> = std::env::var("ZED_ADMIN_API_TOKEN")
|
||||
.ok()
|
||||
.and_then(|s| if s.is_empty() { None } else { Some(s) });
|
||||
}
|
||||
|
||||
pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894";
|
||||
pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(100);
|
||||
pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
actions!(client, [Authenticate]);
|
||||
|
||||
@@ -74,6 +85,7 @@ pub struct Client {
|
||||
peer: Arc<Peer>,
|
||||
http: Arc<dyn HttpClient>,
|
||||
telemetry: Arc<Telemetry>,
|
||||
amplitude_telemetry: Arc<AmplitudeTelemetry>,
|
||||
state: RwLock<ClientState>,
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -141,11 +153,16 @@ pub enum Status {
|
||||
Authenticating,
|
||||
Connecting,
|
||||
ConnectionError,
|
||||
Connected { connection_id: ConnectionId },
|
||||
Connected {
|
||||
peer_id: PeerId,
|
||||
connection_id: ConnectionId,
|
||||
},
|
||||
ConnectionLost,
|
||||
Reauthenticating,
|
||||
Reconnecting,
|
||||
ReconnectionError { next_reconnection: Instant },
|
||||
ReconnectionError {
|
||||
next_reconnection: Instant,
|
||||
},
|
||||
}
|
||||
|
||||
impl Status {
|
||||
@@ -248,6 +265,7 @@ impl Client {
|
||||
id: 0,
|
||||
peer: Peer::new(),
|
||||
telemetry: Telemetry::new(http.clone(), cx),
|
||||
amplitude_telemetry: AmplitudeTelemetry::new(http.clone(), cx),
|
||||
http,
|
||||
state: Default::default(),
|
||||
|
||||
@@ -312,6 +330,14 @@ impl Client {
|
||||
.map(|credentials| credentials.user_id)
|
||||
}
|
||||
|
||||
pub fn peer_id(&self) -> Option<PeerId> {
|
||||
if let Status::Connected { peer_id, .. } = &*self.status().borrow() {
|
||||
Some(*peer_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> watch::Receiver<Status> {
|
||||
self.state.read().status.1.clone()
|
||||
}
|
||||
@@ -330,7 +356,7 @@ impl Client {
|
||||
let reconnect_interval = state.reconnect_interval;
|
||||
state._reconnect_task = Some(cx.spawn(|cx| async move {
|
||||
let mut rng = StdRng::from_entropy();
|
||||
let mut delay = Duration::from_millis(100);
|
||||
let mut delay = INITIAL_RECONNECTION_DELAY;
|
||||
while let Err(error) = this.authenticate_and_connect(true, &cx).await {
|
||||
log::error!("failed to connect {}", error);
|
||||
if matches!(*this.status().borrow(), Status::ConnectionError) {
|
||||
@@ -351,7 +377,9 @@ impl Client {
|
||||
}));
|
||||
}
|
||||
Status::SignedOut | Status::UpgradeRequired => {
|
||||
self.telemetry.set_metrics_id(None);
|
||||
self.telemetry.set_authenticated_user_info(None, false);
|
||||
self.amplitude_telemetry
|
||||
.set_authenticated_user_info(None, false);
|
||||
state._reconnect_task.take();
|
||||
}
|
||||
_ => {}
|
||||
@@ -434,6 +462,29 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_request_handler<M, E, H, F>(
|
||||
self: &Arc<Self>,
|
||||
model: ModelHandle<E>,
|
||||
handler: H,
|
||||
) -> Subscription
|
||||
where
|
||||
M: RequestMessage,
|
||||
E: Entity,
|
||||
H: 'static
|
||||
+ Send
|
||||
+ Sync
|
||||
+ Fn(ModelHandle<E>, TypedEnvelope<M>, Arc<Self>, AsyncAppContext) -> F,
|
||||
F: 'static + Future<Output = Result<M::Response>>,
|
||||
{
|
||||
self.add_message_handler(model, move |handle, envelope, this, cx| {
|
||||
Self::respond_to_request(
|
||||
envelope.receipt(),
|
||||
handler(handle, envelope, this.clone(), cx),
|
||||
this,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_view_message_handler<M, E, H, F>(self: &Arc<Self>, handler: H)
|
||||
where
|
||||
M: EntityMessage,
|
||||
@@ -638,46 +689,104 @@ impl Client {
|
||||
self.set_status(Status::Reconnecting, cx);
|
||||
}
|
||||
|
||||
match self.establish_connection(&credentials, cx).await {
|
||||
Ok(conn) => {
|
||||
self.state.write().credentials = Some(credentials.clone());
|
||||
if !read_from_keychain && IMPERSONATE_LOGIN.is_none() {
|
||||
write_credentials_to_keychain(&credentials, cx).log_err();
|
||||
}
|
||||
self.set_connection(conn, cx).await;
|
||||
Ok(())
|
||||
}
|
||||
Err(EstablishConnectionError::Unauthorized) => {
|
||||
self.state.write().credentials.take();
|
||||
if read_from_keychain {
|
||||
cx.platform().delete_credentials(&ZED_SERVER_URL).log_err();
|
||||
self.set_status(Status::SignedOut, cx);
|
||||
self.authenticate_and_connect(false, cx).await
|
||||
} else {
|
||||
self.set_status(Status::ConnectionError, cx);
|
||||
Err(EstablishConnectionError::Unauthorized)?
|
||||
let mut timeout = cx.background().timer(CONNECTION_TIMEOUT).fuse();
|
||||
futures::select_biased! {
|
||||
connection = self.establish_connection(&credentials, cx).fuse() => {
|
||||
match connection {
|
||||
Ok(conn) => {
|
||||
self.state.write().credentials = Some(credentials.clone());
|
||||
if !read_from_keychain && IMPERSONATE_LOGIN.is_none() {
|
||||
write_credentials_to_keychain(&credentials, cx).log_err();
|
||||
}
|
||||
|
||||
futures::select_biased! {
|
||||
result = self.set_connection(conn, cx).fuse() => result,
|
||||
_ = timeout => {
|
||||
self.set_status(Status::ConnectionError, cx);
|
||||
Err(anyhow!("timed out waiting on hello message from server"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(EstablishConnectionError::Unauthorized) => {
|
||||
self.state.write().credentials.take();
|
||||
if read_from_keychain {
|
||||
cx.platform().delete_credentials(&ZED_SERVER_URL).log_err();
|
||||
self.set_status(Status::SignedOut, cx);
|
||||
self.authenticate_and_connect(false, cx).await
|
||||
} else {
|
||||
self.set_status(Status::ConnectionError, cx);
|
||||
Err(EstablishConnectionError::Unauthorized)?
|
||||
}
|
||||
}
|
||||
Err(EstablishConnectionError::UpgradeRequired) => {
|
||||
self.set_status(Status::UpgradeRequired, cx);
|
||||
Err(EstablishConnectionError::UpgradeRequired)?
|
||||
}
|
||||
Err(error) => {
|
||||
self.set_status(Status::ConnectionError, cx);
|
||||
Err(error)?
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(EstablishConnectionError::UpgradeRequired) => {
|
||||
self.set_status(Status::UpgradeRequired, cx);
|
||||
Err(EstablishConnectionError::UpgradeRequired)?
|
||||
}
|
||||
Err(error) => {
|
||||
_ = &mut timeout => {
|
||||
self.set_status(Status::ConnectionError, cx);
|
||||
Err(error)?
|
||||
Err(anyhow!("timed out trying to establish connection"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_connection(self: &Arc<Self>, conn: Connection, cx: &AsyncAppContext) {
|
||||
async fn set_connection(
|
||||
self: &Arc<Self>,
|
||||
conn: Connection,
|
||||
cx: &AsyncAppContext,
|
||||
) -> Result<()> {
|
||||
let executor = cx.background();
|
||||
log::info!("add connection to peer");
|
||||
let (connection_id, handle_io, mut incoming) = self
|
||||
.peer
|
||||
.add_connection(conn, move |duration| executor.timer(duration))
|
||||
.await;
|
||||
log::info!("set status to connected {}", connection_id);
|
||||
self.set_status(Status::Connected { connection_id }, cx);
|
||||
.add_connection(conn, move |duration| executor.timer(duration));
|
||||
let handle_io = cx.background().spawn(handle_io);
|
||||
|
||||
let peer_id = async {
|
||||
log::info!("waiting for server hello");
|
||||
let message = incoming
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("no hello message received"))?;
|
||||
log::info!("got server hello");
|
||||
let hello_message_type_name = message.payload_type_name().to_string();
|
||||
let hello = message
|
||||
.into_any()
|
||||
.downcast::<TypedEnvelope<proto::Hello>>()
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"invalid hello message received: {:?}",
|
||||
hello_message_type_name
|
||||
)
|
||||
})?;
|
||||
Ok(PeerId(hello.payload.peer_id))
|
||||
};
|
||||
|
||||
let peer_id = match peer_id.await {
|
||||
Ok(peer_id) => peer_id,
|
||||
Err(error) => {
|
||||
self.peer.disconnect(connection_id);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"set status to connected (connection id: {}, peer id: {})",
|
||||
connection_id,
|
||||
peer_id
|
||||
);
|
||||
self.set_status(
|
||||
Status::Connected {
|
||||
peer_id,
|
||||
connection_id,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
cx.foreground()
|
||||
.spawn({
|
||||
let cx = cx.clone();
|
||||
@@ -775,14 +884,18 @@ impl Client {
|
||||
})
|
||||
.detach();
|
||||
|
||||
let handle_io = cx.background().spawn(handle_io);
|
||||
let this = self.clone();
|
||||
let cx = cx.clone();
|
||||
cx.foreground()
|
||||
.spawn(async move {
|
||||
match handle_io.await {
|
||||
Ok(()) => {
|
||||
if *this.status().borrow() == (Status::Connected { connection_id }) {
|
||||
if *this.status().borrow()
|
||||
== (Status::Connected {
|
||||
connection_id,
|
||||
peer_id,
|
||||
})
|
||||
{
|
||||
this.set_status(Status::SignedOut, &cx);
|
||||
}
|
||||
}
|
||||
@@ -793,6 +906,8 @@ impl Client {
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn authenticate(self: &Arc<Self>, cx: &AsyncAppContext) -> Task<Result<Credentials>> {
|
||||
@@ -817,11 +932,51 @@ impl Client {
|
||||
self.establish_websocket_connection(credentials, cx)
|
||||
}
|
||||
|
||||
async fn get_rpc_url(http: Arc<dyn HttpClient>, is_preview: bool) -> Result<Url> {
|
||||
let preview_param = if is_preview { "?preview=1" } else { "" };
|
||||
let url = format!("{}/rpc{preview_param}", *ZED_SERVER_URL);
|
||||
let response = http.get(&url, Default::default(), false).await?;
|
||||
|
||||
// Normally, ZED_SERVER_URL is set to the URL of zed.dev website.
|
||||
// The website's /rpc endpoint redirects to a collab server's /rpc endpoint,
|
||||
// which requires authorization via an HTTP header.
|
||||
//
|
||||
// For testing purposes, ZED_SERVER_URL can also set to the direct URL of
|
||||
// of a collab server. In that case, a request to the /rpc endpoint will
|
||||
// return an 'unauthorized' response.
|
||||
let collab_url = if response.status().is_redirection() {
|
||||
response
|
||||
.headers()
|
||||
.get("Location")
|
||||
.ok_or_else(|| anyhow!("missing location header in /rpc response"))?
|
||||
.to_str()
|
||||
.map_err(EstablishConnectionError::other)?
|
||||
.to_string()
|
||||
} else if response.status() == StatusCode::UNAUTHORIZED {
|
||||
url
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"unexpected /rpc response status {}",
|
||||
response.status()
|
||||
))?
|
||||
};
|
||||
|
||||
Url::parse(&collab_url).context("invalid rpc url")
|
||||
}
|
||||
|
||||
fn establish_websocket_connection(
|
||||
self: &Arc<Self>,
|
||||
credentials: &Credentials,
|
||||
cx: &AsyncAppContext,
|
||||
) -> Task<Result<Connection, EstablishConnectionError>> {
|
||||
let is_preview = cx.read(|cx| {
|
||||
if cx.has_global::<ReleaseChannel>() {
|
||||
*cx.global::<ReleaseChannel>() == ReleaseChannel::Preview
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
let request = Request::builder()
|
||||
.header(
|
||||
"Authorization",
|
||||
@@ -831,28 +986,7 @@ impl Client {
|
||||
|
||||
let http = self.http.clone();
|
||||
cx.background().spawn(async move {
|
||||
let mut rpc_url = format!("{}/rpc", *ZED_SERVER_URL);
|
||||
let rpc_response = http.get(&rpc_url, Default::default(), false).await?;
|
||||
if rpc_response.status().is_redirection() {
|
||||
rpc_url = rpc_response
|
||||
.headers()
|
||||
.get("Location")
|
||||
.ok_or_else(|| anyhow!("missing location header in /rpc response"))?
|
||||
.to_str()
|
||||
.map_err(EstablishConnectionError::other)?
|
||||
.to_string();
|
||||
}
|
||||
// Until we switch the zed.dev domain to point to the new Next.js app, there
|
||||
// will be no redirect required, and the app will connect directly to
|
||||
// wss://zed.dev/rpc.
|
||||
else if rpc_response.status() != StatusCode::UPGRADE_REQUIRED {
|
||||
Err(anyhow!(
|
||||
"unexpected /rpc response status {}",
|
||||
rpc_response.status()
|
||||
))?
|
||||
}
|
||||
|
||||
let mut rpc_url = Url::parse(&rpc_url).context("invalid rpc url")?;
|
||||
let mut rpc_url = Self::get_rpc_url(http, is_preview).await?;
|
||||
let rpc_host = rpc_url
|
||||
.host_str()
|
||||
.zip(rpc_url.port_or_known_default())
|
||||
@@ -895,6 +1029,8 @@ impl Client {
|
||||
let platform = cx.platform();
|
||||
let executor = cx.background();
|
||||
let telemetry = self.telemetry.clone();
|
||||
let amplitude_telemetry = self.amplitude_telemetry.clone();
|
||||
let http = self.http.clone();
|
||||
executor.clone().spawn(async move {
|
||||
// Generate a pair of asymmetric encryption keys. The public key will be used by the
|
||||
// zed server to encrypt the user's access token, so that it can'be intercepted by
|
||||
@@ -904,6 +1040,10 @@ impl Client {
|
||||
let public_key_string =
|
||||
String::try_from(public_key).expect("failed to serialize public key for auth");
|
||||
|
||||
if let Some((login, token)) = IMPERSONATE_LOGIN.as_ref().zip(ADMIN_API_TOKEN.as_ref()) {
|
||||
return Self::authenticate_as_admin(http, login.clone(), token.clone()).await;
|
||||
}
|
||||
|
||||
// Start an HTTP server to receive the redirect from Zed's sign-in page.
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").expect("failed to find open port");
|
||||
let port = server.server_addr().port();
|
||||
@@ -974,6 +1114,7 @@ impl Client {
|
||||
platform.activate(true);
|
||||
|
||||
telemetry.report_event("authenticate with browser", Default::default());
|
||||
amplitude_telemetry.report_event("authenticate with browser", Default::default());
|
||||
|
||||
Ok(Credentials {
|
||||
user_id: user_id.parse()?,
|
||||
@@ -982,6 +1123,50 @@ impl Client {
|
||||
})
|
||||
}
|
||||
|
||||
async fn authenticate_as_admin(
|
||||
http: Arc<dyn HttpClient>,
|
||||
login: String,
|
||||
mut api_token: String,
|
||||
) -> Result<Credentials> {
|
||||
#[derive(Deserialize)]
|
||||
struct AuthenticatedUserResponse {
|
||||
user: User,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct User {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
// Use the collab server's admin API to retrieve the id
|
||||
// of the impersonated user.
|
||||
let mut url = Self::get_rpc_url(http.clone(), false).await?;
|
||||
url.set_path("/user");
|
||||
url.set_query(Some(&format!("github_login={login}")));
|
||||
let request = Request::get(url.as_str())
|
||||
.header("Authorization", format!("token {api_token}"))
|
||||
.body("".into())?;
|
||||
|
||||
let mut response = http.send(request).await?;
|
||||
let mut body = String::new();
|
||||
response.body_mut().read_to_string(&mut body).await?;
|
||||
if !response.status().is_success() {
|
||||
Err(anyhow!(
|
||||
"admin user request failed {} - {}",
|
||||
response.status().as_u16(),
|
||||
body,
|
||||
))?;
|
||||
}
|
||||
let response: AuthenticatedUserResponse = serde_json::from_str(&body)?;
|
||||
|
||||
// Use the admin API token to authenticate as the impersonated user.
|
||||
api_token.insert_str(0, "ADMIN_TOKEN:");
|
||||
Ok(Credentials {
|
||||
user_id: response.user.id,
|
||||
access_token: api_token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn disconnect(self: &Arc<Self>, cx: &AsyncAppContext) -> Result<()> {
|
||||
let conn_id = self.connection_id()?;
|
||||
self.peer.disconnect(conn_id);
|
||||
@@ -1040,15 +1225,18 @@ impl Client {
|
||||
self.peer.respond_with_error(receipt, error)
|
||||
}
|
||||
|
||||
pub fn start_telemetry(&self, db: Arc<Db>) {
|
||||
self.telemetry.start(db);
|
||||
pub fn start_telemetry(&self, db: Db) {
|
||||
self.telemetry.start(db.clone());
|
||||
self.amplitude_telemetry.start(db);
|
||||
}
|
||||
|
||||
pub fn report_event(&self, kind: &str, properties: Value) {
|
||||
self.telemetry.report_event(kind, properties)
|
||||
self.telemetry.report_event(kind, properties.clone());
|
||||
self.amplitude_telemetry.report_event(kind, properties);
|
||||
}
|
||||
|
||||
pub fn telemetry_log_file_path(&self) -> Option<PathBuf> {
|
||||
self.amplitude_telemetry.log_file_path();
|
||||
self.telemetry.log_file_path()
|
||||
}
|
||||
}
|
||||
@@ -1146,6 +1334,76 @@ mod tests {
|
||||
assert_eq!(server.auth_count(), 2); // Client re-authenticated due to an invalid token
|
||||
}
|
||||
|
||||
#[gpui::test(iterations = 10)]
|
||||
async fn test_connection_timeout(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
|
||||
deterministic.forbid_parking();
|
||||
|
||||
let user_id = 5;
|
||||
let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
|
||||
let mut status = client.status();
|
||||
|
||||
// Time out when client tries to connect.
|
||||
client.override_authenticate(move |cx| {
|
||||
cx.foreground().spawn(async move {
|
||||
Ok(Credentials {
|
||||
user_id,
|
||||
access_token: "token".into(),
|
||||
})
|
||||
})
|
||||
});
|
||||
client.override_establish_connection(|_, cx| {
|
||||
cx.foreground().spawn(async move {
|
||||
future::pending::<()>().await;
|
||||
unreachable!()
|
||||
})
|
||||
});
|
||||
let auth_and_connect = cx.spawn({
|
||||
let client = client.clone();
|
||||
|cx| async move { client.authenticate_and_connect(false, &cx).await }
|
||||
});
|
||||
deterministic.run_until_parked();
|
||||
assert!(matches!(status.next().await, Some(Status::Connecting)));
|
||||
|
||||
deterministic.advance_clock(CONNECTION_TIMEOUT);
|
||||
assert!(matches!(
|
||||
status.next().await,
|
||||
Some(Status::ConnectionError { .. })
|
||||
));
|
||||
auth_and_connect.await.unwrap_err();
|
||||
|
||||
// Allow the connection to be established.
|
||||
let server = FakeServer::for_client(user_id, &client, cx).await;
|
||||
assert!(matches!(
|
||||
status.next().await,
|
||||
Some(Status::Connected { .. })
|
||||
));
|
||||
|
||||
// Disconnect client.
|
||||
server.forbid_connections();
|
||||
server.disconnect();
|
||||
while !matches!(status.next().await, Some(Status::ReconnectionError { .. })) {}
|
||||
|
||||
// Time out when re-establishing the connection.
|
||||
server.allow_connections();
|
||||
client.override_establish_connection(|_, cx| {
|
||||
cx.foreground().spawn(async move {
|
||||
future::pending::<()>().await;
|
||||
unreachable!()
|
||||
})
|
||||
});
|
||||
deterministic.advance_clock(2 * INITIAL_RECONNECTION_DELAY);
|
||||
assert!(matches!(
|
||||
status.next().await,
|
||||
Some(Status::Reconnecting { .. })
|
||||
));
|
||||
|
||||
deterministic.advance_clock(CONNECTION_TIMEOUT);
|
||||
assert!(matches!(
|
||||
status.next().await,
|
||||
Some(Status::ReconnectionError { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[gpui::test(iterations = 10)]
|
||||
async fn test_authenticating_more_than_once(
|
||||
cx: &mut TestAppContext,
|
||||
|
||||
@@ -9,6 +9,8 @@ use isahc::Request;
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use settings::ReleaseChannel;
|
||||
use std::{
|
||||
io::Write,
|
||||
mem,
|
||||
@@ -23,7 +25,6 @@ use uuid::Uuid;
|
||||
pub struct Telemetry {
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
executor: Arc<Background>,
|
||||
session_id: u128,
|
||||
state: Mutex<TelemetryState>,
|
||||
}
|
||||
|
||||
@@ -32,50 +33,63 @@ struct TelemetryState {
|
||||
metrics_id: Option<Arc<str>>,
|
||||
device_id: Option<Arc<str>>,
|
||||
app_version: Option<Arc<str>>,
|
||||
release_channel: Option<&'static str>,
|
||||
os_version: Option<Arc<str>>,
|
||||
os_name: &'static str,
|
||||
queue: Vec<AmplitudeEvent>,
|
||||
queue: Vec<MixpanelEvent>,
|
||||
next_event_id: usize,
|
||||
flush_task: Option<Task<()>>,
|
||||
log_file: Option<NamedTempFile>,
|
||||
}
|
||||
|
||||
const AMPLITUDE_EVENTS_URL: &'static str = "https://api2.amplitude.com/batch";
|
||||
const MIXPANEL_EVENTS_URL: &'static str = "https://api.mixpanel.com/track";
|
||||
const MIXPANEL_ENGAGE_URL: &'static str = "https://api.mixpanel.com/engage#profile-set";
|
||||
|
||||
lazy_static! {
|
||||
static ref AMPLITUDE_API_KEY: Option<String> = std::env::var("ZED_AMPLITUDE_API_KEY")
|
||||
static ref MIXPANEL_TOKEN: Option<String> = std::env::var("ZED_MIXPANEL_TOKEN")
|
||||
.ok()
|
||||
.or_else(|| option_env!("ZED_AMPLITUDE_API_KEY").map(|key| key.to_string()));
|
||||
.or_else(|| option_env!("ZED_MIXPANEL_TOKEN").map(|key| key.to_string()));
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AmplitudeEventBatch {
|
||||
api_key: &'static str,
|
||||
events: Vec<AmplitudeEvent>,
|
||||
options: AmplitudeEventBatchOptions,
|
||||
#[derive(Serialize, Debug)]
|
||||
struct MixpanelEvent {
|
||||
event: String,
|
||||
properties: MixpanelEventProperties,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AmplitudeEventBatchOptions {
|
||||
min_id_length: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AmplitudeEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user_id: Option<Arc<str>>,
|
||||
device_id: Option<Arc<str>>,
|
||||
event_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
event_properties: Option<Map<String, Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user_properties: Option<Map<String, Value>>,
|
||||
os_name: &'static str,
|
||||
os_version: Option<Arc<str>>,
|
||||
app_version: Option<Arc<str>>,
|
||||
event_id: usize,
|
||||
session_id: u128,
|
||||
#[derive(Serialize, Debug)]
|
||||
struct MixpanelEventProperties {
|
||||
// Mixpanel required fields
|
||||
#[serde(skip_serializing_if = "str::is_empty")]
|
||||
token: &'static str,
|
||||
time: u128,
|
||||
distinct_id: Option<Arc<str>>,
|
||||
#[serde(rename = "$insert_id")]
|
||||
insert_id: usize,
|
||||
// Custom fields
|
||||
#[serde(skip_serializing_if = "Option::is_none", flatten)]
|
||||
event_properties: Option<Map<String, Value>>,
|
||||
#[serde(rename = "OS Name")]
|
||||
os_name: &'static str,
|
||||
#[serde(rename = "OS Version")]
|
||||
os_version: Option<Arc<str>>,
|
||||
#[serde(rename = "Release Channel")]
|
||||
release_channel: Option<&'static str>,
|
||||
#[serde(rename = "App Version")]
|
||||
app_version: Option<Arc<str>>,
|
||||
#[serde(rename = "Signed In")]
|
||||
signed_in: bool,
|
||||
platform: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MixpanelEngageRequest {
|
||||
#[serde(rename = "$token")]
|
||||
token: &'static str,
|
||||
#[serde(rename = "$distinct_id")]
|
||||
distinct_id: Arc<str>,
|
||||
#[serde(rename = "$set")]
|
||||
set: Value,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
@@ -93,33 +107,29 @@ const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(30);
|
||||
impl Telemetry {
|
||||
pub fn new(client: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
|
||||
let platform = cx.platform();
|
||||
let release_channel = if cx.has_global::<ReleaseChannel>() {
|
||||
Some(cx.global::<ReleaseChannel>().name())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let this = Arc::new(Self {
|
||||
http_client: client,
|
||||
executor: cx.background().clone(),
|
||||
session_id: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis(),
|
||||
state: Mutex::new(TelemetryState {
|
||||
os_version: platform
|
||||
.os_version()
|
||||
.log_err()
|
||||
.map(|v| v.to_string().into()),
|
||||
os_version: platform.os_version().ok().map(|v| v.to_string().into()),
|
||||
os_name: platform.os_name().into(),
|
||||
app_version: platform
|
||||
.app_version()
|
||||
.log_err()
|
||||
.map(|v| v.to_string().into()),
|
||||
app_version: platform.app_version().ok().map(|v| v.to_string().into()),
|
||||
release_channel,
|
||||
device_id: None,
|
||||
metrics_id: None,
|
||||
queue: Default::default(),
|
||||
flush_task: Default::default(),
|
||||
next_event_id: 0,
|
||||
log_file: None,
|
||||
metrics_id: None,
|
||||
}),
|
||||
});
|
||||
|
||||
if AMPLITUDE_API_KEY.is_some() {
|
||||
if MIXPANEL_TOKEN.is_some() {
|
||||
this.executor
|
||||
.spawn({
|
||||
let this = this.clone();
|
||||
@@ -139,30 +149,27 @@ impl Telemetry {
|
||||
Some(self.state.lock().log_file.as_ref()?.path().to_path_buf())
|
||||
}
|
||||
|
||||
pub fn start(self: &Arc<Self>, db: Arc<Db>) {
|
||||
pub fn start(self: &Arc<Self>, db: Db) {
|
||||
let this = self.clone();
|
||||
self.executor
|
||||
.spawn(
|
||||
async move {
|
||||
let device_id = if let Some(device_id) = db
|
||||
.read(["device_id"])?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.next()
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
{
|
||||
let device_id = if let Ok(Some(device_id)) = db.read_kvp("device_id") {
|
||||
device_id
|
||||
} else {
|
||||
let device_id = Uuid::new_v4().to_string();
|
||||
db.write([("device_id", device_id.as_bytes())])?;
|
||||
db.write_kvp("device_id", &device_id)?;
|
||||
device_id
|
||||
};
|
||||
|
||||
let device_id = Some(Arc::from(device_id));
|
||||
let device_id: Arc<str> = device_id.into();
|
||||
let mut state = this.state.lock();
|
||||
state.device_id = device_id.clone();
|
||||
state.device_id = Some(device_id.clone());
|
||||
for event in &mut state.queue {
|
||||
event.device_id = device_id.clone();
|
||||
event
|
||||
.properties
|
||||
.distinct_id
|
||||
.get_or_insert_with(|| device_id.clone());
|
||||
}
|
||||
if !state.queue.is_empty() {
|
||||
drop(state);
|
||||
@@ -176,35 +183,63 @@ impl Telemetry {
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn set_metrics_id(&self, metrics_id: Option<String>) {
|
||||
self.state.lock().metrics_id = metrics_id.map(|s| s.into());
|
||||
pub fn set_authenticated_user_info(
|
||||
self: &Arc<Self>,
|
||||
metrics_id: Option<String>,
|
||||
is_staff: bool,
|
||||
) {
|
||||
let this = self.clone();
|
||||
let mut state = self.state.lock();
|
||||
let device_id = state.device_id.clone();
|
||||
let metrics_id: Option<Arc<str>> = metrics_id.map(|id| id.into());
|
||||
state.metrics_id = metrics_id.clone();
|
||||
drop(state);
|
||||
|
||||
if let Some((token, device_id)) = MIXPANEL_TOKEN.as_ref().zip(device_id) {
|
||||
self.executor
|
||||
.spawn(
|
||||
async move {
|
||||
let json_bytes = serde_json::to_vec(&[MixpanelEngageRequest {
|
||||
token,
|
||||
distinct_id: device_id,
|
||||
set: json!({ "Staff": is_staff, "ID": metrics_id }),
|
||||
}])?;
|
||||
let request = Request::post(MIXPANEL_ENGAGE_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(json_bytes.into())?;
|
||||
this.http_client.send(request).await?;
|
||||
Ok(())
|
||||
}
|
||||
.log_err(),
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn report_event(self: &Arc<Self>, kind: &str, properties: Value) {
|
||||
if AMPLITUDE_API_KEY.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut state = self.state.lock();
|
||||
let event = AmplitudeEvent {
|
||||
event_type: kind.to_string(),
|
||||
time: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis(),
|
||||
session_id: self.session_id,
|
||||
event_properties: if let Value::Object(properties) = properties {
|
||||
Some(properties)
|
||||
} else {
|
||||
None
|
||||
let event = MixpanelEvent {
|
||||
event: kind.to_string(),
|
||||
properties: MixpanelEventProperties {
|
||||
token: "",
|
||||
time: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis(),
|
||||
distinct_id: state.device_id.clone(),
|
||||
insert_id: post_inc(&mut state.next_event_id),
|
||||
event_properties: if let Value::Object(properties) = properties {
|
||||
Some(properties)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
os_name: state.os_name,
|
||||
os_version: state.os_version.clone(),
|
||||
release_channel: state.release_channel,
|
||||
app_version: state.app_version.clone(),
|
||||
signed_in: state.metrics_id.is_some(),
|
||||
platform: "Zed",
|
||||
},
|
||||
user_properties: None,
|
||||
user_id: state.metrics_id.clone(),
|
||||
device_id: state.device_id.clone(),
|
||||
os_name: state.os_name,
|
||||
os_version: state.os_version.clone(),
|
||||
app_version: state.app_version.clone(),
|
||||
event_id: post_inc(&mut state.next_event_id),
|
||||
};
|
||||
state.queue.push(event);
|
||||
if state.device_id.is_some() {
|
||||
@@ -224,11 +259,11 @@ impl Telemetry {
|
||||
|
||||
fn flush(self: &Arc<Self>) {
|
||||
let mut state = self.state.lock();
|
||||
let events = mem::take(&mut state.queue);
|
||||
let mut events = mem::take(&mut state.queue);
|
||||
state.flush_task.take();
|
||||
drop(state);
|
||||
|
||||
if let Some(api_key) = AMPLITUDE_API_KEY.as_ref() {
|
||||
if let Some(token) = MIXPANEL_TOKEN.as_ref() {
|
||||
let this = self.clone();
|
||||
self.executor
|
||||
.spawn(
|
||||
@@ -237,23 +272,21 @@ impl Telemetry {
|
||||
|
||||
if let Some(file) = &mut this.state.lock().log_file {
|
||||
let file = file.as_file_mut();
|
||||
for event in &events {
|
||||
for event in &mut events {
|
||||
json_bytes.clear();
|
||||
serde_json::to_writer(&mut json_bytes, event)?;
|
||||
file.write_all(&json_bytes)?;
|
||||
file.write(b"\n")?;
|
||||
|
||||
event.properties.token = token;
|
||||
}
|
||||
}
|
||||
|
||||
let batch = AmplitudeEventBatch {
|
||||
api_key,
|
||||
events,
|
||||
options: AmplitudeEventBatchOptions { min_id_length: 1 },
|
||||
};
|
||||
json_bytes.clear();
|
||||
serde_json::to_writer(&mut json_bytes, &batch)?;
|
||||
let request =
|
||||
Request::post(AMPLITUDE_EVENTS_URL).body(json_bytes.into())?;
|
||||
serde_json::to_writer(&mut json_bytes, &events)?;
|
||||
let request = Request::post(MIXPANEL_EVENTS_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(json_bytes.into())?;
|
||||
this.http_client.send(request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -82,11 +82,21 @@ impl FakeServer {
|
||||
|
||||
let (client_conn, server_conn, _) = Connection::in_memory(cx.background());
|
||||
let (connection_id, io, incoming) =
|
||||
peer.add_test_connection(server_conn, cx.background()).await;
|
||||
peer.add_test_connection(server_conn, cx.background());
|
||||
cx.background().spawn(io).detach();
|
||||
let mut state = state.lock();
|
||||
state.connection_id = Some(connection_id);
|
||||
state.incoming = Some(incoming);
|
||||
{
|
||||
let mut state = state.lock();
|
||||
state.connection_id = Some(connection_id);
|
||||
state.incoming = Some(incoming);
|
||||
}
|
||||
peer.send(
|
||||
connection_id,
|
||||
proto::Hello {
|
||||
peer_id: connection_id.0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Ok(client_conn)
|
||||
})
|
||||
}
|
||||
@@ -101,10 +111,12 @@ impl FakeServer {
|
||||
}
|
||||
|
||||
pub fn disconnect(&self) {
|
||||
self.peer.disconnect(self.connection_id());
|
||||
let mut state = self.state.lock();
|
||||
state.connection_id.take();
|
||||
state.incoming.take();
|
||||
if self.state.lock().connection_id.is_some() {
|
||||
self.peer.disconnect(self.connection_id());
|
||||
let mut state = self.state.lock();
|
||||
state.connection_id.take();
|
||||
state.incoming.take();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth_count(&self) -> usize {
|
||||
@@ -157,6 +169,7 @@ impl FakeServer {
|
||||
.receipt(),
|
||||
GetPrivateUserInfoResponse {
|
||||
metrics_id: "the-metrics-id".into(),
|
||||
staff: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use super::{http::HttpClient, proto, Client, Status, TypedEnvelope};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use collections::{hash_map::Entry, BTreeSet, HashMap, HashSet};
|
||||
use collections::{hash_map::Entry, HashMap, HashSet};
|
||||
use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt};
|
||||
use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task};
|
||||
use postage::{prelude::Stream, sink::Sink, watch};
|
||||
use postage::{sink::Sink, watch};
|
||||
use rpc::proto::{RequestMessage, UsersResponse};
|
||||
use std::sync::{Arc, Weak};
|
||||
use util::TryFutureExt as _;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Default, Debug)]
|
||||
pub struct User {
|
||||
pub id: u64,
|
||||
pub github_login: String,
|
||||
@@ -39,14 +39,7 @@ impl Eq for User {}
|
||||
pub struct Contact {
|
||||
pub user: Arc<User>,
|
||||
pub online: bool,
|
||||
pub projects: Vec<ProjectMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProjectMetadata {
|
||||
pub id: u64,
|
||||
pub visible_worktree_root_names: Vec<String>,
|
||||
pub guests: BTreeSet<Arc<User>>,
|
||||
pub busy: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -138,18 +131,36 @@ impl UserStore {
|
||||
}),
|
||||
_maintain_current_user: cx.spawn_weak(|this, mut cx| async move {
|
||||
let mut status = client.status();
|
||||
while let Some(status) = status.recv().await {
|
||||
while let Some(status) = status.next().await {
|
||||
match status {
|
||||
Status::Connected { .. } => {
|
||||
if let Some((this, user_id)) = this.upgrade(&cx).zip(client.user_id()) {
|
||||
let fetch_user = this
|
||||
.update(&mut cx, |this, cx| this.fetch_user(user_id, cx))
|
||||
.update(&mut cx, |this, cx| this.get_user(user_id, cx))
|
||||
.log_err();
|
||||
let fetch_metrics_id =
|
||||
client.request(proto::GetPrivateUserInfo {}).log_err();
|
||||
let (user, info) = futures::join!(fetch_user, fetch_metrics_id);
|
||||
client.telemetry.set_metrics_id(info.map(|i| i.metrics_id));
|
||||
if let Some(info) = info {
|
||||
client.telemetry.set_authenticated_user_info(
|
||||
Some(info.metrics_id.clone()),
|
||||
info.staff,
|
||||
);
|
||||
client.amplitude_telemetry.set_authenticated_user_info(
|
||||
Some(info.metrics_id),
|
||||
info.staff,
|
||||
);
|
||||
} else {
|
||||
client.telemetry.set_authenticated_user_info(None, false);
|
||||
client
|
||||
.amplitude_telemetry
|
||||
.set_authenticated_user_info(None, false);
|
||||
}
|
||||
|
||||
client.telemetry.report_event("sign in", Default::default());
|
||||
client
|
||||
.amplitude_telemetry
|
||||
.report_event("sign in", Default::default());
|
||||
current_user_tx.send(user).await.ok();
|
||||
}
|
||||
}
|
||||
@@ -237,7 +248,6 @@ impl UserStore {
|
||||
let mut user_ids = HashSet::default();
|
||||
for contact in &message.contacts {
|
||||
user_ids.insert(contact.user_id);
|
||||
user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied());
|
||||
}
|
||||
user_ids.extend(message.incoming_requests.iter().map(|req| req.requester_id));
|
||||
user_ids.extend(message.outgoing_requests.iter());
|
||||
@@ -261,9 +271,7 @@ impl UserStore {
|
||||
for request in message.incoming_requests {
|
||||
incoming_requests.push({
|
||||
let user = this
|
||||
.update(&mut cx, |this, cx| {
|
||||
this.fetch_user(request.requester_id, cx)
|
||||
})
|
||||
.update(&mut cx, |this, cx| this.get_user(request.requester_id, cx))
|
||||
.await?;
|
||||
(user, request.should_notify)
|
||||
});
|
||||
@@ -272,7 +280,7 @@ impl UserStore {
|
||||
let mut outgoing_requests = Vec::new();
|
||||
for requested_user_id in message.outgoing_requests {
|
||||
outgoing_requests.push(
|
||||
this.update(&mut cx, |this, cx| this.fetch_user(requested_user_id, cx))
|
||||
this.update(&mut cx, |this, cx| this.get_user(requested_user_id, cx))
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
@@ -497,7 +505,7 @@ impl UserStore {
|
||||
.unbounded_send(UpdateContacts::Clear(tx))
|
||||
.unwrap();
|
||||
async move {
|
||||
rx.recv().await;
|
||||
rx.next().await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,25 +515,43 @@ impl UserStore {
|
||||
.unbounded_send(UpdateContacts::Wait(tx))
|
||||
.unwrap();
|
||||
async move {
|
||||
rx.recv().await;
|
||||
rx.next().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_users(
|
||||
&mut self,
|
||||
mut user_ids: Vec<u64>,
|
||||
user_ids: Vec<u64>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
user_ids.retain(|id| !self.users.contains_key(id));
|
||||
if user_ids.is_empty() {
|
||||
Task::ready(Ok(()))
|
||||
} else {
|
||||
let load = self.load_users(proto::GetUsers { user_ids }, cx);
|
||||
cx.foreground().spawn(async move {
|
||||
load.await?;
|
||||
Ok(())
|
||||
) -> Task<Result<Vec<Arc<User>>>> {
|
||||
let mut user_ids_to_fetch = user_ids.clone();
|
||||
user_ids_to_fetch.retain(|id| !self.users.contains_key(id));
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if !user_ids_to_fetch.is_empty() {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.load_users(
|
||||
proto::GetUsers {
|
||||
user_ids: user_ids_to_fetch,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
this.read_with(&cx, |this, _| {
|
||||
user_ids
|
||||
.iter()
|
||||
.map(|user_id| {
|
||||
this.users
|
||||
.get(user_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("user {} not found", user_id))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fuzzy_search_users(
|
||||
@@ -536,7 +562,7 @@ impl UserStore {
|
||||
self.load_users(proto::FuzzySearchUsers { query }, cx)
|
||||
}
|
||||
|
||||
pub fn fetch_user(
|
||||
pub fn get_user(
|
||||
&mut self,
|
||||
user_id: u64,
|
||||
cx: &mut ModelContext<Self>,
|
||||
@@ -616,39 +642,15 @@ impl Contact {
|
||||
) -> Result<Self> {
|
||||
let user = user_store
|
||||
.update(cx, |user_store, cx| {
|
||||
user_store.fetch_user(contact.user_id, cx)
|
||||
user_store.get_user(contact.user_id, cx)
|
||||
})
|
||||
.await?;
|
||||
let mut projects = Vec::new();
|
||||
for project in contact.projects {
|
||||
let mut guests = BTreeSet::new();
|
||||
for participant_id in project.guests {
|
||||
guests.insert(
|
||||
user_store
|
||||
.update(cx, |user_store, cx| {
|
||||
user_store.fetch_user(participant_id, cx)
|
||||
})
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
projects.push(ProjectMetadata {
|
||||
id: project.id,
|
||||
visible_worktree_root_names: project.visible_worktree_root_names.clone(),
|
||||
guests,
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
user,
|
||||
online: contact.online,
|
||||
projects,
|
||||
busy: contact.busy,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn non_empty_projects(&self) -> impl Iterator<Item = &ProjectMetadata> {
|
||||
self.projects
|
||||
.iter()
|
||||
.filter(|project| !project.visible_worktree_root_names.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_avatar(http: &dyn HttpClient, url: &str) -> Result<Arc<ImageData>> {
|
||||
|
||||
@@ -2,8 +2,9 @@ DATABASE_URL = "postgres://postgres@localhost/zed"
|
||||
HTTP_PORT = 8080
|
||||
API_TOKEN = "secret"
|
||||
INVITE_LINK_PREFIX = "http://localhost:3000/invites/"
|
||||
LIVE_KIT_SERVER = "http://localhost:7880"
|
||||
LIVE_KIT_KEY = "devkey"
|
||||
LIVE_KIT_SECRET = "secret"
|
||||
|
||||
# HONEYCOMB_API_KEY=
|
||||
# HONEYCOMB_DATASET=
|
||||
# RUST_LOG=info
|
||||
# LOG_JSON=true
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
authors = ["Nathan Sobo <nathan@warp.dev>"]
|
||||
authors = ["Nathan Sobo <nathan@zed.dev>"]
|
||||
default-run = "collab"
|
||||
edition = "2021"
|
||||
name = "collab"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
|
||||
[[bin]]
|
||||
name = "collab"
|
||||
@@ -14,6 +14,7 @@ required-features = ["seed-support"]
|
||||
|
||||
[dependencies]
|
||||
collections = { path = "../collections" }
|
||||
live_kit_server = { path = "../live_kit_server" }
|
||||
rpc = { path = "../rpc" }
|
||||
util = { path = "../util" }
|
||||
|
||||
@@ -55,21 +56,27 @@ features = ["runtime-tokio-rustls", "postgres", "time", "uuid"]
|
||||
[dev-dependencies]
|
||||
collections = { path = "../collections", features = ["test-support"] }
|
||||
gpui = { path = "../gpui", features = ["test-support"] }
|
||||
rpc = { path = "../rpc", features = ["test-support"] }
|
||||
call = { path = "../call", features = ["test-support"] }
|
||||
client = { path = "../client", features = ["test-support"] }
|
||||
editor = { path = "../editor", features = ["test-support"] }
|
||||
language = { path = "../language", features = ["test-support"] }
|
||||
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
|
||||
fs = { path = "../fs", features = ["test-support"] }
|
||||
git = { path = "../git", features = ["test-support"] }
|
||||
live_kit_client = { path = "../live_kit_client", features = ["test-support"] }
|
||||
lsp = { path = "../lsp", features = ["test-support"] }
|
||||
project = { path = "../project", features = ["test-support"] }
|
||||
rpc = { path = "../rpc", features = ["test-support"] }
|
||||
settings = { path = "../settings", features = ["test-support"] }
|
||||
theme = { path = "../theme" }
|
||||
workspace = { path = "../workspace", features = ["test-support"] }
|
||||
|
||||
ctor = "0.1"
|
||||
env_logger = "0.9"
|
||||
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
|
||||
util = { path = "../util" }
|
||||
lazy_static = "1.4"
|
||||
serde_json = { version = "1.0", features = ["preserve_order"] }
|
||||
unindent = "0.1"
|
||||
|
||||
[features]
|
||||
seed-support = ["clap", "lipsum", "reqwest"]
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
collab: ./target/release/collab
|
||||
release: ./target/release/sqlx migrate run
|
||||
3
crates/collab/k8s/environments/preview.sh
Normal file
3
crates/collab/k8s/environments/preview.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
ZED_ENVIRONMENT=preview
|
||||
RUST_LOG=info
|
||||
INVITE_LINK_PREFIX=https://zed.dev/invites/
|
||||
@@ -11,7 +11,7 @@ metadata:
|
||||
name: collab
|
||||
annotations:
|
||||
service.beta.kubernetes.io/do-loadbalancer-tls-ports: "443"
|
||||
service.beta.kubernetes.io/do-loadbalancer-certificate-id: "40879815-9a6b-4bbb-8207-8f2c7c0218f9"
|
||||
service.beta.kubernetes.io/do-loadbalancer-certificate-id: "08d9d8ce-761f-4ab3-bc78-4923ab5b0e33"
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
selector:
|
||||
@@ -54,6 +54,8 @@ spec:
|
||||
containers:
|
||||
- name: collab
|
||||
image: "${ZED_IMAGE_ID}"
|
||||
args:
|
||||
- serve
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
protocol: TCP
|
||||
@@ -65,49 +67,32 @@ spec:
|
||||
secretKeyRef:
|
||||
name: database
|
||||
key: url
|
||||
- name: SESSION_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: session
|
||||
key: secret
|
||||
- name: GITHUB_APP_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: github
|
||||
key: appId
|
||||
- name: GITHUB_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: github
|
||||
key: clientId
|
||||
- name: GITHUB_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: github
|
||||
key: clientSecret
|
||||
- name: GITHUB_PRIVATE_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: github
|
||||
key: privateKey
|
||||
- name: API_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: api
|
||||
key: token
|
||||
- name: LIVE_KIT_SERVER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: livekit
|
||||
key: server
|
||||
- name: LIVE_KIT_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: livekit
|
||||
key: key
|
||||
- name: LIVE_KIT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: livekit
|
||||
key: secret
|
||||
- name: INVITE_LINK_PREFIX
|
||||
value: ${INVITE_LINK_PREFIX}
|
||||
- name: RUST_LOG
|
||||
value: ${RUST_LOG}
|
||||
- name: LOG_JSON
|
||||
value: "true"
|
||||
- name: HONEYCOMB_DATASET
|
||||
value: "collab"
|
||||
- name: HONEYCOMB_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: honeycomb
|
||||
key: apiKey
|
||||
securityContext:
|
||||
capabilities:
|
||||
# FIXME - Switch to the more restrictive `PERFMON` capability.
|
||||
|
||||
@@ -9,7 +9,10 @@ spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: migrator
|
||||
imagePullPolicy: Always
|
||||
image: ${ZED_IMAGE_ID}
|
||||
args:
|
||||
- migrate
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
|
||||
@@ -22,7 +22,7 @@ use time::OffsetDateTime;
|
||||
use tower::ServiceBuilder;
|
||||
use tracing::instrument;
|
||||
|
||||
pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
|
||||
pub fn routes(rpc_server: Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
|
||||
Router::new()
|
||||
.route("/user", get(get_authenticated_user))
|
||||
.route("/users", get(get_users).post(create_user))
|
||||
@@ -50,7 +50,7 @@ pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Bod
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(Extension(state))
|
||||
.layer(Extension(rpc_server.clone()))
|
||||
.layer(Extension(rpc_server))
|
||||
.layer(middleware::from_fn(validate_api_token)),
|
||||
)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoR
|
||||
|
||||
let state = req.extensions().get::<Arc<AppState>>().unwrap();
|
||||
|
||||
if token != state.api_token {
|
||||
if token != state.config.api_token {
|
||||
Err(Error::Http(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid authorization token".to_string(),
|
||||
@@ -88,7 +88,7 @@ pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoR
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthenticatedUserParams {
|
||||
github_user_id: i32,
|
||||
github_user_id: Option<i32>,
|
||||
github_login: String,
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ async fn get_authenticated_user(
|
||||
) -> Result<Json<AuthenticatedUserResponse>> {
|
||||
let user = app
|
||||
.db
|
||||
.get_user_by_github_account(¶ms.github_login, Some(params.github_user_id))
|
||||
.get_user_by_github_account(¶ms.github_login, params.github_user_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "user not found".into()))?;
|
||||
let metrics_id = app.db.get_user_metrics_id(user.id).await?;
|
||||
@@ -156,7 +156,7 @@ async fn create_user(
|
||||
Json(params): Json<CreateUserParams>,
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Extension(rpc_server): Extension<Arc<rpc::Server>>,
|
||||
) -> Result<Json<CreateUserResponse>> {
|
||||
) -> Result<Json<Option<CreateUserResponse>>> {
|
||||
let user = NewUserParams {
|
||||
github_login: params.github_login,
|
||||
github_user_id: params.github_user_id,
|
||||
@@ -165,7 +165,8 @@ async fn create_user(
|
||||
|
||||
// Creating a user via the normal signup process
|
||||
let result = if let Some(email_confirmation_code) = params.email_confirmation_code {
|
||||
app.db
|
||||
if let Some(result) = app
|
||||
.db
|
||||
.create_user_from_invite(
|
||||
&Invite {
|
||||
email_address: params.email_address,
|
||||
@@ -174,6 +175,11 @@ async fn create_user(
|
||||
user,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
result
|
||||
} else {
|
||||
return Ok(Json(None));
|
||||
}
|
||||
}
|
||||
// Creating a user as an admin
|
||||
else if params.admin {
|
||||
@@ -200,11 +206,11 @@ async fn create_user(
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("couldn't find the user we just created"))?;
|
||||
|
||||
Ok(Json(CreateUserResponse {
|
||||
Ok(Json(Some(CreateUserResponse {
|
||||
user,
|
||||
metrics_id: result.metrics_id,
|
||||
signup_device_id: result.signup_device_id,
|
||||
}))
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::db::{self, UserId};
|
||||
use crate::{AppState, Error, Result};
|
||||
use crate::{
|
||||
db::{self, UserId},
|
||||
AppState, Error, Result,
|
||||
};
|
||||
use anyhow::{anyhow, Context};
|
||||
use axum::{
|
||||
http::{self, Request, StatusCode},
|
||||
@@ -13,6 +13,7 @@ use scrypt::{
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Scrypt,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl IntoResponse {
|
||||
let mut auth_header = req
|
||||
@@ -21,7 +22,7 @@ pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl Into
|
||||
.and_then(|header| header.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
Error::Http(
|
||||
StatusCode::BAD_REQUEST,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing authorization header".to_string(),
|
||||
)
|
||||
})?
|
||||
@@ -41,12 +42,18 @@ pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl Into
|
||||
)
|
||||
})?;
|
||||
|
||||
let state = req.extensions().get::<Arc<AppState>>().unwrap();
|
||||
let mut credentials_valid = false;
|
||||
for password_hash in state.db.get_access_token_hashes(user_id).await? {
|
||||
if verify_access_token(access_token, &password_hash)? {
|
||||
let state = req.extensions().get::<Arc<AppState>>().unwrap();
|
||||
if let Some(admin_token) = access_token.strip_prefix("ADMIN_TOKEN:") {
|
||||
if state.config.api_token == admin_token {
|
||||
credentials_valid = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
for password_hash in state.db.get_access_token_hashes(user_id).await? {
|
||||
if verify_access_token(access_token, &password_hash)? {
|
||||
credentials_valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,23 @@ async fn main() {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("failed to insert user"),
|
||||
.expect("failed to insert user")
|
||||
.user_id,
|
||||
);
|
||||
} else if admin {
|
||||
zed_user_ids.push(
|
||||
db.create_user(
|
||||
&format!("{}@zed.dev", github_user.login),
|
||||
admin,
|
||||
db::NewUserParams {
|
||||
github_login: github_user.login,
|
||||
github_user_id: github_user.id,
|
||||
invite_count: 5,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("failed to insert user")
|
||||
.user_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,12 @@ use collections::HashMap;
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use sqlx::postgres::PgPoolOptions as DbOptions;
|
||||
use sqlx::{types::Uuid, FromRow, QueryBuilder};
|
||||
use std::{cmp, ops::Range, time::Duration};
|
||||
use sqlx::{
|
||||
migrate::{Migrate as _, Migration, MigrationSource},
|
||||
types::Uuid,
|
||||
FromRow, QueryBuilder,
|
||||
};
|
||||
use std::{cmp, ops::Range, path::Path, time::Duration};
|
||||
use time::{OffsetDateTime, PrimitiveDateTime};
|
||||
|
||||
#[async_trait]
|
||||
@@ -51,7 +55,7 @@ pub trait Db: Send + Sync {
|
||||
&self,
|
||||
invite: &Invite,
|
||||
user: NewUserParams,
|
||||
) -> Result<NewUserResult>;
|
||||
) -> Result<Option<NewUserResult>>;
|
||||
|
||||
/// Registers a new project for the given user.
|
||||
async fn register_project(&self, host_user_id: UserId) -> Result<ProjectId>;
|
||||
@@ -173,6 +177,13 @@ pub trait Db: Send + Sync {
|
||||
fn as_fake(&self) -> Option<&FakeDb>;
|
||||
}
|
||||
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub const DEFAULT_MIGRATIONS_PATH: Option<&'static str> =
|
||||
Some(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"));
|
||||
|
||||
#[cfg(not(any(test, debug_assertions)))]
|
||||
pub const DEFAULT_MIGRATIONS_PATH: Option<&'static str> = None;
|
||||
|
||||
pub struct PostgresDb {
|
||||
pool: sqlx::PgPool,
|
||||
}
|
||||
@@ -187,6 +198,47 @@ impl PostgresDb {
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub async fn migrate(
|
||||
&self,
|
||||
migrations_path: &Path,
|
||||
ignore_checksum_mismatch: bool,
|
||||
) -> anyhow::Result<Vec<(Migration, Duration)>> {
|
||||
let migrations = MigrationSource::resolve(migrations_path)
|
||||
.await
|
||||
.map_err(|err| anyhow!("failed to load migrations: {err:?}"))?;
|
||||
|
||||
let mut conn = self.pool.acquire().await?;
|
||||
|
||||
conn.ensure_migrations_table().await?;
|
||||
let applied_migrations: HashMap<_, _> = conn
|
||||
.list_applied_migrations()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|m| (m.version, m))
|
||||
.collect();
|
||||
|
||||
let mut new_migrations = Vec::new();
|
||||
for migration in migrations {
|
||||
match applied_migrations.get(&migration.version) {
|
||||
Some(applied_migration) => {
|
||||
if migration.checksum != applied_migration.checksum && !ignore_checksum_mismatch
|
||||
{
|
||||
Err(anyhow!(
|
||||
"checksum mismatch for applied migration {}",
|
||||
migration.description
|
||||
))?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let elapsed = conn.apply(&migration).await?;
|
||||
new_migrations.push((migration, elapsed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_migrations)
|
||||
}
|
||||
|
||||
pub fn fuzzy_like_string(string: &str) -> String {
|
||||
let mut result = String::with_capacity(string.len() * 2 + 1);
|
||||
for c in string.chars() {
|
||||
@@ -428,7 +480,8 @@ impl Db for PostgresDb {
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(CASE WHEN platform_linux THEN 1 ELSE 0 END), 0) as linux_count,
|
||||
COALESCE(SUM(CASE WHEN platform_mac THEN 1 ELSE 0 END), 0) as mac_count,
|
||||
COALESCE(SUM(CASE WHEN platform_windows THEN 1 ELSE 0 END), 0) as windows_count
|
||||
COALESCE(SUM(CASE WHEN platform_windows THEN 1 ELSE 0 END), 0) as windows_count,
|
||||
COALESCE(SUM(CASE WHEN platform_unknown THEN 1 ELSE 0 END), 0) as unknown_count
|
||||
FROM (
|
||||
SELECT *
|
||||
FROM signups
|
||||
@@ -449,7 +502,7 @@ impl Db for PostgresDb {
|
||||
FROM signups
|
||||
WHERE
|
||||
NOT email_confirmation_sent AND
|
||||
platform_mac
|
||||
(platform_mac OR platform_unknown)
|
||||
LIMIT $1
|
||||
",
|
||||
)
|
||||
@@ -481,7 +534,7 @@ impl Db for PostgresDb {
|
||||
&self,
|
||||
invite: &Invite,
|
||||
user: NewUserParams,
|
||||
) -> Result<NewUserResult> {
|
||||
) -> Result<Option<NewUserResult>> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let (signup_id, existing_user_id, inviting_user_id, signup_device_id): (
|
||||
@@ -505,10 +558,7 @@ impl Db for PostgresDb {
|
||||
.ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "no such invite".to_string()))?;
|
||||
|
||||
if existing_user_id.is_some() {
|
||||
Err(Error::Http(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invitation already redeemed".to_string(),
|
||||
))?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (user_id, metrics_id): (UserId, String) = sqlx::query_as(
|
||||
@@ -517,6 +567,10 @@ impl Db for PostgresDb {
|
||||
(email_address, github_login, github_user_id, admin, invite_count, invite_code)
|
||||
VALUES
|
||||
($1, $2, $3, 'f', $4, $5)
|
||||
ON CONFLICT (github_login) DO UPDATE SET
|
||||
email_address = excluded.email_address,
|
||||
github_user_id = excluded.github_user_id,
|
||||
admin = excluded.admin
|
||||
RETURNING id, metrics_id::text
|
||||
",
|
||||
)
|
||||
@@ -566,6 +620,7 @@ impl Db for PostgresDb {
|
||||
(user_id_a, user_id_b, a_to_b, should_notify, accepted)
|
||||
VALUES
|
||||
($1, $2, 't', 't', 't')
|
||||
ON CONFLICT DO NOTHING
|
||||
",
|
||||
)
|
||||
.bind(inviting_user_id)
|
||||
@@ -575,12 +630,12 @@ impl Db for PostgresDb {
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(NewUserResult {
|
||||
Ok(Some(NewUserResult {
|
||||
user_id,
|
||||
metrics_id,
|
||||
inviting_user_id,
|
||||
signup_device_id,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// invite codes
|
||||
@@ -1098,10 +1153,7 @@ impl Db for PostgresDb {
|
||||
.bind(user_id)
|
||||
.fetch(&self.pool);
|
||||
|
||||
let mut contacts = vec![Contact::Accepted {
|
||||
user_id,
|
||||
should_notify: false,
|
||||
}];
|
||||
let mut contacts = Vec::new();
|
||||
while let Some(row) = rows.next().await {
|
||||
let (user_id_a, user_id_b, a_to_b, accepted, should_notify) = row?;
|
||||
|
||||
@@ -1723,6 +1775,8 @@ pub struct WaitlistSummary {
|
||||
pub mac_count: i64,
|
||||
#[sqlx(default)]
|
||||
pub windows_count: i64,
|
||||
#[sqlx(default)]
|
||||
pub unknown_count: i64,
|
||||
}
|
||||
|
||||
#[derive(FromRow, PartialEq, Debug, Serialize, Deserialize)]
|
||||
@@ -1766,11 +1820,8 @@ mod test {
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::Mutex;
|
||||
use rand::prelude::*;
|
||||
use sqlx::{
|
||||
migrate::{MigrateDatabase, Migrator},
|
||||
Postgres,
|
||||
};
|
||||
use std::{path::Path, sync::Arc};
|
||||
use sqlx::{migrate::MigrateDatabase, Postgres};
|
||||
use std::sync::Arc;
|
||||
use util::post_inc;
|
||||
|
||||
pub struct FakeDb {
|
||||
@@ -1958,7 +2009,7 @@ mod test {
|
||||
&self,
|
||||
_invite: &Invite,
|
||||
_user: NewUserParams,
|
||||
) -> Result<NewUserResult> {
|
||||
) -> Result<Option<NewUserResult>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -2080,10 +2131,7 @@ mod test {
|
||||
|
||||
async fn get_contacts(&self, id: UserId) -> Result<Vec<Contact>> {
|
||||
self.background.simulate_random_delay().await;
|
||||
let mut contacts = vec![Contact::Accepted {
|
||||
user_id: id,
|
||||
should_notify: false,
|
||||
}];
|
||||
let mut contacts = Vec::new();
|
||||
|
||||
for contact in self.contacts.lock().iter() {
|
||||
if contact.requester_id == id {
|
||||
@@ -2436,13 +2484,13 @@ mod test {
|
||||
let mut rng = StdRng::from_entropy();
|
||||
let name = format!("zed-test-{}", rng.gen::<u128>());
|
||||
let url = format!("postgres://postgres@localhost/{}", name);
|
||||
let migrations_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"));
|
||||
Postgres::create_database(&url)
|
||||
.await
|
||||
.expect("failed to create test db");
|
||||
let db = PostgresDb::new(&url, 5).await.unwrap();
|
||||
let migrator = Migrator::new(migrations_path).await.unwrap();
|
||||
migrator.run(&db.pool).await.unwrap();
|
||||
db.migrate(Path::new(DEFAULT_MIGRATIONS_PATH.unwrap()), false)
|
||||
.await
|
||||
.unwrap();
|
||||
Self {
|
||||
db: Some(Arc::new(db)),
|
||||
url,
|
||||
|
||||
@@ -666,13 +666,7 @@ async fn test_add_contacts() {
|
||||
let user_3 = user_ids[2];
|
||||
|
||||
// User starts with no contacts
|
||||
assert_eq!(
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
vec![Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
}],
|
||||
);
|
||||
assert_eq!(db.get_contacts(user_1).await.unwrap(), &[]);
|
||||
|
||||
// User requests a contact. Both users see the pending request.
|
||||
db.send_contact_request(user_1, user_2).await.unwrap();
|
||||
@@ -680,26 +674,14 @@ async fn test_add_contacts() {
|
||||
assert!(!db.has_contact(user_2, user_1).await.unwrap());
|
||||
assert_eq!(
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[
|
||||
Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Outgoing { user_id: user_2 }
|
||||
],
|
||||
&[Contact::Outgoing { user_id: user_2 }],
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_contacts(user_2).await.unwrap(),
|
||||
&[
|
||||
Contact::Incoming {
|
||||
user_id: user_1,
|
||||
should_notify: true
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: false
|
||||
},
|
||||
]
|
||||
&[Contact::Incoming {
|
||||
user_id: user_1,
|
||||
should_notify: true
|
||||
}]
|
||||
);
|
||||
|
||||
// User 2 dismisses the contact request notification without accepting or rejecting.
|
||||
@@ -712,16 +694,10 @@ async fn test_add_contacts() {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.get_contacts(user_2).await.unwrap(),
|
||||
&[
|
||||
Contact::Incoming {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: false
|
||||
},
|
||||
]
|
||||
&[Contact::Incoming {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
}]
|
||||
);
|
||||
|
||||
// User can't accept their own contact request
|
||||
@@ -735,31 +711,19 @@ async fn test_add_contacts() {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[
|
||||
Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: true
|
||||
}
|
||||
],
|
||||
&[Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: true
|
||||
}],
|
||||
);
|
||||
assert!(db.has_contact(user_1, user_2).await.unwrap());
|
||||
assert!(db.has_contact(user_2, user_1).await.unwrap());
|
||||
assert_eq!(
|
||||
db.get_contacts(user_2).await.unwrap(),
|
||||
&[
|
||||
Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false,
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: false,
|
||||
},
|
||||
]
|
||||
&[Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false,
|
||||
}]
|
||||
);
|
||||
|
||||
// Users cannot re-request existing contacts.
|
||||
@@ -772,16 +736,10 @@ async fn test_add_contacts() {
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[
|
||||
Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: true,
|
||||
},
|
||||
]
|
||||
&[Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: true,
|
||||
}]
|
||||
);
|
||||
|
||||
// Users can dismiss notifications of other users accepting their requests.
|
||||
@@ -790,16 +748,10 @@ async fn test_add_contacts() {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[
|
||||
Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: false,
|
||||
},
|
||||
]
|
||||
&[Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: false,
|
||||
}]
|
||||
);
|
||||
|
||||
// Users send each other concurrent contact requests and
|
||||
@@ -809,10 +761,6 @@ async fn test_add_contacts() {
|
||||
assert_eq!(
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[
|
||||
Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: false,
|
||||
@@ -820,21 +768,15 @@ async fn test_add_contacts() {
|
||||
Contact::Accepted {
|
||||
user_id: user_3,
|
||||
should_notify: false
|
||||
},
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_contacts(user_3).await.unwrap(),
|
||||
&[
|
||||
Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user_3,
|
||||
should_notify: false
|
||||
}
|
||||
],
|
||||
&[Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
}],
|
||||
);
|
||||
|
||||
// User declines a contact request. Both users see that it is gone.
|
||||
@@ -846,29 +788,17 @@ async fn test_add_contacts() {
|
||||
assert!(!db.has_contact(user_3, user_2).await.unwrap());
|
||||
assert_eq!(
|
||||
db.get_contacts(user_2).await.unwrap(),
|
||||
&[
|
||||
Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user_2,
|
||||
should_notify: false
|
||||
}
|
||||
]
|
||||
&[Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_contacts(user_3).await.unwrap(),
|
||||
&[
|
||||
Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user_3,
|
||||
should_notify: false
|
||||
}
|
||||
],
|
||||
&[Contact::Accepted {
|
||||
user_id: user_1,
|
||||
should_notify: false
|
||||
}],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -922,6 +852,7 @@ async fn test_invite_codes() {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
|
||||
assert_eq!(invite_count, 1);
|
||||
@@ -930,29 +861,17 @@ async fn test_invite_codes() {
|
||||
assert_eq!(db.get_user_metrics_id(user2).await.unwrap(), metrics_id);
|
||||
assert_eq!(
|
||||
db.get_contacts(user1).await.unwrap(),
|
||||
[
|
||||
Contact::Accepted {
|
||||
user_id: user1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user2,
|
||||
should_notify: true
|
||||
}
|
||||
]
|
||||
[Contact::Accepted {
|
||||
user_id: user2,
|
||||
should_notify: true
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_contacts(user2).await.unwrap(),
|
||||
[
|
||||
Contact::Accepted {
|
||||
user_id: user1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user2,
|
||||
should_notify: false
|
||||
}
|
||||
]
|
||||
[Contact::Accepted {
|
||||
user_id: user1,
|
||||
should_notify: false
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_invite_code_for_user(user2).await.unwrap().unwrap().1,
|
||||
@@ -979,6 +898,7 @@ async fn test_invite_codes() {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
|
||||
assert_eq!(invite_count, 0);
|
||||
@@ -987,10 +907,6 @@ async fn test_invite_codes() {
|
||||
assert_eq!(
|
||||
db.get_contacts(user1).await.unwrap(),
|
||||
[
|
||||
Contact::Accepted {
|
||||
user_id: user1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user2,
|
||||
should_notify: true
|
||||
@@ -1003,16 +919,10 @@ async fn test_invite_codes() {
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_contacts(user3).await.unwrap(),
|
||||
[
|
||||
Contact::Accepted {
|
||||
user_id: user1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user3,
|
||||
should_notify: false
|
||||
},
|
||||
]
|
||||
[Contact::Accepted {
|
||||
user_id: user1,
|
||||
should_notify: false
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_invite_code_for_user(user3).await.unwrap().unwrap().1,
|
||||
@@ -1046,6 +956,7 @@ async fn test_invite_codes() {
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.user_id;
|
||||
|
||||
let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
|
||||
@@ -1053,10 +964,6 @@ async fn test_invite_codes() {
|
||||
assert_eq!(
|
||||
db.get_contacts(user1).await.unwrap(),
|
||||
[
|
||||
Contact::Accepted {
|
||||
user_id: user1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user2,
|
||||
should_notify: true
|
||||
@@ -1073,16 +980,10 @@ async fn test_invite_codes() {
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_contacts(user4).await.unwrap(),
|
||||
[
|
||||
Contact::Accepted {
|
||||
user_id: user1,
|
||||
should_notify: false
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user4,
|
||||
should_notify: false
|
||||
},
|
||||
]
|
||||
[Contact::Accepted {
|
||||
user_id: user1,
|
||||
should_notify: false
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_invite_code_for_user(user4).await.unwrap().unwrap().1,
|
||||
@@ -1124,6 +1025,7 @@ async fn test_signups() {
|
||||
mac_count: 8,
|
||||
linux_count: 4,
|
||||
windows_count: 2,
|
||||
unknown_count: 0,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1176,6 +1078,7 @@ async fn test_signups() {
|
||||
mac_count: 5,
|
||||
linux_count: 2,
|
||||
windows_count: 1,
|
||||
unknown_count: 0,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1199,6 +1102,7 @@ async fn test_signups() {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let user = db.get_user_by_id(user_id).await.unwrap().unwrap();
|
||||
assert!(inviting_user_id.is_none());
|
||||
@@ -1208,19 +1112,21 @@ async fn test_signups() {
|
||||
assert_eq!(signup_device_id.unwrap(), "device_id_0");
|
||||
|
||||
// cannot redeem the same signup again.
|
||||
db.create_user_from_invite(
|
||||
&Invite {
|
||||
email_address: signups_batch1[0].email_address.clone(),
|
||||
email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(),
|
||||
},
|
||||
NewUserParams {
|
||||
github_login: "some-other-github_account".into(),
|
||||
github_user_id: 1,
|
||||
invite_count: 5,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(db
|
||||
.create_user_from_invite(
|
||||
&Invite {
|
||||
email_address: signups_batch1[0].email_address.clone(),
|
||||
email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(),
|
||||
},
|
||||
NewUserParams {
|
||||
github_login: "some-other-github_account".into(),
|
||||
github_user_id: 1,
|
||||
invite_count: 5,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
// cannot redeem a signup with the wrong confirmation code.
|
||||
db.create_user_from_invite(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,44 +9,73 @@ mod db_tests;
|
||||
#[cfg(test)]
|
||||
mod integration_tests;
|
||||
|
||||
use axum::{body::Body, Router};
|
||||
use crate::rpc::ResultExt as _;
|
||||
use anyhow::anyhow;
|
||||
use axum::{routing::get, Router};
|
||||
use collab::{Error, Result};
|
||||
use db::{Db, PostgresDb};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
env::args,
|
||||
net::{SocketAddr, TcpListener},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::signal;
|
||||
use tracing_log::LogTracer;
|
||||
use tracing_subscriber::{filter::EnvFilter, fmt::format::JsonFields, Layer};
|
||||
use util::ResultExt;
|
||||
|
||||
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
pub struct Config {
|
||||
pub http_port: u16,
|
||||
pub database_url: String,
|
||||
pub api_token: String,
|
||||
pub invite_link_prefix: String,
|
||||
pub honeycomb_api_key: Option<String>,
|
||||
pub honeycomb_dataset: Option<String>,
|
||||
pub live_kit_server: Option<String>,
|
||||
pub live_kit_key: Option<String>,
|
||||
pub live_kit_secret: Option<String>,
|
||||
pub rust_log: Option<String>,
|
||||
pub log_json: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
pub struct MigrateConfig {
|
||||
pub database_url: String,
|
||||
pub migrations_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
db: Arc<dyn Db>,
|
||||
api_token: String,
|
||||
invite_link_prefix: String,
|
||||
live_kit_client: Option<Arc<dyn live_kit_server::api::Client>>,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
async fn new(config: &Config) -> Result<Arc<Self>> {
|
||||
async fn new(config: Config) -> Result<Arc<Self>> {
|
||||
let db = PostgresDb::new(&config.database_url, 5).await?;
|
||||
let live_kit_client = if let Some(((server, key), secret)) = config
|
||||
.live_kit_server
|
||||
.as_ref()
|
||||
.zip(config.live_kit_key.as_ref())
|
||||
.zip(config.live_kit_secret.as_ref())
|
||||
{
|
||||
Some(Arc::new(live_kit_server::api::LiveKitClient::new(
|
||||
server.clone(),
|
||||
key.clone(),
|
||||
secret.clone(),
|
||||
)) as Arc<dyn live_kit_server::api::Client>)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let this = Self {
|
||||
db: Arc::new(db),
|
||||
api_token: config.api_token.clone(),
|
||||
invite_link_prefix: config.invite_link_prefix.clone(),
|
||||
live_kit_client,
|
||||
config,
|
||||
};
|
||||
Ok(Arc::new(this))
|
||||
}
|
||||
@@ -61,27 +90,62 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let config = envy::from_env::<Config>().expect("error loading config");
|
||||
init_tracing(&config);
|
||||
let state = AppState::new(&config).await?;
|
||||
match args().skip(1).next().as_deref() {
|
||||
Some("version") => {
|
||||
println!("collab v{VERSION}");
|
||||
}
|
||||
Some("migrate") => {
|
||||
let config = envy::from_env::<MigrateConfig>().expect("error loading config");
|
||||
let db = PostgresDb::new(&config.database_url, 5).await?;
|
||||
|
||||
let listener = TcpListener::bind(&format!("0.0.0.0:{}", config.http_port))
|
||||
.expect("failed to bind TCP listener");
|
||||
let rpc_server = rpc::Server::new(state.clone(), None);
|
||||
let migrations_path = config
|
||||
.migrations_path
|
||||
.as_deref()
|
||||
.or(db::DEFAULT_MIGRATIONS_PATH.map(|s| s.as_ref()))
|
||||
.ok_or_else(|| anyhow!("missing MIGRATIONS_PATH environment variable"))?;
|
||||
|
||||
rpc_server.start_recording_project_activity(Duration::from_secs(5 * 60), rpc::RealExecutor);
|
||||
let migrations = db.migrate(&migrations_path, false).await?;
|
||||
for (migration, duration) in migrations {
|
||||
println!(
|
||||
"Ran {} {} {:?}",
|
||||
migration.version, migration.description, duration
|
||||
);
|
||||
}
|
||||
|
||||
let app = Router::<Body>::new()
|
||||
.merge(api::routes(&rpc_server, state.clone()))
|
||||
.merge(rpc::routes(rpc_server));
|
||||
return Ok(());
|
||||
}
|
||||
Some("serve") => {
|
||||
let config = envy::from_env::<Config>().expect("error loading config");
|
||||
init_tracing(&config);
|
||||
|
||||
axum::Server::from_tcp(listener)?
|
||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.await?;
|
||||
let state = AppState::new(config).await?;
|
||||
let listener = TcpListener::bind(&format!("0.0.0.0:{}", state.config.http_port))
|
||||
.expect("failed to bind TCP listener");
|
||||
|
||||
let rpc_server = rpc::Server::new(state.clone(), None);
|
||||
rpc_server
|
||||
.start_recording_project_activity(Duration::from_secs(5 * 60), rpc::RealExecutor);
|
||||
|
||||
let app = api::routes(rpc_server.clone(), state.clone())
|
||||
.merge(rpc::routes(rpc_server.clone()))
|
||||
.merge(Router::new().route("/", get(handle_root)));
|
||||
|
||||
axum::Server::from_tcp(listener)?
|
||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.with_graceful_shutdown(graceful_shutdown(rpc_server, state))
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
Err(anyhow!("usage: collab <version | migrate | serve>"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_root() -> String {
|
||||
format!("collab v{VERSION}")
|
||||
}
|
||||
|
||||
pub fn init_tracing(config: &Config) -> Option<()> {
|
||||
use std::str::FromStr;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
@@ -113,3 +177,52 @@ pub fn init_tracing(config: &Config) -> Option<()> {
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn graceful_shutdown(rpc_server: Arc<rpc::Server>, state: Arc<AppState>) {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install signal handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
|
||||
if let Some(live_kit) = state.live_kit_client.as_ref() {
|
||||
let deletions = rpc_server
|
||||
.store()
|
||||
.await
|
||||
.rooms()
|
||||
.values()
|
||||
.map(|room| {
|
||||
let name = room.live_kit_room.clone();
|
||||
async {
|
||||
live_kit.delete_room(name).await.trace_err();
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
tracing::info!("deleting all live-kit rooms");
|
||||
if let Err(_) = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
futures::future::join_all(deletions),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("timed out waiting for live-kit room deletion");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
53
crates/collab_ui/Cargo.toml
Normal file
53
crates/collab_ui/Cargo.toml
Normal file
@@ -0,0 +1,53 @@
|
||||
[package]
|
||||
name = "collab_ui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/collab_ui.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = [
|
||||
"call/test-support",
|
||||
"client/test-support",
|
||||
"collections/test-support",
|
||||
"editor/test-support",
|
||||
"gpui/test-support",
|
||||
"project/test-support",
|
||||
"settings/test-support",
|
||||
"util/test-support",
|
||||
"workspace/test-support",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
call = { path = "../call" }
|
||||
client = { path = "../client" }
|
||||
clock = { path = "../clock" }
|
||||
collections = { path = "../collections" }
|
||||
editor = { path = "../editor" }
|
||||
fuzzy = { path = "../fuzzy" }
|
||||
gpui = { path = "../gpui" }
|
||||
menu = { path = "../menu" }
|
||||
picker = { path = "../picker" }
|
||||
project = { path = "../project" }
|
||||
settings = { path = "../settings" }
|
||||
theme = { path = "../theme" }
|
||||
util = { path = "../util" }
|
||||
workspace = { path = "../workspace" }
|
||||
anyhow = "1.0"
|
||||
futures = "0.3"
|
||||
log = "0.4"
|
||||
postage = { version = "0.4.1", features = ["futures-traits"] }
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
|
||||
[dev-dependencies]
|
||||
call = { path = "../call", features = ["test-support"] }
|
||||
client = { path = "../client", features = ["test-support"] }
|
||||
collections = { path = "../collections", features = ["test-support"] }
|
||||
editor = { path = "../editor", features = ["test-support"] }
|
||||
gpui = { path = "../gpui", features = ["test-support"] }
|
||||
project = { path = "../project", features = ["test-support"] }
|
||||
settings = { path = "../settings", features = ["test-support"] }
|
||||
util = { path = "../util", features = ["test-support"] }
|
||||
workspace = { path = "../workspace", features = ["test-support"] }
|
||||
620
crates/collab_ui/src/collab_titlebar_item.rs
Normal file
620
crates/collab_ui/src/collab_titlebar_item.rs
Normal file
@@ -0,0 +1,620 @@
|
||||
use crate::{contact_notification::ContactNotification, contacts_popover};
|
||||
use call::{ActiveCall, ParticipantLocation};
|
||||
use client::{Authenticate, ContactEventKind, PeerId, User, UserStore};
|
||||
use clock::ReplicaId;
|
||||
use contacts_popover::ContactsPopover;
|
||||
use gpui::{
|
||||
actions,
|
||||
color::Color,
|
||||
elements::*,
|
||||
geometry::{rect::RectF, vector::vec2f, PathBuilder},
|
||||
json::{self, ToJson},
|
||||
Border, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext,
|
||||
Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
|
||||
};
|
||||
use settings::Settings;
|
||||
use std::ops::Range;
|
||||
use theme::Theme;
|
||||
use workspace::{FollowNextCollaborator, JoinProject, ToggleFollow, Workspace};
|
||||
|
||||
actions!(
|
||||
collab,
|
||||
[ToggleCollaborationMenu, ToggleScreenSharing, ShareProject]
|
||||
);
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
|
||||
cx.add_action(CollabTitlebarItem::toggle_screen_sharing);
|
||||
cx.add_action(CollabTitlebarItem::share_project);
|
||||
}
|
||||
|
||||
pub struct CollabTitlebarItem {
|
||||
workspace: WeakViewHandle<Workspace>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
contacts_popover: Option<ViewHandle<ContactsPopover>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl Entity for CollabTitlebarItem {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for CollabTitlebarItem {
|
||||
fn ui_name() -> &'static str {
|
||||
"CollabTitlebarItem"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
let workspace = if let Some(workspace) = self.workspace.upgrade(cx) {
|
||||
workspace
|
||||
} else {
|
||||
return Empty::new().boxed();
|
||||
};
|
||||
|
||||
let theme = cx.global::<Settings>().theme.clone();
|
||||
|
||||
let mut container = Flex::row();
|
||||
container.add_children(self.render_toggle_screen_sharing_button(&theme, cx));
|
||||
|
||||
if workspace.read(cx).client().status().borrow().is_connected() {
|
||||
let project = workspace.read(cx).project().read(cx);
|
||||
if project.is_shared()
|
||||
|| project.is_remote()
|
||||
|| ActiveCall::global(cx).read(cx).room().is_none()
|
||||
{
|
||||
container.add_child(self.render_toggle_contacts_button(&theme, cx));
|
||||
} else {
|
||||
container.add_child(self.render_share_button(&theme, cx));
|
||||
}
|
||||
}
|
||||
container.add_children(self.render_collaborators(&workspace, &theme, cx));
|
||||
container.add_children(self.render_current_user(&workspace, &theme, cx));
|
||||
container.add_children(self.render_connection_status(&workspace, cx));
|
||||
container.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
impl CollabTitlebarItem {
|
||||
pub fn new(
|
||||
workspace: &ViewHandle<Workspace>,
|
||||
user_store: &ModelHandle<UserStore>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
let mut subscriptions = Vec::new();
|
||||
subscriptions.push(cx.observe(workspace, |_, _, cx| cx.notify()));
|
||||
subscriptions.push(cx.observe(&active_call, |_, _, cx| cx.notify()));
|
||||
subscriptions.push(cx.observe_window_activation(|this, active, cx| {
|
||||
this.window_activation_changed(active, cx)
|
||||
}));
|
||||
subscriptions.push(cx.observe(user_store, |_, _, cx| cx.notify()));
|
||||
subscriptions.push(
|
||||
cx.subscribe(user_store, move |this, user_store, event, cx| {
|
||||
if let Some(workspace) = this.workspace.upgrade(cx) {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let client::Event::Contact { user, kind } = event {
|
||||
if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
|
||||
workspace.show_notification(user.id as usize, cx, |cx| {
|
||||
cx.add_view(|cx| {
|
||||
ContactNotification::new(
|
||||
user.clone(),
|
||||
*kind,
|
||||
user_store,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
workspace: workspace.downgrade(),
|
||||
user_store: user_store.clone(),
|
||||
contacts_popover: None,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
|
||||
if let Some(workspace) = self.workspace.upgrade(cx) {
|
||||
let project = if active {
|
||||
Some(workspace.read(cx).project().clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
ActiveCall::global(cx)
|
||||
.update(cx, |call, cx| call.set_location(project.as_ref(), cx))
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
|
||||
if let Some(workspace) = self.workspace.upgrade(cx) {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
let project = workspace.read(cx).project().clone();
|
||||
active_call
|
||||
.update(cx, |call, cx| call.share_project(project, cx))
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_contacts_popover(
|
||||
&mut self,
|
||||
_: &ToggleCollaborationMenu,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match self.contacts_popover.take() {
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if let Some(workspace) = self.workspace.upgrade(cx) {
|
||||
let project = workspace.read(cx).project().clone();
|
||||
let user_store = workspace.read(cx).user_store().clone();
|
||||
let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx));
|
||||
cx.subscribe(&view, |this, _, event, cx| {
|
||||
match event {
|
||||
contacts_popover::Event::Dismissed => {
|
||||
this.contacts_popover = None;
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.detach();
|
||||
self.contacts_popover = Some(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn toggle_screen_sharing(&mut self, _: &ToggleScreenSharing, cx: &mut ViewContext<Self>) {
|
||||
if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
|
||||
let toggle_screen_sharing = room.update(cx, |room, cx| {
|
||||
if room.is_screen_sharing() {
|
||||
Task::ready(room.unshare_screen(cx))
|
||||
} else {
|
||||
room.share_screen(cx)
|
||||
}
|
||||
});
|
||||
toggle_screen_sharing.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_toggle_contacts_button(
|
||||
&self,
|
||||
theme: &Theme,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> ElementBox {
|
||||
let titlebar = &theme.workspace.titlebar;
|
||||
let badge = if self
|
||||
.user_store
|
||||
.read(cx)
|
||||
.incoming_contact_requests()
|
||||
.is_empty()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
Empty::new()
|
||||
.collapsed()
|
||||
.contained()
|
||||
.with_style(titlebar.toggle_contacts_badge)
|
||||
.contained()
|
||||
.with_margin_left(titlebar.toggle_contacts_button.default.icon_width)
|
||||
.with_margin_top(titlebar.toggle_contacts_button.default.icon_width)
|
||||
.aligned()
|
||||
.boxed(),
|
||||
)
|
||||
};
|
||||
Stack::new()
|
||||
.with_child(
|
||||
MouseEventHandler::<ToggleCollaborationMenu>::new(0, cx, |state, _| {
|
||||
let style = titlebar
|
||||
.toggle_contacts_button
|
||||
.style_for(state, self.contacts_popover.is_some());
|
||||
Svg::new("icons/plus_8.svg")
|
||||
.with_color(style.color)
|
||||
.constrained()
|
||||
.with_width(style.icon_width)
|
||||
.aligned()
|
||||
.constrained()
|
||||
.with_width(style.button_width)
|
||||
.with_height(style.button_width)
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(ToggleCollaborationMenu);
|
||||
})
|
||||
.aligned()
|
||||
.boxed(),
|
||||
)
|
||||
.with_children(badge)
|
||||
.with_children(self.contacts_popover.as_ref().map(|popover| {
|
||||
Overlay::new(
|
||||
ChildView::new(popover, cx)
|
||||
.contained()
|
||||
.with_margin_top(titlebar.height)
|
||||
.with_margin_left(titlebar.toggle_contacts_button.default.button_width)
|
||||
.with_margin_right(-titlebar.toggle_contacts_button.default.button_width)
|
||||
.boxed(),
|
||||
)
|
||||
.with_fit_mode(OverlayFitMode::SwitchAnchor)
|
||||
.with_anchor_corner(AnchorCorner::BottomLeft)
|
||||
.with_z_index(999)
|
||||
.boxed()
|
||||
}))
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_toggle_screen_sharing_button(
|
||||
&self,
|
||||
theme: &Theme,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> Option<ElementBox> {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
let room = active_call.read(cx).room().cloned()?;
|
||||
let icon;
|
||||
let tooltip;
|
||||
|
||||
if room.read(cx).is_screen_sharing() {
|
||||
icon = "icons/disable_screen_sharing_12.svg";
|
||||
tooltip = "Stop Sharing Screen"
|
||||
} else {
|
||||
icon = "icons/enable_screen_sharing_12.svg";
|
||||
tooltip = "Share Screen";
|
||||
}
|
||||
|
||||
let titlebar = &theme.workspace.titlebar;
|
||||
Some(
|
||||
MouseEventHandler::<ToggleScreenSharing>::new(0, cx, |state, _| {
|
||||
let style = titlebar.call_control.style_for(state, false);
|
||||
Svg::new(icon)
|
||||
.with_color(style.color)
|
||||
.constrained()
|
||||
.with_width(style.icon_width)
|
||||
.aligned()
|
||||
.constrained()
|
||||
.with_width(style.button_width)
|
||||
.with_height(style.button_width)
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(ToggleScreenSharing);
|
||||
})
|
||||
.with_tooltip::<ToggleScreenSharing, _>(
|
||||
0,
|
||||
tooltip.into(),
|
||||
Some(Box::new(ToggleScreenSharing)),
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.aligned()
|
||||
.boxed(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_share_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
enum Share {}
|
||||
|
||||
let titlebar = &theme.workspace.titlebar;
|
||||
MouseEventHandler::<Share>::new(0, cx, |state, _| {
|
||||
let style = titlebar.share_button.style_for(state, false);
|
||||
Label::new("Share".into(), style.text.clone())
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject))
|
||||
.with_tooltip::<Share, _>(
|
||||
0,
|
||||
"Share project with call participants".into(),
|
||||
None,
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.aligned()
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_collaborators(
|
||||
&self,
|
||||
workspace: &ViewHandle<Workspace>,
|
||||
theme: &Theme,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> Vec<ElementBox> {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
if let Some(room) = active_call.read(cx).room().cloned() {
|
||||
let project = workspace.read(cx).project().read(cx);
|
||||
let mut participants = room
|
||||
.read(cx)
|
||||
.remote_participants()
|
||||
.iter()
|
||||
.map(|(peer_id, collaborator)| (*peer_id, collaborator.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
participants
|
||||
.sort_by_key(|(peer_id, _)| Some(project.collaborators().get(peer_id)?.replica_id));
|
||||
participants
|
||||
.into_iter()
|
||||
.filter_map(|(peer_id, participant)| {
|
||||
let project = workspace.read(cx).project().read(cx);
|
||||
let replica_id = project
|
||||
.collaborators()
|
||||
.get(&peer_id)
|
||||
.map(|collaborator| collaborator.replica_id);
|
||||
let user = participant.user.clone();
|
||||
Some(self.render_avatar(
|
||||
&user,
|
||||
replica_id,
|
||||
Some((peer_id, &user.github_login, participant.location)),
|
||||
workspace,
|
||||
theme,
|
||||
cx,
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_current_user(
|
||||
&self,
|
||||
workspace: &ViewHandle<Workspace>,
|
||||
theme: &Theme,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> Option<ElementBox> {
|
||||
let user = workspace.read(cx).user_store().read(cx).current_user();
|
||||
let replica_id = workspace.read(cx).project().read(cx).replica_id();
|
||||
let status = *workspace.read(cx).client().status().borrow();
|
||||
if let Some(user) = user {
|
||||
Some(self.render_avatar(&user, Some(replica_id), None, workspace, theme, cx))
|
||||
} else if matches!(status, client::Status::UpgradeRequired) {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
|
||||
let style = theme
|
||||
.workspace
|
||||
.titlebar
|
||||
.sign_in_prompt
|
||||
.style_for(state, false);
|
||||
Label::new("Sign in".to_string(), style.text.clone())
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.boxed()
|
||||
})
|
||||
.on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.aligned()
|
||||
.boxed(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_avatar(
|
||||
&self,
|
||||
user: &User,
|
||||
replica_id: Option<ReplicaId>,
|
||||
peer: Option<(PeerId, &str, ParticipantLocation)>,
|
||||
workspace: &ViewHandle<Workspace>,
|
||||
theme: &Theme,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> ElementBox {
|
||||
let is_followed = peer.map_or(false, |(peer_id, _, _)| {
|
||||
workspace.read(cx).is_following(peer_id)
|
||||
});
|
||||
|
||||
let mut avatar_style;
|
||||
if let Some((_, _, location)) = peer.as_ref() {
|
||||
if let ParticipantLocation::SharedProject { project_id } = *location {
|
||||
if Some(project_id) == workspace.read(cx).project().read(cx).remote_id() {
|
||||
avatar_style = theme.workspace.titlebar.avatar;
|
||||
} else {
|
||||
avatar_style = theme.workspace.titlebar.inactive_avatar;
|
||||
}
|
||||
} else {
|
||||
avatar_style = theme.workspace.titlebar.inactive_avatar;
|
||||
}
|
||||
} else {
|
||||
avatar_style = theme.workspace.titlebar.avatar;
|
||||
}
|
||||
|
||||
let mut replica_color = None;
|
||||
if let Some(replica_id) = replica_id {
|
||||
let color = theme.editor.replica_selection_style(replica_id).cursor;
|
||||
replica_color = Some(color);
|
||||
if is_followed {
|
||||
avatar_style.border = Border::all(1.0, color);
|
||||
}
|
||||
}
|
||||
|
||||
let content = Stack::new()
|
||||
.with_children(user.avatar.as_ref().map(|avatar| {
|
||||
Image::new(avatar.clone())
|
||||
.with_style(avatar_style)
|
||||
.constrained()
|
||||
.with_width(theme.workspace.titlebar.avatar_width)
|
||||
.aligned()
|
||||
.boxed()
|
||||
}))
|
||||
.with_children(replica_color.map(|replica_color| {
|
||||
AvatarRibbon::new(replica_color)
|
||||
.constrained()
|
||||
.with_width(theme.workspace.titlebar.avatar_ribbon.width)
|
||||
.with_height(theme.workspace.titlebar.avatar_ribbon.height)
|
||||
.aligned()
|
||||
.bottom()
|
||||
.boxed()
|
||||
}))
|
||||
.constrained()
|
||||
.with_width(theme.workspace.titlebar.avatar_width)
|
||||
.contained()
|
||||
.with_margin_left(theme.workspace.titlebar.avatar_margin)
|
||||
.boxed();
|
||||
|
||||
if let Some((peer_id, peer_github_login, location)) = peer {
|
||||
if let Some(replica_id) = replica_id {
|
||||
MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(ToggleFollow(peer_id))
|
||||
})
|
||||
.with_tooltip::<ToggleFollow, _>(
|
||||
peer_id.0 as usize,
|
||||
if is_followed {
|
||||
format!("Unfollow {}", peer_github_login)
|
||||
} else {
|
||||
format!("Follow {}", peer_github_login)
|
||||
},
|
||||
Some(Box::new(FollowNextCollaborator)),
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.boxed()
|
||||
} else if let ParticipantLocation::SharedProject { project_id } = location {
|
||||
let user_id = user.id;
|
||||
MouseEventHandler::<JoinProject>::new(peer_id.0 as usize, cx, move |_, _| content)
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(JoinProject {
|
||||
project_id,
|
||||
follow_user_id: user_id,
|
||||
})
|
||||
})
|
||||
.with_tooltip::<JoinProject, _>(
|
||||
peer_id.0 as usize,
|
||||
format!("Follow {} into external project", peer_github_login),
|
||||
Some(Box::new(FollowNextCollaborator)),
|
||||
theme.tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
.boxed()
|
||||
} else {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
fn render_connection_status(
|
||||
&self,
|
||||
workspace: &ViewHandle<Workspace>,
|
||||
cx: &mut RenderContext<Self>,
|
||||
) -> Option<ElementBox> {
|
||||
let theme = &cx.global::<Settings>().theme;
|
||||
match &*workspace.read(cx).client().status().borrow() {
|
||||
client::Status::ConnectionError
|
||||
| client::Status::ConnectionLost
|
||||
| client::Status::Reauthenticating { .. }
|
||||
| client::Status::Reconnecting { .. }
|
||||
| client::Status::ReconnectionError { .. } => Some(
|
||||
Container::new(
|
||||
Align::new(
|
||||
ConstrainedBox::new(
|
||||
Svg::new("icons/cloud_slash_12.svg")
|
||||
.with_color(theme.workspace.titlebar.offline_icon.color)
|
||||
.boxed(),
|
||||
)
|
||||
.with_width(theme.workspace.titlebar.offline_icon.width)
|
||||
.boxed(),
|
||||
)
|
||||
.boxed(),
|
||||
)
|
||||
.with_style(theme.workspace.titlebar.offline_icon.container)
|
||||
.boxed(),
|
||||
),
|
||||
client::Status::UpgradeRequired => Some(
|
||||
Label::new(
|
||||
"Please update Zed to collaborate".to_string(),
|
||||
theme.workspace.titlebar.outdated_warning.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(theme.workspace.titlebar.outdated_warning.container)
|
||||
.aligned()
|
||||
.boxed(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AvatarRibbon {
|
||||
color: Color,
|
||||
}
|
||||
|
||||
impl AvatarRibbon {
|
||||
pub fn new(color: Color) -> AvatarRibbon {
|
||||
AvatarRibbon { color }
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for AvatarRibbon {
|
||||
type LayoutState = ();
|
||||
|
||||
type PaintState = ();
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
constraint: gpui::SizeConstraint,
|
||||
_: &mut gpui::LayoutContext,
|
||||
) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
|
||||
(constraint.max, ())
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
bounds: gpui::geometry::rect::RectF,
|
||||
_: gpui::geometry::rect::RectF,
|
||||
_: &mut Self::LayoutState,
|
||||
cx: &mut gpui::PaintContext,
|
||||
) -> Self::PaintState {
|
||||
let mut path = PathBuilder::new();
|
||||
path.reset(bounds.lower_left());
|
||||
path.curve_to(
|
||||
bounds.origin() + vec2f(bounds.height(), 0.),
|
||||
bounds.origin(),
|
||||
);
|
||||
path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.));
|
||||
path.curve_to(bounds.lower_right(), bounds.upper_right());
|
||||
path.line_to(bounds.lower_left());
|
||||
cx.scene.push_path(path.build(self.color, None));
|
||||
}
|
||||
|
||||
fn rect_for_text_range(
|
||||
&self,
|
||||
_: Range<usize>,
|
||||
_: RectF,
|
||||
_: RectF,
|
||||
_: &Self::LayoutState,
|
||||
_: &Self::PaintState,
|
||||
_: &gpui::MeasurementContext,
|
||||
) -> Option<RectF> {
|
||||
None
|
||||
}
|
||||
|
||||
fn debug(
|
||||
&self,
|
||||
bounds: gpui::geometry::rect::RectF,
|
||||
_: &Self::LayoutState,
|
||||
_: &Self::PaintState,
|
||||
_: &gpui::DebugContext,
|
||||
) -> gpui::json::Value {
|
||||
json::json!({
|
||||
"type": "AvatarRibbon",
|
||||
"bounds": bounds.to_json(),
|
||||
"color": self.color.to_json(),
|
||||
})
|
||||
}
|
||||
}
|
||||
97
crates/collab_ui/src/collab_ui.rs
Normal file
97
crates/collab_ui/src/collab_ui.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
mod collab_titlebar_item;
|
||||
mod contact_finder;
|
||||
mod contact_list;
|
||||
mod contact_notification;
|
||||
mod contacts_popover;
|
||||
mod incoming_call_notification;
|
||||
mod notifications;
|
||||
mod project_shared_notification;
|
||||
|
||||
use call::ActiveCall;
|
||||
pub use collab_titlebar_item::{CollabTitlebarItem, ToggleCollaborationMenu};
|
||||
use gpui::MutableAppContext;
|
||||
use project::Project;
|
||||
use std::sync::Arc;
|
||||
use workspace::{AppState, JoinProject, ToggleFollow, Workspace};
|
||||
|
||||
pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
|
||||
collab_titlebar_item::init(cx);
|
||||
contact_notification::init(cx);
|
||||
contact_list::init(cx);
|
||||
contact_finder::init(cx);
|
||||
contacts_popover::init(cx);
|
||||
incoming_call_notification::init(cx);
|
||||
project_shared_notification::init(cx);
|
||||
|
||||
cx.add_global_action(move |action: &JoinProject, cx| {
|
||||
let project_id = action.project_id;
|
||||
let follow_user_id = action.follow_user_id;
|
||||
let app_state = app_state.clone();
|
||||
cx.spawn(|mut cx| async move {
|
||||
let existing_workspace = cx.update(|cx| {
|
||||
cx.window_ids()
|
||||
.filter_map(|window_id| cx.root_view::<Workspace>(window_id))
|
||||
.find(|workspace| {
|
||||
workspace.read(cx).project().read(cx).remote_id() == Some(project_id)
|
||||
})
|
||||
});
|
||||
|
||||
let workspace = if let Some(existing_workspace) = existing_workspace {
|
||||
existing_workspace
|
||||
} else {
|
||||
let project = Project::remote(
|
||||
project_id,
|
||||
app_state.client.clone(),
|
||||
app_state.user_store.clone(),
|
||||
app_state.project_store.clone(),
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
cx.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (_, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
|
||||
let mut workspace = Workspace::new(project, app_state.default_item_factory, cx);
|
||||
(app_state.initialize_workspace)(&mut workspace, &app_state, cx);
|
||||
workspace
|
||||
});
|
||||
workspace
|
||||
};
|
||||
|
||||
cx.activate_window(workspace.window_id());
|
||||
cx.platform().activate(true);
|
||||
|
||||
workspace.update(&mut cx, |workspace, cx| {
|
||||
if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
|
||||
let follow_peer_id = room
|
||||
.read(cx)
|
||||
.remote_participants()
|
||||
.iter()
|
||||
.find(|(_, participant)| participant.user.id == follow_user_id)
|
||||
.map(|(peer_id, _)| *peer_id)
|
||||
.or_else(|| {
|
||||
// If we couldn't follow the given user, follow the host instead.
|
||||
let collaborator = workspace
|
||||
.project()
|
||||
.read(cx)
|
||||
.collaborators()
|
||||
.values()
|
||||
.find(|collaborator| collaborator.replica_id == 0)?;
|
||||
Some(collaborator.peer_id)
|
||||
});
|
||||
|
||||
if let Some(follow_peer_id) = follow_peer_id {
|
||||
if !workspace.is_following(follow_peer_id) {
|
||||
workspace
|
||||
.toggle_follow(&ToggleFollow(follow_peer_id), cx)
|
||||
.map(|follow| follow.detach_and_log_err(cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
});
|
||||
}
|
||||
@@ -1,21 +1,15 @@
|
||||
use client::{ContactRequestStatus, User, UserStore};
|
||||
use gpui::{
|
||||
actions, elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext,
|
||||
RenderContext, Task, View, ViewContext, ViewHandle,
|
||||
elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext, RenderContext,
|
||||
Task, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use settings::Settings;
|
||||
use std::sync::Arc;
|
||||
use util::TryFutureExt;
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::render_icon_button;
|
||||
|
||||
actions!(contact_finder, [Toggle]);
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
Picker::<ContactFinder>::init(cx);
|
||||
cx.add_action(ContactFinder::toggle);
|
||||
}
|
||||
|
||||
pub struct ContactFinder {
|
||||
@@ -38,11 +32,11 @@ impl View for ContactFinder {
|
||||
"ContactFinder"
|
||||
}
|
||||
|
||||
fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
|
||||
ChildView::new(self.picker.clone()).boxed()
|
||||
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
ChildView::new(self.picker.clone(), cx).boxed()
|
||||
}
|
||||
|
||||
fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
|
||||
fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
|
||||
if cx.is_self_focused() {
|
||||
cx.focus(&self.picker);
|
||||
}
|
||||
@@ -107,7 +101,7 @@ impl PickerDelegate for ContactFinder {
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
mouse_state: MouseState,
|
||||
mouse_state: &mut MouseState,
|
||||
selected: bool,
|
||||
cx: &gpui::AppContext,
|
||||
) -> ElementBox {
|
||||
@@ -117,18 +111,21 @@ impl PickerDelegate for ContactFinder {
|
||||
|
||||
let icon_path = match request_status {
|
||||
ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
|
||||
"icons/check_8.svg"
|
||||
}
|
||||
ContactRequestStatus::RequestSent | ContactRequestStatus::RequestAccepted => {
|
||||
"icons/x_mark_8.svg"
|
||||
Some("icons/check_8.svg")
|
||||
}
|
||||
ContactRequestStatus::RequestSent => Some("icons/x_mark_8.svg"),
|
||||
ContactRequestStatus::RequestAccepted => None,
|
||||
};
|
||||
let button_style = if self.user_store.read(cx).is_contact_request_pending(user) {
|
||||
&theme.contact_finder.disabled_contact_button
|
||||
} else {
|
||||
&theme.contact_finder.contact_button
|
||||
};
|
||||
let style = theme.picker.item.style_for(mouse_state, selected);
|
||||
let style = theme
|
||||
.contact_finder
|
||||
.picker
|
||||
.item
|
||||
.style_for(mouse_state, selected);
|
||||
Flex::row()
|
||||
.with_children(user.avatar.clone().map(|avatar| {
|
||||
Image::new(avatar)
|
||||
@@ -145,12 +142,21 @@ impl PickerDelegate for ContactFinder {
|
||||
.left()
|
||||
.boxed(),
|
||||
)
|
||||
.with_child(
|
||||
render_icon_button(button_style, icon_path)
|
||||
.with_children(icon_path.map(|icon_path| {
|
||||
Svg::new(icon_path)
|
||||
.with_color(button_style.color)
|
||||
.constrained()
|
||||
.with_width(button_style.icon_width)
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_style(button_style.container)
|
||||
.constrained()
|
||||
.with_width(button_style.button_width)
|
||||
.with_height(button_style.button_width)
|
||||
.aligned()
|
||||
.flex_float()
|
||||
.boxed(),
|
||||
)
|
||||
.boxed()
|
||||
}))
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.constrained()
|
||||
@@ -160,34 +166,16 @@ impl PickerDelegate for ContactFinder {
|
||||
}
|
||||
|
||||
impl ContactFinder {
|
||||
fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
|
||||
workspace.toggle_modal(cx, |workspace, cx| {
|
||||
let finder = cx.add_view(|cx| Self::new(workspace.user_store().clone(), cx));
|
||||
cx.subscribe(&finder, Self::on_event).detach();
|
||||
finder
|
||||
});
|
||||
}
|
||||
|
||||
pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
|
||||
let this = cx.weak_handle();
|
||||
Self {
|
||||
picker: cx.add_view(|cx| Picker::new(this, cx)),
|
||||
picker: cx.add_view(|cx| {
|
||||
Picker::new(this, cx)
|
||||
.with_theme(|cx| &cx.global::<Settings>().theme.contact_finder.picker)
|
||||
}),
|
||||
potential_contacts: Arc::from([]),
|
||||
user_store,
|
||||
selected_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_event(
|
||||
workspace: &mut Workspace,
|
||||
_: ViewHandle<Self>,
|
||||
event: &Event,
|
||||
cx: &mut ViewContext<Workspace>,
|
||||
) {
|
||||
match event {
|
||||
Event::Dismissed => {
|
||||
workspace.dismiss_modal(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1338
crates/collab_ui/src/contact_list.rs
Normal file
1338
crates/collab_ui/src/contact_list.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -49,10 +49,7 @@ impl View for ContactNotification {
|
||||
self.user.clone(),
|
||||
"wants to add you as a contact",
|
||||
Some("They won't know if you decline."),
|
||||
RespondToContactRequest {
|
||||
user_id: self.user.id,
|
||||
accept: false,
|
||||
},
|
||||
Dismiss(self.user.id),
|
||||
vec![
|
||||
(
|
||||
"Decline",
|
||||
171
crates/collab_ui/src/contacts_popover.rs
Normal file
171
crates/collab_ui/src/contacts_popover.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use crate::{contact_finder::ContactFinder, contact_list::ContactList, ToggleCollaborationMenu};
|
||||
use client::UserStore;
|
||||
use gpui::{
|
||||
actions, elements::*, ClipboardItem, CursorStyle, Entity, ModelHandle, MouseButton,
|
||||
MutableAppContext, RenderContext, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use project::Project;
|
||||
use settings::Settings;
|
||||
|
||||
actions!(contacts_popover, [ToggleContactFinder]);
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
cx.add_action(ContactsPopover::toggle_contact_finder);
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
enum Child {
|
||||
ContactList(ViewHandle<ContactList>),
|
||||
ContactFinder(ViewHandle<ContactFinder>),
|
||||
}
|
||||
|
||||
pub struct ContactsPopover {
|
||||
child: Child,
|
||||
project: ModelHandle<Project>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
_subscription: Option<gpui::Subscription>,
|
||||
}
|
||||
|
||||
impl ContactsPopover {
|
||||
pub fn new(
|
||||
project: ModelHandle<Project>,
|
||||
user_store: ModelHandle<UserStore>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let mut this = Self {
|
||||
child: Child::ContactList(
|
||||
cx.add_view(|cx| ContactList::new(project.clone(), user_store.clone(), cx)),
|
||||
),
|
||||
project,
|
||||
user_store,
|
||||
_subscription: None,
|
||||
};
|
||||
this.show_contact_list(cx);
|
||||
this
|
||||
}
|
||||
|
||||
fn toggle_contact_finder(&mut self, _: &ToggleContactFinder, cx: &mut ViewContext<Self>) {
|
||||
match &self.child {
|
||||
Child::ContactList(_) => self.show_contact_finder(cx),
|
||||
Child::ContactFinder(_) => self.show_contact_list(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn show_contact_finder(&mut self, cx: &mut ViewContext<ContactsPopover>) {
|
||||
let child = cx.add_view(|cx| ContactFinder::new(self.user_store.clone(), cx));
|
||||
cx.focus(&child);
|
||||
self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
|
||||
crate::contact_finder::Event::Dismissed => cx.emit(Event::Dismissed),
|
||||
}));
|
||||
self.child = Child::ContactFinder(child);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn show_contact_list(&mut self, cx: &mut ViewContext<ContactsPopover>) {
|
||||
let child =
|
||||
cx.add_view(|cx| ContactList::new(self.project.clone(), self.user_store.clone(), cx));
|
||||
cx.focus(&child);
|
||||
self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
|
||||
crate::contact_list::Event::Dismissed => cx.emit(Event::Dismissed),
|
||||
}));
|
||||
self.child = Child::ContactList(child);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ContactsPopover {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
impl View for ContactsPopover {
|
||||
fn ui_name() -> &'static str {
|
||||
"ContactsPopover"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
let theme = cx.global::<Settings>().theme.clone();
|
||||
let child = match &self.child {
|
||||
Child::ContactList(child) => ChildView::new(child, cx),
|
||||
Child::ContactFinder(child) => ChildView::new(child, cx),
|
||||
};
|
||||
|
||||
MouseEventHandler::<ContactsPopover>::new(0, cx, |_, cx| {
|
||||
Flex::column()
|
||||
.with_child(child.flex(1., true).boxed())
|
||||
.with_children(
|
||||
self.user_store
|
||||
.read(cx)
|
||||
.invite_info()
|
||||
.cloned()
|
||||
.and_then(|info| {
|
||||
enum InviteLink {}
|
||||
|
||||
if info.count > 0 {
|
||||
Some(
|
||||
MouseEventHandler::<InviteLink>::new(0, cx, |state, cx| {
|
||||
let style = theme
|
||||
.contacts_popover
|
||||
.invite_row
|
||||
.style_for(state, false)
|
||||
.clone();
|
||||
|
||||
let copied =
|
||||
cx.read_from_clipboard().map_or(false, |item| {
|
||||
item.text().as_str() == info.url.as_ref()
|
||||
});
|
||||
|
||||
Label::new(
|
||||
format!(
|
||||
"{} invite link ({} left)",
|
||||
if copied { "Copied" } else { "Copy" },
|
||||
info.count
|
||||
),
|
||||
style.label.clone(),
|
||||
)
|
||||
.aligned()
|
||||
.left()
|
||||
.constrained()
|
||||
.with_height(theme.contacts_popover.invite_row_height)
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new(
|
||||
info.url.to_string(),
|
||||
));
|
||||
cx.notify();
|
||||
})
|
||||
.boxed(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
)
|
||||
.contained()
|
||||
.with_style(theme.contacts_popover.container)
|
||||
.constrained()
|
||||
.with_width(theme.contacts_popover.width)
|
||||
.with_height(theme.contacts_popover.height)
|
||||
.boxed()
|
||||
})
|
||||
.on_down_out(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(ToggleCollaborationMenu);
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
|
||||
if cx.is_self_focused() {
|
||||
match &self.child {
|
||||
Child::ContactList(child) => cx.focus(child),
|
||||
Child::ContactFinder(child) => cx.focus(child),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
235
crates/collab_ui/src/incoming_call_notification.rs
Normal file
235
crates/collab_ui/src/incoming_call_notification.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use call::{ActiveCall, IncomingCall};
|
||||
use client::proto;
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
elements::*,
|
||||
geometry::{rect::RectF, vector::vec2f},
|
||||
impl_internal_actions, CursorStyle, Entity, MouseButton, MutableAppContext, RenderContext,
|
||||
View, ViewContext, WindowBounds, WindowKind, WindowOptions,
|
||||
};
|
||||
use settings::Settings;
|
||||
use util::ResultExt;
|
||||
use workspace::JoinProject;
|
||||
|
||||
impl_internal_actions!(incoming_call_notification, [RespondToCall]);
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
cx.add_action(IncomingCallNotification::respond_to_call);
|
||||
|
||||
let mut incoming_call = ActiveCall::global(cx).read(cx).incoming();
|
||||
cx.spawn(|mut cx| async move {
|
||||
let mut notification_windows = Vec::new();
|
||||
while let Some(incoming_call) = incoming_call.next().await {
|
||||
for window_id in notification_windows.drain(..) {
|
||||
cx.remove_window(window_id);
|
||||
}
|
||||
|
||||
if let Some(incoming_call) = incoming_call {
|
||||
const PADDING: f32 = 16.;
|
||||
let window_size = cx.read(|cx| {
|
||||
let theme = &cx.global::<Settings>().theme.incoming_call_notification;
|
||||
vec2f(theme.window_width, theme.window_height)
|
||||
});
|
||||
|
||||
for screen in cx.platform().screens() {
|
||||
let screen_size = screen.size();
|
||||
let (window_id, _) = cx.add_window(
|
||||
WindowOptions {
|
||||
bounds: WindowBounds::Fixed(RectF::new(
|
||||
vec2f(screen_size.x() - window_size.x() - PADDING, PADDING),
|
||||
window_size,
|
||||
)),
|
||||
titlebar: None,
|
||||
center: false,
|
||||
kind: WindowKind::PopUp,
|
||||
is_movable: false,
|
||||
screen: Some(screen),
|
||||
},
|
||||
|_| IncomingCallNotification::new(incoming_call.clone()),
|
||||
);
|
||||
notification_windows.push(window_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct RespondToCall {
|
||||
accept: bool,
|
||||
}
|
||||
|
||||
pub struct IncomingCallNotification {
|
||||
call: IncomingCall,
|
||||
}
|
||||
|
||||
impl IncomingCallNotification {
|
||||
pub fn new(call: IncomingCall) -> Self {
|
||||
Self { call }
|
||||
}
|
||||
|
||||
fn respond_to_call(&mut self, action: &RespondToCall, cx: &mut ViewContext<Self>) {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
if action.accept {
|
||||
let join = active_call.update(cx, |active_call, cx| active_call.accept_incoming(cx));
|
||||
let caller_user_id = self.call.caller.id;
|
||||
let initial_project_id = self.call.initial_project.as_ref().map(|project| project.id);
|
||||
cx.spawn_weak(|_, mut cx| async move {
|
||||
join.await?;
|
||||
if let Some(project_id) = initial_project_id {
|
||||
cx.update(|cx| {
|
||||
cx.dispatch_global_action(JoinProject {
|
||||
project_id,
|
||||
follow_user_id: caller_user_id,
|
||||
})
|
||||
});
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
} else {
|
||||
active_call.update(cx, |active_call, _| {
|
||||
active_call.decline_incoming().log_err();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn render_caller(&self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
let theme = &cx.global::<Settings>().theme.incoming_call_notification;
|
||||
let default_project = proto::ParticipantProject::default();
|
||||
let initial_project = self
|
||||
.call
|
||||
.initial_project
|
||||
.as_ref()
|
||||
.unwrap_or(&default_project);
|
||||
Flex::row()
|
||||
.with_children(self.call.caller.avatar.clone().map(|avatar| {
|
||||
Image::new(avatar)
|
||||
.with_style(theme.caller_avatar)
|
||||
.aligned()
|
||||
.boxed()
|
||||
}))
|
||||
.with_child(
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Label::new(
|
||||
self.call.caller.github_login.clone(),
|
||||
theme.caller_username.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(theme.caller_username.container)
|
||||
.boxed(),
|
||||
)
|
||||
.with_child(
|
||||
Label::new(
|
||||
format!(
|
||||
"is sharing a project in Zed{}",
|
||||
if initial_project.worktree_root_names.is_empty() {
|
||||
""
|
||||
} else {
|
||||
":"
|
||||
}
|
||||
),
|
||||
theme.caller_message.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(theme.caller_message.container)
|
||||
.boxed(),
|
||||
)
|
||||
.with_children(if initial_project.worktree_root_names.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
Label::new(
|
||||
initial_project.worktree_root_names.join(", "),
|
||||
theme.worktree_roots.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(theme.worktree_roots.container)
|
||||
.boxed(),
|
||||
)
|
||||
})
|
||||
.contained()
|
||||
.with_style(theme.caller_metadata)
|
||||
.aligned()
|
||||
.boxed(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(theme.caller_container)
|
||||
.flex(1., true)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_buttons(&self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
enum Accept {}
|
||||
enum Decline {}
|
||||
|
||||
Flex::column()
|
||||
.with_child(
|
||||
MouseEventHandler::<Accept>::new(0, cx, |_, cx| {
|
||||
let theme = &cx.global::<Settings>().theme.incoming_call_notification;
|
||||
Label::new("Accept".to_string(), theme.accept_button.text.clone())
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_style(theme.accept_button.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, |_, cx| {
|
||||
cx.dispatch_action(RespondToCall { accept: true });
|
||||
})
|
||||
.flex(1., true)
|
||||
.boxed(),
|
||||
)
|
||||
.with_child(
|
||||
MouseEventHandler::<Decline>::new(0, cx, |_, cx| {
|
||||
let theme = &cx.global::<Settings>().theme.incoming_call_notification;
|
||||
Label::new("Decline".to_string(), theme.decline_button.text.clone())
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_style(theme.decline_button.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, |_, cx| {
|
||||
cx.dispatch_action(RespondToCall { accept: false });
|
||||
})
|
||||
.flex(1., true)
|
||||
.boxed(),
|
||||
)
|
||||
.constrained()
|
||||
.with_width(
|
||||
cx.global::<Settings>()
|
||||
.theme
|
||||
.incoming_call_notification
|
||||
.button_width,
|
||||
)
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for IncomingCallNotification {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for IncomingCallNotification {
|
||||
fn ui_name() -> &'static str {
|
||||
"IncomingCallNotification"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
|
||||
let background = cx
|
||||
.global::<Settings>()
|
||||
.theme
|
||||
.incoming_call_notification
|
||||
.background;
|
||||
Flex::row()
|
||||
.with_child(self.render_caller(cx))
|
||||
.with_child(self.render_buttons(cx))
|
||||
.contained()
|
||||
.with_background_color(background)
|
||||
.expanded()
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
use crate::render_icon_button;
|
||||
use client::User;
|
||||
use gpui::{
|
||||
elements::{Flex, Image, Label, MouseEventHandler, Padding, ParentElement, Text},
|
||||
platform::CursorStyle,
|
||||
Action, Element, ElementBox, MouseButton, RenderContext, View,
|
||||
elements::*, platform::CursorStyle, Action, Element, ElementBox, MouseButton, RenderContext,
|
||||
View,
|
||||
};
|
||||
use settings::Settings;
|
||||
use std::sync::Arc;
|
||||
@@ -53,11 +51,18 @@ pub fn render_user_notification<V: View, A: Action + Clone>(
|
||||
)
|
||||
.with_child(
|
||||
MouseEventHandler::<Dismiss>::new(user.id as usize, cx, |state, _| {
|
||||
render_icon_button(
|
||||
theme.dismiss_button.style_for(state, false),
|
||||
"icons/x_mark_thin_8.svg",
|
||||
)
|
||||
.boxed()
|
||||
let style = theme.dismiss_button.style_for(state, false);
|
||||
Svg::new("icons/x_mark_thin_8.svg")
|
||||
.with_color(style.color)
|
||||
.constrained()
|
||||
.with_width(style.icon_width)
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_style(style.container)
|
||||
.constrained()
|
||||
.with_width(style.button_width)
|
||||
.with_height(style.button_width)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.with_padding(Padding::uniform(5.))
|
||||
243
crates/collab_ui/src/project_shared_notification.rs
Normal file
243
crates/collab_ui/src/project_shared_notification.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
use call::{room, ActiveCall};
|
||||
use client::User;
|
||||
use collections::HashMap;
|
||||
use gpui::{
|
||||
actions,
|
||||
elements::*,
|
||||
geometry::{rect::RectF, vector::vec2f},
|
||||
CursorStyle, Entity, MouseButton, MutableAppContext, RenderContext, View, ViewContext,
|
||||
WindowBounds, WindowKind, WindowOptions,
|
||||
};
|
||||
use settings::Settings;
|
||||
use std::sync::Arc;
|
||||
use workspace::JoinProject;
|
||||
|
||||
actions!(project_shared_notification, [DismissProject]);
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
cx.add_action(ProjectSharedNotification::join);
|
||||
cx.add_action(ProjectSharedNotification::dismiss);
|
||||
|
||||
let active_call = ActiveCall::global(cx);
|
||||
let mut notification_windows = HashMap::default();
|
||||
cx.subscribe(&active_call, move |_, event, cx| match event {
|
||||
room::Event::RemoteProjectShared {
|
||||
owner,
|
||||
project_id,
|
||||
worktree_root_names,
|
||||
} => {
|
||||
const PADDING: f32 = 16.;
|
||||
let theme = &cx.global::<Settings>().theme.project_shared_notification;
|
||||
let window_size = vec2f(theme.window_width, theme.window_height);
|
||||
|
||||
for screen in cx.platform().screens() {
|
||||
let screen_size = screen.size();
|
||||
let (window_id, _) = cx.add_window(
|
||||
WindowOptions {
|
||||
bounds: WindowBounds::Fixed(RectF::new(
|
||||
vec2f(screen_size.x() - window_size.x() - PADDING, PADDING),
|
||||
window_size,
|
||||
)),
|
||||
titlebar: None,
|
||||
center: false,
|
||||
kind: WindowKind::PopUp,
|
||||
is_movable: false,
|
||||
screen: Some(screen),
|
||||
},
|
||||
|_| {
|
||||
ProjectSharedNotification::new(
|
||||
owner.clone(),
|
||||
*project_id,
|
||||
worktree_root_names.clone(),
|
||||
)
|
||||
},
|
||||
);
|
||||
notification_windows
|
||||
.entry(*project_id)
|
||||
.or_insert(Vec::new())
|
||||
.push(window_id);
|
||||
}
|
||||
}
|
||||
room::Event::RemoteProjectUnshared { project_id } => {
|
||||
if let Some(window_ids) = notification_windows.remove(&project_id) {
|
||||
for window_id in window_ids {
|
||||
cx.remove_window(window_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
room::Event::Left => {
|
||||
for (_, window_ids) in notification_windows.drain() {
|
||||
for window_id in window_ids {
|
||||
cx.remove_window(window_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub struct ProjectSharedNotification {
|
||||
project_id: u64,
|
||||
worktree_root_names: Vec<String>,
|
||||
owner: Arc<User>,
|
||||
}
|
||||
|
||||
impl ProjectSharedNotification {
|
||||
fn new(owner: Arc<User>, project_id: u64, worktree_root_names: Vec<String>) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
worktree_root_names,
|
||||
owner,
|
||||
}
|
||||
}
|
||||
|
||||
fn join(&mut self, _: &JoinProject, cx: &mut ViewContext<Self>) {
|
||||
let window_id = cx.window_id();
|
||||
cx.remove_window(window_id);
|
||||
cx.propagate_action();
|
||||
}
|
||||
|
||||
fn dismiss(&mut self, _: &DismissProject, cx: &mut ViewContext<Self>) {
|
||||
let window_id = cx.window_id();
|
||||
cx.remove_window(window_id);
|
||||
}
|
||||
|
||||
fn render_owner(&self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
let theme = &cx.global::<Settings>().theme.project_shared_notification;
|
||||
Flex::row()
|
||||
.with_children(self.owner.avatar.clone().map(|avatar| {
|
||||
Image::new(avatar)
|
||||
.with_style(theme.owner_avatar)
|
||||
.aligned()
|
||||
.boxed()
|
||||
}))
|
||||
.with_child(
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Label::new(
|
||||
self.owner.github_login.clone(),
|
||||
theme.owner_username.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(theme.owner_username.container)
|
||||
.boxed(),
|
||||
)
|
||||
.with_child(
|
||||
Label::new(
|
||||
format!(
|
||||
"is sharing a project in Zed{}",
|
||||
if self.worktree_root_names.is_empty() {
|
||||
""
|
||||
} else {
|
||||
":"
|
||||
}
|
||||
),
|
||||
theme.message.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(theme.message.container)
|
||||
.boxed(),
|
||||
)
|
||||
.with_children(if self.worktree_root_names.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
Label::new(
|
||||
self.worktree_root_names.join(", "),
|
||||
theme.worktree_roots.text.clone(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(theme.worktree_roots.container)
|
||||
.boxed(),
|
||||
)
|
||||
})
|
||||
.contained()
|
||||
.with_style(theme.owner_metadata)
|
||||
.aligned()
|
||||
.boxed(),
|
||||
)
|
||||
.contained()
|
||||
.with_style(theme.owner_container)
|
||||
.flex(1., true)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn render_buttons(&self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
enum Open {}
|
||||
enum Dismiss {}
|
||||
|
||||
let project_id = self.project_id;
|
||||
let owner_user_id = self.owner.id;
|
||||
|
||||
Flex::column()
|
||||
.with_child(
|
||||
MouseEventHandler::<Open>::new(0, cx, |_, cx| {
|
||||
let theme = &cx.global::<Settings>().theme.project_shared_notification;
|
||||
Label::new("Open".to_string(), theme.open_button.text.clone())
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_style(theme.open_button.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, move |_, cx| {
|
||||
cx.dispatch_action(JoinProject {
|
||||
project_id,
|
||||
follow_user_id: owner_user_id,
|
||||
});
|
||||
})
|
||||
.flex(1., true)
|
||||
.boxed(),
|
||||
)
|
||||
.with_child(
|
||||
MouseEventHandler::<Dismiss>::new(0, cx, |_, cx| {
|
||||
let theme = &cx.global::<Settings>().theme.project_shared_notification;
|
||||
Label::new("Dismiss".to_string(), theme.dismiss_button.text.clone())
|
||||
.aligned()
|
||||
.contained()
|
||||
.with_style(theme.dismiss_button.container)
|
||||
.boxed()
|
||||
})
|
||||
.with_cursor_style(CursorStyle::PointingHand)
|
||||
.on_click(MouseButton::Left, |_, cx| {
|
||||
cx.dispatch_action(DismissProject);
|
||||
})
|
||||
.flex(1., true)
|
||||
.boxed(),
|
||||
)
|
||||
.constrained()
|
||||
.with_width(
|
||||
cx.global::<Settings>()
|
||||
.theme
|
||||
.project_shared_notification
|
||||
.button_width,
|
||||
)
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ProjectSharedNotification {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for ProjectSharedNotification {
|
||||
fn ui_name() -> &'static str {
|
||||
"ProjectSharedNotification"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
|
||||
let background = cx
|
||||
.global::<Settings>()
|
||||
.theme
|
||||
.project_shared_notification
|
||||
.background;
|
||||
Flex::row()
|
||||
.with_child(self.render_owner(cx))
|
||||
.with_child(self.render_buttons(cx))
|
||||
.contained()
|
||||
.with_background_color(background)
|
||||
.expanded()
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ use gpui::{
|
||||
actions,
|
||||
elements::{ChildView, Flex, Label, ParentElement},
|
||||
keymap::Keystroke,
|
||||
Action, AnyViewHandle, Element, Entity, MouseState, MutableAppContext, View, ViewContext,
|
||||
ViewHandle,
|
||||
Action, AnyViewHandle, Element, Entity, MouseState, MutableAppContext, RenderContext, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use settings::Settings;
|
||||
@@ -131,11 +131,11 @@ impl View for CommandPalette {
|
||||
"CommandPalette"
|
||||
}
|
||||
|
||||
fn render(&mut self, _: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
|
||||
ChildView::new(self.picker.clone()).boxed()
|
||||
fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
|
||||
ChildView::new(self.picker.clone(), cx).boxed()
|
||||
}
|
||||
|
||||
fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
|
||||
fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
|
||||
if cx.is_self_focused() {
|
||||
cx.focus(&self.picker);
|
||||
}
|
||||
@@ -224,7 +224,7 @@ impl PickerDelegate for CommandPalette {
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
mouse_state: MouseState,
|
||||
mouse_state: &mut MouseState,
|
||||
selected: bool,
|
||||
cx: &gpui::AppContext,
|
||||
) -> gpui::ElementBox {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
[package]
|
||||
name = "contacts_panel"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/contacts_panel.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
client = { path = "../client" }
|
||||
collections = { path = "../collections" }
|
||||
editor = { path = "../editor" }
|
||||
fuzzy = { path = "../fuzzy" }
|
||||
gpui = { path = "../gpui" }
|
||||
menu = { path = "../menu" }
|
||||
picker = { path = "../picker" }
|
||||
project = { path = "../project" }
|
||||
settings = { path = "../settings" }
|
||||
theme = { path = "../theme" }
|
||||
util = { path = "../util" }
|
||||
workspace = { path = "../workspace" }
|
||||
anyhow = "1.0"
|
||||
futures = "0.3"
|
||||
log = "0.4"
|
||||
postage = { version = "0.4.1", features = ["futures-traits"] }
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
|
||||
[dev-dependencies]
|
||||
language = { path = "../language", features = ["test-support"] }
|
||||
project = { path = "../project", features = ["test-support"] }
|
||||
workspace = { path = "../workspace", features = ["test-support"] }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,80 +0,0 @@
|
||||
use client::User;
|
||||
use gpui::{
|
||||
actions, ElementBox, Entity, ModelHandle, MutableAppContext, RenderContext, View, ViewContext,
|
||||
};
|
||||
use project::Project;
|
||||
use std::sync::Arc;
|
||||
use workspace::Notification;
|
||||
|
||||
use crate::notifications::render_user_notification;
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
cx.add_action(JoinProjectNotification::decline);
|
||||
cx.add_action(JoinProjectNotification::accept);
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
actions!(contacts_panel, [Accept, Decline]);
|
||||
|
||||
pub struct JoinProjectNotification {
|
||||
project: ModelHandle<Project>,
|
||||
user: Arc<User>,
|
||||
}
|
||||
|
||||
impl JoinProjectNotification {
|
||||
pub fn new(project: ModelHandle<Project>, user: Arc<User>, cx: &mut ViewContext<Self>) -> Self {
|
||||
cx.subscribe(&project, |this, _, event, cx| {
|
||||
if let project::Event::ContactCancelledJoinRequest(user) = event {
|
||||
if *user == this.user {
|
||||
cx.emit(Event::Dismiss);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
Self { project, user }
|
||||
}
|
||||
|
||||
fn decline(&mut self, _: &Decline, cx: &mut ViewContext<Self>) {
|
||||
self.project.update(cx, |project, cx| {
|
||||
project.respond_to_join_request(self.user.id, false, cx)
|
||||
});
|
||||
cx.emit(Event::Dismiss)
|
||||
}
|
||||
|
||||
fn accept(&mut self, _: &Accept, cx: &mut ViewContext<Self>) {
|
||||
self.project.update(cx, |project, cx| {
|
||||
project.respond_to_join_request(self.user.id, true, cx)
|
||||
});
|
||||
cx.emit(Event::Dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for JoinProjectNotification {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
impl View for JoinProjectNotification {
|
||||
fn ui_name() -> &'static str {
|
||||
"JoinProjectNotification"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
render_user_notification(
|
||||
self.user.clone(),
|
||||
"wants to join your project",
|
||||
None,
|
||||
Decline,
|
||||
vec![("Decline", Box::new(Decline)), ("Accept", Box::new(Accept))],
|
||||
cx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Notification for JoinProjectNotification {
|
||||
fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
|
||||
matches!(event, Event::Dismiss)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
[package]
|
||||
name = "contacts_status_item"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/contacts_status_item.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
client = { path = "../client" }
|
||||
collections = { path = "../collections" }
|
||||
editor = { path = "../editor" }
|
||||
fuzzy = { path = "../fuzzy" }
|
||||
gpui = { path = "../gpui" }
|
||||
menu = { path = "../menu" }
|
||||
picker = { path = "../picker" }
|
||||
project = { path = "../project" }
|
||||
settings = { path = "../settings" }
|
||||
theme = { path = "../theme" }
|
||||
util = { path = "../util" }
|
||||
workspace = { path = "../workspace" }
|
||||
anyhow = "1.0"
|
||||
futures = "0.3"
|
||||
log = "0.4"
|
||||
postage = { version = "0.4.1", features = ["futures-traits"] }
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
|
||||
[dev-dependencies]
|
||||
language = { path = "../language", features = ["test-support"] }
|
||||
project = { path = "../project", features = ["test-support"] }
|
||||
workspace = { path = "../workspace", features = ["test-support"] }
|
||||
@@ -1,94 +0,0 @@
|
||||
use editor::Editor;
|
||||
use gpui::{elements::*, Entity, RenderContext, View, ViewContext, ViewHandle};
|
||||
use settings::Settings;
|
||||
|
||||
pub enum Event {
|
||||
Deactivated,
|
||||
}
|
||||
|
||||
pub struct ContactsPopover {
|
||||
filter_editor: ViewHandle<Editor>,
|
||||
}
|
||||
|
||||
impl Entity for ContactsPopover {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
impl View for ContactsPopover {
|
||||
fn ui_name() -> &'static str {
|
||||
"ContactsPopover"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
let theme = &cx.global::<Settings>().theme.contacts_popover;
|
||||
|
||||
Flex::row()
|
||||
.with_child(
|
||||
ChildView::new(self.filter_editor.clone())
|
||||
.contained()
|
||||
.with_style(
|
||||
cx.global::<Settings>()
|
||||
.theme
|
||||
.contacts_panel
|
||||
.user_query_editor
|
||||
.container,
|
||||
)
|
||||
.flex(1., true)
|
||||
.boxed(),
|
||||
)
|
||||
// .with_child(
|
||||
// MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
|
||||
// Svg::new("icons/user_plus_16.svg")
|
||||
// .with_color(theme.add_contact_button.color)
|
||||
// .constrained()
|
||||
// .with_height(16.)
|
||||
// .contained()
|
||||
// .with_style(theme.add_contact_button.container)
|
||||
// .aligned()
|
||||
// .boxed()
|
||||
// })
|
||||
// .with_cursor_style(CursorStyle::PointingHand)
|
||||
// .on_click(MouseButton::Left, |_, cx| {
|
||||
// cx.dispatch_action(contact_finder::Toggle)
|
||||
// })
|
||||
// .boxed(),
|
||||
// )
|
||||
.constrained()
|
||||
.with_height(
|
||||
cx.global::<Settings>()
|
||||
.theme
|
||||
.contacts_panel
|
||||
.user_query_editor_height,
|
||||
)
|
||||
.aligned()
|
||||
.top()
|
||||
.contained()
|
||||
.with_background_color(theme.background)
|
||||
.with_uniform_padding(4.)
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
impl ContactsPopover {
|
||||
pub fn new(cx: &mut ViewContext<Self>) -> Self {
|
||||
cx.observe_window_activation(Self::window_activation_changed)
|
||||
.detach();
|
||||
|
||||
let filter_editor = cx.add_view(|cx| {
|
||||
let mut editor = Editor::single_line(
|
||||
Some(|theme| theme.contacts_panel.user_query_editor.clone()),
|
||||
cx,
|
||||
);
|
||||
editor.set_placeholder_text("Filter contacts", cx);
|
||||
editor
|
||||
});
|
||||
|
||||
Self { filter_editor }
|
||||
}
|
||||
|
||||
fn window_activation_changed(&mut self, is_active: bool, cx: &mut ViewContext<Self>) {
|
||||
if !is_active {
|
||||
cx.emit(Event::Deactivated);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
mod contacts_popover;
|
||||
|
||||
use contacts_popover::ContactsPopover;
|
||||
use gpui::{
|
||||
actions,
|
||||
color::Color,
|
||||
elements::*,
|
||||
geometry::{rect::RectF, vector::vec2f},
|
||||
Appearance, Entity, MouseButton, MutableAppContext, RenderContext, View, ViewContext,
|
||||
ViewHandle, WindowKind,
|
||||
};
|
||||
|
||||
actions!(contacts_status_item, [ToggleContactsPopover]);
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
cx.add_action(ContactsStatusItem::toggle_contacts_popover);
|
||||
}
|
||||
|
||||
pub struct ContactsStatusItem {
|
||||
popover: Option<ViewHandle<ContactsPopover>>,
|
||||
}
|
||||
|
||||
impl Entity for ContactsStatusItem {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for ContactsStatusItem {
|
||||
fn ui_name() -> &'static str {
|
||||
"ContactsStatusItem"
|
||||
}
|
||||
|
||||
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
|
||||
let color = match cx.appearance {
|
||||
Appearance::Light | Appearance::VibrantLight => Color::black(),
|
||||
Appearance::Dark | Appearance::VibrantDark => Color::white(),
|
||||
};
|
||||
MouseEventHandler::<Self>::new(0, cx, |_, _| {
|
||||
Svg::new("icons/zed_22.svg")
|
||||
.with_color(color)
|
||||
.aligned()
|
||||
.boxed()
|
||||
})
|
||||
.on_click(MouseButton::Left, |_, cx| {
|
||||
cx.dispatch_action(ToggleContactsPopover);
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
impl ContactsStatusItem {
|
||||
pub fn new() -> Self {
|
||||
Self { popover: None }
|
||||
}
|
||||
|
||||
fn toggle_contacts_popover(&mut self, _: &ToggleContactsPopover, cx: &mut ViewContext<Self>) {
|
||||
match self.popover.take() {
|
||||
Some(popover) => {
|
||||
cx.remove_window(popover.window_id());
|
||||
}
|
||||
None => {
|
||||
let window_bounds = cx.window_bounds();
|
||||
let size = vec2f(360., 460.);
|
||||
let origin = window_bounds.lower_left()
|
||||
+ vec2f(window_bounds.width() / 2. - size.x() / 2., 0.);
|
||||
let (_, popover) = cx.add_window(
|
||||
gpui::WindowOptions {
|
||||
bounds: gpui::WindowBounds::Fixed(RectF::new(origin, size)),
|
||||
titlebar: None,
|
||||
center: false,
|
||||
kind: WindowKind::PopUp,
|
||||
is_movable: false,
|
||||
},
|
||||
|cx| ContactsPopover::new(cx),
|
||||
);
|
||||
cx.subscribe(&popover, Self::on_popover_event).detach();
|
||||
self.popover = Some(popover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_popover_event(
|
||||
&mut self,
|
||||
popover: ViewHandle<ContactsPopover>,
|
||||
event: &contacts_popover::Event,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
contacts_popover::Event::Deactivated => {
|
||||
self.popover.take();
|
||||
cx.remove_window(popover.window_id());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ impl View for ContextMenu {
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn on_focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
|
||||
fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
|
||||
self.reset(cx);
|
||||
}
|
||||
}
|
||||
@@ -258,9 +258,10 @@ impl ContextMenu {
|
||||
.with_children(self.items.iter().enumerate().map(|(ix, item)| {
|
||||
match item {
|
||||
ContextMenuItem::Item { label, .. } => {
|
||||
let style = style
|
||||
.item
|
||||
.style_for(Default::default(), Some(ix) == self.selected_index);
|
||||
let style = style.item.style_for(
|
||||
&mut Default::default(),
|
||||
Some(ix) == self.selected_index,
|
||||
);
|
||||
|
||||
Label::new(label.to_string(), style.label.clone())
|
||||
.contained()
|
||||
@@ -283,9 +284,10 @@ impl ContextMenu {
|
||||
.with_children(self.items.iter().enumerate().map(|(ix, item)| {
|
||||
match item {
|
||||
ContextMenuItem::Item { action, .. } => {
|
||||
let style = style
|
||||
.item
|
||||
.style_for(Default::default(), Some(ix) == self.selected_index);
|
||||
let style = style.item.style_for(
|
||||
&mut Default::default(),
|
||||
Some(ix) == self.selected_index,
|
||||
);
|
||||
KeystrokeLabel::new(
|
||||
action.boxed_clone(),
|
||||
style.keystroke.container,
|
||||
@@ -313,13 +315,16 @@ impl ContextMenu {
|
||||
fn render_menu(&self, cx: &mut RenderContext<Self>) -> impl Element {
|
||||
enum Menu {}
|
||||
enum MenuItem {}
|
||||
|
||||
let style = cx.global::<Settings>().theme.context_menu.clone();
|
||||
|
||||
MouseEventHandler::<Menu>::new(0, cx, |_, cx| {
|
||||
Flex::column()
|
||||
.with_children(self.items.iter().enumerate().map(|(ix, item)| {
|
||||
match item {
|
||||
ContextMenuItem::Item { label, action } => {
|
||||
let action = action.boxed_clone();
|
||||
|
||||
MouseEventHandler::<MenuItem>::new(ix, cx, |state, _| {
|
||||
let style =
|
||||
style.item.style_for(state, Some(ix) == self.selected_index);
|
||||
@@ -348,6 +353,7 @@ impl ContextMenu {
|
||||
cx.dispatch_action(Clicked);
|
||||
cx.dispatch_any_action(action.boxed_clone());
|
||||
})
|
||||
.on_drag(MouseButton::Left, |_, _| {})
|
||||
.boxed()
|
||||
}
|
||||
ContextMenuItem::Separator => Empty::new()
|
||||
|
||||
@@ -14,8 +14,13 @@ test-support = []
|
||||
collections = { path = "../collections" }
|
||||
anyhow = "1.0.57"
|
||||
async-trait = "0.1"
|
||||
lazy_static = "1.4.0"
|
||||
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
|
||||
parking_lot = "0.11.1"
|
||||
rocksdb = "0.18"
|
||||
rusqlite = { version = "0.28.0", features = ["bundled", "serde_json"] }
|
||||
rusqlite_migration = { git = "https://github.com/cljoly/rusqlite_migration", rev = "c433555d7c1b41b103426e35756eb3144d0ebbc6" }
|
||||
serde = { workspace = true }
|
||||
serde_rusqlite = "0.31.0"
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { path = "../gpui", features = ["test-support"] }
|
||||
|
||||
@@ -1,161 +1,119 @@
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
mod kvp;
|
||||
mod migrations;
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct Db(DbStore);
|
||||
use anyhow::Result;
|
||||
use log::error;
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::Connection;
|
||||
|
||||
enum DbStore {
|
||||
use migrations::MIGRATIONS;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Db {
|
||||
Real(Arc<RealDb>),
|
||||
Null,
|
||||
Real(rocksdb::DB),
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
Fake {
|
||||
data: parking_lot::Mutex<collections::HashMap<Vec<u8>, Vec<u8>>>,
|
||||
},
|
||||
pub struct RealDb {
|
||||
connection: Mutex<Connection>,
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Open or create a database at the given file path.
|
||||
pub fn open(path: &Path) -> Result<Arc<Self>> {
|
||||
let db = rocksdb::DB::open_default(path)?;
|
||||
Ok(Arc::new(Self(DbStore::Real(db))))
|
||||
/// Open or create a database at the given directory path.
|
||||
pub fn open(db_dir: &Path, channel: &'static str) -> Self {
|
||||
// Use 0 for now. Will implement incrementing and clearing of old db files soon TM
|
||||
let current_db_dir = db_dir.join(Path::new(&format!("0-{}", channel)));
|
||||
fs::create_dir_all(¤t_db_dir)
|
||||
.expect("Should be able to create the database directory");
|
||||
let db_path = current_db_dir.join(Path::new("db.sqlite"));
|
||||
|
||||
Connection::open(db_path)
|
||||
.map_err(Into::into)
|
||||
.and_then(|connection| Self::initialize(connection))
|
||||
.map(|connection| {
|
||||
Db::Real(Arc::new(RealDb {
|
||||
connection,
|
||||
path: Some(db_dir.to_path_buf()),
|
||||
}))
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
error!(
|
||||
"Connecting to file backed db failed. Reverting to null db. {}",
|
||||
e
|
||||
);
|
||||
Self::Null
|
||||
})
|
||||
}
|
||||
|
||||
/// Open a null database that stores no data, for use as a fallback
|
||||
/// when there is an error opening the real database.
|
||||
pub fn null() -> Arc<Self> {
|
||||
Arc::new(Self(DbStore::Null))
|
||||
}
|
||||
|
||||
/// Open a fake database for testing.
|
||||
/// Open a in memory database for testing and as a fallback.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn open_fake() -> Arc<Self> {
|
||||
Arc::new(Self(DbStore::Fake {
|
||||
data: Default::default(),
|
||||
}))
|
||||
pub fn open_in_memory() -> Self {
|
||||
Connection::open_in_memory()
|
||||
.map_err(Into::into)
|
||||
.and_then(|connection| Self::initialize(connection))
|
||||
.map(|connection| {
|
||||
Db::Real(Arc::new(RealDb {
|
||||
connection,
|
||||
path: None,
|
||||
}))
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
error!(
|
||||
"Connecting to in memory db failed. Reverting to null db. {}",
|
||||
e
|
||||
);
|
||||
Self::Null
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read<K, I>(&self, keys: I) -> Result<Vec<Option<Vec<u8>>>>
|
||||
where
|
||||
K: AsRef<[u8]>,
|
||||
I: IntoIterator<Item = K>,
|
||||
{
|
||||
match &self.0 {
|
||||
DbStore::Real(db) => db
|
||||
.multi_get(keys)
|
||||
.into_iter()
|
||||
.map(|e| e.map_err(Into::into))
|
||||
.collect(),
|
||||
fn initialize(mut conn: Connection) -> Result<Mutex<Connection>> {
|
||||
MIGRATIONS.to_latest(&mut conn)?;
|
||||
|
||||
DbStore::Null => Ok(keys.into_iter().map(|_| None).collect()),
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
conn.pragma_update(None, "case_sensitive_like", true)?;
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
DbStore::Fake { data: db } => {
|
||||
let db = db.lock();
|
||||
Ok(keys
|
||||
.into_iter()
|
||||
.map(|key| db.get(key.as_ref()).cloned())
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
Ok(Mutex::new(conn))
|
||||
}
|
||||
|
||||
pub fn delete<K, I>(&self, keys: I) -> Result<()>
|
||||
where
|
||||
K: AsRef<[u8]>,
|
||||
I: IntoIterator<Item = K>,
|
||||
{
|
||||
match &self.0 {
|
||||
DbStore::Real(db) => {
|
||||
let mut batch = rocksdb::WriteBatch::default();
|
||||
for key in keys {
|
||||
batch.delete(key);
|
||||
}
|
||||
db.write(batch)?;
|
||||
}
|
||||
|
||||
DbStore::Null => {}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
DbStore::Fake { data: db } => {
|
||||
let mut db = db.lock();
|
||||
for key in keys {
|
||||
db.remove(key.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
pub fn persisting(&self) -> bool {
|
||||
self.real().and_then(|db| db.path.as_ref()).is_some()
|
||||
}
|
||||
|
||||
pub fn write<K, V, I>(&self, entries: I) -> Result<()>
|
||||
where
|
||||
K: AsRef<[u8]>,
|
||||
V: AsRef<[u8]>,
|
||||
I: IntoIterator<Item = (K, V)>,
|
||||
{
|
||||
match &self.0 {
|
||||
DbStore::Real(db) => {
|
||||
let mut batch = rocksdb::WriteBatch::default();
|
||||
for (key, value) in entries {
|
||||
batch.put(key, value);
|
||||
}
|
||||
db.write(batch)?;
|
||||
}
|
||||
|
||||
DbStore::Null => {}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
DbStore::Fake { data: db } => {
|
||||
let mut db = db.lock();
|
||||
for (key, value) in entries {
|
||||
db.insert(key.as_ref().into(), value.as_ref().into());
|
||||
}
|
||||
}
|
||||
pub fn real(&self) -> Option<&RealDb> {
|
||||
match self {
|
||||
Db::Real(db) => Some(&db),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Db {
|
||||
fn drop(&mut self) {
|
||||
match self {
|
||||
Db::Real(real_db) => {
|
||||
let lock = real_db.connection.lock();
|
||||
|
||||
let _ = lock.pragma_update(None, "analysis_limit", "500");
|
||||
let _ = lock.pragma_update(None, "optimize", "");
|
||||
}
|
||||
Db::Null => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempdir::TempDir;
|
||||
use crate::migrations::MIGRATIONS;
|
||||
|
||||
#[gpui::test]
|
||||
fn test_db() {
|
||||
let dir = TempDir::new("db-test").unwrap();
|
||||
let fake_db = Db::open_fake();
|
||||
let real_db = Db::open(&dir.path().join("test.db")).unwrap();
|
||||
|
||||
for db in [&real_db, &fake_db] {
|
||||
assert_eq!(
|
||||
db.read(["key-1", "key-2", "key-3"]).unwrap(),
|
||||
&[None, None, None]
|
||||
);
|
||||
|
||||
db.write([("key-1", "one"), ("key-3", "three")]).unwrap();
|
||||
assert_eq!(
|
||||
db.read(["key-1", "key-2", "key-3"]).unwrap(),
|
||||
&[
|
||||
Some("one".as_bytes().to_vec()),
|
||||
None,
|
||||
Some("three".as_bytes().to_vec())
|
||||
]
|
||||
);
|
||||
|
||||
db.delete(["key-3", "key-4"]).unwrap();
|
||||
assert_eq!(
|
||||
db.read(["key-1", "key-2", "key-3"]).unwrap(),
|
||||
&[Some("one".as_bytes().to_vec()), None, None,]
|
||||
);
|
||||
}
|
||||
|
||||
drop(real_db);
|
||||
|
||||
let real_db = Db::open(&dir.path().join("test.db")).unwrap();
|
||||
assert_eq!(
|
||||
real_db.read(["key-1", "key-2", "key-3"]).unwrap(),
|
||||
&[Some("one".as_bytes().to_vec()), None, None,]
|
||||
);
|
||||
#[test]
|
||||
fn test_migrations() {
|
||||
assert!(MIGRATIONS.validate().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
311
crates/db/src/items.rs
Normal file
311
crates/db/src/items.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
use std::{ffi::OsStr, fmt::Display, hash::Hash, os::unix::prelude::OsStrExt, path::PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use collections::HashSet;
|
||||
use rusqlite::{named_params, params};
|
||||
|
||||
use super::Db;
|
||||
|
||||
pub(crate) const ITEMS_M_1: &str = "
|
||||
CREATE TABLE items(
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE item_path(
|
||||
item_id INTEGER PRIMARY KEY,
|
||||
path BLOB
|
||||
) STRICT;
|
||||
CREATE TABLE item_query(
|
||||
item_id INTEGER PRIMARY KEY,
|
||||
query TEXT
|
||||
) STRICT;
|
||||
";
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Debug)]
|
||||
pub enum SerializedItemKind {
|
||||
Editor,
|
||||
Terminal,
|
||||
ProjectSearch,
|
||||
Diagnostics,
|
||||
}
|
||||
|
||||
impl Display for SerializedItemKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&format!("{:?}", self))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum SerializedItem {
|
||||
Editor(usize, PathBuf),
|
||||
Terminal(usize),
|
||||
ProjectSearch(usize, String),
|
||||
Diagnostics(usize),
|
||||
}
|
||||
|
||||
impl SerializedItem {
|
||||
fn kind(&self) -> SerializedItemKind {
|
||||
match self {
|
||||
SerializedItem::Editor(_, _) => SerializedItemKind::Editor,
|
||||
SerializedItem::Terminal(_) => SerializedItemKind::Terminal,
|
||||
SerializedItem::ProjectSearch(_, _) => SerializedItemKind::ProjectSearch,
|
||||
SerializedItem::Diagnostics(_) => SerializedItemKind::Diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
fn id(&self) -> usize {
|
||||
match self {
|
||||
SerializedItem::Editor(id, _)
|
||||
| SerializedItem::Terminal(id)
|
||||
| SerializedItem::ProjectSearch(id, _)
|
||||
| SerializedItem::Diagnostics(id) => *id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Db {
|
||||
fn write_item(&self, serialized_item: SerializedItem) -> Result<()> {
|
||||
self.real()
|
||||
.map(|db| {
|
||||
let mut lock = db.connection.lock();
|
||||
let tx = lock.transaction()?;
|
||||
|
||||
// Serialize the item
|
||||
let id = serialized_item.id();
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"INSERT OR REPLACE INTO items(id, kind) VALUES ((?), (?))",
|
||||
)?;
|
||||
|
||||
dbg!("inserting item");
|
||||
stmt.execute(params![id, serialized_item.kind().to_string()])?;
|
||||
}
|
||||
|
||||
// Serialize item data
|
||||
match &serialized_item {
|
||||
SerializedItem::Editor(_, path) => {
|
||||
dbg!("inserting path");
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"INSERT OR REPLACE INTO item_path(item_id, path) VALUES ((?), (?))",
|
||||
)?;
|
||||
|
||||
let path_bytes = path.as_os_str().as_bytes();
|
||||
stmt.execute(params![id, path_bytes])?;
|
||||
}
|
||||
SerializedItem::ProjectSearch(_, query) => {
|
||||
dbg!("inserting query");
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"INSERT OR REPLACE INTO item_query(item_id, query) VALUES ((?), (?))",
|
||||
)?;
|
||||
|
||||
stmt.execute(params![id, query])?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
|
||||
let mut stmt = lock.prepare_cached("SELECT id, kind FROM items")?;
|
||||
let _ = stmt
|
||||
.query_map([], |row| {
|
||||
let zero: usize = row.get(0)?;
|
||||
let one: String = row.get(1)?;
|
||||
|
||||
dbg!(zero, one);
|
||||
Ok(())
|
||||
})?
|
||||
.collect::<Vec<Result<(), _>>>();
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
fn delete_item(&self, item_id: usize) -> Result<()> {
|
||||
self.real()
|
||||
.map(|db| {
|
||||
let lock = db.connection.lock();
|
||||
|
||||
let mut stmt = lock.prepare_cached(
|
||||
r#"
|
||||
DELETE FROM items WHERE id = (:id);
|
||||
DELETE FROM item_path WHERE id = (:id);
|
||||
DELETE FROM item_query WHERE id = (:id);
|
||||
"#,
|
||||
)?;
|
||||
|
||||
stmt.execute(named_params! {":id": item_id})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
fn take_items(&self) -> Result<HashSet<SerializedItem>> {
|
||||
self.real()
|
||||
.map(|db| {
|
||||
let mut lock = db.connection.lock();
|
||||
|
||||
let tx = lock.transaction()?;
|
||||
|
||||
// When working with transactions in rusqlite, need to make this kind of scope
|
||||
// To make the borrow stuff work correctly. Don't know why, rust is wild.
|
||||
let result = {
|
||||
let mut editors_stmt = tx.prepare_cached(
|
||||
r#"
|
||||
SELECT items.id, item_path.path
|
||||
FROM items
|
||||
LEFT JOIN item_path
|
||||
ON items.id = item_path.item_id
|
||||
WHERE items.kind = ?;
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let editors_iter = editors_stmt.query_map(
|
||||
[SerializedItemKind::Editor.to_string()],
|
||||
|row| {
|
||||
let id: usize = row.get(0)?;
|
||||
|
||||
let buf: Vec<u8> = row.get(1)?;
|
||||
let path: PathBuf = OsStr::from_bytes(&buf).into();
|
||||
|
||||
Ok(SerializedItem::Editor(id, path))
|
||||
},
|
||||
)?;
|
||||
|
||||
let mut terminals_stmt = tx.prepare_cached(
|
||||
r#"
|
||||
SELECT items.id
|
||||
FROM items
|
||||
WHERE items.kind = ?;
|
||||
"#,
|
||||
)?;
|
||||
let terminals_iter = terminals_stmt.query_map(
|
||||
[SerializedItemKind::Terminal.to_string()],
|
||||
|row| {
|
||||
let id: usize = row.get(0)?;
|
||||
|
||||
Ok(SerializedItem::Terminal(id))
|
||||
},
|
||||
)?;
|
||||
|
||||
let mut search_stmt = tx.prepare_cached(
|
||||
r#"
|
||||
SELECT items.id, item_query.query
|
||||
FROM items
|
||||
LEFT JOIN item_query
|
||||
ON items.id = item_query.item_id
|
||||
WHERE items.kind = ?;
|
||||
"#,
|
||||
)?;
|
||||
let searches_iter = search_stmt.query_map(
|
||||
[SerializedItemKind::ProjectSearch.to_string()],
|
||||
|row| {
|
||||
let id: usize = row.get(0)?;
|
||||
let query = row.get(1)?;
|
||||
|
||||
Ok(SerializedItem::ProjectSearch(id, query))
|
||||
},
|
||||
)?;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let tmp =
|
||||
searches_iter.collect::<Vec<Result<SerializedItem, rusqlite::Error>>>();
|
||||
#[cfg(debug_assertions)]
|
||||
debug_assert!(tmp.len() == 0 || tmp.len() == 1);
|
||||
#[cfg(debug_assertions)]
|
||||
let searches_iter = tmp.into_iter();
|
||||
|
||||
let mut diagnostic_stmt = tx.prepare_cached(
|
||||
r#"
|
||||
SELECT items.id
|
||||
FROM items
|
||||
WHERE items.kind = ?;
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let diagnostics_iter = diagnostic_stmt.query_map(
|
||||
[SerializedItemKind::Diagnostics.to_string()],
|
||||
|row| {
|
||||
let id: usize = row.get(0)?;
|
||||
|
||||
Ok(SerializedItem::Diagnostics(id))
|
||||
},
|
||||
)?;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let tmp =
|
||||
diagnostics_iter.collect::<Vec<Result<SerializedItem, rusqlite::Error>>>();
|
||||
#[cfg(debug_assertions)]
|
||||
debug_assert!(tmp.len() == 0 || tmp.len() == 1);
|
||||
#[cfg(debug_assertions)]
|
||||
let diagnostics_iter = tmp.into_iter();
|
||||
|
||||
let res = editors_iter
|
||||
.chain(terminals_iter)
|
||||
.chain(diagnostics_iter)
|
||||
.chain(searches_iter)
|
||||
.collect::<Result<HashSet<SerializedItem>, rusqlite::Error>>()?;
|
||||
|
||||
let mut delete_stmt = tx.prepare_cached(
|
||||
r#"
|
||||
DELETE FROM items;
|
||||
DELETE FROM item_path;
|
||||
DELETE FROM item_query;
|
||||
"#,
|
||||
)?;
|
||||
|
||||
delete_stmt.execute([])?;
|
||||
|
||||
res
|
||||
};
|
||||
|
||||
tx.commit()?;
|
||||
|
||||
Ok(result)
|
||||
})
|
||||
.unwrap_or(Ok(HashSet::default()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use anyhow::Result;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_items_round_trip() -> Result<()> {
|
||||
let db = Db::open_in_memory();
|
||||
|
||||
let mut items = vec![
|
||||
SerializedItem::Editor(0, PathBuf::from("/tmp/test.txt")),
|
||||
SerializedItem::Terminal(1),
|
||||
SerializedItem::ProjectSearch(2, "Test query!".to_string()),
|
||||
SerializedItem::Diagnostics(3),
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for item in items.iter() {
|
||||
dbg!("Inserting... ");
|
||||
db.write_item(item.clone())?;
|
||||
}
|
||||
|
||||
assert_eq!(items, db.take_items()?);
|
||||
|
||||
// Check that it's empty, as expected
|
||||
assert_eq!(HashSet::default(), db.take_items()?);
|
||||
|
||||
for item in items.iter() {
|
||||
db.write_item(item.clone())?;
|
||||
}
|
||||
|
||||
items.remove(&SerializedItem::ProjectSearch(2, "Test query!".to_string()));
|
||||
db.delete_item(2)?;
|
||||
|
||||
assert_eq!(items, db.take_items()?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
82
crates/db/src/kvp.rs
Normal file
82
crates/db/src/kvp.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use anyhow::Result;
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
use super::Db;
|
||||
|
||||
pub(crate) const KVP_M_1_UP: &str = "
|
||||
CREATE TABLE kv_store(
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
) STRICT;
|
||||
";
|
||||
|
||||
impl Db {
|
||||
pub fn read_kvp(&self, key: &str) -> Result<Option<String>> {
|
||||
self.real()
|
||||
.map(|db| {
|
||||
let lock = db.connection.lock();
|
||||
let mut stmt = lock.prepare_cached("SELECT value FROM kv_store WHERE key = (?)")?;
|
||||
|
||||
Ok(stmt.query_row([key], |row| row.get(0)).optional()?)
|
||||
})
|
||||
.unwrap_or(Ok(None))
|
||||
}
|
||||
|
||||
pub fn write_kvp(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.real()
|
||||
.map(|db| {
|
||||
let lock = db.connection.lock();
|
||||
|
||||
let mut stmt = lock.prepare_cached(
|
||||
"INSERT OR REPLACE INTO kv_store(key, value) VALUES ((?), (?))",
|
||||
)?;
|
||||
|
||||
stmt.execute([key, value])?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
pub fn delete_kvp(&self, key: &str) -> Result<()> {
|
||||
self.real()
|
||||
.map(|db| {
|
||||
let lock = db.connection.lock();
|
||||
|
||||
let mut stmt = lock.prepare_cached("DELETE FROM kv_store WHERE key = (?)")?;
|
||||
|
||||
stmt.execute([key])?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_or(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use anyhow::Result;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_kvp() -> Result<()> {
|
||||
let db = Db::open_in_memory();
|
||||
|
||||
assert_eq!(db.read_kvp("key-1")?, None);
|
||||
|
||||
db.write_kvp("key-1", "one")?;
|
||||
assert_eq!(db.read_kvp("key-1")?, Some("one".to_string()));
|
||||
|
||||
db.write_kvp("key-1", "one-2")?;
|
||||
assert_eq!(db.read_kvp("key-1")?, Some("one-2".to_string()));
|
||||
|
||||
db.write_kvp("key-2", "two")?;
|
||||
assert_eq!(db.read_kvp("key-2")?, Some("two".to_string()));
|
||||
|
||||
db.delete_kvp("key-1")?;
|
||||
assert_eq!(db.read_kvp("key-1")?, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
15
crates/db/src/migrations.rs
Normal file
15
crates/db/src/migrations.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use rusqlite_migration::{Migrations, M};
|
||||
|
||||
// use crate::items::ITEMS_M_1;
|
||||
use crate::kvp::KVP_M_1_UP;
|
||||
|
||||
// This must be ordered by development time! Only ever add new migrations to the end!!
|
||||
// Bad things will probably happen if you don't monotonically edit this vec!!!!
|
||||
// And no re-ordering ever!!!!!!!!!! The results of these migrations are on the user's
|
||||
// file system and so everything we do here is locked in _f_o_r_e_v_e_r_.
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref MIGRATIONS: Migrations<'static> = Migrations::new(vec![
|
||||
M::up(KVP_M_1_UP),
|
||||
// M::up(ITEMS_M_1),
|
||||
]);
|
||||
}
|
||||
@@ -95,11 +95,11 @@ impl View for ProjectDiagnosticsEditor {
|
||||
.with_style(theme.container)
|
||||
.boxed()
|
||||
} else {
|
||||
ChildView::new(&self.editor).boxed()
|
||||
ChildView::new(&self.editor, cx).boxed()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
|
||||
fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
|
||||
if !self.path_states.is_empty() {
|
||||
cx.focus(&self.editor);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use collections::HashSet;
|
||||
use gpui::{
|
||||
elements::{MouseEventHandler, Overlay},
|
||||
geometry::vector::Vector2F,
|
||||
scene::DragRegionEvent,
|
||||
scene::MouseDrag,
|
||||
CursorStyle, Element, ElementBox, EventContext, MouseButton, MutableAppContext, RenderContext,
|
||||
View, WeakViewHandle,
|
||||
};
|
||||
@@ -70,7 +70,7 @@ impl<V: View> DragAndDrop<V> {
|
||||
}
|
||||
|
||||
pub fn dragging<T: Any>(
|
||||
event: DragRegionEvent,
|
||||
event: MouseDrag,
|
||||
payload: Rc<T>,
|
||||
cx: &mut EventContext,
|
||||
render: Rc<impl 'static + Fn(&T, &mut RenderContext<V>) -> ElementBox>,
|
||||
@@ -125,7 +125,7 @@ impl<V: View> DragAndDrop<V> {
|
||||
cx.defer(|cx| {
|
||||
cx.update_global::<Self, _, _>(|this, cx| this.stop_dragging(cx));
|
||||
});
|
||||
cx.propogate_event();
|
||||
cx.propagate_event();
|
||||
})
|
||||
.on_up_out(MouseButton::Left, |_, cx| {
|
||||
cx.defer(|cx| {
|
||||
|
||||
@@ -20,11 +20,13 @@ test-support = [
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
drag_and_drop = { path = "../drag_and_drop" }
|
||||
text = { path = "../text" }
|
||||
clock = { path = "../clock" }
|
||||
collections = { path = "../collections" }
|
||||
context_menu = { path = "../context_menu" }
|
||||
fuzzy = { path = "../fuzzy" }
|
||||
git = { path = "../git" }
|
||||
gpui = { path = "../gpui" }
|
||||
language = { path = "../language" }
|
||||
lsp = { path = "../lsp" }
|
||||
@@ -47,10 +49,12 @@ ordered-float = "2.1.1"
|
||||
parking_lot = "0.11"
|
||||
postage = { version = "0.4", features = ["futures-traits"] }
|
||||
rand = { version = "0.8.3", optional = true }
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde = { workspace = true }
|
||||
smallvec = { version = "1.6", features = ["union"] }
|
||||
smol = "1.2"
|
||||
tree-sitter-rust = { version = "*", optional = true }
|
||||
tree-sitter-html = { version = "*", optional = true }
|
||||
tree-sitter-javascript = { version = "*", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
text = { path = "../text", features = ["test-support"] }
|
||||
@@ -67,3 +71,5 @@ rand = "0.8"
|
||||
unindent = "0.1.7"
|
||||
tree-sitter = "0.20"
|
||||
tree-sitter-rust = "0.20"
|
||||
tree-sitter-html = "0.19"
|
||||
tree-sitter-javascript = "0.20"
|
||||
|
||||
110
crates/editor/src/blink_manager.rs
Normal file
110
crates/editor/src/blink_manager.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{Entity, ModelContext};
|
||||
use settings::Settings;
|
||||
use smol::Timer;
|
||||
|
||||
pub struct BlinkManager {
|
||||
blink_interval: Duration,
|
||||
|
||||
blink_epoch: usize,
|
||||
blinking_paused: bool,
|
||||
visible: bool,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl BlinkManager {
|
||||
pub fn new(blink_interval: Duration, cx: &mut ModelContext<Self>) -> Self {
|
||||
let weak_handle = cx.weak_handle();
|
||||
cx.observe_global::<Settings, _>(move |_, cx| {
|
||||
if let Some(this) = weak_handle.upgrade(cx) {
|
||||
// Make sure we blink the cursors if the setting is re-enabled
|
||||
this.update(cx, |this, cx| this.blink_cursors(this.blink_epoch, cx));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
blink_interval,
|
||||
|
||||
blink_epoch: 0,
|
||||
blinking_paused: false,
|
||||
visible: true,
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_blink_epoch(&mut self) -> usize {
|
||||
self.blink_epoch += 1;
|
||||
self.blink_epoch
|
||||
}
|
||||
|
||||
pub fn pause_blinking(&mut self, cx: &mut ModelContext<Self>) {
|
||||
if !self.visible {
|
||||
self.visible = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
let epoch = self.next_blink_epoch();
|
||||
let interval = self.blink_interval;
|
||||
cx.spawn(|this, mut cx| {
|
||||
let this = this.downgrade();
|
||||
async move {
|
||||
Timer::after(interval).await;
|
||||
if let Some(this) = this.upgrade(&cx) {
|
||||
this.update(&mut cx, |this, cx| this.resume_cursor_blinking(epoch, cx))
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn resume_cursor_blinking(&mut self, epoch: usize, cx: &mut ModelContext<Self>) {
|
||||
if epoch == self.blink_epoch {
|
||||
self.blinking_paused = false;
|
||||
self.blink_cursors(epoch, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn blink_cursors(&mut self, epoch: usize, cx: &mut ModelContext<Self>) {
|
||||
if cx.global::<Settings>().cursor_blink {
|
||||
if epoch == self.blink_epoch && self.enabled && !self.blinking_paused {
|
||||
self.visible = !self.visible;
|
||||
cx.notify();
|
||||
|
||||
let epoch = self.next_blink_epoch();
|
||||
let interval = self.blink_interval;
|
||||
cx.spawn(|this, mut cx| {
|
||||
let this = this.downgrade();
|
||||
async move {
|
||||
Timer::after(interval).await;
|
||||
if let Some(this) = this.upgrade(&cx) {
|
||||
this.update(&mut cx, |this, cx| this.blink_cursors(epoch, cx));
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
} else if !self.visible {
|
||||
self.visible = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enable(&mut self, cx: &mut ModelContext<Self>) {
|
||||
self.enabled = true;
|
||||
self.blink_cursors(self.blink_epoch, cx);
|
||||
}
|
||||
|
||||
pub fn disable(&mut self, _cx: &mut ModelContext<Self>) {
|
||||
self.enabled = false;
|
||||
}
|
||||
|
||||
pub fn visible(&self) -> bool {
|
||||
self.visible
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for BlinkManager {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -330,34 +330,91 @@ impl DisplaySnapshot {
|
||||
DisplayPoint(self.blocks_snapshot.max_point())
|
||||
}
|
||||
|
||||
/// Returns text chunks starting at the given display row until the end of the file
|
||||
pub fn text_chunks(&self, display_row: u32) -> impl Iterator<Item = &str> {
|
||||
self.blocks_snapshot
|
||||
.chunks(display_row..self.max_point().row() + 1, false, None)
|
||||
.map(|h| h.text)
|
||||
}
|
||||
|
||||
// Returns text chunks starting at the end of the given display row in reverse until the start of the file
|
||||
pub fn reverse_text_chunks(&self, display_row: u32) -> impl Iterator<Item = &str> {
|
||||
(0..=display_row).into_iter().rev().flat_map(|row| {
|
||||
self.blocks_snapshot
|
||||
.chunks(row..row + 1, false, None)
|
||||
.map(|h| h.text)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn chunks(&self, display_rows: Range<u32>, language_aware: bool) -> DisplayChunks<'_> {
|
||||
self.blocks_snapshot
|
||||
.chunks(display_rows, language_aware, Some(&self.text_highlights))
|
||||
}
|
||||
|
||||
pub fn chars_at(&self, point: DisplayPoint) -> impl Iterator<Item = char> + '_ {
|
||||
let mut column = 0;
|
||||
let mut chars = self.text_chunks(point.row()).flat_map(str::chars);
|
||||
while column < point.column() {
|
||||
if let Some(c) = chars.next() {
|
||||
column += c.len_utf8() as u32;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
chars
|
||||
pub fn chars_at(
|
||||
&self,
|
||||
mut point: DisplayPoint,
|
||||
) -> impl Iterator<Item = (char, DisplayPoint)> + '_ {
|
||||
point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left));
|
||||
self.text_chunks(point.row())
|
||||
.flat_map(str::chars)
|
||||
.skip_while({
|
||||
let mut column = 0;
|
||||
move |char| {
|
||||
let at_point = column >= point.column();
|
||||
column += char.len_utf8() as u32;
|
||||
!at_point
|
||||
}
|
||||
})
|
||||
.map(move |ch| {
|
||||
let result = (ch, point);
|
||||
if ch == '\n' {
|
||||
*point.row_mut() += 1;
|
||||
*point.column_mut() = 0;
|
||||
} else {
|
||||
*point.column_mut() += ch.len_utf8() as u32;
|
||||
}
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reverse_chars_at(
|
||||
&self,
|
||||
mut point: DisplayPoint,
|
||||
) -> impl Iterator<Item = (char, DisplayPoint)> + '_ {
|
||||
point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left));
|
||||
self.reverse_text_chunks(point.row())
|
||||
.flat_map(|chunk| chunk.chars().rev())
|
||||
.skip_while({
|
||||
let mut column = self.line_len(point.row());
|
||||
if self.max_point().row() > point.row() {
|
||||
column += 1;
|
||||
}
|
||||
|
||||
move |char| {
|
||||
let at_point = column <= point.column();
|
||||
column = column.saturating_sub(char.len_utf8() as u32);
|
||||
!at_point
|
||||
}
|
||||
})
|
||||
.map(move |ch| {
|
||||
if ch == '\n' {
|
||||
*point.row_mut() -= 1;
|
||||
*point.column_mut() = self.line_len(point.row());
|
||||
} else {
|
||||
*point.column_mut() = point.column().saturating_sub(ch.len_utf8() as u32);
|
||||
}
|
||||
(ch, point)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn column_to_chars(&self, display_row: u32, target: u32) -> u32 {
|
||||
let mut count = 0;
|
||||
let mut column = 0;
|
||||
for c in self.chars_at(DisplayPoint::new(display_row, 0)) {
|
||||
for (c, _) in self.chars_at(DisplayPoint::new(display_row, 0)) {
|
||||
if column >= target {
|
||||
break;
|
||||
}
|
||||
@@ -370,7 +427,7 @@ impl DisplaySnapshot {
|
||||
pub fn column_from_chars(&self, display_row: u32, char_count: u32) -> u32 {
|
||||
let mut column = 0;
|
||||
|
||||
for (count, c) in self.chars_at(DisplayPoint::new(display_row, 0)).enumerate() {
|
||||
for (count, (c, _)) in self.chars_at(DisplayPoint::new(display_row, 0)).enumerate() {
|
||||
if c == '\n' || count >= char_count as usize {
|
||||
break;
|
||||
}
|
||||
@@ -454,7 +511,7 @@ impl DisplaySnapshot {
|
||||
pub fn line_indent(&self, display_row: u32) -> (u32, bool) {
|
||||
let mut indent = 0;
|
||||
let mut is_blank = true;
|
||||
for c in self.chars_at(DisplayPoint::new(display_row, 0)) {
|
||||
for (c, _) in self.chars_at(DisplayPoint::new(display_row, 0)) {
|
||||
if c == ' ' {
|
||||
indent += 1;
|
||||
} else {
|
||||
@@ -565,7 +622,7 @@ pub mod tests {
|
||||
use super::*;
|
||||
use crate::{movement, test::marked_display_snapshot};
|
||||
use gpui::{color::Color, elements::*, test::observe, MutableAppContext};
|
||||
use language::{Buffer, Language, LanguageConfig, RandomCharIter, SelectionGoal};
|
||||
use language::{Buffer, Language, LanguageConfig, SelectionGoal};
|
||||
use rand::{prelude::*, Rng};
|
||||
use smol::stream::StreamExt;
|
||||
use std::{env, sync::Arc};
|
||||
@@ -609,7 +666,9 @@ pub mod tests {
|
||||
let buffer = cx.update(|cx| {
|
||||
if rng.gen() {
|
||||
let len = rng.gen_range(0..10);
|
||||
let text = RandomCharIter::new(&mut rng).take(len).collect::<String>();
|
||||
let text = util::RandomCharIter::new(&mut rng)
|
||||
.take(len)
|
||||
.collect::<String>();
|
||||
MultiBuffer::build_simple(&text, cx)
|
||||
} else {
|
||||
MultiBuffer::build_random(&mut rng, cx)
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::{
|
||||
use crate::{Anchor, ExcerptRange, ToPoint as _};
|
||||
use collections::{Bound, HashMap, HashSet};
|
||||
use gpui::{ElementBox, RenderContext};
|
||||
use language::{BufferSnapshot, Chunk, Patch};
|
||||
use language::{BufferSnapshot, Chunk, Patch, Point};
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
@@ -18,7 +18,7 @@ use std::{
|
||||
},
|
||||
};
|
||||
use sum_tree::{Bias, SumTree};
|
||||
use text::{Edit, Point};
|
||||
use text::Edit;
|
||||
|
||||
const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize];
|
||||
|
||||
@@ -42,7 +42,7 @@ pub struct BlockSnapshot {
|
||||
pub struct BlockId(usize);
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
|
||||
pub struct BlockPoint(pub super::Point);
|
||||
pub struct BlockPoint(pub Point);
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
|
||||
struct BlockRow(u32);
|
||||
@@ -157,6 +157,7 @@ pub struct BlockChunks<'a> {
|
||||
max_output_row: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BlockBufferRows<'a> {
|
||||
transforms: sum_tree::Cursor<'a, Transform, (BlockRow, WrapRow)>,
|
||||
input_buffer_rows: wrap_map::WrapBufferRows<'a>,
|
||||
@@ -994,7 +995,7 @@ mod tests {
|
||||
use rand::prelude::*;
|
||||
use settings::Settings;
|
||||
use std::env;
|
||||
use text::RandomCharIter;
|
||||
use util::RandomCharIter;
|
||||
|
||||
#[gpui::test]
|
||||
fn test_offset_for_row() {
|
||||
|
||||
@@ -18,11 +18,11 @@ use std::{
|
||||
use sum_tree::{Bias, Cursor, FilterCursor, SumTree};
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
|
||||
pub struct FoldPoint(pub super::Point);
|
||||
pub struct FoldPoint(pub Point);
|
||||
|
||||
impl FoldPoint {
|
||||
pub fn new(row: u32, column: u32) -> Self {
|
||||
Self(super::Point::new(row, column))
|
||||
Self(Point::new(row, column))
|
||||
}
|
||||
|
||||
pub fn row(self) -> u32 {
|
||||
@@ -274,6 +274,7 @@ impl FoldMap {
|
||||
if buffer.edit_count() != new_buffer.edit_count()
|
||||
|| buffer.parse_count() != new_buffer.parse_count()
|
||||
|| buffer.diagnostics_update_count() != new_buffer.diagnostics_update_count()
|
||||
|| buffer.git_diff_update_count() != new_buffer.git_diff_update_count()
|
||||
|| buffer.trailing_excerpt_update_count()
|
||||
!= new_buffer.trailing_excerpt_update_count()
|
||||
{
|
||||
@@ -986,6 +987,7 @@ impl<'a> sum_tree::Dimension<'a, FoldSummary> for usize {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FoldBufferRows<'a> {
|
||||
cursor: Cursor<'a, Transform, (FoldPoint, Point)>,
|
||||
input_buffer_rows: MultiBufferRows<'a>,
|
||||
@@ -1195,8 +1197,8 @@ mod tests {
|
||||
use settings::Settings;
|
||||
use std::{cmp::Reverse, env, mem, sync::Arc};
|
||||
use sum_tree::TreeMap;
|
||||
use text::RandomCharIter;
|
||||
use util::test::sample_text;
|
||||
use util::RandomCharIter;
|
||||
use Bias::{Left, Right};
|
||||
|
||||
#[gpui::test]
|
||||
|
||||
@@ -3,11 +3,10 @@ use super::{
|
||||
TextHighlights,
|
||||
};
|
||||
use crate::MultiBufferSnapshot;
|
||||
use language::{rope, Chunk};
|
||||
use language::{Chunk, Point};
|
||||
use parking_lot::Mutex;
|
||||
use std::{cmp, mem, num::NonZeroU32, ops::Range};
|
||||
use sum_tree::Bias;
|
||||
use text::Point;
|
||||
|
||||
pub struct TabMap(Mutex<TabSnapshot>);
|
||||
|
||||
@@ -332,11 +331,11 @@ impl TabSnapshot {
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
|
||||
pub struct TabPoint(pub super::Point);
|
||||
pub struct TabPoint(pub Point);
|
||||
|
||||
impl TabPoint {
|
||||
pub fn new(row: u32, column: u32) -> Self {
|
||||
Self(super::Point::new(row, column))
|
||||
Self(Point::new(row, column))
|
||||
}
|
||||
|
||||
pub fn zero() -> Self {
|
||||
@@ -352,8 +351,8 @@ impl TabPoint {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<super::Point> for TabPoint {
|
||||
fn from(point: super::Point) -> Self {
|
||||
impl From<Point> for TabPoint {
|
||||
fn from(point: Point) -> Self {
|
||||
Self(point)
|
||||
}
|
||||
}
|
||||
@@ -362,7 +361,7 @@ pub type TabEdit = text::Edit<TabPoint>;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct TextSummary {
|
||||
pub lines: super::Point,
|
||||
pub lines: Point,
|
||||
pub first_line_chars: u32,
|
||||
pub last_line_chars: u32,
|
||||
pub longest_row: u32,
|
||||
@@ -371,7 +370,7 @@ pub struct TextSummary {
|
||||
|
||||
impl<'a> From<&'a str> for TextSummary {
|
||||
fn from(text: &'a str) -> Self {
|
||||
let sum = rope::TextSummary::from(text);
|
||||
let sum = text::TextSummary::from(text);
|
||||
|
||||
TextSummary {
|
||||
lines: sum.lines,
|
||||
@@ -485,7 +484,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{display_map::fold_map::FoldMap, MultiBuffer};
|
||||
use rand::{prelude::StdRng, Rng};
|
||||
use text::{RandomCharIter, Rope};
|
||||
|
||||
#[test]
|
||||
fn test_expand_tabs() {
|
||||
@@ -508,7 +506,9 @@ mod tests {
|
||||
let tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap();
|
||||
let len = rng.gen_range(0..30);
|
||||
let buffer = if rng.gen() {
|
||||
let text = RandomCharIter::new(&mut rng).take(len).collect::<String>();
|
||||
let text = util::RandomCharIter::new(&mut rng)
|
||||
.take(len)
|
||||
.collect::<String>();
|
||||
MultiBuffer::build_simple(&text, cx)
|
||||
} else {
|
||||
MultiBuffer::build_random(&mut rng, cx)
|
||||
@@ -522,7 +522,7 @@ mod tests {
|
||||
log::info!("FoldMap text: {:?}", folds_snapshot.text());
|
||||
|
||||
let (_, tabs_snapshot) = TabMap::new(folds_snapshot.clone(), tab_size);
|
||||
let text = Rope::from(tabs_snapshot.text().as_str());
|
||||
let text = text::Rope::from(tabs_snapshot.text().as_str());
|
||||
log::info!(
|
||||
"TabMap text (tab size: {}): {:?}",
|
||||
tab_size,
|
||||
|
||||
@@ -3,12 +3,12 @@ use super::{
|
||||
tab_map::{self, TabEdit, TabPoint, TabSnapshot},
|
||||
TextHighlights,
|
||||
};
|
||||
use crate::{MultiBufferSnapshot, Point};
|
||||
use crate::MultiBufferSnapshot;
|
||||
use gpui::{
|
||||
fonts::FontId, text_layout::LineWrapper, Entity, ModelContext, ModelHandle, MutableAppContext,
|
||||
Task,
|
||||
};
|
||||
use language::Chunk;
|
||||
use language::{Chunk, Point};
|
||||
use lazy_static::lazy_static;
|
||||
use smol::future::yield_now;
|
||||
use std::{cmp, collections::VecDeque, mem, ops::Range, time::Duration};
|
||||
@@ -52,7 +52,7 @@ struct TransformSummary {
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
|
||||
pub struct WrapPoint(pub super::Point);
|
||||
pub struct WrapPoint(pub Point);
|
||||
|
||||
pub struct WrapChunks<'a> {
|
||||
input_chunks: tab_map::TabChunks<'a>,
|
||||
@@ -62,6 +62,7 @@ pub struct WrapChunks<'a> {
|
||||
transforms: Cursor<'a, Transform, (WrapPoint, TabPoint)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WrapBufferRows<'a> {
|
||||
input_buffer_rows: fold_map::FoldBufferRows<'a>,
|
||||
input_buffer_row: Option<u32>,
|
||||
@@ -959,7 +960,7 @@ impl SumTreeExt for SumTree<Transform> {
|
||||
|
||||
impl WrapPoint {
|
||||
pub fn new(row: u32, column: u32) -> Self {
|
||||
Self(super::Point::new(row, column))
|
||||
Self(Point::new(row, column))
|
||||
}
|
||||
|
||||
pub fn row(self) -> u32 {
|
||||
@@ -1029,7 +1030,6 @@ mod tests {
|
||||
MultiBuffer,
|
||||
};
|
||||
use gpui::test::observe;
|
||||
use language::RandomCharIter;
|
||||
use rand::prelude::*;
|
||||
use settings::Settings;
|
||||
use smol::stream::StreamExt;
|
||||
@@ -1067,7 +1067,9 @@ mod tests {
|
||||
MultiBuffer::build_random(&mut rng, cx)
|
||||
} else {
|
||||
let len = rng.gen_range(0..10);
|
||||
let text = RandomCharIter::new(&mut rng).take(len).collect::<String>();
|
||||
let text = util::RandomCharIter::new(&mut rng)
|
||||
.take(len)
|
||||
.collect::<String>();
|
||||
MultiBuffer::build_simple(&text, cx)
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
5096
crates/editor/src/editor_tests.rs
Normal file
5096
crates/editor/src/editor_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,13 +9,14 @@ use crate::{
|
||||
HoverAt, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT,
|
||||
},
|
||||
link_go_to_definition::{
|
||||
CmdShiftChanged, GoToFetchedDefinition, GoToFetchedTypeDefinition, UpdateGoToDefinitionLink,
|
||||
GoToFetchedDefinition, GoToFetchedTypeDefinition, UpdateGoToDefinitionLink,
|
||||
},
|
||||
mouse_context_menu::DeployMouseContextMenu,
|
||||
EditorStyle,
|
||||
AnchorRangeExt, EditorStyle,
|
||||
};
|
||||
use clock::ReplicaId;
|
||||
use collections::{BTreeMap, HashMap};
|
||||
use git::diff::DiffHunkStatus;
|
||||
use gpui::{
|
||||
color::Color,
|
||||
elements::*,
|
||||
@@ -28,26 +29,33 @@ use gpui::{
|
||||
json::{self, ToJson},
|
||||
platform::CursorStyle,
|
||||
text_layout::{self, Line, RunStyle, TextLayoutCache},
|
||||
AppContext, Axis, Border, CursorRegion, Element, ElementBox, Event, EventContext,
|
||||
LayoutContext, ModifiersChangedEvent, MouseButton, MouseButtonEvent, MouseMovedEvent,
|
||||
MouseRegion, MutableAppContext, PaintContext, Quad, Scene, SizeConstraint, ViewContext,
|
||||
WeakViewHandle,
|
||||
AppContext, Axis, Border, CursorRegion, Element, ElementBox, EventContext, LayoutContext,
|
||||
Modifiers, MouseButton, MouseButtonEvent, MouseMovedEvent, MouseRegion, MutableAppContext,
|
||||
PaintContext, Quad, SceneBuilder, SizeConstraint, ViewContext, WeakViewHandle,
|
||||
};
|
||||
use json::json;
|
||||
use language::{Bias, DiagnosticSeverity, OffsetUtf16, Selection};
|
||||
use language::{Bias, CursorShape, DiagnosticSeverity, OffsetUtf16, Point, Selection};
|
||||
use project::ProjectPath;
|
||||
use settings::Settings;
|
||||
use settings::{GitGutter, Settings};
|
||||
use smallvec::SmallVec;
|
||||
use std::{
|
||||
cmp::{self, Ordering},
|
||||
fmt::Write,
|
||||
iter,
|
||||
ops::Range,
|
||||
ops::{DerefMut, Range},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DiffHunkLayout {
|
||||
visual_range: Range<u32>,
|
||||
status: DiffHunkStatus,
|
||||
is_folded: bool,
|
||||
}
|
||||
|
||||
struct SelectionLayout {
|
||||
head: DisplayPoint,
|
||||
cursor_shape: CursorShape,
|
||||
range: Range<DisplayPoint>,
|
||||
}
|
||||
|
||||
@@ -55,6 +63,7 @@ impl SelectionLayout {
|
||||
fn new<T: ToPoint + ToDisplayPoint + Clone>(
|
||||
selection: Selection<T>,
|
||||
line_mode: bool,
|
||||
cursor_shape: CursorShape,
|
||||
map: &DisplaySnapshot,
|
||||
) -> Self {
|
||||
if line_mode {
|
||||
@@ -62,6 +71,7 @@ impl SelectionLayout {
|
||||
let point_range = map.expand_to_line(selection.range());
|
||||
Self {
|
||||
head: selection.head().to_display_point(map),
|
||||
cursor_shape,
|
||||
range: point_range.start.to_display_point(map)
|
||||
..point_range.end.to_display_point(map),
|
||||
}
|
||||
@@ -69,6 +79,7 @@ impl SelectionLayout {
|
||||
let selection = selection.map(|p| p.to_display_point(map));
|
||||
Self {
|
||||
head: selection.head(),
|
||||
cursor_shape,
|
||||
range: selection.range(),
|
||||
}
|
||||
}
|
||||
@@ -79,19 +90,13 @@ impl SelectionLayout {
|
||||
pub struct EditorElement {
|
||||
view: WeakViewHandle<Editor>,
|
||||
style: Arc<EditorStyle>,
|
||||
cursor_shape: CursorShape,
|
||||
}
|
||||
|
||||
impl EditorElement {
|
||||
pub fn new(
|
||||
view: WeakViewHandle<Editor>,
|
||||
style: EditorStyle,
|
||||
cursor_shape: CursorShape,
|
||||
) -> Self {
|
||||
pub fn new(view: WeakViewHandle<Editor>, style: EditorStyle) -> Self {
|
||||
Self {
|
||||
view,
|
||||
style: Arc::new(style),
|
||||
cursor_shape,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +137,7 @@ impl EditorElement {
|
||||
gutter_bounds,
|
||||
cx,
|
||||
) {
|
||||
cx.propogate_event();
|
||||
cx.propagate_event();
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -145,7 +150,7 @@ impl EditorElement {
|
||||
text_bounds,
|
||||
cx,
|
||||
) {
|
||||
cx.propogate_event();
|
||||
cx.propagate_event();
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -162,7 +167,7 @@ impl EditorElement {
|
||||
text_bounds,
|
||||
cx,
|
||||
) {
|
||||
cx.propogate_event()
|
||||
cx.propagate_event()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -177,7 +182,7 @@ impl EditorElement {
|
||||
text_bounds,
|
||||
cx,
|
||||
) {
|
||||
cx.propogate_event()
|
||||
cx.propagate_event()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -185,7 +190,7 @@ impl EditorElement {
|
||||
let position_map = position_map.clone();
|
||||
move |e, cx| {
|
||||
if !Self::mouse_moved(e.platform_event, &position_map, text_bounds, cx) {
|
||||
cx.propogate_event()
|
||||
cx.propagate_event()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -194,7 +199,7 @@ impl EditorElement {
|
||||
move |e, cx| {
|
||||
if !Self::scroll(e.position, e.delta, e.precise, &position_map, bounds, cx)
|
||||
{
|
||||
cx.propogate_event()
|
||||
cx.propagate_event()
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -204,10 +209,14 @@ impl EditorElement {
|
||||
fn mouse_down(
|
||||
MouseButtonEvent {
|
||||
position,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
cmd,
|
||||
modifiers:
|
||||
Modifiers {
|
||||
shift,
|
||||
ctrl,
|
||||
alt,
|
||||
cmd,
|
||||
..
|
||||
},
|
||||
mut click_count,
|
||||
..
|
||||
}: MouseButtonEvent,
|
||||
@@ -298,8 +307,7 @@ impl EditorElement {
|
||||
fn mouse_dragged(
|
||||
view: WeakViewHandle<Editor>,
|
||||
MouseMovedEvent {
|
||||
cmd,
|
||||
shift,
|
||||
modifiers: Modifiers { cmd, shift, .. },
|
||||
position,
|
||||
..
|
||||
}: MouseMovedEvent,
|
||||
@@ -374,8 +382,7 @@ impl EditorElement {
|
||||
|
||||
fn mouse_moved(
|
||||
MouseMovedEvent {
|
||||
cmd,
|
||||
shift,
|
||||
modifiers: Modifiers { shift, cmd, .. },
|
||||
position,
|
||||
..
|
||||
}: MouseMovedEvent,
|
||||
@@ -406,14 +413,6 @@ impl EditorElement {
|
||||
true
|
||||
}
|
||||
|
||||
fn modifiers_changed(&self, event: ModifiersChangedEvent, cx: &mut EventContext) -> bool {
|
||||
cx.dispatch_action(CmdShiftChanged {
|
||||
cmd_down: event.cmd,
|
||||
shift_down: event.shift,
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
fn scroll(
|
||||
position: Vector2F,
|
||||
mut delta: Vector2F,
|
||||
@@ -426,18 +425,27 @@ impl EditorElement {
|
||||
return false;
|
||||
}
|
||||
|
||||
let line_height = position_map.line_height;
|
||||
let max_glyph_width = position_map.em_width;
|
||||
if !precise {
|
||||
delta *= vec2f(max_glyph_width, position_map.line_height);
|
||||
}
|
||||
|
||||
let axis = if precise {
|
||||
//Trackpad
|
||||
position_map.snapshot.ongoing_scroll.filter(&mut delta)
|
||||
} else {
|
||||
//Not trackpad
|
||||
delta *= vec2f(max_glyph_width, line_height);
|
||||
None //Resets ongoing scroll
|
||||
};
|
||||
|
||||
let scroll_position = position_map.snapshot.scroll_position();
|
||||
let x = (scroll_position.x() * max_glyph_width - delta.x()) / max_glyph_width;
|
||||
let y =
|
||||
(scroll_position.y() * position_map.line_height - delta.y()) / position_map.line_height;
|
||||
let y = (scroll_position.y() * line_height - delta.y()) / line_height;
|
||||
let scroll_position = vec2f(x, y).clamp(Vector2F::zero(), position_map.scroll_max);
|
||||
|
||||
cx.dispatch_action(Scroll(scroll_position));
|
||||
cx.dispatch_action(Scroll {
|
||||
scroll_position,
|
||||
axis,
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
@@ -452,7 +460,6 @@ impl EditorElement {
|
||||
let bounds = gutter_bounds.union_rect(text_bounds);
|
||||
let scroll_top =
|
||||
layout.position_map.snapshot.scroll_position().y() * layout.position_map.line_height;
|
||||
let editor = self.view(cx.app);
|
||||
cx.scene.push_quad(Quad {
|
||||
bounds: gutter_bounds,
|
||||
background: Some(self.style.gutter_background),
|
||||
@@ -466,7 +473,7 @@ impl EditorElement {
|
||||
corner_radius: 0.,
|
||||
});
|
||||
|
||||
if let EditorMode::Full = editor.mode {
|
||||
if let EditorMode::Full = layout.mode {
|
||||
let mut active_rows = layout.active_rows.iter().peekable();
|
||||
while let Some((start_row, contains_non_empty_selection)) = active_rows.next() {
|
||||
let mut end_row = *start_row;
|
||||
@@ -524,34 +531,120 @@ impl EditorElement {
|
||||
layout: &mut LayoutState,
|
||||
cx: &mut PaintContext,
|
||||
) {
|
||||
let scroll_top =
|
||||
layout.position_map.snapshot.scroll_position().y() * layout.position_map.line_height;
|
||||
let line_height = layout.position_map.line_height;
|
||||
|
||||
let scroll_position = layout.position_map.snapshot.scroll_position();
|
||||
let scroll_top = scroll_position.y() * line_height;
|
||||
|
||||
let show_gutter = matches!(
|
||||
&cx.global::<Settings>()
|
||||
.git_overrides
|
||||
.git_gutter
|
||||
.unwrap_or_default(),
|
||||
GitGutter::TrackedFiles
|
||||
);
|
||||
|
||||
if show_gutter {
|
||||
Self::paint_diff_hunks(bounds, layout, cx);
|
||||
}
|
||||
|
||||
for (ix, line) in layout.line_number_layouts.iter().enumerate() {
|
||||
if let Some(line) = line {
|
||||
let line_origin = bounds.origin()
|
||||
+ vec2f(
|
||||
bounds.width() - line.width() - layout.gutter_padding,
|
||||
ix as f32 * layout.position_map.line_height
|
||||
- (scroll_top % layout.position_map.line_height),
|
||||
ix as f32 * line_height - (scroll_top % line_height),
|
||||
);
|
||||
line.paint(
|
||||
line_origin,
|
||||
visible_bounds,
|
||||
layout.position_map.line_height,
|
||||
cx,
|
||||
);
|
||||
|
||||
line.paint(line_origin, visible_bounds, line_height, cx);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((row, indicator)) = layout.code_actions_indicator.as_mut() {
|
||||
let mut x = bounds.width() - layout.gutter_padding;
|
||||
let mut y = *row as f32 * layout.position_map.line_height - scroll_top;
|
||||
let mut y = *row as f32 * line_height - scroll_top;
|
||||
x += ((layout.gutter_padding + layout.gutter_margin) - indicator.size().x()) / 2.;
|
||||
y += (layout.position_map.line_height - indicator.size().y()) / 2.;
|
||||
y += (line_height - indicator.size().y()) / 2.;
|
||||
indicator.paint(bounds.origin() + vec2f(x, y), visible_bounds, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_diff_hunks(bounds: RectF, layout: &mut LayoutState, cx: &mut PaintContext) {
|
||||
let diff_style = &cx.global::<Settings>().theme.editor.diff.clone();
|
||||
let line_height = layout.position_map.line_height;
|
||||
|
||||
let scroll_position = layout.position_map.snapshot.scroll_position();
|
||||
let scroll_top = scroll_position.y() * line_height;
|
||||
|
||||
for hunk in &layout.hunk_layouts {
|
||||
let color = match (hunk.status, hunk.is_folded) {
|
||||
(DiffHunkStatus::Added, false) => diff_style.inserted,
|
||||
(DiffHunkStatus::Modified, false) => diff_style.modified,
|
||||
|
||||
//TODO: This rendering is entirely a horrible hack
|
||||
(DiffHunkStatus::Removed, false) => {
|
||||
let row = hunk.visual_range.start;
|
||||
|
||||
let offset = line_height / 2.;
|
||||
let start_y = row as f32 * line_height - offset - scroll_top;
|
||||
let end_y = start_y + line_height;
|
||||
|
||||
let width = diff_style.removed_width_em * line_height;
|
||||
let highlight_origin = bounds.origin() + vec2f(-width, start_y);
|
||||
let highlight_size = vec2f(width * 2., end_y - start_y);
|
||||
let highlight_bounds = RectF::new(highlight_origin, highlight_size);
|
||||
|
||||
cx.scene.push_quad(Quad {
|
||||
bounds: highlight_bounds,
|
||||
background: Some(diff_style.deleted),
|
||||
border: Border::new(0., Color::transparent_black()),
|
||||
corner_radius: 1. * line_height,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
(_, true) => {
|
||||
let row = hunk.visual_range.start;
|
||||
let start_y = row as f32 * line_height - scroll_top;
|
||||
let end_y = start_y + line_height;
|
||||
|
||||
let width = diff_style.removed_width_em * line_height;
|
||||
let highlight_origin = bounds.origin() + vec2f(-width, start_y);
|
||||
let highlight_size = vec2f(width * 2., end_y - start_y);
|
||||
let highlight_bounds = RectF::new(highlight_origin, highlight_size);
|
||||
|
||||
cx.scene.push_quad(Quad {
|
||||
bounds: highlight_bounds,
|
||||
background: Some(diff_style.modified),
|
||||
border: Border::new(0., Color::transparent_black()),
|
||||
corner_radius: 1. * line_height,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let start_row = hunk.visual_range.start;
|
||||
let end_row = hunk.visual_range.end;
|
||||
|
||||
let start_y = start_row as f32 * line_height - scroll_top;
|
||||
let end_y = end_row as f32 * line_height - scroll_top;
|
||||
|
||||
let width = diff_style.width_em * line_height;
|
||||
let highlight_origin = bounds.origin() + vec2f(-width, start_y);
|
||||
let highlight_size = vec2f(width * 2., end_y - start_y);
|
||||
let highlight_bounds = RectF::new(highlight_origin, highlight_size);
|
||||
|
||||
cx.scene.push_quad(Quad {
|
||||
bounds: highlight_bounds,
|
||||
background: Some(color),
|
||||
border: Border::new(0., Color::transparent_black()),
|
||||
corner_radius: diff_style.corner_radius * line_height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_text(
|
||||
&mut self,
|
||||
bounds: RectF,
|
||||
@@ -563,10 +656,8 @@ impl EditorElement {
|
||||
let style = &self.style;
|
||||
let local_replica_id = view.replica_id(cx);
|
||||
let scroll_position = layout.position_map.snapshot.scroll_position();
|
||||
let start_row = scroll_position.y() as u32;
|
||||
let start_row = layout.visible_display_row_range.start;
|
||||
let scroll_top = scroll_position.y() * layout.position_map.line_height;
|
||||
let end_row =
|
||||
((scroll_top + bounds.height()) / layout.position_map.line_height).ceil() as u32 + 1; // Add 1 to ensure selections bleed off screen
|
||||
let max_glyph_width = layout.position_map.em_width;
|
||||
let scroll_left = scroll_position.x() * max_glyph_width;
|
||||
let content_origin = bounds.origin() + vec2f(layout.gutter_margin, 0.);
|
||||
@@ -585,8 +676,6 @@ impl EditorElement {
|
||||
for (range, color) in &layout.highlighted_ranges {
|
||||
self.paint_highlighted_range(
|
||||
range.clone(),
|
||||
start_row,
|
||||
end_row,
|
||||
*color,
|
||||
0.,
|
||||
0.15 * layout.position_map.line_height,
|
||||
@@ -607,8 +696,6 @@ impl EditorElement {
|
||||
for selection in selections {
|
||||
self.paint_highlighted_range(
|
||||
selection.range.clone(),
|
||||
start_row,
|
||||
end_row,
|
||||
selection_style.selection,
|
||||
corner_radius,
|
||||
corner_radius * 2.,
|
||||
@@ -620,9 +707,12 @@ impl EditorElement {
|
||||
cx,
|
||||
);
|
||||
|
||||
if view.show_local_cursors() || *replica_id != local_replica_id {
|
||||
if view.show_local_cursors(cx) || *replica_id != local_replica_id {
|
||||
let cursor_position = selection.head;
|
||||
if (start_row..end_row).contains(&cursor_position.row()) {
|
||||
if layout
|
||||
.visible_display_row_range
|
||||
.contains(&cursor_position.row())
|
||||
{
|
||||
let cursor_row_layout = &layout.position_map.line_layouts
|
||||
[(cursor_position.row() - start_row) as usize];
|
||||
let cursor_column = cursor_position.column() as usize;
|
||||
@@ -633,13 +723,13 @@ impl EditorElement {
|
||||
if block_width == 0.0 {
|
||||
block_width = layout.position_map.em_width;
|
||||
}
|
||||
let block_text = if let CursorShape::Block = self.cursor_shape {
|
||||
let block_text = if let CursorShape::Block = selection.cursor_shape {
|
||||
layout
|
||||
.position_map
|
||||
.snapshot
|
||||
.chars_at(cursor_position)
|
||||
.next()
|
||||
.and_then(|character| {
|
||||
.and_then(|(character, _)| {
|
||||
let font_id =
|
||||
cursor_row_layout.font_for_index(cursor_column)?;
|
||||
let text = character.to_string();
|
||||
@@ -669,7 +759,7 @@ impl EditorElement {
|
||||
block_width,
|
||||
origin: vec2f(x, y),
|
||||
line_height: layout.position_map.line_height,
|
||||
shape: self.cursor_shape,
|
||||
shape: selection.cursor_shape,
|
||||
block_text,
|
||||
});
|
||||
}
|
||||
@@ -701,7 +791,7 @@ impl EditorElement {
|
||||
cx.scene.pop_layer();
|
||||
|
||||
if let Some((position, context_menu)) = layout.context_menu.as_mut() {
|
||||
cx.scene.push_stacking_context(None);
|
||||
cx.scene.push_stacking_context(None, None);
|
||||
let cursor_row_layout =
|
||||
&layout.position_map.line_layouts[(position.row() - start_row) as usize];
|
||||
let x = cursor_row_layout.x_for_index(position.column() as usize) - scroll_left;
|
||||
@@ -730,7 +820,7 @@ impl EditorElement {
|
||||
}
|
||||
|
||||
if let Some((position, hover_popovers)) = layout.hover_popovers.as_mut() {
|
||||
cx.scene.push_stacking_context(None);
|
||||
cx.scene.push_stacking_context(None, None);
|
||||
|
||||
// This is safe because we check on layout whether the required row is available
|
||||
let hovered_row_layout =
|
||||
@@ -796,12 +886,123 @@ impl EditorElement {
|
||||
cx.scene.pop_layer();
|
||||
}
|
||||
|
||||
fn paint_scrollbar(&mut self, bounds: RectF, layout: &mut LayoutState, cx: &mut PaintContext) {
|
||||
enum ScrollbarMouseHandlers {}
|
||||
if layout.mode != EditorMode::Full {
|
||||
return;
|
||||
}
|
||||
|
||||
let view = self.view.clone();
|
||||
let style = &self.style.theme.scrollbar;
|
||||
|
||||
let top = bounds.min_y();
|
||||
let bottom = bounds.max_y();
|
||||
let right = bounds.max_x();
|
||||
let left = right - style.width;
|
||||
let row_range = &layout.scrollbar_row_range;
|
||||
let max_row = layout.max_row as f32 + (row_range.end - row_range.start);
|
||||
|
||||
let mut height = bounds.height();
|
||||
let mut first_row_y_offset = 0.0;
|
||||
|
||||
// Impose a minimum height on the scrollbar thumb
|
||||
let min_thumb_height =
|
||||
style.min_height_factor * cx.font_cache.line_height(self.style.text.font_size);
|
||||
let thumb_height = (row_range.end - row_range.start) * height / max_row;
|
||||
if thumb_height < min_thumb_height {
|
||||
first_row_y_offset = (min_thumb_height - thumb_height) / 2.0;
|
||||
height -= min_thumb_height - thumb_height;
|
||||
}
|
||||
|
||||
let y_for_row = |row: f32| -> f32 { top + first_row_y_offset + row * height / max_row };
|
||||
|
||||
let thumb_top = y_for_row(row_range.start) - first_row_y_offset;
|
||||
let thumb_bottom = y_for_row(row_range.end) + first_row_y_offset;
|
||||
let track_bounds = RectF::from_points(vec2f(left, top), vec2f(right, bottom));
|
||||
let thumb_bounds = RectF::from_points(vec2f(left, thumb_top), vec2f(right, thumb_bottom));
|
||||
|
||||
if layout.show_scrollbars {
|
||||
cx.scene.push_quad(Quad {
|
||||
bounds: track_bounds,
|
||||
border: style.track.border,
|
||||
background: style.track.background_color,
|
||||
..Default::default()
|
||||
});
|
||||
cx.scene.push_quad(Quad {
|
||||
bounds: thumb_bounds,
|
||||
border: style.thumb.border,
|
||||
background: style.thumb.background_color,
|
||||
corner_radius: style.thumb.corner_radius,
|
||||
});
|
||||
}
|
||||
|
||||
cx.scene.push_cursor_region(CursorRegion {
|
||||
bounds: track_bounds,
|
||||
style: CursorStyle::Arrow,
|
||||
});
|
||||
cx.scene.push_mouse_region(
|
||||
MouseRegion::new::<ScrollbarMouseHandlers>(view.id(), view.id(), track_bounds)
|
||||
.on_move({
|
||||
let view = view.clone();
|
||||
move |_, cx| {
|
||||
if let Some(view) = view.upgrade(cx.deref_mut()) {
|
||||
view.update(cx.deref_mut(), |view, cx| {
|
||||
view.make_scrollbar_visible(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_down(MouseButton::Left, {
|
||||
let view = view.clone();
|
||||
let row_range = row_range.clone();
|
||||
move |e, cx| {
|
||||
let y = e.position.y();
|
||||
if let Some(view) = view.upgrade(cx.deref_mut()) {
|
||||
view.update(cx.deref_mut(), |view, cx| {
|
||||
if y < thumb_top || thumb_bottom < y {
|
||||
let center_row =
|
||||
((y - top) * max_row as f32 / height).round() as u32;
|
||||
let top_row = center_row.saturating_sub(
|
||||
(row_range.end - row_range.start) as u32 / 2,
|
||||
);
|
||||
let mut position = view.scroll_position(cx);
|
||||
position.set_y(top_row as f32);
|
||||
view.set_scroll_position(position, cx);
|
||||
} else {
|
||||
view.make_scrollbar_visible(cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_drag(MouseButton::Left, {
|
||||
let view = view.clone();
|
||||
move |e, cx| {
|
||||
let y = e.prev_mouse_position.y();
|
||||
let new_y = e.position.y();
|
||||
if thumb_top < y && y < thumb_bottom {
|
||||
if let Some(view) = view.upgrade(cx.deref_mut()) {
|
||||
view.update(cx.deref_mut(), |view, cx| {
|
||||
let mut position = view.scroll_position(cx);
|
||||
position.set_y(
|
||||
position.y() + (new_y - y) * (max_row as f32) / height,
|
||||
);
|
||||
if position.y() < 0.0 {
|
||||
position.set_y(0.);
|
||||
}
|
||||
view.set_scroll_position(position, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_highlighted_range(
|
||||
&self,
|
||||
range: Range<DisplayPoint>,
|
||||
start_row: u32,
|
||||
end_row: u32,
|
||||
color: Color,
|
||||
corner_radius: f32,
|
||||
line_end_overshoot: f32,
|
||||
@@ -812,6 +1013,8 @@ impl EditorElement {
|
||||
bounds: RectF,
|
||||
cx: &mut PaintContext,
|
||||
) {
|
||||
let start_row = layout.visible_display_row_range.start;
|
||||
let end_row = layout.visible_display_row_range.end;
|
||||
if range.start != range.end {
|
||||
let row_range = if range.end.column() == 0 {
|
||||
cmp::max(range.start.row(), start_row)..cmp::min(range.end.row(), end_row)
|
||||
@@ -900,6 +1103,75 @@ impl EditorElement {
|
||||
.width()
|
||||
}
|
||||
|
||||
//Folds contained in a hunk are ignored apart from shrinking visual size
|
||||
//If a fold contains any hunks then that fold line is marked as modified
|
||||
fn layout_git_gutters(
|
||||
&self,
|
||||
rows: Range<u32>,
|
||||
snapshot: &EditorSnapshot,
|
||||
) -> Vec<DiffHunkLayout> {
|
||||
let buffer_snapshot = &snapshot.buffer_snapshot;
|
||||
let visual_start = DisplayPoint::new(rows.start, 0).to_point(snapshot).row;
|
||||
let visual_end = DisplayPoint::new(rows.end, 0).to_point(snapshot).row;
|
||||
let hunks = buffer_snapshot.git_diff_hunks_in_range(visual_start..visual_end);
|
||||
|
||||
let mut layouts = Vec::<DiffHunkLayout>::new();
|
||||
|
||||
for hunk in hunks {
|
||||
let hunk_start_point = Point::new(hunk.buffer_range.start, 0);
|
||||
let hunk_end_point = Point::new(hunk.buffer_range.end, 0);
|
||||
let hunk_start_point_sub = Point::new(hunk.buffer_range.start.saturating_sub(1), 0);
|
||||
let hunk_end_point_sub = Point::new(
|
||||
hunk.buffer_range
|
||||
.end
|
||||
.saturating_sub(1)
|
||||
.max(hunk.buffer_range.start),
|
||||
0,
|
||||
);
|
||||
|
||||
let is_removal = hunk.status() == DiffHunkStatus::Removed;
|
||||
|
||||
let folds_start = Point::new(hunk.buffer_range.start.saturating_sub(1), 0);
|
||||
let folds_end = Point::new(hunk.buffer_range.end + 1, 0);
|
||||
let folds_range = folds_start..folds_end;
|
||||
|
||||
let containing_fold = snapshot.folds_in_range(folds_range).find(|fold_range| {
|
||||
let fold_point_range = fold_range.to_point(buffer_snapshot);
|
||||
let fold_point_range = fold_point_range.start..=fold_point_range.end;
|
||||
|
||||
let folded_start = fold_point_range.contains(&hunk_start_point);
|
||||
let folded_end = fold_point_range.contains(&hunk_end_point_sub);
|
||||
let folded_start_sub = fold_point_range.contains(&hunk_start_point_sub);
|
||||
|
||||
(folded_start && folded_end) || (is_removal && folded_start_sub)
|
||||
});
|
||||
|
||||
let visual_range = if let Some(fold) = containing_fold {
|
||||
let row = fold.start.to_display_point(snapshot).row();
|
||||
row..row
|
||||
} else {
|
||||
let start = hunk_start_point.to_display_point(snapshot).row();
|
||||
let end = hunk_end_point.to_display_point(snapshot).row();
|
||||
start..end
|
||||
};
|
||||
|
||||
let has_existing_layout = match layouts.last() {
|
||||
Some(e) => visual_range == e.visual_range && e.status == hunk.status(),
|
||||
None => false,
|
||||
};
|
||||
|
||||
if !has_existing_layout {
|
||||
layouts.push(DiffHunkLayout {
|
||||
visual_range,
|
||||
status: hunk.status(),
|
||||
is_folded: containing_fold.is_some(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
layouts
|
||||
}
|
||||
|
||||
fn layout_line_numbers(
|
||||
&self,
|
||||
rows: Range<u32>,
|
||||
@@ -1054,6 +1326,7 @@ impl EditorElement {
|
||||
line_height: f32,
|
||||
style: &EditorStyle,
|
||||
line_layouts: &[text_layout::Line],
|
||||
include_root: bool,
|
||||
cx: &mut LayoutContext,
|
||||
) -> (f32, Vec<BlockLayout>) {
|
||||
let editor = if let Some(editor) = self.view.upgrade(cx) {
|
||||
@@ -1157,10 +1430,11 @@ impl EditorElement {
|
||||
let font_size =
|
||||
(style.text_scale_factor * self.style.text.font_size).round();
|
||||
|
||||
let path = buffer.resolve_file_path(cx, include_root);
|
||||
let mut filename = None;
|
||||
let mut parent_path = None;
|
||||
if let Some(file) = buffer.file() {
|
||||
let path = file.path();
|
||||
// Can't use .and_then() because `.file_name()` and `.parent()` return references :(
|
||||
if let Some(path) = path {
|
||||
filename = path.file_name().map(|f| f.to_string_lossy().to_string());
|
||||
parent_path =
|
||||
path.parent().map(|p| p.to_string_lossy().to_string() + "/");
|
||||
@@ -1288,6 +1562,8 @@ impl Element for EditorElement {
|
||||
let em_advance = style.text.em_advance(cx.font_cache);
|
||||
let overscroll = vec2f(em_width, 0.);
|
||||
let snapshot = self.update_view(cx.app, |view, cx| {
|
||||
view.set_visible_line_count(size.y() / line_height);
|
||||
|
||||
let wrap_width = match view.soft_wrap_mode(cx) {
|
||||
SoftWrap::None => Some((MAX_LINE_LEN / 2) as f32 * em_advance),
|
||||
SoftWrap::EditorWidth => {
|
||||
@@ -1333,12 +1609,13 @@ impl Element for EditorElement {
|
||||
// The scroll position is a fractional point, the whole number of which represents
|
||||
// the top of the window in terms of display rows.
|
||||
let start_row = scroll_position.y() as u32;
|
||||
let scroll_top = scroll_position.y() * line_height;
|
||||
let height_in_lines = size.y() / line_height;
|
||||
let max_row = snapshot.max_point().row();
|
||||
|
||||
// Add 1 to ensure selections bleed off screen
|
||||
let end_row = 1 + cmp::min(
|
||||
((scroll_top + size.y()) / line_height).ceil() as u32,
|
||||
snapshot.max_point().row(),
|
||||
(scroll_position.y() + height_in_lines).ceil() as u32,
|
||||
max_row,
|
||||
);
|
||||
|
||||
let start_anchor = if start_row == 0 {
|
||||
@@ -1348,7 +1625,7 @@ impl Element for EditorElement {
|
||||
.buffer_snapshot
|
||||
.anchor_before(DisplayPoint::new(start_row, 0).to_offset(&snapshot, Bias::Left))
|
||||
};
|
||||
let end_anchor = if end_row > snapshot.max_point().row() {
|
||||
let end_anchor = if end_row > max_row {
|
||||
Anchor::max()
|
||||
} else {
|
||||
snapshot
|
||||
@@ -1360,6 +1637,8 @@ impl Element for EditorElement {
|
||||
let mut active_rows = BTreeMap::new();
|
||||
let mut highlighted_rows = None;
|
||||
let mut highlighted_ranges = Vec::new();
|
||||
let mut show_scrollbars = false;
|
||||
let mut include_root = false;
|
||||
self.update_view(cx.app, |view, cx| {
|
||||
let display_map = view.display_map.update(cx, |map, cx| map.snapshot(cx));
|
||||
|
||||
@@ -1372,7 +1651,7 @@ impl Element for EditorElement {
|
||||
);
|
||||
|
||||
let mut remote_selections = HashMap::default();
|
||||
for (replica_id, line_mode, selection) in display_map
|
||||
for (replica_id, line_mode, cursor_shape, selection) in display_map
|
||||
.buffer_snapshot
|
||||
.remote_selections_in_range(&(start_anchor.clone()..end_anchor.clone()))
|
||||
{
|
||||
@@ -1383,7 +1662,12 @@ impl Element for EditorElement {
|
||||
remote_selections
|
||||
.entry(replica_id)
|
||||
.or_insert(Vec::new())
|
||||
.push(SelectionLayout::new(selection, line_mode, &display_map));
|
||||
.push(SelectionLayout::new(
|
||||
selection,
|
||||
line_mode,
|
||||
cursor_shape,
|
||||
&display_map,
|
||||
));
|
||||
}
|
||||
selections.extend(remote_selections);
|
||||
|
||||
@@ -1415,16 +1699,32 @@ impl Element for EditorElement {
|
||||
local_selections
|
||||
.into_iter()
|
||||
.map(|selection| {
|
||||
SelectionLayout::new(selection, view.selections.line_mode, &display_map)
|
||||
SelectionLayout::new(
|
||||
selection,
|
||||
view.selections.line_mode,
|
||||
view.cursor_shape,
|
||||
&display_map,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
|
||||
show_scrollbars = view.show_scrollbars();
|
||||
include_root = view
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
let line_number_layouts =
|
||||
self.layout_line_numbers(start_row..end_row, &active_rows, &snapshot, cx);
|
||||
|
||||
let hunk_layouts = self.layout_git_gutters(start_row..end_row, &snapshot);
|
||||
|
||||
let scrollbar_row_range = scroll_position.y()..(scroll_position.y() + height_in_lines);
|
||||
|
||||
let mut max_visible_line_width = 0.0;
|
||||
let line_layouts = self.layout_lines(start_row..end_row, &snapshot, cx);
|
||||
for line in &line_layouts {
|
||||
@@ -1455,13 +1755,13 @@ impl Element for EditorElement {
|
||||
line_height,
|
||||
&style,
|
||||
&line_layouts,
|
||||
include_root,
|
||||
cx,
|
||||
);
|
||||
|
||||
let max_row = snapshot.max_point().row();
|
||||
let scroll_max = vec2f(
|
||||
((scroll_width - text_size.x()) / em_width).max(0.0),
|
||||
max_row.saturating_sub(1) as f32,
|
||||
max_row as f32,
|
||||
);
|
||||
|
||||
self.update_view(cx.app, |view, cx| {
|
||||
@@ -1488,6 +1788,7 @@ impl Element for EditorElement {
|
||||
let mut context_menu = None;
|
||||
let mut code_actions_indicator = None;
|
||||
let mut hover = None;
|
||||
let mut mode = EditorMode::Full;
|
||||
cx.render(&self.view.upgrade(cx).unwrap(), |view, cx| {
|
||||
let newest_selection_head = view
|
||||
.selections
|
||||
@@ -1509,6 +1810,7 @@ impl Element for EditorElement {
|
||||
|
||||
let visible_rows = start_row..start_row + line_layouts.len() as u32;
|
||||
hover = view.hover_state.render(&snapshot, &style, visible_rows, cx);
|
||||
mode = view.mode;
|
||||
});
|
||||
|
||||
if let Some((_, context_menu)) = context_menu.as_mut() {
|
||||
@@ -1556,6 +1858,7 @@ impl Element for EditorElement {
|
||||
(
|
||||
size,
|
||||
LayoutState {
|
||||
mode,
|
||||
position_map: Arc::new(PositionMap {
|
||||
size,
|
||||
scroll_max,
|
||||
@@ -1565,14 +1868,19 @@ impl Element for EditorElement {
|
||||
em_advance,
|
||||
snapshot,
|
||||
}),
|
||||
visible_display_row_range: start_row..end_row,
|
||||
gutter_size,
|
||||
gutter_padding,
|
||||
text_size,
|
||||
scrollbar_row_range,
|
||||
show_scrollbars,
|
||||
max_row,
|
||||
gutter_margin,
|
||||
active_rows,
|
||||
highlighted_rows,
|
||||
highlighted_ranges,
|
||||
line_number_layouts,
|
||||
hunk_layouts,
|
||||
blocks,
|
||||
selections,
|
||||
context_menu,
|
||||
@@ -1589,7 +1897,8 @@ impl Element for EditorElement {
|
||||
layout: &mut Self::LayoutState,
|
||||
cx: &mut PaintContext,
|
||||
) -> Self::PaintState {
|
||||
cx.scene.push_layer(Some(bounds));
|
||||
let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
|
||||
cx.scene.push_layer(Some(visible_bounds));
|
||||
|
||||
let gutter_bounds = RectF::new(bounds.origin(), layout.gutter_size);
|
||||
let text_bounds = RectF::new(
|
||||
@@ -1613,31 +1922,16 @@ impl Element for EditorElement {
|
||||
}
|
||||
self.paint_text(text_bounds, visible_bounds, layout, cx);
|
||||
|
||||
cx.scene.push_layer(Some(bounds));
|
||||
if !layout.blocks.is_empty() {
|
||||
cx.scene.push_layer(Some(bounds));
|
||||
self.paint_blocks(bounds, visible_bounds, layout, cx);
|
||||
cx.scene.pop_layer();
|
||||
}
|
||||
self.paint_scrollbar(bounds, layout, cx);
|
||||
cx.scene.pop_layer();
|
||||
|
||||
cx.scene.pop_layer();
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
_: RectF,
|
||||
_: RectF,
|
||||
_: &mut LayoutState,
|
||||
_: &mut (),
|
||||
cx: &mut EventContext,
|
||||
) -> bool {
|
||||
if let Event::ModifiersChanged(event) = event {
|
||||
self.modifiers_changed(*event, cx);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn rect_for_text_range(
|
||||
&self,
|
||||
range_utf16: Range<usize>,
|
||||
@@ -1703,12 +1997,18 @@ pub struct LayoutState {
|
||||
gutter_padding: f32,
|
||||
gutter_margin: f32,
|
||||
text_size: Vector2F,
|
||||
mode: EditorMode,
|
||||
visible_display_row_range: Range<u32>,
|
||||
active_rows: BTreeMap<u32, bool>,
|
||||
highlighted_rows: Option<Range<u32>>,
|
||||
line_number_layouts: Vec<Option<text_layout::Line>>,
|
||||
hunk_layouts: Vec<DiffHunkLayout>,
|
||||
blocks: Vec<BlockLayout>,
|
||||
highlighted_ranges: Vec<(Range<DisplayPoint>, Color)>,
|
||||
selections: Vec<(ReplicaId, Vec<SelectionLayout>)>,
|
||||
scrollbar_row_range: Range<f32>,
|
||||
show_scrollbars: bool,
|
||||
max_row: u32,
|
||||
context_menu: Option<(DisplayPoint, ElementBox)>,
|
||||
code_actions_indicator: Option<(u32, ElementBox)>,
|
||||
hover_popovers: Option<(DisplayPoint, Vec<ElementBox>)>,
|
||||
@@ -1797,20 +2097,6 @@ fn layout_line(
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum CursorShape {
|
||||
Bar,
|
||||
Block,
|
||||
Underscore,
|
||||
Hollow,
|
||||
}
|
||||
|
||||
impl Default for CursorShape {
|
||||
fn default() -> Self {
|
||||
CursorShape::Bar
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Cursor {
|
||||
origin: Vector2F,
|
||||
@@ -1903,7 +2189,7 @@ pub struct HighlightedRangeLine {
|
||||
}
|
||||
|
||||
impl HighlightedRange {
|
||||
pub fn paint(&self, bounds: RectF, scene: &mut Scene) {
|
||||
pub fn paint(&self, bounds: RectF, scene: &mut SceneBuilder) {
|
||||
if self.lines.len() >= 2 && self.lines[0].start_x > self.lines[1].end_x {
|
||||
self.paint_lines(self.start_y, &self.lines[0..1], bounds, scene);
|
||||
self.paint_lines(
|
||||
@@ -1922,7 +2208,7 @@ impl HighlightedRange {
|
||||
start_y: f32,
|
||||
lines: &[HighlightedRangeLine],
|
||||
bounds: RectF,
|
||||
scene: &mut Scene,
|
||||
scene: &mut SceneBuilder,
|
||||
) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
@@ -2051,11 +2337,7 @@ mod tests {
|
||||
let (window_id, editor) = cx.add_window(Default::default(), |cx| {
|
||||
Editor::new(EditorMode::Full, buffer, None, None, cx)
|
||||
});
|
||||
let element = EditorElement::new(
|
||||
editor.downgrade(),
|
||||
editor.read(cx).style(cx),
|
||||
CursorShape::Bar,
|
||||
);
|
||||
let element = EditorElement::new(editor.downgrade(), editor.read(cx).style(cx));
|
||||
|
||||
let layouts = editor.update(cx, |editor, cx| {
|
||||
let snapshot = editor.snapshot(cx);
|
||||
@@ -2091,13 +2373,9 @@ mod tests {
|
||||
cx.blur();
|
||||
});
|
||||
|
||||
let mut element = EditorElement::new(
|
||||
editor.downgrade(),
|
||||
editor.read(cx).style(cx),
|
||||
CursorShape::Bar,
|
||||
);
|
||||
let mut element = EditorElement::new(editor.downgrade(), editor.read(cx).style(cx));
|
||||
|
||||
let mut scene = Scene::new(1.0);
|
||||
let mut scene = SceneBuilder::new(1.0);
|
||||
let mut presenter = cx.build_presenter(window_id, 30., Default::default());
|
||||
let mut layout_cx = presenter.build_layout_context(Vector2F::zero(), false, cx);
|
||||
let (size, mut state) = element.layout(
|
||||
|
||||
@@ -32,8 +32,9 @@ pub fn refresh_matching_bracket_highlights(editor: &mut Editor, cx: &mut ViewCon
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::test::editor_lsp_test_context::EditorLspTestContext;
|
||||
|
||||
use super::*;
|
||||
use crate::test::EditorLspTestContext;
|
||||
use indoc::indoc;
|
||||
use language::{BracketPair, Language, LanguageConfig};
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ impl InfoPopover {
|
||||
.with_style(style.hover_popover.container)
|
||||
.boxed()
|
||||
})
|
||||
.on_move(|_, _| {})
|
||||
.on_move(|_, _| {}) // Consume move events so they don't reach regions underneath.
|
||||
.with_cursor_style(CursorStyle::Arrow)
|
||||
.with_padding(Padding {
|
||||
bottom: HOVER_POPOVER_GAP,
|
||||
@@ -400,7 +400,7 @@ impl DiagnosticPopover {
|
||||
bottom: HOVER_POPOVER_GAP,
|
||||
..Default::default()
|
||||
})
|
||||
.on_move(|_, _| {})
|
||||
.on_move(|_, _| {}) // Consume move events so they don't reach regions underneath.
|
||||
.on_click(MouseButton::Left, |_, cx| {
|
||||
cx.dispatch_action(GoToDiagnostic)
|
||||
})
|
||||
@@ -427,13 +427,13 @@ impl DiagnosticPopover {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use futures::StreamExt;
|
||||
use indoc::indoc;
|
||||
|
||||
use language::{Diagnostic, DiagnosticSet};
|
||||
use project::HoverBlock;
|
||||
use smol::stream::StreamExt;
|
||||
|
||||
use crate::test::EditorLspTestContext;
|
||||
use crate::test::editor_lsp_test_context::EditorLspTestContext;
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use gpui::{
|
||||
elements::*, geometry::vector::vec2f, AppContext, Entity, ModelHandle, MutableAppContext,
|
||||
RenderContext, Subscription, Task, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use language::{Bias, Buffer, File as _, OffsetRangeExt, SelectionGoal};
|
||||
use language::{Bias, Buffer, File as _, OffsetRangeExt, Point, SelectionGoal};
|
||||
use project::{File, FormatTrigger, Project, ProjectEntryId, ProjectPath};
|
||||
use rpc::proto::{self, update_view};
|
||||
use settings::Settings;
|
||||
@@ -21,7 +21,7 @@ use std::{
|
||||
ops::Range,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use text::{Point, Selection};
|
||||
use text::Selection;
|
||||
use util::TryFutureExt;
|
||||
use workspace::{
|
||||
searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
|
||||
@@ -120,6 +120,7 @@ impl FollowableItem for Editor {
|
||||
buffer.set_active_selections(
|
||||
&self.selections.disjoint_anchors(),
|
||||
self.selections.line_mode,
|
||||
self.cursor_shape,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
@@ -478,6 +479,17 @@ impl Item for Editor {
|
||||
})
|
||||
}
|
||||
|
||||
fn git_diff_recalc(
|
||||
&mut self,
|
||||
_project: ModelHandle<Project>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.buffer().update(cx, |multibuffer, cx| {
|
||||
multibuffer.git_diff_recalc(cx);
|
||||
});
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
|
||||
fn to_item_events(event: &Self::Event) -> Vec<workspace::ItemEvent> {
|
||||
let mut result = Vec::new();
|
||||
match event {
|
||||
@@ -520,21 +532,17 @@ impl Item for Editor {
|
||||
let buffer = multibuffer.buffer(buffer_id)?;
|
||||
|
||||
let buffer = buffer.read(cx);
|
||||
let filename = if let Some(file) = buffer.file() {
|
||||
if file.path().file_name().is_none()
|
||||
|| self
|
||||
.project
|
||||
let filename = buffer
|
||||
.snapshot()
|
||||
.resolve_file_path(
|
||||
cx,
|
||||
self.project
|
||||
.as_ref()
|
||||
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
file.full_path(cx).to_string_lossy().to_string()
|
||||
} else {
|
||||
file.path().to_string_lossy().to_string()
|
||||
}
|
||||
} else {
|
||||
"untitled".to_string()
|
||||
};
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "untitled".to_string());
|
||||
|
||||
let mut breadcrumbs = vec![Label::new(filename, theme.breadcrumbs.text.clone()).boxed()];
|
||||
breadcrumbs.extend(symbols.into_iter().map(|symbol| {
|
||||
|
||||
@@ -19,12 +19,6 @@ pub struct UpdateGoToDefinitionLink {
|
||||
pub shift_held: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct CmdShiftChanged {
|
||||
pub cmd_down: bool,
|
||||
pub shift_down: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct GoToFetchedDefinition {
|
||||
pub point: DisplayPoint,
|
||||
@@ -39,7 +33,6 @@ impl_internal_actions!(
|
||||
editor,
|
||||
[
|
||||
UpdateGoToDefinitionLink,
|
||||
CmdShiftChanged,
|
||||
GoToFetchedDefinition,
|
||||
GoToFetchedTypeDefinition
|
||||
]
|
||||
@@ -47,7 +40,6 @@ impl_internal_actions!(
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
cx.add_action(update_go_to_definition_link);
|
||||
cx.add_action(cmd_shift_changed);
|
||||
cx.add_action(go_to_fetched_definition);
|
||||
cx.add_action(go_to_fetched_type_definition);
|
||||
}
|
||||
@@ -113,37 +105,6 @@ pub fn update_go_to_definition_link(
|
||||
hide_link_definition(editor, cx);
|
||||
}
|
||||
|
||||
pub fn cmd_shift_changed(
|
||||
editor: &mut Editor,
|
||||
&CmdShiftChanged {
|
||||
cmd_down,
|
||||
shift_down,
|
||||
}: &CmdShiftChanged,
|
||||
cx: &mut ViewContext<Editor>,
|
||||
) {
|
||||
let pending_selection = editor.has_pending_selection();
|
||||
|
||||
if let Some(point) = editor
|
||||
.link_go_to_definition_state
|
||||
.last_mouse_location
|
||||
.clone()
|
||||
{
|
||||
if cmd_down && !pending_selection {
|
||||
let snapshot = editor.snapshot(cx);
|
||||
let kind = if shift_down {
|
||||
LinkDefinitionKind::Type
|
||||
} else {
|
||||
LinkDefinitionKind::Symbol
|
||||
};
|
||||
|
||||
show_link_definition(kind, editor, point, snapshot, cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
hide_link_definition(editor, cx)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LinkDefinitionKind {
|
||||
Symbol,
|
||||
@@ -397,10 +358,11 @@ fn go_to_fetched_definition_of_kind(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use futures::StreamExt;
|
||||
use gpui::{Modifiers, ModifiersChangedEvent, View};
|
||||
use indoc::indoc;
|
||||
use lsp::request::{GotoDefinition, GotoTypeDefinition};
|
||||
|
||||
use crate::test::EditorLspTestContext;
|
||||
use crate::test::editor_lsp_test_context::EditorLspTestContext;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -467,11 +429,13 @@ mod tests {
|
||||
|
||||
// Unpress shift causes highlight to go away (normal goto-definition is not valid here)
|
||||
cx.update_editor(|editor, cx| {
|
||||
cmd_shift_changed(
|
||||
editor,
|
||||
&CmdShiftChanged {
|
||||
cmd_down: true,
|
||||
shift_down: false,
|
||||
editor.modifiers_changed(
|
||||
&gpui::ModifiersChangedEvent {
|
||||
modifiers: Modifiers {
|
||||
cmd: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
@@ -581,14 +545,7 @@ mod tests {
|
||||
|
||||
// Unpress cmd causes highlight to go away
|
||||
cx.update_editor(|editor, cx| {
|
||||
cmd_shift_changed(
|
||||
editor,
|
||||
&CmdShiftChanged {
|
||||
cmd_down: false,
|
||||
shift_down: false,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
editor.modifiers_changed(&Default::default(), cx);
|
||||
});
|
||||
|
||||
// Assert no link highlights
|
||||
@@ -704,11 +661,12 @@ mod tests {
|
||||
])))
|
||||
});
|
||||
cx.update_editor(|editor, cx| {
|
||||
cmd_shift_changed(
|
||||
editor,
|
||||
&CmdShiftChanged {
|
||||
cmd_down: true,
|
||||
shift_down: false,
|
||||
editor.modifiers_changed(
|
||||
&ModifiersChangedEvent {
|
||||
modifiers: Modifiers {
|
||||
cmd: true,
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -70,8 +70,9 @@ pub fn deploy_context_menu(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::test::editor_lsp_test_context::EditorLspTestContext;
|
||||
|
||||
use super::*;
|
||||
use crate::test::EditorLspTestContext;
|
||||
use indoc::indoc;
|
||||
|
||||
#[gpui::test]
|
||||
|
||||
@@ -29,6 +29,25 @@ pub fn up(
|
||||
start: DisplayPoint,
|
||||
goal: SelectionGoal,
|
||||
preserve_column_at_start: bool,
|
||||
) -> (DisplayPoint, SelectionGoal) {
|
||||
up_by_rows(map, start, 1, goal, preserve_column_at_start)
|
||||
}
|
||||
|
||||
pub fn down(
|
||||
map: &DisplaySnapshot,
|
||||
start: DisplayPoint,
|
||||
goal: SelectionGoal,
|
||||
preserve_column_at_end: bool,
|
||||
) -> (DisplayPoint, SelectionGoal) {
|
||||
down_by_rows(map, start, 1, goal, preserve_column_at_end)
|
||||
}
|
||||
|
||||
pub fn up_by_rows(
|
||||
map: &DisplaySnapshot,
|
||||
start: DisplayPoint,
|
||||
row_count: u32,
|
||||
goal: SelectionGoal,
|
||||
preserve_column_at_start: bool,
|
||||
) -> (DisplayPoint, SelectionGoal) {
|
||||
let mut goal_column = if let SelectionGoal::Column(column) = goal {
|
||||
column
|
||||
@@ -36,7 +55,7 @@ pub fn up(
|
||||
map.column_to_chars(start.row(), start.column())
|
||||
};
|
||||
|
||||
let prev_row = start.row().saturating_sub(1);
|
||||
let prev_row = start.row().saturating_sub(row_count);
|
||||
let mut point = map.clip_point(
|
||||
DisplayPoint::new(prev_row, map.line_len(prev_row)),
|
||||
Bias::Left,
|
||||
@@ -62,9 +81,10 @@ pub fn up(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn down(
|
||||
pub fn down_by_rows(
|
||||
map: &DisplaySnapshot,
|
||||
start: DisplayPoint,
|
||||
row_count: u32,
|
||||
goal: SelectionGoal,
|
||||
preserve_column_at_end: bool,
|
||||
) -> (DisplayPoint, SelectionGoal) {
|
||||
@@ -74,8 +94,8 @@ pub fn down(
|
||||
map.column_to_chars(start.row(), start.column())
|
||||
};
|
||||
|
||||
let next_row = start.row() + 1;
|
||||
let mut point = map.clip_point(DisplayPoint::new(next_row, 0), Bias::Right);
|
||||
let new_row = start.row() + row_count;
|
||||
let mut point = map.clip_point(DisplayPoint::new(new_row, 0), Bias::Right);
|
||||
if point.row() > start.row() {
|
||||
*point.column_mut() = map.column_from_chars(point.row(), goal_column);
|
||||
} else if preserve_column_at_end {
|
||||
@@ -101,6 +121,22 @@ pub fn line_beginning(
|
||||
map: &DisplaySnapshot,
|
||||
display_point: DisplayPoint,
|
||||
stop_at_soft_boundaries: bool,
|
||||
) -> DisplayPoint {
|
||||
let point = display_point.to_point(map);
|
||||
let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right);
|
||||
let line_start = map.prev_line_boundary(point).1;
|
||||
|
||||
if stop_at_soft_boundaries && display_point != soft_line_start {
|
||||
soft_line_start
|
||||
} else {
|
||||
line_start
|
||||
}
|
||||
}
|
||||
|
||||
pub fn indented_line_beginning(
|
||||
map: &DisplaySnapshot,
|
||||
display_point: DisplayPoint,
|
||||
stop_at_soft_boundaries: bool,
|
||||
) -> DisplayPoint {
|
||||
let point = display_point.to_point(map);
|
||||
let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right);
|
||||
@@ -167,54 +203,79 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo
|
||||
})
|
||||
}
|
||||
|
||||
/// Scans for a boundary from the start of each line preceding the given end point until a boundary
|
||||
/// is found, indicated by the given predicate returning true. The predicate is called with the
|
||||
/// character to the left and right of the candidate boundary location, and will be called with `\n`
|
||||
/// characters indicating the start or end of a line. If the predicate returns true multiple times
|
||||
/// on a line, the *rightmost* boundary is returned.
|
||||
/// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the
|
||||
/// given predicate returning true. The predicate is called with the character to the left and right
|
||||
/// of the candidate boundary location, and will be called with `\n` characters indicating the start
|
||||
/// or end of a line.
|
||||
pub fn find_preceding_boundary(
|
||||
map: &DisplaySnapshot,
|
||||
end: DisplayPoint,
|
||||
from: DisplayPoint,
|
||||
mut is_boundary: impl FnMut(char, char) -> bool,
|
||||
) -> DisplayPoint {
|
||||
let mut point = end;
|
||||
loop {
|
||||
*point.column_mut() = 0;
|
||||
if point.row() > 0 {
|
||||
if let Some(indent) = map.soft_wrap_indent(point.row() - 1) {
|
||||
*point.column_mut() = indent;
|
||||
let mut start_column = 0;
|
||||
let mut soft_wrap_row = from.row() + 1;
|
||||
|
||||
let mut prev = None;
|
||||
for (ch, point) in map.reverse_chars_at(from) {
|
||||
// Recompute soft_wrap_indent if the row has changed
|
||||
if point.row() != soft_wrap_row {
|
||||
soft_wrap_row = point.row();
|
||||
|
||||
if point.row() == 0 {
|
||||
start_column = 0;
|
||||
} else if let Some(indent) = map.soft_wrap_indent(point.row() - 1) {
|
||||
start_column = indent;
|
||||
}
|
||||
}
|
||||
|
||||
let mut boundary = None;
|
||||
let mut prev_ch = if point.is_zero() { None } else { Some('\n') };
|
||||
for ch in map.chars_at(point) {
|
||||
if point >= end {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(prev_ch) = prev_ch {
|
||||
if is_boundary(prev_ch, ch) {
|
||||
boundary = Some(point);
|
||||
}
|
||||
}
|
||||
|
||||
if ch == '\n' {
|
||||
break;
|
||||
}
|
||||
|
||||
prev_ch = Some(ch);
|
||||
*point.column_mut() += ch.len_utf8() as u32;
|
||||
// If the current point is in the soft_wrap, skip comparing it
|
||||
if point.column() < start_column {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(boundary) = boundary {
|
||||
return boundary;
|
||||
} else if point.row() == 0 {
|
||||
return DisplayPoint::zero();
|
||||
} else {
|
||||
*point.row_mut() -= 1;
|
||||
if let Some((prev_ch, prev_point)) = prev {
|
||||
if is_boundary(ch, prev_ch) {
|
||||
return prev_point;
|
||||
}
|
||||
}
|
||||
|
||||
prev = Some((ch, point));
|
||||
}
|
||||
DisplayPoint::zero()
|
||||
}
|
||||
|
||||
/// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the
|
||||
/// given predicate returning true. The predicate is called with the character to the left and right
|
||||
/// of the candidate boundary location, and will be called with `\n` characters indicating the start
|
||||
/// or end of a line. If no boundary is found, the start of the line is returned.
|
||||
pub fn find_preceding_boundary_in_line(
|
||||
map: &DisplaySnapshot,
|
||||
from: DisplayPoint,
|
||||
mut is_boundary: impl FnMut(char, char) -> bool,
|
||||
) -> DisplayPoint {
|
||||
let mut start_column = 0;
|
||||
if from.row() > 0 {
|
||||
if let Some(indent) = map.soft_wrap_indent(from.row() - 1) {
|
||||
start_column = indent;
|
||||
}
|
||||
}
|
||||
|
||||
let mut prev = None;
|
||||
for (ch, point) in map.reverse_chars_at(from) {
|
||||
if let Some((prev_ch, prev_point)) = prev {
|
||||
if is_boundary(ch, prev_ch) {
|
||||
return prev_point;
|
||||
}
|
||||
}
|
||||
|
||||
if ch == '\n' || point.column() < start_column {
|
||||
break;
|
||||
}
|
||||
|
||||
prev = Some((ch, point));
|
||||
}
|
||||
|
||||
prev.map(|(_, point)| point).unwrap_or(from)
|
||||
}
|
||||
|
||||
/// Scans for a boundary following the given start point until a boundary is found, indicated by the
|
||||
@@ -223,26 +284,48 @@ pub fn find_preceding_boundary(
|
||||
/// or end of a line.
|
||||
pub fn find_boundary(
|
||||
map: &DisplaySnapshot,
|
||||
mut point: DisplayPoint,
|
||||
from: DisplayPoint,
|
||||
mut is_boundary: impl FnMut(char, char) -> bool,
|
||||
) -> DisplayPoint {
|
||||
let mut prev_ch = None;
|
||||
for ch in map.chars_at(point) {
|
||||
for (ch, point) in map.chars_at(from) {
|
||||
if let Some(prev_ch) = prev_ch {
|
||||
if is_boundary(prev_ch, ch) {
|
||||
break;
|
||||
return map.clip_point(point, Bias::Right);
|
||||
}
|
||||
}
|
||||
|
||||
if ch == '\n' {
|
||||
*point.row_mut() += 1;
|
||||
*point.column_mut() = 0;
|
||||
} else {
|
||||
*point.column_mut() += ch.len_utf8() as u32;
|
||||
}
|
||||
prev_ch = Some(ch);
|
||||
}
|
||||
map.clip_point(point, Bias::Right)
|
||||
map.clip_point(map.max_point(), Bias::Right)
|
||||
}
|
||||
|
||||
/// Scans for a boundary following the given start point until a boundary is found, indicated by the
|
||||
/// given predicate returning true. The predicate is called with the character to the left and right
|
||||
/// of the candidate boundary location, and will be called with `\n` characters indicating the start
|
||||
/// or end of a line. If no boundary is found, the end of the line is returned
|
||||
pub fn find_boundary_in_line(
|
||||
map: &DisplaySnapshot,
|
||||
from: DisplayPoint,
|
||||
mut is_boundary: impl FnMut(char, char) -> bool,
|
||||
) -> DisplayPoint {
|
||||
let mut prev = None;
|
||||
for (ch, point) in map.chars_at(from) {
|
||||
if let Some((prev_ch, _)) = prev {
|
||||
if is_boundary(prev_ch, ch) {
|
||||
return map.clip_point(point, Bias::Right);
|
||||
}
|
||||
}
|
||||
|
||||
prev = Some((ch, point));
|
||||
|
||||
if ch == '\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the last position checked so that we give a point right before the newline or eof.
|
||||
map.clip_point(prev.map(|(_, point)| point).unwrap_or(from), Bias::Right)
|
||||
}
|
||||
|
||||
pub fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
|
||||
@@ -273,7 +356,6 @@ pub fn surrounding_word(map: &DisplaySnapshot, position: DisplayPoint) -> Range<
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{test::marked_display_snapshot, Buffer, DisplayMap, ExcerptRange, MultiBuffer};
|
||||
use language::Point;
|
||||
use settings::Settings;
|
||||
|
||||
#[gpui::test]
|
||||
|
||||
@@ -4,12 +4,14 @@ pub use anchor::{Anchor, AnchorRangeExt};
|
||||
use anyhow::Result;
|
||||
use clock::ReplicaId;
|
||||
use collections::{BTreeMap, Bound, HashMap, HashSet};
|
||||
use git::diff::DiffHunk;
|
||||
use gpui::{AppContext, Entity, ModelContext, ModelHandle, Task};
|
||||
pub use language::Completion;
|
||||
use language::{
|
||||
char_kind, AutoindentMode, Buffer, BufferChunks, BufferSnapshot, CharKind, Chunk,
|
||||
DiagnosticEntry, Event, File, IndentSize, Language, OffsetRangeExt, Outline, OutlineItem,
|
||||
Selection, ToOffset as _, ToOffsetUtf16 as _, ToPoint as _, ToPointUtf16 as _, TransactionId,
|
||||
char_kind, AutoindentMode, Buffer, BufferChunks, BufferSnapshot, CharKind, Chunk, CursorShape,
|
||||
DiagnosticEntry, Event, File, IndentSize, Language, OffsetRangeExt, OffsetUtf16, Outline,
|
||||
OutlineItem, Point, PointUtf16, Selection, TextDimension, ToOffset as _, ToOffsetUtf16 as _,
|
||||
ToPoint as _, ToPointUtf16 as _, TransactionId,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use std::{
|
||||
@@ -26,9 +28,8 @@ use std::{
|
||||
use sum_tree::{Bias, Cursor, SumTree};
|
||||
use text::{
|
||||
locator::Locator,
|
||||
rope::TextDimension,
|
||||
subscription::{Subscription, Topic},
|
||||
Edit, OffsetUtf16, Point, PointUtf16, TextSummary,
|
||||
Edit, TextSummary,
|
||||
};
|
||||
use theme::SyntaxTheme;
|
||||
use util::post_inc;
|
||||
@@ -90,6 +91,7 @@ struct BufferState {
|
||||
last_selections_update_count: usize,
|
||||
last_diagnostics_update_count: usize,
|
||||
last_file_update_count: usize,
|
||||
last_git_diff_update_count: usize,
|
||||
excerpts: Vec<ExcerptId>,
|
||||
_subscriptions: [gpui::Subscription; 2],
|
||||
}
|
||||
@@ -101,6 +103,7 @@ pub struct MultiBufferSnapshot {
|
||||
parse_count: usize,
|
||||
diagnostics_update_count: usize,
|
||||
trailing_excerpt_update_count: usize,
|
||||
git_diff_update_count: usize,
|
||||
edit_count: usize,
|
||||
is_dirty: bool,
|
||||
has_conflict: bool,
|
||||
@@ -140,6 +143,7 @@ struct ExcerptSummary {
|
||||
text: TextSummary,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MultiBufferRows<'a> {
|
||||
buffer_row_range: Range<u32>,
|
||||
excerpts: Cursor<'a, Excerpt, Point>,
|
||||
@@ -165,7 +169,7 @@ struct ExcerptChunks<'a> {
|
||||
}
|
||||
|
||||
struct ExcerptBytes<'a> {
|
||||
content_bytes: language::rope::Bytes<'a>,
|
||||
content_bytes: text::Bytes<'a>,
|
||||
footer_height: usize,
|
||||
}
|
||||
|
||||
@@ -202,6 +206,7 @@ impl MultiBuffer {
|
||||
last_selections_update_count: buffer_state.last_selections_update_count,
|
||||
last_diagnostics_update_count: buffer_state.last_diagnostics_update_count,
|
||||
last_file_update_count: buffer_state.last_file_update_count,
|
||||
last_git_diff_update_count: buffer_state.last_git_diff_update_count,
|
||||
excerpts: buffer_state.excerpts.clone(),
|
||||
_subscriptions: [
|
||||
new_cx.observe(&buffer_state.buffer, |_, _, cx| cx.notify()),
|
||||
@@ -308,6 +313,17 @@ impl MultiBuffer {
|
||||
self.read(cx).symbols_containing(offset, theme)
|
||||
}
|
||||
|
||||
pub fn git_diff_recalc(&mut self, cx: &mut ModelContext<Self>) {
|
||||
let buffers = self.buffers.borrow();
|
||||
for buffer_state in buffers.values() {
|
||||
if buffer_state.buffer.read(cx).needs_git_diff_recalc() {
|
||||
buffer_state
|
||||
.buffer
|
||||
.update(cx, |buffer, cx| buffer.git_diff_recalc(cx))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn edit<I, S, T>(
|
||||
&mut self,
|
||||
edits: I,
|
||||
@@ -588,6 +604,7 @@ impl MultiBuffer {
|
||||
&mut self,
|
||||
selections: &[Selection<Anchor>],
|
||||
line_mode: bool,
|
||||
cursor_shape: CursorShape,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let mut selections_by_buffer: HashMap<usize, Vec<Selection<text::Anchor>>> =
|
||||
@@ -652,7 +669,7 @@ impl MultiBuffer {
|
||||
}
|
||||
Some(selection)
|
||||
}));
|
||||
buffer.set_active_selections(merged_selections, line_mode, cx);
|
||||
buffer.set_active_selections(merged_selections, line_mode, cursor_shape, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -827,6 +844,7 @@ impl MultiBuffer {
|
||||
last_selections_update_count: buffer_snapshot.selections_update_count(),
|
||||
last_diagnostics_update_count: buffer_snapshot.diagnostics_update_count(),
|
||||
last_file_update_count: buffer_snapshot.file_update_count(),
|
||||
last_git_diff_update_count: buffer_snapshot.git_diff_update_count(),
|
||||
excerpts: Default::default(),
|
||||
_subscriptions: [
|
||||
cx.observe(&buffer, |_, _, cx| cx.notify()),
|
||||
@@ -1212,9 +1230,9 @@ impl MultiBuffer {
|
||||
&self,
|
||||
point: T,
|
||||
cx: &'a AppContext,
|
||||
) -> Option<&'a Arc<Language>> {
|
||||
) -> Option<Arc<Language>> {
|
||||
self.point_to_buffer_offset(point, cx)
|
||||
.and_then(|(buffer, _)| buffer.read(cx).language())
|
||||
.and_then(|(buffer, offset)| buffer.read(cx).language_at(offset))
|
||||
}
|
||||
|
||||
pub fn files<'a>(&'a self, cx: &'a AppContext) -> SmallVec<[&'a dyn File; 2]> {
|
||||
@@ -1249,6 +1267,7 @@ impl MultiBuffer {
|
||||
let mut excerpts_to_edit = Vec::new();
|
||||
let mut reparsed = false;
|
||||
let mut diagnostics_updated = false;
|
||||
let mut git_diff_updated = false;
|
||||
let mut is_dirty = false;
|
||||
let mut has_conflict = false;
|
||||
let mut edited = false;
|
||||
@@ -1260,6 +1279,7 @@ impl MultiBuffer {
|
||||
let selections_update_count = buffer.selections_update_count();
|
||||
let diagnostics_update_count = buffer.diagnostics_update_count();
|
||||
let file_update_count = buffer.file_update_count();
|
||||
let git_diff_update_count = buffer.git_diff_update_count();
|
||||
|
||||
let buffer_edited = version.changed_since(&buffer_state.last_version);
|
||||
let buffer_reparsed = parse_count > buffer_state.last_parse_count;
|
||||
@@ -1268,17 +1288,21 @@ impl MultiBuffer {
|
||||
let buffer_diagnostics_updated =
|
||||
diagnostics_update_count > buffer_state.last_diagnostics_update_count;
|
||||
let buffer_file_updated = file_update_count > buffer_state.last_file_update_count;
|
||||
let buffer_git_diff_updated =
|
||||
git_diff_update_count > buffer_state.last_git_diff_update_count;
|
||||
if buffer_edited
|
||||
|| buffer_reparsed
|
||||
|| buffer_selections_updated
|
||||
|| buffer_diagnostics_updated
|
||||
|| buffer_file_updated
|
||||
|| buffer_git_diff_updated
|
||||
{
|
||||
buffer_state.last_version = version;
|
||||
buffer_state.last_parse_count = parse_count;
|
||||
buffer_state.last_selections_update_count = selections_update_count;
|
||||
buffer_state.last_diagnostics_update_count = diagnostics_update_count;
|
||||
buffer_state.last_file_update_count = file_update_count;
|
||||
buffer_state.last_git_diff_update_count = git_diff_update_count;
|
||||
excerpts_to_edit.extend(
|
||||
buffer_state
|
||||
.excerpts
|
||||
@@ -1290,6 +1314,7 @@ impl MultiBuffer {
|
||||
edited |= buffer_edited;
|
||||
reparsed |= buffer_reparsed;
|
||||
diagnostics_updated |= buffer_diagnostics_updated;
|
||||
git_diff_updated |= buffer_git_diff_updated;
|
||||
is_dirty |= buffer.is_dirty();
|
||||
has_conflict |= buffer.has_conflict();
|
||||
}
|
||||
@@ -1302,6 +1327,9 @@ impl MultiBuffer {
|
||||
if diagnostics_updated {
|
||||
snapshot.diagnostics_update_count += 1;
|
||||
}
|
||||
if git_diff_updated {
|
||||
snapshot.git_diff_update_count += 1;
|
||||
}
|
||||
snapshot.is_dirty = is_dirty;
|
||||
snapshot.has_conflict = has_conflict;
|
||||
|
||||
@@ -1386,7 +1414,7 @@ impl MultiBuffer {
|
||||
edit_count: usize,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) {
|
||||
use text::RandomCharIter;
|
||||
use util::RandomCharIter;
|
||||
|
||||
let snapshot = self.read(cx);
|
||||
let mut edits: Vec<(Range<usize>, Arc<str>)> = Vec::new();
|
||||
@@ -1425,7 +1453,7 @@ impl MultiBuffer {
|
||||
) {
|
||||
use rand::prelude::*;
|
||||
use std::env;
|
||||
use text::RandomCharIter;
|
||||
use util::RandomCharIter;
|
||||
|
||||
let max_excerpts = env::var("MAX_EXCERPTS")
|
||||
.map(|i| i.parse().expect("invalid `MAX_EXCERPTS` variable"))
|
||||
@@ -1940,6 +1968,24 @@ impl MultiBufferSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn point_to_buffer_offset<T: ToOffset>(
|
||||
&self,
|
||||
point: T,
|
||||
) -> Option<(&BufferSnapshot, usize)> {
|
||||
let offset = point.to_offset(&self);
|
||||
let mut cursor = self.excerpts.cursor::<usize>();
|
||||
cursor.seek(&offset, Bias::Right, &());
|
||||
if cursor.item().is_none() {
|
||||
cursor.prev(&());
|
||||
}
|
||||
|
||||
cursor.item().map(|excerpt| {
|
||||
let excerpt_start = excerpt.range.context.start.to_offset(&excerpt.buffer);
|
||||
let buffer_point = excerpt_start + offset - *cursor.start();
|
||||
(&excerpt.buffer, buffer_point)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn suggested_indents(
|
||||
&self,
|
||||
rows: impl IntoIterator<Item = u32>,
|
||||
@@ -1949,8 +1995,10 @@ impl MultiBufferSnapshot {
|
||||
|
||||
let mut rows_for_excerpt = Vec::new();
|
||||
let mut cursor = self.excerpts.cursor::<Point>();
|
||||
|
||||
let mut rows = rows.into_iter().peekable();
|
||||
let mut prev_row = u32::MAX;
|
||||
let mut prev_language_indent_size = IndentSize::default();
|
||||
|
||||
while let Some(row) = rows.next() {
|
||||
cursor.seek(&Point::new(row, 0), Bias::Right, &());
|
||||
let excerpt = match cursor.item() {
|
||||
@@ -1958,7 +2006,17 @@ impl MultiBufferSnapshot {
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let single_indent_size = excerpt.buffer.single_indent_size(cx);
|
||||
// Retrieve the language and indent size once for each disjoint region being indented.
|
||||
let single_indent_size = if row.saturating_sub(1) == prev_row {
|
||||
prev_language_indent_size
|
||||
} else {
|
||||
excerpt
|
||||
.buffer
|
||||
.language_indent_size_at(Point::new(row, 0), cx)
|
||||
};
|
||||
prev_language_indent_size = single_indent_size;
|
||||
prev_row = row;
|
||||
|
||||
let start_buffer_row = excerpt.range.context.start.to_point(&excerpt.buffer).row;
|
||||
let start_multibuffer_row = cursor.start().row;
|
||||
|
||||
@@ -2479,15 +2537,17 @@ impl MultiBufferSnapshot {
|
||||
self.diagnostics_update_count
|
||||
}
|
||||
|
||||
pub fn git_diff_update_count(&self) -> usize {
|
||||
self.git_diff_update_count
|
||||
}
|
||||
|
||||
pub fn trailing_excerpt_update_count(&self) -> usize {
|
||||
self.trailing_excerpt_update_count
|
||||
}
|
||||
|
||||
pub fn language(&self) -> Option<&Arc<Language>> {
|
||||
self.excerpts
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|excerpt| excerpt.buffer.language())
|
||||
pub fn language_at<'a, T: ToOffset>(&'a self, point: T) -> Option<&'a Arc<Language>> {
|
||||
self.point_to_buffer_offset(point)
|
||||
.and_then(|(buffer, offset)| buffer.language_at(offset))
|
||||
}
|
||||
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -2529,6 +2589,15 @@ impl MultiBufferSnapshot {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn git_diff_hunks_in_range<'a>(
|
||||
&'a self,
|
||||
row_range: Range<u32>,
|
||||
) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
|
||||
self.as_singleton()
|
||||
.into_iter()
|
||||
.flat_map(move |(_, _, buffer)| buffer.git_diff_hunks_in_range(row_range.clone()))
|
||||
}
|
||||
|
||||
pub fn range_for_syntax_ancestor<T: ToOffset>(&self, range: Range<T>) -> Option<Range<usize>> {
|
||||
let range = range.start.to_offset(self)..range.end.to_offset(self);
|
||||
|
||||
@@ -2630,7 +2699,7 @@ impl MultiBufferSnapshot {
|
||||
pub fn remote_selections_in_range<'a>(
|
||||
&'a self,
|
||||
range: &'a Range<Anchor>,
|
||||
) -> impl 'a + Iterator<Item = (ReplicaId, bool, Selection<Anchor>)> {
|
||||
) -> impl 'a + Iterator<Item = (ReplicaId, bool, CursorShape, Selection<Anchor>)> {
|
||||
let mut cursor = self.excerpts.cursor::<Option<&ExcerptId>>();
|
||||
cursor.seek(&Some(&range.start.excerpt_id), Bias::Left, &());
|
||||
cursor
|
||||
@@ -2647,7 +2716,7 @@ impl MultiBufferSnapshot {
|
||||
excerpt
|
||||
.buffer
|
||||
.remote_selections_in_range(query_range)
|
||||
.flat_map(move |(replica_id, line_mode, selections)| {
|
||||
.flat_map(move |(replica_id, line_mode, cursor_shape, selections)| {
|
||||
selections.map(move |selection| {
|
||||
let mut start = Anchor {
|
||||
buffer_id: Some(excerpt.buffer_id),
|
||||
@@ -2669,6 +2738,7 @@ impl MultiBufferSnapshot {
|
||||
(
|
||||
replica_id,
|
||||
line_mode,
|
||||
cursor_shape,
|
||||
Selection {
|
||||
id: selection.id,
|
||||
start,
|
||||
@@ -3270,7 +3340,7 @@ mod tests {
|
||||
use rand::prelude::*;
|
||||
use settings::Settings;
|
||||
use std::{env, rc::Rc};
|
||||
use text::{Point, RandomCharIter};
|
||||
|
||||
use util::test::sample_text;
|
||||
|
||||
#[gpui::test]
|
||||
@@ -3888,7 +3958,9 @@ mod tests {
|
||||
}
|
||||
_ => {
|
||||
let buffer_handle = if buffers.is_empty() || rng.gen_bool(0.4) {
|
||||
let base_text = RandomCharIter::new(&mut rng).take(10).collect::<String>();
|
||||
let base_text = util::RandomCharIter::new(&mut rng)
|
||||
.take(10)
|
||||
.collect::<String>();
|
||||
buffers.push(cx.add_model(|cx| Buffer::new(0, base_text, cx)));
|
||||
buffers.last().unwrap()
|
||||
} else {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::{ExcerptId, MultiBufferSnapshot, ToOffset, ToOffsetUtf16, ToPoint};
|
||||
use language::{OffsetUtf16, Point, TextDimension};
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
ops::{Range, Sub},
|
||||
};
|
||||
use sum_tree::Bias;
|
||||
use text::{rope::TextDimension, OffsetUtf16, Point};
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug, Hash)]
|
||||
pub struct Anchor {
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::{
|
||||
use collections::HashMap;
|
||||
use gpui::{AppContext, ModelHandle, MutableAppContext};
|
||||
use itertools::Itertools;
|
||||
use language::{rope::TextDimension, Bias, Point, Selection, SelectionGoal, ToPoint};
|
||||
use language::{Bias, Point, Selection, SelectionGoal, TextDimension, ToPoint};
|
||||
use util::post_inc;
|
||||
|
||||
use crate::{
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
pub mod editor_lsp_test_context;
|
||||
pub mod editor_test_context;
|
||||
|
||||
use crate::{
|
||||
display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint},
|
||||
multi_buffer::ToPointUtf16,
|
||||
AnchorRangeExt, Autoscroll, DisplayPoint, Editor, EditorMode, MultiBuffer, ToPoint,
|
||||
DisplayPoint, Editor, EditorMode, MultiBuffer,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use futures::{Future, StreamExt};
|
||||
use gpui::{
|
||||
json, keymap::Keystroke, AppContext, ModelContext, ModelHandle, ViewContext, ViewHandle,
|
||||
};
|
||||
use indoc::indoc;
|
||||
use language::{point_to_lsp, Buffer, BufferSnapshot, FakeLspAdapter, Language, LanguageConfig};
|
||||
use lsp::{notification, request};
|
||||
use project::Project;
|
||||
use settings::Settings;
|
||||
use std::{
|
||||
any::TypeId,
|
||||
ops::{Deref, DerefMut, Range},
|
||||
sync::Arc,
|
||||
};
|
||||
use util::{
|
||||
assert_set_eq, set_eq,
|
||||
test::{generate_marked_text, marked_text_offsets, marked_text_ranges},
|
||||
};
|
||||
use workspace::{pane, AppState, Workspace, WorkspaceHandle};
|
||||
|
||||
use gpui::{ModelHandle, ViewContext};
|
||||
|
||||
use util::test::{marked_text_offsets, marked_text_ranges};
|
||||
|
||||
#[cfg(test)]
|
||||
#[ctor::ctor]
|
||||
@@ -80,430 +66,3 @@ pub(crate) fn build_editor(
|
||||
) -> Editor {
|
||||
Editor::new(EditorMode::Full, buffer, None, None, cx)
|
||||
}
|
||||
|
||||
pub struct EditorTestContext<'a> {
|
||||
pub cx: &'a mut gpui::TestAppContext,
|
||||
pub window_id: usize,
|
||||
pub editor: ViewHandle<Editor>,
|
||||
}
|
||||
|
||||
impl<'a> EditorTestContext<'a> {
|
||||
pub fn new(cx: &'a mut gpui::TestAppContext) -> EditorTestContext<'a> {
|
||||
let (window_id, editor) = cx.update(|cx| {
|
||||
cx.set_global(Settings::test(cx));
|
||||
crate::init(cx);
|
||||
|
||||
let (window_id, editor) = cx.add_window(Default::default(), |cx| {
|
||||
build_editor(MultiBuffer::build_simple("", cx), cx)
|
||||
});
|
||||
|
||||
editor.update(cx, |_, cx| cx.focus_self());
|
||||
|
||||
(window_id, editor)
|
||||
});
|
||||
|
||||
Self {
|
||||
cx,
|
||||
window_id,
|
||||
editor,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn condition(
|
||||
&self,
|
||||
predicate: impl FnMut(&Editor, &AppContext) -> bool,
|
||||
) -> impl Future<Output = ()> {
|
||||
self.editor.condition(self.cx, predicate)
|
||||
}
|
||||
|
||||
pub fn editor<F, T>(&self, read: F) -> T
|
||||
where
|
||||
F: FnOnce(&Editor, &AppContext) -> T,
|
||||
{
|
||||
self.editor.read_with(self.cx, read)
|
||||
}
|
||||
|
||||
pub fn update_editor<F, T>(&mut self, update: F) -> T
|
||||
where
|
||||
F: FnOnce(&mut Editor, &mut ViewContext<Editor>) -> T,
|
||||
{
|
||||
self.editor.update(self.cx, update)
|
||||
}
|
||||
|
||||
pub fn multibuffer<F, T>(&self, read: F) -> T
|
||||
where
|
||||
F: FnOnce(&MultiBuffer, &AppContext) -> T,
|
||||
{
|
||||
self.editor(|editor, cx| read(editor.buffer().read(cx), cx))
|
||||
}
|
||||
|
||||
pub fn update_multibuffer<F, T>(&mut self, update: F) -> T
|
||||
where
|
||||
F: FnOnce(&mut MultiBuffer, &mut ModelContext<MultiBuffer>) -> T,
|
||||
{
|
||||
self.update_editor(|editor, cx| editor.buffer().update(cx, update))
|
||||
}
|
||||
|
||||
pub fn buffer_text(&self) -> String {
|
||||
self.multibuffer(|buffer, cx| buffer.snapshot(cx).text())
|
||||
}
|
||||
|
||||
pub fn buffer<F, T>(&self, read: F) -> T
|
||||
where
|
||||
F: FnOnce(&Buffer, &AppContext) -> T,
|
||||
{
|
||||
self.multibuffer(|multibuffer, cx| {
|
||||
let buffer = multibuffer.as_singleton().unwrap().read(cx);
|
||||
read(buffer, cx)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_buffer<F, T>(&mut self, update: F) -> T
|
||||
where
|
||||
F: FnOnce(&mut Buffer, &mut ModelContext<Buffer>) -> T,
|
||||
{
|
||||
self.update_multibuffer(|multibuffer, cx| {
|
||||
let buffer = multibuffer.as_singleton().unwrap();
|
||||
buffer.update(cx, update)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn buffer_snapshot(&self) -> BufferSnapshot {
|
||||
self.buffer(|buffer, _| buffer.snapshot())
|
||||
}
|
||||
|
||||
pub fn simulate_keystroke(&mut self, keystroke_text: &str) {
|
||||
let keystroke = Keystroke::parse(keystroke_text).unwrap();
|
||||
self.cx.dispatch_keystroke(self.window_id, keystroke, false);
|
||||
}
|
||||
|
||||
pub fn simulate_keystrokes<const COUNT: usize>(&mut self, keystroke_texts: [&str; COUNT]) {
|
||||
for keystroke_text in keystroke_texts.into_iter() {
|
||||
self.simulate_keystroke(keystroke_text);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ranges(&self, marked_text: &str) -> Vec<Range<usize>> {
|
||||
let (unmarked_text, ranges) = marked_text_ranges(marked_text, false);
|
||||
assert_eq!(self.buffer_text(), unmarked_text);
|
||||
ranges
|
||||
}
|
||||
|
||||
pub fn display_point(&mut self, marked_text: &str) -> DisplayPoint {
|
||||
let ranges = self.ranges(marked_text);
|
||||
let snapshot = self
|
||||
.editor
|
||||
.update(self.cx, |editor, cx| editor.snapshot(cx));
|
||||
ranges[0].start.to_display_point(&snapshot)
|
||||
}
|
||||
|
||||
// Returns anchors for the current buffer using `«` and `»`
|
||||
pub fn text_anchor_range(&self, marked_text: &str) -> Range<language::Anchor> {
|
||||
let ranges = self.ranges(marked_text);
|
||||
let snapshot = self.buffer_snapshot();
|
||||
snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end)
|
||||
}
|
||||
|
||||
/// Change the editor's text and selections using a string containing
|
||||
/// embedded range markers that represent the ranges and directions of
|
||||
/// each selection.
|
||||
///
|
||||
/// See the `util::test::marked_text_ranges` function for more information.
|
||||
pub fn set_state(&mut self, marked_text: &str) {
|
||||
let (unmarked_text, selection_ranges) = marked_text_ranges(marked_text, true);
|
||||
self.editor.update(self.cx, |editor, cx| {
|
||||
editor.set_text(unmarked_text, cx);
|
||||
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
|
||||
s.select_ranges(selection_ranges)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Make an assertion about the editor's text and the ranges and directions
|
||||
/// of its selections using a string containing embedded range markers.
|
||||
///
|
||||
/// See the `util::test::marked_text_ranges` function for more information.
|
||||
pub fn assert_editor_state(&mut self, marked_text: &str) {
|
||||
let (unmarked_text, expected_selections) = marked_text_ranges(marked_text, true);
|
||||
let buffer_text = self.buffer_text();
|
||||
assert_eq!(
|
||||
buffer_text, unmarked_text,
|
||||
"Unmarked text doesn't match buffer text"
|
||||
);
|
||||
self.assert_selections(expected_selections, marked_text.to_string())
|
||||
}
|
||||
|
||||
pub fn assert_editor_background_highlights<Tag: 'static>(&mut self, marked_text: &str) {
|
||||
let expected_ranges = self.ranges(marked_text);
|
||||
let actual_ranges: Vec<Range<usize>> = self.update_editor(|editor, cx| {
|
||||
let snapshot = editor.snapshot(cx);
|
||||
editor
|
||||
.background_highlights
|
||||
.get(&TypeId::of::<Tag>())
|
||||
.map(|h| h.1.clone())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|range| range.to_offset(&snapshot.buffer_snapshot))
|
||||
.collect()
|
||||
});
|
||||
assert_set_eq!(actual_ranges, expected_ranges);
|
||||
}
|
||||
|
||||
pub fn assert_editor_text_highlights<Tag: ?Sized + 'static>(&mut self, marked_text: &str) {
|
||||
let expected_ranges = self.ranges(marked_text);
|
||||
let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
|
||||
let actual_ranges: Vec<Range<usize>> = snapshot
|
||||
.highlight_ranges::<Tag>()
|
||||
.map(|ranges| ranges.as_ref().clone().1)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|range| range.to_offset(&snapshot.buffer_snapshot))
|
||||
.collect();
|
||||
assert_set_eq!(actual_ranges, expected_ranges);
|
||||
}
|
||||
|
||||
pub fn assert_editor_selections(&mut self, expected_selections: Vec<Range<usize>>) {
|
||||
let expected_marked_text =
|
||||
generate_marked_text(&self.buffer_text(), &expected_selections, true);
|
||||
self.assert_selections(expected_selections, expected_marked_text)
|
||||
}
|
||||
|
||||
fn assert_selections(
|
||||
&mut self,
|
||||
expected_selections: Vec<Range<usize>>,
|
||||
expected_marked_text: String,
|
||||
) {
|
||||
let actual_selections = self
|
||||
.editor
|
||||
.read_with(self.cx, |editor, cx| editor.selections.all::<usize>(cx))
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
if s.reversed {
|
||||
s.end..s.start
|
||||
} else {
|
||||
s.start..s.end
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let actual_marked_text =
|
||||
generate_marked_text(&self.buffer_text(), &actual_selections, true);
|
||||
if expected_selections != actual_selections {
|
||||
panic!(
|
||||
indoc! {"
|
||||
Editor has unexpected selections.
|
||||
|
||||
Expected selections:
|
||||
{}
|
||||
|
||||
Actual selections:
|
||||
{}
|
||||
"},
|
||||
expected_marked_text, actual_marked_text,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Deref for EditorTestContext<'a> {
|
||||
type Target = gpui::TestAppContext;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.cx
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> DerefMut for EditorTestContext<'a> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.cx
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EditorLspTestContext<'a> {
|
||||
pub cx: EditorTestContext<'a>,
|
||||
pub lsp: lsp::FakeLanguageServer,
|
||||
pub workspace: ViewHandle<Workspace>,
|
||||
pub buffer_lsp_url: lsp::Url,
|
||||
}
|
||||
|
||||
impl<'a> EditorLspTestContext<'a> {
|
||||
pub async fn new(
|
||||
mut language: Language,
|
||||
capabilities: lsp::ServerCapabilities,
|
||||
cx: &'a mut gpui::TestAppContext,
|
||||
) -> EditorLspTestContext<'a> {
|
||||
use json::json;
|
||||
|
||||
cx.update(|cx| {
|
||||
crate::init(cx);
|
||||
pane::init(cx);
|
||||
});
|
||||
|
||||
let params = cx.update(AppState::test);
|
||||
|
||||
let file_name = format!(
|
||||
"file.{}",
|
||||
language
|
||||
.path_suffixes()
|
||||
.first()
|
||||
.unwrap_or(&"txt".to_string())
|
||||
);
|
||||
|
||||
let mut fake_servers = language
|
||||
.set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
|
||||
capabilities,
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
|
||||
let project = Project::test(params.fs.clone(), [], cx).await;
|
||||
project.update(cx, |project, _| project.languages().add(Arc::new(language)));
|
||||
|
||||
params
|
||||
.fs
|
||||
.as_fake()
|
||||
.insert_tree("/root", json!({ "dir": { file_name: "" }}))
|
||||
.await;
|
||||
|
||||
let (window_id, workspace) =
|
||||
cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
|
||||
project
|
||||
.update(cx, |project, cx| {
|
||||
project.find_or_create_local_worktree("/root", true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
|
||||
.await;
|
||||
|
||||
let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
|
||||
let item = workspace
|
||||
.update(cx, |workspace, cx| workspace.open_path(file, true, cx))
|
||||
.await
|
||||
.expect("Could not open test file");
|
||||
|
||||
let editor = cx.update(|cx| {
|
||||
item.act_as::<Editor>(cx)
|
||||
.expect("Opened test file wasn't an editor")
|
||||
});
|
||||
editor.update(cx, |_, cx| cx.focus_self());
|
||||
|
||||
let lsp = fake_servers.next().await.unwrap();
|
||||
|
||||
Self {
|
||||
cx: EditorTestContext {
|
||||
cx,
|
||||
window_id,
|
||||
editor,
|
||||
},
|
||||
lsp,
|
||||
workspace,
|
||||
buffer_lsp_url: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn new_rust(
|
||||
capabilities: lsp::ServerCapabilities,
|
||||
cx: &'a mut gpui::TestAppContext,
|
||||
) -> EditorLspTestContext<'a> {
|
||||
let language = Language::new(
|
||||
LanguageConfig {
|
||||
name: "Rust".into(),
|
||||
path_suffixes: vec!["rs".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
Some(tree_sitter_rust::language()),
|
||||
);
|
||||
|
||||
Self::new(language, capabilities, cx).await
|
||||
}
|
||||
|
||||
// Constructs lsp range using a marked string with '[', ']' range delimiters
|
||||
pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range {
|
||||
let ranges = self.ranges(marked_text);
|
||||
self.to_lsp_range(ranges[0].clone())
|
||||
}
|
||||
|
||||
pub fn to_lsp_range(&mut self, range: Range<usize>) -> lsp::Range {
|
||||
let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
|
||||
let start_point = range.start.to_point(&snapshot.buffer_snapshot);
|
||||
let end_point = range.end.to_point(&snapshot.buffer_snapshot);
|
||||
|
||||
self.editor(|editor, cx| {
|
||||
let buffer = editor.buffer().read(cx);
|
||||
let start = point_to_lsp(
|
||||
buffer
|
||||
.point_to_buffer_offset(start_point, cx)
|
||||
.unwrap()
|
||||
.1
|
||||
.to_point_utf16(&buffer.read(cx)),
|
||||
);
|
||||
let end = point_to_lsp(
|
||||
buffer
|
||||
.point_to_buffer_offset(end_point, cx)
|
||||
.unwrap()
|
||||
.1
|
||||
.to_point_utf16(&buffer.read(cx)),
|
||||
);
|
||||
|
||||
lsp::Range { start, end }
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_lsp(&mut self, offset: usize) -> lsp::Position {
|
||||
let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
|
||||
let point = offset.to_point(&snapshot.buffer_snapshot);
|
||||
|
||||
self.editor(|editor, cx| {
|
||||
let buffer = editor.buffer().read(cx);
|
||||
point_to_lsp(
|
||||
buffer
|
||||
.point_to_buffer_offset(point, cx)
|
||||
.unwrap()
|
||||
.1
|
||||
.to_point_utf16(&buffer.read(cx)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_workspace<F, T>(&mut self, update: F) -> T
|
||||
where
|
||||
F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>) -> T,
|
||||
{
|
||||
self.workspace.update(self.cx.cx, update)
|
||||
}
|
||||
|
||||
pub fn handle_request<T, F, Fut>(
|
||||
&self,
|
||||
mut handler: F,
|
||||
) -> futures::channel::mpsc::UnboundedReceiver<()>
|
||||
where
|
||||
T: 'static + request::Request,
|
||||
T::Params: 'static + Send,
|
||||
F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut,
|
||||
Fut: 'static + Send + Future<Output = Result<T::Result>>,
|
||||
{
|
||||
let url = self.buffer_lsp_url.clone();
|
||||
self.lsp.handle_request::<T, _, _>(move |params, cx| {
|
||||
let url = url.clone();
|
||||
handler(url, params, cx)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn notify<T: notification::Notification>(&self, params: T::Params) {
|
||||
self.lsp.notify::<T>(params);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Deref for EditorLspTestContext<'a> {
|
||||
type Target = EditorTestContext<'a>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.cx
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> DerefMut for EditorLspTestContext<'a> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.cx
|
||||
}
|
||||
}
|
||||
|
||||
208
crates/editor/src/test/editor_lsp_test_context.rs
Normal file
208
crates/editor/src/test/editor_lsp_test_context.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use std::{
|
||||
ops::{Deref, DerefMut, Range},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use futures::Future;
|
||||
use gpui::{json, ViewContext, ViewHandle};
|
||||
use language::{point_to_lsp, FakeLspAdapter, Language, LanguageConfig};
|
||||
use lsp::{notification, request};
|
||||
use project::Project;
|
||||
use smol::stream::StreamExt;
|
||||
use workspace::{pane, AppState, Workspace, WorkspaceHandle};
|
||||
|
||||
use crate::{multi_buffer::ToPointUtf16, Editor, ToPoint};
|
||||
|
||||
use super::editor_test_context::EditorTestContext;
|
||||
|
||||
pub struct EditorLspTestContext<'a> {
|
||||
pub cx: EditorTestContext<'a>,
|
||||
pub lsp: lsp::FakeLanguageServer,
|
||||
pub workspace: ViewHandle<Workspace>,
|
||||
pub buffer_lsp_url: lsp::Url,
|
||||
}
|
||||
|
||||
impl<'a> EditorLspTestContext<'a> {
|
||||
pub async fn new(
|
||||
mut language: Language,
|
||||
capabilities: lsp::ServerCapabilities,
|
||||
cx: &'a mut gpui::TestAppContext,
|
||||
) -> EditorLspTestContext<'a> {
|
||||
use json::json;
|
||||
|
||||
cx.update(|cx| {
|
||||
crate::init(cx);
|
||||
pane::init(cx);
|
||||
});
|
||||
|
||||
let params = cx.update(AppState::test);
|
||||
|
||||
let file_name = format!(
|
||||
"file.{}",
|
||||
language
|
||||
.path_suffixes()
|
||||
.first()
|
||||
.unwrap_or(&"txt".to_string())
|
||||
);
|
||||
|
||||
let mut fake_servers = language
|
||||
.set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
|
||||
capabilities,
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
|
||||
let project = Project::test(params.fs.clone(), [], cx).await;
|
||||
project.update(cx, |project, _| project.languages().add(Arc::new(language)));
|
||||
|
||||
params
|
||||
.fs
|
||||
.as_fake()
|
||||
.insert_tree("/root", json!({ "dir": { file_name: "" }}))
|
||||
.await;
|
||||
|
||||
let (window_id, workspace) =
|
||||
cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
|
||||
project
|
||||
.update(cx, |project, cx| {
|
||||
project.find_or_create_local_worktree("/root", true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
|
||||
.await;
|
||||
|
||||
let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
|
||||
let item = workspace
|
||||
.update(cx, |workspace, cx| workspace.open_path(file, true, cx))
|
||||
.await
|
||||
.expect("Could not open test file");
|
||||
|
||||
let editor = cx.update(|cx| {
|
||||
item.act_as::<Editor>(cx)
|
||||
.expect("Opened test file wasn't an editor")
|
||||
});
|
||||
editor.update(cx, |_, cx| cx.focus_self());
|
||||
|
||||
let lsp = fake_servers.next().await.unwrap();
|
||||
|
||||
Self {
|
||||
cx: EditorTestContext {
|
||||
cx,
|
||||
window_id,
|
||||
editor,
|
||||
},
|
||||
lsp,
|
||||
workspace,
|
||||
buffer_lsp_url: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn new_rust(
|
||||
capabilities: lsp::ServerCapabilities,
|
||||
cx: &'a mut gpui::TestAppContext,
|
||||
) -> EditorLspTestContext<'a> {
|
||||
let language = Language::new(
|
||||
LanguageConfig {
|
||||
name: "Rust".into(),
|
||||
path_suffixes: vec!["rs".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
Some(tree_sitter_rust::language()),
|
||||
);
|
||||
|
||||
Self::new(language, capabilities, cx).await
|
||||
}
|
||||
|
||||
// Constructs lsp range using a marked string with '[', ']' range delimiters
|
||||
pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range {
|
||||
let ranges = self.ranges(marked_text);
|
||||
self.to_lsp_range(ranges[0].clone())
|
||||
}
|
||||
|
||||
pub fn to_lsp_range(&mut self, range: Range<usize>) -> lsp::Range {
|
||||
let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
|
||||
let start_point = range.start.to_point(&snapshot.buffer_snapshot);
|
||||
let end_point = range.end.to_point(&snapshot.buffer_snapshot);
|
||||
|
||||
self.editor(|editor, cx| {
|
||||
let buffer = editor.buffer().read(cx);
|
||||
let start = point_to_lsp(
|
||||
buffer
|
||||
.point_to_buffer_offset(start_point, cx)
|
||||
.unwrap()
|
||||
.1
|
||||
.to_point_utf16(&buffer.read(cx)),
|
||||
);
|
||||
let end = point_to_lsp(
|
||||
buffer
|
||||
.point_to_buffer_offset(end_point, cx)
|
||||
.unwrap()
|
||||
.1
|
||||
.to_point_utf16(&buffer.read(cx)),
|
||||
);
|
||||
|
||||
lsp::Range { start, end }
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_lsp(&mut self, offset: usize) -> lsp::Position {
|
||||
let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
|
||||
let point = offset.to_point(&snapshot.buffer_snapshot);
|
||||
|
||||
self.editor(|editor, cx| {
|
||||
let buffer = editor.buffer().read(cx);
|
||||
point_to_lsp(
|
||||
buffer
|
||||
.point_to_buffer_offset(point, cx)
|
||||
.unwrap()
|
||||
.1
|
||||
.to_point_utf16(&buffer.read(cx)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_workspace<F, T>(&mut self, update: F) -> T
|
||||
where
|
||||
F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>) -> T,
|
||||
{
|
||||
self.workspace.update(self.cx.cx, update)
|
||||
}
|
||||
|
||||
pub fn handle_request<T, F, Fut>(
|
||||
&self,
|
||||
mut handler: F,
|
||||
) -> futures::channel::mpsc::UnboundedReceiver<()>
|
||||
where
|
||||
T: 'static + request::Request,
|
||||
T::Params: 'static + Send,
|
||||
F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut,
|
||||
Fut: 'static + Send + Future<Output = Result<T::Result>>,
|
||||
{
|
||||
let url = self.buffer_lsp_url.clone();
|
||||
self.lsp.handle_request::<T, _, _>(move |params, cx| {
|
||||
let url = url.clone();
|
||||
handler(url, params, cx)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn notify<T: notification::Notification>(&self, params: T::Params) {
|
||||
self.lsp.notify::<T>(params);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Deref for EditorLspTestContext<'a> {
|
||||
type Target = EditorTestContext<'a>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.cx
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> DerefMut for EditorLspTestContext<'a> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.cx
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user