mirror of
https://github.com/xpipe-io/xpipe.git
synced 2025-04-20 03:03:39 +00:00
Compare commits
2230 commits
early-acce
...
master
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a49d1baf87 | ||
![]() |
fe8d8fe383 | ||
![]() |
ab71d178f3 | ||
![]() |
9318a24120 | ||
![]() |
a69ecdc94c | ||
![]() |
937d59a27a | ||
![]() |
f648d63f4b | ||
![]() |
881452ccd0 | ||
![]() |
c7bde460af | ||
![]() |
12fe24a695 | ||
![]() |
3c13cf5e2a | ||
![]() |
bc0e14a332 | ||
![]() |
10dab33ed4 | ||
![]() |
c67411815e | ||
![]() |
74bedf630c | ||
![]() |
93413c3da1 | ||
![]() |
b39c74b4ff | ||
![]() |
474d201708 | ||
![]() |
5c6acc50e4 | ||
![]() |
0c0d91b67d | ||
![]() |
894b93e3c2 | ||
![]() |
8c516c043c | ||
![]() |
9dc368ae7b | ||
![]() |
f5543beb71 | ||
![]() |
8c51eac399 | ||
![]() |
d4a713f29b | ||
![]() |
c738331acd | ||
![]() |
25fc3ca0c1 | ||
![]() |
e1ebd24c04 | ||
![]() |
96fb6b579b | ||
![]() |
a01756562d | ||
![]() |
ad0e628b60 | ||
![]() |
a7f33dd0d2 | ||
![]() |
5b6dadedfe | ||
![]() |
45ef2eda8c | ||
![]() |
b4d1a9e68b | ||
![]() |
086237f965 | ||
![]() |
0417ce635e | ||
![]() |
fa02ee1bc2 | ||
![]() |
7e2663c6ea | ||
![]() |
39e6e66b16 | ||
![]() |
7b9afaef08 | ||
![]() |
a130bcfa91 | ||
![]() |
6084fb8d4d | ||
![]() |
72a5c67aab | ||
![]() |
e0bdf3f52d | ||
![]() |
e4b01ccf0b | ||
![]() |
516cfced4a | ||
![]() |
40e2015780 | ||
![]() |
fe632cd474 | ||
![]() |
8ee9c25f5f | ||
![]() |
e1d0642557 | ||
![]() |
6f29cfd637 | ||
![]() |
4f15a39280 | ||
![]() |
10c8ba62e7 | ||
![]() |
28dd386752 | ||
![]() |
ca1559937b | ||
![]() |
62ef05f707 | ||
![]() |
e78a791c06 | ||
![]() |
72f2decd75 | ||
![]() |
d93eacdb9b | ||
![]() |
795a3dde7c | ||
![]() |
4ecc55443a | ||
![]() |
51c8fff9bd | ||
![]() |
25e2ddd2a3 | ||
![]() |
5473474334 | ||
![]() |
c94e270eee | ||
![]() |
8850a78a36 | ||
![]() |
bffe75a540 | ||
![]() |
484028f22f | ||
![]() |
b2ac3c1fba | ||
![]() |
12c414eecc | ||
![]() |
c42b6d8439 | ||
![]() |
05d93c68ee | ||
![]() |
ec3b95c11b | ||
![]() |
e69da5d5b6 | ||
![]() |
c265b6b87d | ||
![]() |
9844cadbdd | ||
![]() |
1c42605650 | ||
![]() |
5b454cd8cd | ||
![]() |
d7cb5967c6 | ||
![]() |
a98425f100 | ||
![]() |
793ca373aa | ||
![]() |
b0a7f9d17e | ||
![]() |
55e7c65462 | ||
![]() |
c57000acee | ||
![]() |
ff5941249d | ||
![]() |
48450490c3 | ||
![]() |
6cc46ed08f | ||
![]() |
03d222e5f5 | ||
![]() |
b2efb8ddfa | ||
![]() |
f5f14a441f | ||
![]() |
3b84e5e480 | ||
![]() |
0af114ca8b | ||
![]() |
ca5a3a63c6 | ||
![]() |
09a0323069 | ||
![]() |
69524fb140 | ||
![]() |
8dd23fde64 | ||
![]() |
9ce9c31674 | ||
![]() |
907b6b8033 | ||
![]() |
35eb46df84 | ||
![]() |
287e0031a0 | ||
![]() |
1c933cd34f | ||
![]() |
5f0732f5ae | ||
![]() |
2643a47116 | ||
![]() |
50565d28ed | ||
![]() |
d66571b799 | ||
![]() |
b77921549b | ||
![]() |
39ee41e7b4 | ||
![]() |
ee418ed0ff | ||
![]() |
a898341011 | ||
![]() |
e4d29c441f | ||
![]() |
afaf206a42 | ||
![]() |
572a0f0341 | ||
![]() |
ab5fa40828 | ||
![]() |
78f6278e2b | ||
![]() |
98a7a9ae0e | ||
![]() |
04f17c6978 | ||
![]() |
9870a42744 | ||
![]() |
64069c7085 | ||
![]() |
95e5816e1b | ||
![]() |
da30fbd302 | ||
![]() |
97356b45d5 | ||
![]() |
df8f3d1f8a | ||
![]() |
be04a8f700 | ||
![]() |
2f7a2e1b19 | ||
![]() |
cefedcd0c8 | ||
![]() |
0329a87ffd | ||
![]() |
565f2f4cdd | ||
![]() |
70c9152dd6 | ||
![]() |
4f30d5fd14 | ||
![]() |
43f4bfeb0a | ||
![]() |
ee270b6cdc | ||
![]() |
0bd1279399 | ||
![]() |
a01dac767a | ||
![]() |
bbca5bafb3 | ||
![]() |
635a37c7ea | ||
![]() |
a38a8ed8a8 | ||
![]() |
ee82a912fa | ||
![]() |
6958a4979c | ||
![]() |
3a90a2a67a | ||
![]() |
8cdeb54115 | ||
![]() |
574399bf15 | ||
![]() |
671216a04e | ||
![]() |
6e541006c3 | ||
![]() |
c53ac3eba5 | ||
![]() |
34b576e9bb | ||
![]() |
3df09a7eef | ||
![]() |
8005d4b225 | ||
![]() |
c7a147883a | ||
![]() |
241954bdd9 | ||
![]() |
29ba973217 | ||
![]() |
3babf16a5b | ||
![]() |
d84e2fe0ce | ||
![]() |
996423c74e | ||
![]() |
09da9c59cb | ||
![]() |
da6c0600e5 | ||
![]() |
98c1090917 | ||
![]() |
73a2beceac | ||
![]() |
69e959408e | ||
![]() |
04aab314da | ||
![]() |
d6002fa5fc | ||
![]() |
8f653880ba | ||
![]() |
cdfd7dc67d | ||
![]() |
28c039d359 | ||
![]() |
af18da6ead | ||
![]() |
28391e5847 | ||
![]() |
41ecefd090 | ||
![]() |
f2ea4cc4d7 | ||
![]() |
dea1b0ff47 | ||
![]() |
2508fc9ca0 | ||
![]() |
bb69e47cb0 | ||
![]() |
3b2ba7777c | ||
![]() |
f2ba052266 | ||
![]() |
22a9666abb | ||
![]() |
c59446f68d | ||
![]() |
7eab8cb670 | ||
![]() |
4725d28885 | ||
![]() |
d6e977e7a5 | ||
![]() |
3edf7cc17c | ||
![]() |
a7ad65c0a4 | ||
![]() |
41a652e03d | ||
![]() |
c758743912 | ||
![]() |
61cbdc6e6a | ||
![]() |
46c49d164f | ||
![]() |
1d1ceb07f4 | ||
![]() |
5f020c8fe5 | ||
![]() |
0511acc80a | ||
![]() |
2a5f59f327 | ||
![]() |
56ce66d1e9 | ||
![]() |
77fbb24817 | ||
![]() |
98a0abf4ef | ||
![]() |
a1ea4605ab | ||
![]() |
493e041fb9 | ||
![]() |
4638885be8 | ||
![]() |
c7e00238d9 | ||
![]() |
2197cbc909 | ||
![]() |
020c6d9d4b | ||
![]() |
4a115fc411 | ||
![]() |
f6502c664c | ||
![]() |
eb67516209 | ||
![]() |
0a96061b1b | ||
![]() |
5896ea0288 | ||
![]() |
d8155769d9 | ||
![]() |
d76a799969 | ||
![]() |
9e4a205d9a | ||
![]() |
cb937200bc | ||
![]() |
9b613b57ed | ||
![]() |
770cf6d1f3 | ||
![]() |
dae161f0ec | ||
![]() |
d5b36619fc | ||
![]() |
b494c69ded | ||
![]() |
c7171ce204 | ||
![]() |
99383f23c0 | ||
![]() |
5f99ea41fe | ||
![]() |
db4ca6c403 | ||
![]() |
1db6c4edc4 | ||
![]() |
6a2a69f8f6 | ||
![]() |
ddfef15cb4 | ||
![]() |
5f299090d4 | ||
![]() |
6e6e16330c | ||
![]() |
9932482556 | ||
![]() |
453ce21ded | ||
![]() |
bae90df7dc | ||
![]() |
5877d741b0 | ||
![]() |
833a163e71 | ||
![]() |
f7f9c46931 | ||
![]() |
3704c9f737 | ||
![]() |
5c0594817e | ||
![]() |
7cd1f75fcc | ||
![]() |
d9cfe07e53 | ||
![]() |
63ba8d830c | ||
![]() |
1b9e60c6ad | ||
![]() |
cc8b9255cc | ||
![]() |
26804e9813 | ||
![]() |
eb9e0888ca | ||
![]() |
2a0f5f75f0 | ||
![]() |
0cb10b4a0b | ||
![]() |
0fac04a13f | ||
![]() |
5888801bcf | ||
![]() |
50c7bc3181 | ||
![]() |
8518e051de | ||
![]() |
b8e358ed89 | ||
![]() |
2a8a10c4d8 | ||
![]() |
30f9cf15aa | ||
![]() |
bd4ed83e8c | ||
![]() |
b578faccc9 | ||
![]() |
45f6545fc8 | ||
![]() |
48c9f96c03 | ||
![]() |
ed24ad9138 | ||
![]() |
d584a2f74d | ||
![]() |
9f18f7b48e | ||
![]() |
f98c6e47c0 | ||
![]() |
60a007b754 | ||
![]() |
dff204809d | ||
![]() |
b2427fb956 | ||
![]() |
a34edcd497 | ||
![]() |
d07a2e677d | ||
![]() |
b17e57ef5f | ||
![]() |
e1941f83da | ||
![]() |
db5f30cfff | ||
![]() |
12867d1fa3 | ||
![]() |
fe145d3a26 | ||
![]() |
cc8b74be9d | ||
![]() |
3d9f5da20d | ||
![]() |
df21e005c9 | ||
![]() |
c7158a9b94 | ||
![]() |
252b7ca52e | ||
![]() |
aff06ee726 | ||
![]() |
9e8744a1b2 | ||
![]() |
6739219e55 | ||
![]() |
345ab32c75 | ||
![]() |
82253ef4cc | ||
![]() |
8661018619 | ||
![]() |
4e88ed2fff | ||
![]() |
22f14ba6d0 | ||
![]() |
4884205df0 | ||
![]() |
09f1157ae8 | ||
![]() |
68e7abd352 | ||
![]() |
75c624d3c0 | ||
![]() |
95a0a97909 | ||
![]() |
550b8402b5 | ||
![]() |
64dc77f4f5 | ||
![]() |
933bfb9ae2 | ||
![]() |
dc9b40acda | ||
![]() |
315d411c1b | ||
![]() |
af411fa0de | ||
![]() |
b18f44eaf5 | ||
![]() |
51288823e6 | ||
![]() |
f5640797f0 | ||
![]() |
04f39cd0a7 | ||
![]() |
7f85600b13 | ||
![]() |
372dd155f6 | ||
![]() |
e9be54f015 | ||
![]() |
66b6fae017 | ||
![]() |
3211896045 | ||
![]() |
9055b56c3d | ||
![]() |
8541643fc9 | ||
![]() |
ecdf5119b0 | ||
![]() |
9b20fe7e8e | ||
![]() |
b5a030e615 | ||
![]() |
c51bb1723e | ||
![]() |
bc7d601747 | ||
![]() |
7c14e91749 | ||
![]() |
9f08b82e7d | ||
![]() |
9403ebf204 | ||
![]() |
7975a735ef | ||
![]() |
4693213589 | ||
![]() |
520b4d4882 | ||
![]() |
65a4cc424b | ||
![]() |
3be6f70272 | ||
![]() |
b1d8ec2de9 | ||
![]() |
bb0e049835 | ||
![]() |
82e6b7a035 | ||
![]() |
3df5a1f697 | ||
![]() |
e76371518f | ||
![]() |
57ebe195cf | ||
![]() |
5868fcfd33 | ||
![]() |
6dabe53011 | ||
![]() |
7229f98051 | ||
![]() |
323ca02a43 | ||
![]() |
029fd78580 | ||
![]() |
5e88e84185 | ||
![]() |
4f3e7d2028 | ||
![]() |
6d748b24e6 | ||
![]() |
aae09c255f | ||
![]() |
01bac9d5fa | ||
![]() |
5f2cce508d | ||
![]() |
4a00ce19ae | ||
![]() |
f0fe0d7a6e | ||
![]() |
c084a55d15 | ||
![]() |
0c85ba0425 | ||
![]() |
1dd08fce8d | ||
![]() |
8a964cadad | ||
![]() |
63bef7161a | ||
![]() |
e80cf7e4b0 | ||
![]() |
411e9c17b6 | ||
![]() |
2596177ad7 | ||
![]() |
3f61701132 | ||
![]() |
fe7dd6aa71 | ||
![]() |
5346ef2ca5 | ||
![]() |
5e2ee60ce5 | ||
![]() |
0b9c62b285 | ||
![]() |
51d434c3e0 | ||
![]() |
4b297f1ce8 | ||
![]() |
e894390124 | ||
![]() |
d4ad1d2e82 | ||
![]() |
bd06633931 | ||
![]() |
62cb424040 | ||
![]() |
9dc5cf8048 | ||
![]() |
593e0eb7b7 | ||
![]() |
a7a3b22dcd | ||
![]() |
89885c11b8 | ||
![]() |
2680066c7f | ||
![]() |
2191ccbfcc | ||
![]() |
b7d67ba9c5 | ||
![]() |
fd3c6f034c | ||
![]() |
99999eeaec | ||
![]() |
60e0b124db | ||
![]() |
b35b63708d | ||
![]() |
7f334c21e9 | ||
![]() |
aa62161b8f | ||
![]() |
2db320e1bd | ||
![]() |
c2427da60d | ||
![]() |
a77f1b5fb6 | ||
![]() |
e7c7886cba | ||
![]() |
10ae05f887 | ||
![]() |
bfd583e325 | ||
![]() |
531d019b54 | ||
![]() |
41786d99cd | ||
![]() |
89b245a27c | ||
![]() |
30212c6a87 | ||
![]() |
99d867965b | ||
![]() |
b7b3acf168 | ||
![]() |
6d0bb39786 | ||
![]() |
da1944ef3d | ||
![]() |
ae93c49689 | ||
![]() |
830745adbe | ||
![]() |
f13eac1c75 | ||
![]() |
8c6333beb9 | ||
![]() |
cb413ee358 | ||
![]() |
a565c795cf | ||
![]() |
d7527b40e6 | ||
![]() |
c72ed034d1 | ||
![]() |
be464120fa | ||
![]() |
d0b62d4cbd | ||
![]() |
9d635e1224 | ||
![]() |
48523690b7 | ||
![]() |
1924bd1644 | ||
![]() |
3c808810a1 | ||
![]() |
a97fd10e9c | ||
![]() |
09d7d6d0e7 | ||
![]() |
0283c6508f | ||
![]() |
cac46dc026 | ||
![]() |
671270ce40 | ||
![]() |
dbea577662 | ||
![]() |
19b341d848 | ||
![]() |
c430d9857c | ||
![]() |
b8a2d80b60 | ||
![]() |
7b601b8917 | ||
![]() |
02153ad23e | ||
![]() |
5c074615db | ||
![]() |
ccb8888df2 | ||
![]() |
feb8cf9165 | ||
![]() |
ca1a8e9132 | ||
![]() |
6a1efd1981 | ||
![]() |
aa89cf450e | ||
![]() |
1f56eb7e35 | ||
![]() |
94b5673565 | ||
![]() |
5a0e12aa13 | ||
![]() |
5db185168d | ||
![]() |
474606bcb3 | ||
![]() |
ba9ead724f | ||
![]() |
c38962ef2e | ||
![]() |
c5f99af866 | ||
![]() |
eec51705d1 | ||
![]() |
7d8f9facac | ||
![]() |
9092c7a509 | ||
![]() |
83b5b7b2de | ||
![]() |
29a12cb215 | ||
![]() |
ac9b0ea467 | ||
![]() |
db6349201f | ||
![]() |
1627f30a62 | ||
![]() |
f1c5337355 | ||
![]() |
19086e57d8 | ||
![]() |
8c60eea4a7 | ||
![]() |
439fc4dd03 | ||
![]() |
6602cf26a5 | ||
![]() |
a3674a12b5 | ||
![]() |
40597f5527 | ||
![]() |
a8e8c13f18 | ||
![]() |
44ba765e30 | ||
![]() |
44c59d7547 | ||
![]() |
0daec42954 | ||
![]() |
fa2a23ab36 | ||
![]() |
849b99002a | ||
![]() |
08f0001b9d | ||
![]() |
440664e56f | ||
![]() |
0a8e87e26a | ||
![]() |
b8ee5f2349 | ||
![]() |
1f8aab240d | ||
![]() |
64bb401a36 | ||
![]() |
9c435c55f4 | ||
![]() |
c7d38843da | ||
![]() |
b8a981eb1f | ||
![]() |
26762d4d80 | ||
![]() |
89e0b74606 | ||
![]() |
0c1f63d3c9 | ||
![]() |
9529dceb5c | ||
![]() |
a4a7a1ee86 | ||
![]() |
aa552f5837 | ||
![]() |
ac2c97301f | ||
![]() |
2d1e5298a9 | ||
![]() |
13c9644ff5 | ||
![]() |
82b501d4cc | ||
![]() |
ec258a1eaa | ||
![]() |
4f2ccea254 | ||
![]() |
7d1b02bb2f | ||
![]() |
89eba44c9d | ||
![]() |
3dab6a3d18 | ||
![]() |
6cc0771974 | ||
![]() |
3a700643f0 | ||
![]() |
2831e8372a | ||
![]() |
6cb9e1e553 | ||
![]() |
473fcfe843 | ||
![]() |
c06e6032c9 | ||
![]() |
e20a59c15f | ||
![]() |
a49077cc09 | ||
![]() |
14867ba09c | ||
![]() |
c1b5fbfa5f | ||
![]() |
542e535e42 | ||
![]() |
44c2f5db87 | ||
![]() |
38f18df272 | ||
![]() |
54f47a5a1f | ||
![]() |
030faaa601 | ||
![]() |
6c21529789 | ||
![]() |
7aa92b437f | ||
![]() |
6044cd3ead | ||
![]() |
41d419f19d | ||
![]() |
63dba2d33c | ||
![]() |
8ad12ff713 | ||
![]() |
27e6fc10e0 | ||
![]() |
d6cb3bf2bd | ||
![]() |
4adb18249b | ||
![]() |
7992ef7a64 | ||
![]() |
3ca8db6ed4 | ||
![]() |
be684d7b72 | ||
![]() |
a65a0bd1b0 | ||
![]() |
d92b870908 | ||
![]() |
417115c527 | ||
![]() |
ae2a2d10eb | ||
![]() |
c9a07dd061 | ||
![]() |
52eb584c9c | ||
![]() |
26ef089c44 | ||
![]() |
a08dba4d06 | ||
![]() |
4f1ddc6634 | ||
![]() |
7e3ac0cf2c | ||
![]() |
b5471e52d1 | ||
![]() |
ef5427f046 | ||
![]() |
1ebc60cb71 | ||
![]() |
6a946dbc5a | ||
![]() |
749f3e706e | ||
![]() |
4d1eadf712 | ||
![]() |
ab7e2f2220 | ||
![]() |
4ac19f24e1 | ||
![]() |
cb7ecd26af | ||
![]() |
ee78d152c5 | ||
![]() |
79a18260aa | ||
![]() |
d9bc91120e | ||
![]() |
5cda797d5a | ||
![]() |
9f597b1b06 | ||
![]() |
654fd3ae45 | ||
![]() |
eb178350dd | ||
![]() |
0ff69602e9 | ||
![]() |
ac221b3b85 | ||
![]() |
51121d2301 | ||
![]() |
da42eb578f | ||
![]() |
20206b6263 | ||
![]() |
41f71d45a7 | ||
![]() |
9b7cca8589 | ||
![]() |
0ad8df23b6 | ||
![]() |
af71e3015d | ||
![]() |
7dc98b42ca | ||
![]() |
cd0df90dd4 | ||
![]() |
ced43f728b | ||
![]() |
e00ff07775 | ||
![]() |
860ae0ee60 | ||
![]() |
fbd1f1c5ce | ||
![]() |
de03207d90 | ||
![]() |
1caa6cad6b | ||
![]() |
99971caba5 | ||
![]() |
9860b0c10f | ||
![]() |
65b2be5709 | ||
![]() |
a26917b500 | ||
![]() |
675fb6972a | ||
![]() |
081dced632 | ||
![]() |
1140267809 | ||
![]() |
3996d330a6 | ||
![]() |
ebedd332bb | ||
![]() |
65f51b8c2e | ||
![]() |
35704db4c5 | ||
![]() |
b2a988ac06 | ||
![]() |
4640e10ac0 | ||
![]() |
544dc85e6f | ||
![]() |
0014bf6165 | ||
![]() |
ccb018a522 | ||
![]() |
0ee5ee2d88 | ||
![]() |
f580f39e86 | ||
![]() |
3357c73aea | ||
![]() |
f0a91e09c5 | ||
![]() |
bdc0b1352a | ||
![]() |
f04322d30d | ||
![]() |
97d9e7e6a1 | ||
![]() |
8ea0f98ae9 | ||
![]() |
4a84c07c56 | ||
![]() |
bfee4d8edc | ||
![]() |
aaa0c199fd | ||
![]() |
5e1b5fa0d2 | ||
![]() |
6fda1170b5 | ||
![]() |
bbbd8f353a | ||
![]() |
0426d83bef | ||
![]() |
b0b7c7e859 | ||
![]() |
a4b7d42f83 | ||
![]() |
17069267da | ||
![]() |
54f27a274f | ||
![]() |
f11b76fdff | ||
![]() |
7f62c94881 | ||
![]() |
68cf68ea70 | ||
![]() |
da15533220 | ||
![]() |
f15391a1a7 | ||
![]() |
dc93536be9 | ||
![]() |
2ca2eefb29 | ||
![]() |
d8d62c0eff | ||
![]() |
2f4d72c63f | ||
![]() |
d6b1d78e6e | ||
![]() |
37de0f30c6 | ||
![]() |
af77a09bd7 | ||
![]() |
382b07f751 | ||
![]() |
4f1e6ea5f1 | ||
![]() |
167d1c1da2 | ||
![]() |
490bd7953b | ||
![]() |
d4da792e49 | ||
![]() |
3e183a22f4 | ||
![]() |
3bf851e457 | ||
![]() |
3c722ffcd1 | ||
![]() |
e5dbee813c | ||
![]() |
d223c2af75 | ||
![]() |
e7611caf78 | ||
![]() |
e2957a1c67 | ||
![]() |
eef991c031 | ||
![]() |
227559a7df | ||
![]() |
97ff085e15 | ||
![]() |
65ba63aac2 | ||
![]() |
12e2679692 | ||
![]() |
5b01540c98 | ||
![]() |
bec339b7b3 | ||
![]() |
981c51e405 | ||
![]() |
5748bb4bfc | ||
![]() |
15fc94a15e | ||
![]() |
fbf307d0bd | ||
![]() |
031ff66387 | ||
![]() |
1061d407e5 | ||
![]() |
68e29efd1a | ||
![]() |
6887f9b4ca | ||
![]() |
2a2ffb408a | ||
![]() |
914a541ed8 | ||
![]() |
99b16a59e6 | ||
![]() |
bc5ed101ed | ||
![]() |
4d7fcbbef0 | ||
![]() |
74d1e5fb7e | ||
![]() |
1c791160c7 | ||
![]() |
9f27f0837d | ||
![]() |
45c3ba17d5 | ||
![]() |
708c2d0c3f | ||
![]() |
0632875c7d | ||
![]() |
64f58d37a4 | ||
![]() |
0e62af8817 | ||
![]() |
c6b304f68a | ||
![]() |
e9d9152d23 | ||
![]() |
58533aba5b | ||
![]() |
f3f63aa0f7 | ||
![]() |
adf98c5a8c | ||
![]() |
d4ae4abe61 | ||
![]() |
44d33d32f4 | ||
![]() |
f9008aa736 | ||
![]() |
e1f84d4a6b | ||
![]() |
43524d8101 | ||
![]() |
2d07615f5e | ||
![]() |
d67e2c813d | ||
![]() |
26eff90c2b | ||
![]() |
37879f034b | ||
![]() |
99adb0866f | ||
![]() |
21b7e063aa | ||
![]() |
972f106cb0 | ||
![]() |
47bfbcbce5 | ||
![]() |
a59a2275f3 | ||
![]() |
4abe8e81d4 | ||
![]() |
3f10abdea8 | ||
![]() |
73e61f48bd | ||
![]() |
ceee5d56b1 | ||
![]() |
0527dbee39 | ||
![]() |
67762a976b | ||
![]() |
18b6fa0338 | ||
![]() |
a1d564a947 | ||
![]() |
6541a84972 | ||
![]() |
ac07f21fd4 | ||
![]() |
53c36cd4c2 | ||
![]() |
4a2d8cc9b0 | ||
![]() |
fdbfcb6ddc | ||
![]() |
1893464432 | ||
![]() |
26c911c057 | ||
![]() |
4c64eb5ae5 | ||
![]() |
9f232aa7d1 | ||
![]() |
43a512bb30 | ||
![]() |
70ba263ec4 | ||
![]() |
935e28190c | ||
![]() |
9cf9340aca | ||
![]() |
8090c09e0c | ||
![]() |
8e8b9c5330 | ||
![]() |
ddac4cd402 | ||
![]() |
1af837b02e | ||
![]() |
9b20a5019f | ||
![]() |
235f90ad86 | ||
![]() |
5e8ddb881d | ||
![]() |
f5edcb69d3 | ||
![]() |
bbe3f09837 | ||
![]() |
2fb2ce35a0 | ||
![]() |
8ac507a3b5 | ||
![]() |
c1b2086e90 | ||
![]() |
3ef26d0b0b | ||
![]() |
cc88a0594c | ||
![]() |
c83b627307 | ||
![]() |
83a616916e | ||
![]() |
547b335410 | ||
![]() |
1172e0a949 | ||
![]() |
08722f3699 | ||
![]() |
beb2fff315 | ||
![]() |
ca4048570a | ||
![]() |
ddefa34167 | ||
![]() |
0c5617f9a0 | ||
![]() |
7960d9c6ae | ||
![]() |
a75a8e1cd7 | ||
![]() |
d2b509e32d | ||
![]() |
3b4aed5821 | ||
![]() |
30d28b7a19 | ||
![]() |
0329ee614c | ||
![]() |
f6a112510c | ||
![]() |
e26240ac1f | ||
![]() |
0620fe1d2a | ||
![]() |
21b6d71bf7 | ||
![]() |
8df1cf3ca0 | ||
![]() |
b5618f08a4 | ||
![]() |
ce5b943380 | ||
![]() |
fe81900429 | ||
![]() |
eac19b73ae | ||
![]() |
32c3f42aaa | ||
![]() |
d2aa28cfe4 | ||
![]() |
30f750d8f1 | ||
![]() |
61c3547497 | ||
![]() |
fa90137e39 | ||
![]() |
c986030e8e | ||
![]() |
a18d94fb8d | ||
![]() |
abbb8a0fcd | ||
![]() |
4b502aef74 | ||
![]() |
e6a7afd78d | ||
![]() |
e6b6d9b325 | ||
![]() |
f6c06b38c3 | ||
![]() |
fd848e26c1 | ||
![]() |
da6caf0c78 | ||
![]() |
d10397495e | ||
![]() |
5901a9ac5d | ||
![]() |
d6d5c8162f | ||
![]() |
d2c56c0acd | ||
![]() |
5ac45dce41 | ||
![]() |
bc6a25972d | ||
![]() |
aa91e263ca | ||
![]() |
5ca15b2af6 | ||
![]() |
2df431ae79 | ||
![]() |
1ff39b2182 | ||
![]() |
99056e924c | ||
![]() |
9d4903e665 | ||
![]() |
7f0d9746d1 | ||
![]() |
5f01430592 | ||
![]() |
95b716293f | ||
![]() |
dd7c48883f | ||
![]() |
f430210519 | ||
![]() |
06a7ef27b5 | ||
![]() |
dacd24a8e5 | ||
![]() |
7dc13a9a26 | ||
![]() |
c3334c18b5 | ||
![]() |
0de7d36c4f | ||
![]() |
2d636de52a | ||
![]() |
36379174fa | ||
![]() |
dbdcd590ad | ||
![]() |
140040468a | ||
![]() |
eeeef57b8f | ||
![]() |
d1b506415c | ||
![]() |
65fbe13113 | ||
![]() |
382532efb3 | ||
![]() |
9e62de47da | ||
![]() |
6cc7fa180c | ||
![]() |
93eb1075ac | ||
![]() |
f465e19edc | ||
![]() |
1e8699c4fb | ||
![]() |
de3179f968 | ||
![]() |
4f3f2bbfd2 | ||
![]() |
2d67443f22 | ||
![]() |
5e90f169b1 | ||
![]() |
33302fb75d | ||
![]() |
02b979de6b | ||
![]() |
0587dea4ac | ||
![]() |
05bb34eef5 | ||
![]() |
1611ed2743 | ||
![]() |
986c3299de | ||
![]() |
4c21c6a8c2 | ||
![]() |
4d43cc5fd0 | ||
![]() |
914f724577 | ||
![]() |
1f7174db86 | ||
![]() |
734fac9af6 | ||
![]() |
20093becf3 | ||
![]() |
adb621dab4 | ||
![]() |
c5608bd23c | ||
![]() |
33577ca7c1 | ||
![]() |
2810dc4372 | ||
![]() |
2b684c9e5e | ||
![]() |
1702d716cd | ||
![]() |
af4dd0602a | ||
![]() |
be6ed54afd | ||
![]() |
c2e3caa63d | ||
![]() |
f370c5f6cf | ||
![]() |
9090c128e9 | ||
![]() |
4bf33d7e59 | ||
![]() |
f675650701 | ||
![]() |
7b6d4d4dbf | ||
![]() |
368612b70f | ||
![]() |
8f49c35aca | ||
![]() |
5ce9538633 | ||
![]() |
4426eb2424 | ||
![]() |
bc9b962be9 | ||
![]() |
ae02fb3791 | ||
![]() |
ba7c83a1e8 | ||
![]() |
e14b38b31f | ||
![]() |
6f6b4a76d4 | ||
![]() |
f3557bb715 | ||
![]() |
e6b70ff60c | ||
![]() |
7fb54d4fe1 | ||
![]() |
da94bf250a | ||
![]() |
03cd25106c | ||
![]() |
9ad3e1975e | ||
![]() |
a5309f3614 | ||
![]() |
9c565de56b | ||
![]() |
d62047d56b | ||
![]() |
ec5342e50f | ||
![]() |
73524d471f | ||
![]() |
4d9e47b821 | ||
![]() |
8e8f10676a | ||
![]() |
ad7b001f1a | ||
![]() |
05c7028e1e | ||
![]() |
4ea51c1cf4 | ||
![]() |
80e487d0aa | ||
![]() |
a7d825be67 | ||
![]() |
d41b3017f6 | ||
![]() |
54f74c140f | ||
![]() |
c502e0f1b1 | ||
![]() |
ed5e3631c8 | ||
![]() |
8383c9f082 | ||
![]() |
5d45d9c25b | ||
![]() |
56087fec1c | ||
![]() |
f19aed4e0e | ||
![]() |
202dd0a876 | ||
![]() |
07caa29262 | ||
![]() |
7c6906d86e | ||
![]() |
89776bb6da | ||
![]() |
4ed6f05fbd | ||
![]() |
28bbaaf53e | ||
![]() |
7de6a51d73 | ||
![]() |
ef22578d71 | ||
![]() |
79d5dc370a | ||
![]() |
90e1b94944 | ||
![]() |
675c2e9d1e | ||
![]() |
4a351f8598 | ||
![]() |
a1575f4042 | ||
![]() |
8bc5ea0688 | ||
![]() |
b63f899a86 | ||
![]() |
ff9305da3c | ||
![]() |
c05d945694 | ||
![]() |
374a98d22d | ||
![]() |
54e1e6369c | ||
![]() |
59ce98d9db | ||
![]() |
6345be78a7 | ||
![]() |
856322e052 | ||
![]() |
b36ca8f557 | ||
![]() |
b0491bbd66 | ||
![]() |
bdce4576d4 | ||
![]() |
76207cb7cf | ||
![]() |
c73b7de9a6 | ||
![]() |
f4f3f21a70 | ||
![]() |
6dd0e6d255 | ||
![]() |
d77925540e | ||
![]() |
d155dcdeb2 | ||
![]() |
a09a3238cf | ||
![]() |
69dd83e2e4 | ||
![]() |
fd282267de | ||
![]() |
8d60945488 | ||
![]() |
14268d2324 | ||
![]() |
a2896aba76 | ||
![]() |
3e76404758 | ||
![]() |
2e7fae6bde | ||
![]() |
e32ee4da2f | ||
![]() |
4fc95833e5 | ||
![]() |
3ef04b17c9 | ||
![]() |
e63d044313 | ||
![]() |
100820b439 | ||
![]() |
2d38d62428 | ||
![]() |
37eb17ef0d | ||
![]() |
770a9a0263 | ||
![]() |
fe5686c05f | ||
![]() |
2a52591f54 | ||
![]() |
0d989431ae | ||
![]() |
911c85a004 | ||
![]() |
0c06fe99f7 | ||
![]() |
72be31435f | ||
![]() |
620c30382f | ||
![]() |
7507f664df | ||
![]() |
3363ad9f41 | ||
![]() |
63d9cce78e | ||
![]() |
05884660d7 | ||
![]() |
234048109f | ||
![]() |
6571f17e3b | ||
![]() |
933b3f6837 | ||
![]() |
26823e4728 | ||
![]() |
9e95c6b5b4 | ||
![]() |
9d4c4fe97d | ||
![]() |
b98ac6b4ea | ||
![]() |
7309763d42 | ||
![]() |
9ba4c3115a | ||
![]() |
33e75fec2a | ||
![]() |
e4f5738fa5 | ||
![]() |
1cabd5b93d | ||
![]() |
cb9145dd37 | ||
![]() |
ab13f17fe7 | ||
![]() |
8051412e9f | ||
![]() |
650398b541 | ||
![]() |
2d367aadb9 | ||
![]() |
7d0db085c0 | ||
![]() |
db4dc20c70 | ||
![]() |
48e9ece152 | ||
![]() |
12a2e85535 | ||
![]() |
fbf9902b58 | ||
![]() |
5d6c8b95bc | ||
![]() |
dcbbd211fe | ||
![]() |
60207dd24f | ||
![]() |
3bc1dc6cad | ||
![]() |
366a6e74e7 | ||
![]() |
9ad98e4638 | ||
![]() |
e5fdec3a5a | ||
![]() |
e5c2079264 | ||
![]() |
568d1c2e6f | ||
![]() |
fd629c62bc | ||
![]() |
6408390535 | ||
![]() |
c7f6bcf7d7 | ||
![]() |
7176f4dd0a | ||
![]() |
6fc48a7d74 | ||
![]() |
dc50b0b155 | ||
![]() |
733df4c005 | ||
![]() |
9c3eaa479c | ||
![]() |
bed38d425f | ||
![]() |
3d0dd67389 | ||
![]() |
b112d23163 | ||
![]() |
82003734b5 | ||
![]() |
b3f1f77bf2 | ||
![]() |
e079a7feb6 | ||
![]() |
c99e4c2749 | ||
![]() |
9291d46370 | ||
![]() |
1e867d150c | ||
![]() |
304cad9e4b | ||
![]() |
b14a00be7d | ||
![]() |
09ffad1ec6 | ||
![]() |
45e2e9a95c | ||
![]() |
88f3be6525 | ||
![]() |
8199800a4b | ||
![]() |
c8613350ff | ||
![]() |
8346ea90ba | ||
![]() |
9ce3e0b888 | ||
![]() |
36b1b73626 | ||
![]() |
da1b9c5c1a | ||
![]() |
1f8e411c1e | ||
![]() |
f7cedf54da | ||
![]() |
67059394f6 | ||
![]() |
bc3a4fcf15 | ||
![]() |
55007cfc52 | ||
![]() |
9b69f534eb | ||
![]() |
35841c403d | ||
![]() |
6edf216005 | ||
![]() |
984f565ed7 | ||
![]() |
4aea1694ff | ||
![]() |
adc277971b | ||
![]() |
04bca4295d | ||
![]() |
a974e7aac2 | ||
![]() |
627a8141cd | ||
![]() |
24600a98be | ||
![]() |
125d1ddbaf | ||
![]() |
a02d40cab6 | ||
![]() |
188e41e1ba | ||
![]() |
363f79f8ad | ||
![]() |
401ab34689 | ||
![]() |
4ca3d2c52c | ||
![]() |
8564b2e820 | ||
![]() |
8a822b1f4c | ||
![]() |
10d1382327 | ||
![]() |
a87cbb2344 | ||
![]() |
230729d9ab | ||
![]() |
1ddc58d254 | ||
![]() |
46de09ccd3 | ||
![]() |
eb873f2225 | ||
![]() |
409b24ff8a | ||
![]() |
a03f8d7e84 | ||
![]() |
0a3488324d | ||
![]() |
73bc99b3c6 | ||
![]() |
51f7e3f4f5 | ||
![]() |
ccb4e06809 | ||
![]() |
e5c1ebed1d | ||
![]() |
bae68e7ea8 | ||
![]() |
e04c63d36f | ||
![]() |
385068a3de | ||
![]() |
d9110b871c | ||
![]() |
5fbb1703ed | ||
![]() |
60413b7de8 | ||
![]() |
5d339d417e | ||
![]() |
5dacb65e1a | ||
![]() |
363cb80f88 | ||
![]() |
b43a3d6489 | ||
![]() |
7bb5c2a441 | ||
![]() |
b298d3cb81 | ||
![]() |
4b78b5e132 | ||
![]() |
c191f675e0 | ||
![]() |
059b12b654 | ||
![]() |
ba83a9c23a | ||
![]() |
5e80c1a4a1 | ||
![]() |
dd3ccf37d3 | ||
![]() |
d9e23b9ebf | ||
![]() |
7bce1e8f3b | ||
![]() |
f39ec1451b | ||
![]() |
6c0b3cb58c | ||
![]() |
4c26c84a70 | ||
![]() |
f585969233 | ||
![]() |
8a60b29674 | ||
![]() |
922633fb6e | ||
![]() |
8eac06c213 | ||
![]() |
bc1afe3f36 | ||
![]() |
2a6e993d6e | ||
![]() |
eb3794462f | ||
![]() |
cbc5ad473a | ||
![]() |
6bd105b1de | ||
![]() |
c86e65dea4 | ||
![]() |
78a7255bb8 | ||
![]() |
8357641eed | ||
![]() |
1457d003d2 | ||
![]() |
42c149a1ff | ||
![]() |
93786dc76c | ||
![]() |
6f612b82f9 | ||
![]() |
ac36f7043a | ||
![]() |
fa7b6d66bd | ||
![]() |
138dd5a56d | ||
![]() |
91e1a37cdb | ||
![]() |
3293f27e6f | ||
![]() |
3ebaf17c0d | ||
![]() |
fe1665eace | ||
![]() |
d3c173dbcd | ||
![]() |
10ef67a6ec | ||
![]() |
beb467af5a | ||
![]() |
a8f07c3e0f | ||
![]() |
c228e6ba30 | ||
![]() |
3f01bb0028 | ||
![]() |
9ad5b6f7f5 | ||
![]() |
061dbe1cf3 | ||
![]() |
e9b0a19509 | ||
![]() |
d8b9571de8 | ||
![]() |
65607aac53 | ||
![]() |
822cab45e7 | ||
![]() |
d3f4bd3044 | ||
![]() |
c475353c22 | ||
![]() |
6510c3f6b9 | ||
![]() |
e3c4d9a9ec | ||
![]() |
eb60590a50 | ||
![]() |
3cebf750fb | ||
![]() |
7e8d31dd3c | ||
![]() |
baee77d47f | ||
![]() |
76379b5a5d | ||
![]() |
92faa26bbc | ||
![]() |
b1a3caad46 | ||
![]() |
86ae1271a4 | ||
![]() |
07472cad9a | ||
![]() |
7784757393 | ||
![]() |
7a537f1a60 | ||
![]() |
9154a28aca | ||
![]() |
6e5f131658 | ||
![]() |
db94d2d74b | ||
![]() |
e90c6f36d3 | ||
![]() |
ce8c97dce6 | ||
![]() |
4fdf89b77d | ||
![]() |
d5442e858d | ||
![]() |
a44594ef5f | ||
![]() |
57352b566f | ||
![]() |
096666e345 | ||
![]() |
3848fb9d93 | ||
![]() |
a07a18d4f0 | ||
![]() |
0535a5444f | ||
![]() |
0b8fbfabe0 | ||
![]() |
7391310dce | ||
![]() |
147e68efe5 | ||
![]() |
ea7114178f | ||
![]() |
346855826e | ||
![]() |
9c79f245e1 | ||
![]() |
24b6efed96 | ||
![]() |
11bebd059a | ||
![]() |
529563aa24 | ||
![]() |
e345b1eaf2 | ||
![]() |
67dafeabf7 | ||
![]() |
474b529050 | ||
![]() |
3b6ad291a8 | ||
![]() |
f84c06dd8c | ||
![]() |
709d075893 | ||
![]() |
b10b7737a5 | ||
![]() |
4bbdd9315c | ||
![]() |
bdc1444519 | ||
![]() |
3dd7829b23 | ||
![]() |
cad35a6876 | ||
![]() |
e4d9545fe8 | ||
![]() |
9c90ec9336 | ||
![]() |
a329969590 | ||
![]() |
bced7bdc33 | ||
![]() |
e1002cf60f | ||
![]() |
11d6997752 | ||
![]() |
cde12be5c8 | ||
![]() |
50defba23d | ||
![]() |
863b0f8783 | ||
![]() |
1fb68f8ca1 | ||
![]() |
3a47755c73 | ||
![]() |
55441b737f | ||
![]() |
e89fb97d53 | ||
![]() |
c6cca15a9a | ||
![]() |
b9406a4904 | ||
![]() |
31f926f815 | ||
![]() |
c3d7d69476 | ||
![]() |
95b0398490 | ||
![]() |
19251dea9f | ||
![]() |
5af9ec6fde | ||
![]() |
92adaf47c3 | ||
![]() |
89d5d06b22 | ||
![]() |
f17b3c5ec5 | ||
![]() |
21a47de1fa | ||
![]() |
0149137abe | ||
![]() |
32515720fc | ||
![]() |
4320040c9a | ||
![]() |
6f4712934e | ||
![]() |
0b0e92e9da | ||
![]() |
a4faa7c04c | ||
![]() |
c2ac2481e4 | ||
![]() |
770c9eb309 | ||
![]() |
1a29d14a79 | ||
![]() |
cd0c8a1649 | ||
![]() |
bff56ed58c | ||
![]() |
c35fb24bf4 | ||
![]() |
74abedcb6f | ||
![]() |
6ad4f990d1 | ||
![]() |
1e7c2f9c93 | ||
![]() |
fe885d7e80 | ||
![]() |
64ee40afb0 | ||
![]() |
fbfe4bdf4b | ||
![]() |
ccea37e0e0 | ||
![]() |
74d6ff7fa3 | ||
![]() |
bb4e844da9 | ||
![]() |
965dfe04e8 | ||
![]() |
1c43bece80 | ||
![]() |
e70a04d588 | ||
![]() |
aeb921f161 | ||
![]() |
9ac9128712 | ||
![]() |
1b5d2b7741 | ||
![]() |
84ca2545f0 | ||
![]() |
0de5d9da4e | ||
![]() |
84700d2a27 | ||
![]() |
fe2334e936 | ||
![]() |
3a9d06c745 | ||
![]() |
c4f0797960 | ||
![]() |
9abd19483f | ||
![]() |
73efb183e7 | ||
![]() |
ee3f5babe6 | ||
![]() |
a557fcbe73 | ||
![]() |
d54fa908f8 | ||
![]() |
84378c18d8 | ||
![]() |
7821d0e779 | ||
![]() |
a7c046520c | ||
![]() |
bda50dba72 | ||
![]() |
d60fff969b | ||
![]() |
f1742ce37d | ||
![]() |
8483130b09 | ||
![]() |
7117b4f6f2 | ||
![]() |
6df1f70a19 | ||
![]() |
106132ef88 | ||
![]() |
97d7f4f30f | ||
![]() |
bb0f6fbeeb | ||
![]() |
30ebddc904 | ||
![]() |
86df8b8803 | ||
![]() |
b1753e589e | ||
![]() |
8591fe8562 | ||
![]() |
d0497fcba2 | ||
![]() |
2cda040274 | ||
![]() |
a9e00e8bc9 | ||
![]() |
3df16c22a4 | ||
![]() |
9f55deb1e2 | ||
![]() |
4ff0d59dba | ||
![]() |
27fb7370be | ||
![]() |
e7fc20b40d | ||
![]() |
bef976914f | ||
![]() |
405cdf53e5 | ||
![]() |
5ef3e1d508 | ||
![]() |
fb8e990083 | ||
![]() |
f42434c60e | ||
![]() |
37dae87a2f | ||
![]() |
ac1fcf821d | ||
![]() |
b847a6fb9f | ||
![]() |
99cd652536 | ||
![]() |
dbfcfe73f6 | ||
![]() |
fbc56f0b81 | ||
![]() |
e56c159e0e | ||
![]() |
d86925c1a3 | ||
![]() |
f7cf7284c7 | ||
![]() |
d2d0df9789 | ||
![]() |
3e7fbe89ac | ||
![]() |
ce45ff9ec6 | ||
![]() |
404a6e1948 | ||
![]() |
8a7669e832 | ||
![]() |
60111ddd21 | ||
![]() |
315a58675e | ||
![]() |
a11ad4d486 | ||
![]() |
715d36336c | ||
![]() |
2dead13d4d | ||
![]() |
1b7181a744 | ||
![]() |
e76a5f2e94 | ||
![]() |
f660c20188 | ||
![]() |
a4eef3fdf7 | ||
![]() |
afc34a37b5 | ||
![]() |
53926a2830 | ||
![]() |
2310515a4c | ||
![]() |
5ae35ecfe9 | ||
![]() |
f61bef8bc0 | ||
![]() |
809999394d | ||
![]() |
ebc1f33a56 | ||
![]() |
11859d3d10 | ||
![]() |
3f197337c6 | ||
![]() |
3b04427f3c | ||
![]() |
df1a80ed42 | ||
![]() |
79ef71a1d3 | ||
![]() |
2abd95e3e5 | ||
![]() |
ad19bc742b | ||
![]() |
00a36628e9 | ||
![]() |
a1ec6cb422 | ||
![]() |
ac865b4a92 | ||
![]() |
65cec08224 | ||
![]() |
41965a521b | ||
![]() |
acca222a06 | ||
![]() |
f33db9ca49 | ||
![]() |
a08d130314 | ||
![]() |
406e63cbf0 | ||
![]() |
25301c1ac1 | ||
![]() |
46cd57db18 | ||
![]() |
f467d59f49 | ||
![]() |
d9f1a5a769 | ||
![]() |
298cd28872 | ||
![]() |
2565bb250f | ||
![]() |
aa7c04d5ba | ||
![]() |
ff2e5e67c4 | ||
![]() |
f1966c0e39 | ||
![]() |
ab9059881c | ||
![]() |
37ed575266 | ||
![]() |
de6c71fc5d | ||
![]() |
916fd98773 | ||
![]() |
2ff1d57c21 | ||
![]() |
bd2acc6574 | ||
![]() |
dd54dccec7 | ||
![]() |
995dcca50c | ||
![]() |
3e07e890ea | ||
![]() |
057398c317 | ||
![]() |
47125b4615 | ||
![]() |
eef19677ab | ||
![]() |
45d3d40c6f | ||
![]() |
68b4cb2fcc | ||
![]() |
3e81d0a2d3 | ||
![]() |
edf22a0815 | ||
![]() |
c58ad29309 | ||
![]() |
1b1d388941 | ||
![]() |
974da59fc8 | ||
![]() |
54dff11353 | ||
![]() |
2c5d3b326d | ||
![]() |
140b734142 | ||
![]() |
faf1b6b4c7 | ||
![]() |
a6cb7b1a30 | ||
![]() |
522c2c1664 | ||
![]() |
e4d3eb2a3d | ||
![]() |
44945546f7 | ||
![]() |
720e5edfdc | ||
![]() |
f454e6d905 | ||
![]() |
277f7f8c04 | ||
![]() |
bfef4e1426 | ||
![]() |
0f9cae0681 | ||
![]() |
aee7b65bce | ||
![]() |
720716b1c6 | ||
![]() |
fb103e03df | ||
![]() |
950294fff1 | ||
![]() |
0742c0e2d2 | ||
![]() |
8e8186507a | ||
![]() |
4ee5c18960 | ||
![]() |
7521f08512 | ||
![]() |
16de992c29 | ||
![]() |
fcba5d1c54 | ||
![]() |
6c9fddebe6 | ||
![]() |
0070a08109 | ||
![]() |
06544d31e1 | ||
![]() |
c59b6bb1ff | ||
![]() |
c3cbe022b9 | ||
![]() |
362cb12e20 | ||
![]() |
d8b2c44489 | ||
![]() |
e357c96c5d | ||
![]() |
44cae41799 | ||
![]() |
0754e79970 | ||
![]() |
ab8b6d449e | ||
![]() |
0fef5b0b9a | ||
![]() |
8d080bee1e | ||
![]() |
784bff8031 | ||
![]() |
5aa1c35eb1 | ||
![]() |
4542225c3f | ||
![]() |
f398366246 | ||
![]() |
6cc2fd85a3 | ||
![]() |
1e0edf610e | ||
![]() |
8506047f6b | ||
![]() |
17917de665 | ||
![]() |
57c199e6d7 | ||
![]() |
9fa35aff21 | ||
![]() |
8a684caf1b | ||
![]() |
88086dc322 | ||
![]() |
6063e3b092 | ||
![]() |
ae52ab7e53 | ||
![]() |
f67e9ad247 | ||
![]() |
2bc508d448 | ||
![]() |
5f7a37279f | ||
![]() |
1a9d1647e0 | ||
![]() |
e9c9bb8a61 | ||
![]() |
4aa53bbd1d | ||
![]() |
d619f3aacb | ||
![]() |
38e1b929d6 | ||
![]() |
e3b451b600 | ||
![]() |
24c5b31a04 | ||
![]() |
ec6ef44451 | ||
![]() |
ca84b90dbd | ||
![]() |
815787c8a9 | ||
![]() |
aee414f27d | ||
![]() |
16071c0e75 | ||
![]() |
1757684c79 | ||
![]() |
bb1aa5c245 | ||
![]() |
fb3b768352 | ||
![]() |
43f2decca9 | ||
![]() |
82ada6da4a | ||
![]() |
f0deb25d31 | ||
![]() |
7970402411 | ||
![]() |
5f729095d8 | ||
![]() |
8ab8aaf100 | ||
![]() |
dd87d07e1f | ||
![]() |
a533de0300 | ||
![]() |
34ff540386 | ||
![]() |
75d6b1ed4f | ||
![]() |
a81ea216aa | ||
![]() |
d0d55a3a94 | ||
![]() |
f2c5561eac | ||
![]() |
f689e6a2ea | ||
![]() |
1c8a7a98c6 | ||
![]() |
722d239fda | ||
![]() |
d0d8e96e5d | ||
![]() |
e9c1622850 | ||
![]() |
6e3b044fdc | ||
![]() |
404cd744b2 | ||
![]() |
86e15cb6f2 | ||
![]() |
06186295e9 | ||
![]() |
829deddc1d | ||
![]() |
86758f06b7 | ||
![]() |
f980fdc45a | ||
![]() |
b212d32d40 | ||
![]() |
e8ba39f383 | ||
![]() |
4fecc1e58f | ||
![]() |
853e8a2b24 | ||
![]() |
dba1feb81f | ||
![]() |
7bf4b6f4db | ||
![]() |
cbcafb12e3 | ||
![]() |
8c8dd99ad2 | ||
![]() |
ea882bcc43 | ||
![]() |
f752f8b60f | ||
![]() |
07964f4bef | ||
![]() |
0eaa928441 | ||
![]() |
78f302befc | ||
![]() |
17c8913bfc | ||
![]() |
ff4587aae9 | ||
![]() |
eeb0de4a4e | ||
![]() |
741edbec0d | ||
![]() |
c790870ae6 | ||
![]() |
752e04b955 | ||
![]() |
debe83d2f4 | ||
![]() |
af8904ea7a | ||
![]() |
e8566ea137 | ||
![]() |
2a8bd7b2a0 | ||
![]() |
da71e1172c | ||
![]() |
a609ccbde2 | ||
![]() |
21b3dcd921 | ||
![]() |
9b9fed3ec6 | ||
![]() |
270ef73cc1 | ||
![]() |
7d03e22af3 | ||
![]() |
8e1665ba27 | ||
![]() |
09146d1241 | ||
![]() |
5549f04541 | ||
![]() |
1ad11a38a1 | ||
![]() |
aa71a72f58 | ||
![]() |
09a419f628 | ||
![]() |
4daa183dad | ||
![]() |
2af59af190 | ||
![]() |
4fda66e7db | ||
![]() |
32468d1d18 | ||
![]() |
4760b4a443 | ||
![]() |
b668028547 | ||
![]() |
7f8df4f10a | ||
![]() |
09b5a0ad3a | ||
![]() |
27a3aedc00 | ||
![]() |
cda3f0207e | ||
![]() |
215b78b75e | ||
![]() |
3afbb2ed0a | ||
![]() |
102b69f261 | ||
![]() |
843e36de0c | ||
![]() |
30d7cfcca9 | ||
![]() |
0dce414bf7 | ||
![]() |
6df9169e38 | ||
![]() |
42f83e331a | ||
![]() |
2c89984ffb | ||
![]() |
429900e748 | ||
![]() |
95be983c18 | ||
![]() |
08bf3669ba | ||
![]() |
695d478878 | ||
![]() |
efe4a8cc9f | ||
![]() |
e17c5cdad3 | ||
![]() |
c40c9c1f84 | ||
![]() |
575cea68e3 | ||
![]() |
83435b0642 | ||
![]() |
8b547f79ee | ||
![]() |
e1f62830b1 | ||
![]() |
7574ff8666 | ||
![]() |
6ce40962ac | ||
![]() |
bdcc6a1295 | ||
![]() |
0a8881a9cf | ||
![]() |
b134be7fce | ||
![]() |
aa76ce1a94 | ||
![]() |
5274d58c36 | ||
![]() |
d84fafd4a8 | ||
![]() |
8a567380df | ||
![]() |
b061ab7a34 | ||
![]() |
ce32613dd4 | ||
![]() |
b1f5c0549c | ||
![]() |
fb70b6766b | ||
![]() |
d9e496f1cb | ||
![]() |
efe75a7332 | ||
![]() |
c2e5e0fa0d | ||
![]() |
dcb43a43ab | ||
![]() |
6ef7f35887 | ||
![]() |
4afa34378b | ||
![]() |
49755b2785 | ||
![]() |
cb0cb97af3 | ||
![]() |
2b95ea5d6e | ||
![]() |
d04271f632 | ||
![]() |
29ac6b85ca | ||
![]() |
501d64fde2 | ||
![]() |
c5f94a7b0b | ||
![]() |
71e0ce35e8 | ||
![]() |
7a4e79af4f | ||
![]() |
faed51f996 | ||
![]() |
e3265a573d | ||
![]() |
d5b3d1ac65 | ||
![]() |
9eaafa204a | ||
![]() |
a4ba5d9726 | ||
![]() |
ba843e8b13 | ||
![]() |
67b2a7957f | ||
![]() |
700f539dfb | ||
![]() |
68895cfb55 | ||
![]() |
8e6d1cafb4 | ||
![]() |
6b9f32039c | ||
![]() |
031b0c101b | ||
![]() |
66a928fc6f | ||
![]() |
195300607e | ||
![]() |
1853f1a9b5 | ||
![]() |
68c42bfc3d | ||
![]() |
1ea317d05c | ||
![]() |
fa1e3ee52d | ||
![]() |
be36ad0554 | ||
![]() |
df9594ae86 | ||
![]() |
b146b9bb27 | ||
![]() |
767c87670d | ||
![]() |
98721f8dc5 | ||
![]() |
08c1586240 | ||
![]() |
37366ff1a8 | ||
![]() |
aa00cd6521 | ||
![]() |
f5eebdae73 | ||
![]() |
ecd8ab24ce | ||
![]() |
110a84d8fe | ||
![]() |
795b00eb8a | ||
![]() |
dbec0b8fd7 | ||
![]() |
88c5c1c7ac | ||
![]() |
774b005588 | ||
![]() |
1aadbc3a76 | ||
![]() |
df517b9d74 | ||
![]() |
2494d9d45f | ||
![]() |
32e3b2341b | ||
![]() |
68624d8648 | ||
![]() |
7c14674754 | ||
![]() |
d561ea89f3 | ||
![]() |
5a847e047e | ||
![]() |
6c96646476 | ||
![]() |
86b25afb11 | ||
![]() |
fe2a1ffa5e | ||
![]() |
77df569c46 | ||
![]() |
1fdb46eee5 | ||
![]() |
e373a2d241 | ||
![]() |
757c25bbc1 | ||
![]() |
5466fd916a | ||
![]() |
087fcd4b82 | ||
![]() |
bdc2dacf95 | ||
![]() |
76c29f719b | ||
![]() |
56bd49b932 | ||
![]() |
eac9504e14 | ||
![]() |
63ab4cfef5 | ||
![]() |
b91eb0fda5 | ||
![]() |
36a9a78896 | ||
![]() |
bc7bde024a | ||
![]() |
8ef97ccc9f | ||
![]() |
1e75827fd7 | ||
![]() |
685813863b | ||
![]() |
6cdc728aa1 | ||
![]() |
8a252a1137 | ||
![]() |
3a510b0e7a | ||
![]() |
4df64b5e9e | ||
![]() |
8b1754ca4c | ||
![]() |
64cb93b079 | ||
![]() |
6bd96563d0 | ||
![]() |
b410d8e863 | ||
![]() |
9f0eb217f8 | ||
![]() |
119ebcbf6c | ||
![]() |
f800ecd46d | ||
![]() |
54e75ce6bd | ||
![]() |
5d55da8821 | ||
![]() |
656be93d44 | ||
![]() |
d1c2cc27af | ||
![]() |
d38e6ea289 | ||
![]() |
1faff72321 | ||
![]() |
d87f74fffc | ||
![]() |
1bc650d7ac | ||
![]() |
ca3acc2d1d | ||
![]() |
45e501f4fd | ||
![]() |
72f91f39d0 | ||
![]() |
5e1fc3910a | ||
![]() |
9ef6dcacb6 | ||
![]() |
5ec88ccc9e | ||
![]() |
48a43ba5a5 | ||
![]() |
03b09c9c8f | ||
![]() |
fb9b398d7e | ||
![]() |
15088095e2 | ||
![]() |
b7612237e1 | ||
![]() |
d14c244f9a | ||
![]() |
118a1ababc | ||
![]() |
6a6a155056 | ||
![]() |
4f86e6231b | ||
![]() |
7c2b07f560 | ||
![]() |
1f3afa3ad4 | ||
![]() |
e78dba1f2e | ||
![]() |
53185a603f | ||
![]() |
06d9c777fc | ||
![]() |
7995d95b8d | ||
![]() |
c80a31bffe | ||
![]() |
b9ebce0771 | ||
![]() |
1e990389f4 | ||
![]() |
ee740b43f8 | ||
![]() |
e34fd638a3 | ||
![]() |
0d065dc14e | ||
![]() |
09052de9ee | ||
![]() |
a37821e22c | ||
![]() |
b507ed8a11 | ||
![]() |
bc5887537a | ||
![]() |
cad89dd337 | ||
![]() |
7638d8404a | ||
![]() |
65a1ee2e73 | ||
![]() |
ee41792491 | ||
![]() |
f277cc0a37 | ||
![]() |
993e1d5446 | ||
![]() |
45e3323828 | ||
![]() |
43d7e0830c | ||
![]() |
87d1d45cae | ||
![]() |
34b9e1e2c5 | ||
![]() |
cb3b5084fb | ||
![]() |
f5d317e2ab | ||
![]() |
e7d595c70a | ||
![]() |
680dd75abe | ||
![]() |
216a950d5c | ||
![]() |
1b3bf708e6 | ||
![]() |
d96a38d7b2 | ||
![]() |
2429a70c42 | ||
![]() |
b2bd9f093f | ||
![]() |
8ffd1802a1 | ||
![]() |
b72ade3587 | ||
![]() |
80001ffafa | ||
![]() |
631b6edc30 | ||
![]() |
8ae8adbf8c | ||
![]() |
ad5514124c | ||
![]() |
bd8eea3159 | ||
![]() |
10dcdbc5e2 | ||
![]() |
227bcb8015 | ||
![]() |
f295286a8b | ||
![]() |
de0a235d91 | ||
![]() |
d90db71ee7 | ||
![]() |
07d08014d5 | ||
![]() |
72757331c5 | ||
![]() |
d629fdddc8 | ||
![]() |
f382f30476 | ||
![]() |
c19774e141 | ||
![]() |
dbb66c3d31 | ||
![]() |
cc981384b6 | ||
![]() |
141d4b32d6 | ||
![]() |
ccd71c734c | ||
![]() |
fbd7a6e1f4 | ||
![]() |
326961c20d | ||
![]() |
94db70bb2e | ||
![]() |
3f6bba601d | ||
![]() |
e85945f25a | ||
![]() |
6c4feb3539 | ||
![]() |
b2a6549fb6 | ||
![]() |
3de789500e | ||
![]() |
0b163394d2 | ||
![]() |
a10353fc1a | ||
![]() |
ae187e4c1b | ||
![]() |
aeb1904cf3 | ||
![]() |
2da1b6594f | ||
![]() |
6a8707bfcf | ||
![]() |
0812ae3113 | ||
![]() |
52a8ba02cb | ||
![]() |
b98c6ca2c5 | ||
![]() |
48a403e8c9 | ||
![]() |
b2fa7bf0bb | ||
![]() |
018c389534 | ||
![]() |
064ac12b36 | ||
![]() |
00d549d2ac | ||
![]() |
ca2468797d | ||
![]() |
62d9252e59 | ||
![]() |
a5d35ad693 | ||
![]() |
99cc92eb8b | ||
![]() |
99da294b0a | ||
![]() |
b9dff411fb | ||
![]() |
80f44608a1 | ||
![]() |
76cbf75049 | ||
![]() |
28ab1e03ba | ||
![]() |
4180b26b8e | ||
![]() |
a25d629f01 | ||
![]() |
3097d1e02a | ||
![]() |
0cd8a0916b | ||
![]() |
f0e8385e2b | ||
![]() |
a80c22f34c | ||
![]() |
f736ce1a7a | ||
![]() |
a9ce57f6c5 | ||
![]() |
ba295e975e | ||
![]() |
1d55692d26 | ||
![]() |
abcf67099b | ||
![]() |
de9ef6fb6c | ||
![]() |
59c592dc7e | ||
![]() |
4f32420f89 | ||
![]() |
d3869c8818 | ||
![]() |
6dd476a40a | ||
![]() |
267e2a6968 | ||
![]() |
ee6d4cb570 | ||
![]() |
179ace133f | ||
![]() |
b50a6a3955 | ||
![]() |
a938f23a81 | ||
![]() |
11293012e1 | ||
![]() |
378e9bff6f | ||
![]() |
7f85b4b535 | ||
![]() |
d1953fbe10 | ||
![]() |
ce6a56d234 | ||
![]() |
63269bbbfb | ||
![]() |
b16cb3ee59 | ||
![]() |
1f8f078523 | ||
![]() |
ce41640044 | ||
![]() |
e836fdf2a3 | ||
![]() |
be95f78426 | ||
![]() |
9b9af02f3e | ||
![]() |
2d1549a328 | ||
![]() |
3bf0594920 | ||
![]() |
b995277e2e | ||
![]() |
58db351c26 | ||
![]() |
4961f53c26 | ||
![]() |
54aefedd28 | ||
![]() |
830d312245 | ||
![]() |
0c22b62699 | ||
![]() |
e44b6ddc96 | ||
![]() |
473974ada9 | ||
![]() |
202f9e4939 | ||
![]() |
d000bc89c8 | ||
![]() |
fe7586cbec | ||
![]() |
cdf205e705 | ||
![]() |
6666b5bd65 | ||
![]() |
b7704b8013 | ||
![]() |
8199fba5cc | ||
![]() |
8b695c7ba2 | ||
![]() |
33ca8cca9c | ||
![]() |
3b92f7d093 | ||
![]() |
9932895ff5 | ||
![]() |
6ca4c9d3cb | ||
![]() |
fab26e130e | ||
![]() |
a67ee784b3 | ||
![]() |
22dc0b2579 | ||
![]() |
bea3c7c693 | ||
![]() |
06f62c96fb | ||
![]() |
c84db86457 | ||
![]() |
d14088d9bc | ||
![]() |
908b960977 | ||
![]() |
3d32d6cd84 | ||
![]() |
f8b56ab774 | ||
![]() |
e6340e77c4 | ||
![]() |
069b681f21 | ||
![]() |
b0881a2909 | ||
![]() |
5c6b98fd14 | ||
![]() |
03b5b67a42 | ||
![]() |
245ba1a238 | ||
![]() |
1553ca95f4 | ||
![]() |
8c4c68f24a | ||
![]() |
454e8aea75 | ||
![]() |
56930c07a6 | ||
![]() |
c554482bb1 | ||
![]() |
44bcdce14a | ||
![]() |
1f2919278f | ||
![]() |
5be0748c9f | ||
![]() |
c70d6da314 | ||
![]() |
0b31eed2a5 | ||
![]() |
9e085afb46 | ||
![]() |
c9be795984 | ||
![]() |
cc0763c7c1 | ||
![]() |
c2544d15d2 | ||
![]() |
488190bd5e | ||
![]() |
c92dbf762d | ||
![]() |
d01bf6bd48 | ||
![]() |
df85aa08a1 | ||
![]() |
15e2986577 | ||
![]() |
3d2f5b4adf | ||
![]() |
9db4cc300b | ||
![]() |
6c1700cd01 | ||
![]() |
c138c5867f | ||
![]() |
48d53a54a8 | ||
![]() |
a6372f8872 | ||
![]() |
bf5b8c8857 | ||
![]() |
8143f69da2 | ||
![]() |
3c7967d2c0 | ||
![]() |
c8cf6aa3fb | ||
![]() |
a218f9ac35 | ||
![]() |
2172de1865 | ||
![]() |
eb341b0c08 | ||
![]() |
324a48f157 | ||
![]() |
6909019231 | ||
![]() |
e804951763 | ||
![]() |
e73c16892c | ||
![]() |
4631fc88ba | ||
![]() |
b02ac3139f | ||
![]() |
ec96c9fabf | ||
![]() |
f72a8a8de7 | ||
![]() |
1b9a2e9f3b | ||
![]() |
23736d41c0 | ||
![]() |
888fa3d92b | ||
![]() |
286c573ebd | ||
![]() |
df2d4c9ca9 | ||
![]() |
50e3dffd9b | ||
![]() |
47cb5a8b2d | ||
![]() |
7eea7c0fc3 | ||
![]() |
4048e3b730 | ||
![]() |
d1216811c8 | ||
![]() |
40878d3a4d | ||
![]() |
5861007eff | ||
![]() |
9437791629 | ||
![]() |
db18b54c93 | ||
![]() |
5a47eaf3d2 | ||
![]() |
2e54c9368d | ||
![]() |
b3f299dbe6 | ||
![]() |
35ca2ae716 | ||
![]() |
068e79bbe6 | ||
![]() |
b2d685f523 | ||
![]() |
4c08385098 | ||
![]() |
b05d24dd14 | ||
![]() |
5cfe1eddfd | ||
![]() |
e9982b4903 | ||
![]() |
d2e71d0a27 | ||
![]() |
99c93cee84 | ||
![]() |
54cc66d0d2 | ||
![]() |
c93efb6ae0 | ||
![]() |
53813f0cd1 | ||
![]() |
43b2cde018 | ||
![]() |
6868f6567b | ||
![]() |
2af3e33097 | ||
![]() |
87051e1c98 | ||
![]() |
f3a905a7fe | ||
![]() |
db4c0775b3 | ||
![]() |
e85150d1cc | ||
![]() |
3448cf95f7 | ||
![]() |
e4a157fd82 | ||
![]() |
f464c2c1ac | ||
![]() |
447ec12023 | ||
![]() |
a097ae7a41 | ||
![]() |
75e85f3764 | ||
![]() |
313cc922ee | ||
![]() |
d84789b995 | ||
![]() |
8ca763f185 | ||
![]() |
0b6aee858c | ||
![]() |
8b2e21211b | ||
![]() |
882b3c12a0 | ||
![]() |
7ad4dffabb | ||
![]() |
bfde1a8e70 | ||
![]() |
b643472932 | ||
![]() |
a1c544a4d2 | ||
![]() |
e765558b14 | ||
![]() |
c6efb9eec8 | ||
![]() |
ac7140f8ae | ||
![]() |
569e262c97 | ||
![]() |
a2364d6f47 | ||
![]() |
8261f6f7ec | ||
![]() |
29de7cddd2 | ||
![]() |
af207615e6 | ||
![]() |
e96e1f0b41 | ||
![]() |
ee102dd001 | ||
![]() |
1354e00dbd | ||
![]() |
319a1d2750 | ||
![]() |
6b72d28050 | ||
![]() |
4b2c98afb2 | ||
![]() |
827aa96d9d | ||
![]() |
67eee13fca | ||
![]() |
1aa50d50ca | ||
![]() |
4689bcc2aa | ||
![]() |
7605a4331a | ||
![]() |
f8b2afe44c | ||
![]() |
0274ade547 | ||
![]() |
6af56c451a | ||
![]() |
26c3c16872 | ||
![]() |
c2ff618698 | ||
![]() |
0f6616195a | ||
![]() |
67654f3005 | ||
![]() |
6d4e800333 | ||
![]() |
d8089f1b9d | ||
![]() |
06a0fefa8c | ||
![]() |
e085445344 | ||
![]() |
2221430c78 | ||
![]() |
c96284a445 | ||
![]() |
73e351d5ef | ||
![]() |
20b96d7f25 | ||
![]() |
f9df5c1f7e | ||
![]() |
5dd9a3c984 | ||
![]() |
ed60342f65 | ||
![]() |
62d38a55a2 | ||
![]() |
36994d7abf | ||
![]() |
fa96073ada | ||
![]() |
01ebb66500 | ||
![]() |
c82449b138 | ||
![]() |
5573cc92b0 | ||
![]() |
70089f2dde | ||
![]() |
d36c2b00ae | ||
![]() |
e72fc2c487 | ||
![]() |
8406d4c03b | ||
![]() |
8517b87e2e | ||
![]() |
772faef0fe | ||
![]() |
a056cf95a2 | ||
![]() |
b997e7674d | ||
![]() |
84e054978c | ||
![]() |
f58c304d6f | ||
![]() |
b86671d971 | ||
![]() |
271e2c8013 | ||
![]() |
e92ded1c71 | ||
![]() |
ae65f12d81 | ||
![]() |
ae6a3ecb36 | ||
![]() |
cc9941e276 | ||
![]() |
2ffea800b5 | ||
![]() |
fcc47b9038 | ||
![]() |
544597c267 | ||
![]() |
d75aade762 | ||
![]() |
ed809801f4 | ||
![]() |
1d0a77a2ff | ||
![]() |
b889cd3fab | ||
![]() |
f4c440fef1 | ||
![]() |
458d6dc2e9 | ||
![]() |
eca94d24f2 | ||
![]() |
7d09b88a70 | ||
![]() |
0da5f283ed | ||
![]() |
76b565eb0a | ||
![]() |
82030109dc | ||
![]() |
347ce98bef | ||
![]() |
4a21dffdab | ||
![]() |
daa011ffe6 | ||
![]() |
52cdcfa0aa | ||
![]() |
40a5c6a306 | ||
![]() |
ff99506d3a | ||
![]() |
e7b5d7e9ee | ||
![]() |
59d0706129 | ||
![]() |
8d9b4c9d4e | ||
![]() |
ff2956ef29 | ||
![]() |
f36481ba26 | ||
![]() |
52e930c7a3 | ||
![]() |
cad8e5e380 | ||
![]() |
6d586b3045 | ||
![]() |
40f3945e92 | ||
![]() |
b171dd099c | ||
![]() |
442f8f7324 | ||
![]() |
cdb0d02ad5 | ||
![]() |
fbad0be1e2 | ||
![]() |
55dc32bacc | ||
![]() |
cc1fb789c6 | ||
![]() |
f48b1fa212 | ||
![]() |
48d155400c | ||
![]() |
a2e04a24e6 | ||
![]() |
623d6be4ad | ||
![]() |
19f4b0abc4 | ||
![]() |
8038e88b28 | ||
![]() |
2454575049 | ||
![]() |
3ba2664458 | ||
![]() |
f064efaa29 | ||
![]() |
4bf42831f3 | ||
![]() |
d3485e9f6d | ||
![]() |
2c9d26f65f | ||
![]() |
90bc07981f | ||
![]() |
d0ca3e8a27 | ||
![]() |
5845abb481 | ||
![]() |
6fb3cc3978 | ||
![]() |
7b36c1a96b | ||
![]() |
0845c7e27d | ||
![]() |
09f7ebbf45 | ||
![]() |
2a828721db | ||
![]() |
240d6698d6 | ||
![]() |
e9af3d5bc7 | ||
![]() |
4152d6e1db | ||
![]() |
cd771d4039 | ||
![]() |
09178d171b | ||
![]() |
c1886cc9cd | ||
![]() |
758f721690 | ||
![]() |
114332ff2f | ||
![]() |
8f906d4a7d | ||
![]() |
78a51f5f52 | ||
![]() |
e0678aaf74 | ||
![]() |
bc74938a60 | ||
![]() |
db6ba6439e | ||
![]() |
9f4359a4eb | ||
![]() |
e1fa5a9c52 | ||
![]() |
9b2d4b7376 | ||
![]() |
cb0324f81a | ||
![]() |
45de844680 | ||
![]() |
ae42b07759 | ||
![]() |
f2d11c548a | ||
![]() |
1596483250 | ||
![]() |
62a6438e97 | ||
![]() |
39828f4d15 | ||
![]() |
c5901fae7d | ||
![]() |
405c024e9c | ||
![]() |
dbd0cb2d68 | ||
![]() |
a458f6ffe5 | ||
![]() |
1e10c595e4 | ||
![]() |
c511a72187 | ||
![]() |
7c095cea4c | ||
![]() |
d07d59ad7d | ||
![]() |
7f700ee326 | ||
![]() |
d5995a1f94 | ||
![]() |
75f67dd975 | ||
![]() |
b60c3d1bba | ||
![]() |
2b2614cc9b | ||
![]() |
bc52cafaa1 | ||
![]() |
339e9317e2 | ||
![]() |
2c8a08b690 | ||
![]() |
4e124466f8 | ||
![]() |
6d415c6fe3 | ||
![]() |
56dcf905ef | ||
![]() |
b211e10184 | ||
![]() |
6ca4b056e7 | ||
![]() |
bb519fcd1b | ||
![]() |
53d541d1c7 | ||
![]() |
a47eea8d3e | ||
![]() |
5ac27e6ff5 | ||
![]() |
c30a6eb341 | ||
![]() |
7f635590f6 | ||
![]() |
5b7f142175 | ||
![]() |
b6d2b8cb5e | ||
![]() |
8da34c670a | ||
![]() |
fe6d56d71e | ||
![]() |
1d998c7863 | ||
![]() |
a35c6f2d83 | ||
![]() |
0f306a5d50 | ||
![]() |
73131a3f0c | ||
![]() |
097a463e0e | ||
![]() |
d464118d83 | ||
![]() |
3029bd54f6 | ||
![]() |
ccb8072625 | ||
![]() |
aaf4ab9560 | ||
![]() |
61685004e9 | ||
![]() |
780898451b | ||
![]() |
9233089e37 | ||
![]() |
9f5ffd8d53 | ||
![]() |
902b1534a8 | ||
![]() |
fc68b96f72 | ||
![]() |
58da0572a1 | ||
![]() |
c3793ed0c0 | ||
![]() |
8abc82971a | ||
![]() |
f777604573 | ||
![]() |
1e30ca595c | ||
![]() |
202ebdcf1d | ||
![]() |
6e00f55fd8 | ||
![]() |
c86cc138c0 | ||
![]() |
edd4656e3a | ||
![]() |
d0117fb78e | ||
![]() |
86ec53ea56 | ||
![]() |
0f7effef06 | ||
![]() |
6440062efd | ||
![]() |
98e1daf893 | ||
![]() |
8e5cab4ec2 | ||
![]() |
ecef6c15af | ||
![]() |
d9a18daed5 | ||
![]() |
005bba4542 | ||
![]() |
157f16ff9e | ||
![]() |
849b23cede | ||
![]() |
5b68bc0448 | ||
![]() |
4a3cd7377a | ||
![]() |
ff27f54535 | ||
![]() |
ffe2326e7f | ||
![]() |
e91b6da9dd | ||
![]() |
d482c23cf6 | ||
![]() |
848e086daa | ||
![]() |
9a284b9926 | ||
![]() |
fe396e1b11 | ||
![]() |
4726ad975c | ||
![]() |
08032c96e5 | ||
![]() |
9d7fcab111 | ||
![]() |
aee815cb4b | ||
![]() |
b132ed9555 | ||
![]() |
bd9cc5cfcb | ||
![]() |
7eb840e5a8 | ||
![]() |
3b313855b8 | ||
![]() |
e37f6c9658 | ||
![]() |
aa6d646c4e | ||
![]() |
0c8a146096 | ||
![]() |
d836bfcce6 | ||
![]() |
819ad3e839 | ||
![]() |
35dad1156e | ||
![]() |
0cf07edc2d | ||
![]() |
8117b228ee | ||
![]() |
acebff1445 | ||
![]() |
92f319d20c | ||
![]() |
a3d211beeb | ||
![]() |
33dcf90f75 | ||
![]() |
bfa913fd3d | ||
![]() |
b5268a4a76 | ||
![]() |
289d4d89cc | ||
![]() |
75145293c7 | ||
![]() |
e3b6ed2de3 | ||
![]() |
5b0fca5a14 | ||
![]() |
ea24d3e46e | ||
![]() |
94a6b8d5cc | ||
![]() |
a516541c8d | ||
![]() |
e4a85573c0 | ||
![]() |
b1f2272fc2 | ||
![]() |
5444bc785f | ||
![]() |
0d3274b1a4 | ||
![]() |
33836f8690 | ||
![]() |
60c64d8d55 | ||
![]() |
e2308366fb | ||
![]() |
2763ca40c8 | ||
![]() |
7fd2e89c77 | ||
![]() |
56234f2abf | ||
![]() |
90e18d0627 | ||
![]() |
53068eb943 | ||
![]() |
1ff572da03 | ||
![]() |
eeb0a1eefe | ||
![]() |
1eabf90a62 | ||
![]() |
3ebe0d4639 | ||
![]() |
97079fb58d | ||
![]() |
f777352dbe | ||
![]() |
1d2b1539a8 | ||
![]() |
890d110b45 | ||
![]() |
7284081206 | ||
![]() |
eba727619f | ||
![]() |
71e9539efd | ||
![]() |
b881e548fc | ||
![]() |
1f9d5ab2e6 | ||
![]() |
4bb7627488 | ||
![]() |
11667d7876 | ||
![]() |
ae2b7289cc | ||
![]() |
1230a22969 | ||
![]() |
6e39480537 | ||
![]() |
ba56fc25ab | ||
![]() |
d5e024c43e | ||
![]() |
355de8e2fc | ||
![]() |
9e505c68ed | ||
![]() |
516af8c66a | ||
![]() |
849e8f0ef4 | ||
![]() |
add30cb413 | ||
![]() |
9e3637a97b | ||
![]() |
eecafd3cf9 | ||
![]() |
a752b0c79a | ||
![]() |
f2bf943d56 | ||
![]() |
294c9459a2 | ||
![]() |
ce296fe287 | ||
![]() |
9fbbf7d958 | ||
![]() |
1ea4018379 | ||
![]() |
782be482d0 | ||
![]() |
90ce2de529 | ||
![]() |
872406fc4c | ||
![]() |
56048bd5e3 | ||
![]() |
906676cf58 | ||
![]() |
beb4ccd01f | ||
![]() |
3ec708ef37 | ||
![]() |
b7fa352ae5 | ||
![]() |
c936e37509 | ||
![]() |
1b23c833ed | ||
![]() |
d879c13aa4 | ||
![]() |
d5a7e2fb64 | ||
![]() |
1bde4c8909 | ||
![]() |
c19533c92b | ||
![]() |
417aa16f1d | ||
![]() |
26a7592ed6 | ||
![]() |
e0b34ff480 | ||
![]() |
01a3177843 | ||
![]() |
491d3c201c | ||
![]() |
6629f218d7 | ||
![]() |
689962b1fb | ||
![]() |
5d5c3ea140 | ||
![]() |
305f8d69e8 | ||
![]() |
7ca4d64e2d | ||
![]() |
fab1d75f45 | ||
![]() |
27d25ca666 | ||
![]() |
14fa662db5 | ||
![]() |
84a129191d | ||
![]() |
0a23e950d9 | ||
![]() |
3b3f238b68 | ||
![]() |
aa95c746b9 | ||
![]() |
e7e3b50632 | ||
![]() |
bd6b04dca0 | ||
![]() |
8bd3288051 | ||
![]() |
f304b66055 | ||
![]() |
211b516b7b | ||
![]() |
b3a5b427fe | ||
![]() |
767941d096 | ||
![]() |
287f3d084d | ||
![]() |
28a8c95c71 | ||
![]() |
49de9b8a97 | ||
![]() |
4b30bed0a8 | ||
![]() |
7d2c328fa0 | ||
![]() |
642d5c44ec | ||
![]() |
a4b3729896 | ||
![]() |
44f7b4922d | ||
![]() |
5539eaa420 | ||
![]() |
cd7f8d821c | ||
![]() |
3f2d1abd3d | ||
![]() |
3f6831ef87 | ||
![]() |
49f6dd432e | ||
![]() |
c553dca81b | ||
![]() |
197f5220af | ||
![]() |
14d39c23ab | ||
![]() |
8a9452ab46 | ||
![]() |
4bb1ab768b | ||
![]() |
28593307be | ||
![]() |
6f7207645f | ||
![]() |
c91fe5b5c5 | ||
![]() |
2d931ce1a4 | ||
![]() |
84b43ff319 | ||
![]() |
504ceea720 | ||
![]() |
fd7ee5ab17 | ||
![]() |
f2772fb2a4 | ||
![]() |
131dec75ec | ||
![]() |
7be8087b19 | ||
![]() |
51bbf8ad67 | ||
![]() |
d5c99ba49f | ||
![]() |
d58abf83b9 | ||
![]() |
ad92311a7d | ||
![]() |
37760e06fd | ||
![]() |
becda111b0 | ||
![]() |
4e1c38f27d | ||
![]() |
ef3b7c448f | ||
![]() |
137c6ff9f8 | ||
![]() |
45b4b84bf3 | ||
![]() |
22783c949d | ||
![]() |
4222703946 | ||
![]() |
6d07f5bab5 | ||
![]() |
3eb165cce7 | ||
![]() |
6b924e7021 | ||
![]() |
241362dce0 | ||
![]() |
84931ed6ad | ||
![]() |
0ee12c8e5a | ||
![]() |
fd05f9bb0f | ||
![]() |
ded82617e6 | ||
![]() |
480b8d37ff | ||
![]() |
b16a547996 | ||
![]() |
b0b6199dd3 | ||
![]() |
e474b1b423 | ||
![]() |
0bc01de98b | ||
![]() |
c3cd8f0088 | ||
![]() |
bf176393ad | ||
![]() |
12bb4f744d | ||
![]() |
90192b274c | ||
![]() |
5a7864aa8e | ||
![]() |
38640e3dcd | ||
![]() |
c1cafcfced | ||
![]() |
76c78d9d78 | ||
![]() |
de14d0a37b | ||
![]() |
464e04a6bb | ||
![]() |
d3046933c1 | ||
![]() |
51dda0606f | ||
![]() |
219a1a2c9f | ||
![]() |
4b6d0d5f28 | ||
![]() |
06405de396 | ||
![]() |
20a334912a | ||
![]() |
537a73589d | ||
![]() |
4a25a29fbb | ||
![]() |
fe52f10ca8 | ||
![]() |
8036b866ad | ||
![]() |
c882057067 | ||
![]() |
73f3521ee8 | ||
![]() |
18a8bc8dc5 | ||
![]() |
16f165a92c | ||
![]() |
b54066bf89 | ||
![]() |
a6e9b78b09 | ||
![]() |
b14393134f | ||
![]() |
742dde4820 | ||
![]() |
423fad1f05 | ||
![]() |
661ad8a79c | ||
![]() |
b9501bad43 | ||
![]() |
0c8624168f | ||
![]() |
e130d1ab20 | ||
![]() |
a658b0391a | ||
![]() |
38021704ff | ||
![]() |
72b96e8b80 | ||
![]() |
27732bec35 | ||
![]() |
6439f7b754 | ||
![]() |
6b6a6befb8 | ||
![]() |
9a2a1a8745 | ||
![]() |
71b5d2d716 | ||
![]() |
aa32c52284 | ||
![]() |
f6f706a4aa | ||
![]() |
3d9937bafd | ||
![]() |
1dcd88e709 | ||
![]() |
32e9772154 | ||
![]() |
402c6850a0 | ||
![]() |
07fd453268 | ||
![]() |
7be90d0666 | ||
![]() |
6f912798af | ||
![]() |
c38472e870 | ||
![]() |
3436d69d71 | ||
![]() |
a172c9c40b | ||
![]() |
ac1cbd687f | ||
![]() |
1e0c7d0505 | ||
![]() |
a2cdcb149a | ||
![]() |
afd5dd4553 | ||
![]() |
0798c53762 | ||
![]() |
22619224c5 | ||
![]() |
5d849cc048 | ||
![]() |
188051bfa5 | ||
![]() |
da1fe7a511 | ||
![]() |
1b77a7cc81 | ||
![]() |
2b300f32e0 | ||
![]() |
88ca584410 | ||
![]() |
e9340c40a8 | ||
![]() |
87d0830f3c | ||
![]() |
9f9eccffdd | ||
![]() |
1ca9e2c751 | ||
![]() |
b3cfae5a92 | ||
![]() |
1982661aa1 | ||
![]() |
c15c1204f0 | ||
![]() |
936095aa24 | ||
![]() |
40df1a3233 | ||
![]() |
0d65db95f9 | ||
![]() |
768da75992 | ||
![]() |
c5e886fcfb | ||
![]() |
fa93d1c8e1 | ||
![]() |
778485b4a9 | ||
![]() |
5056b248a9 | ||
![]() |
051b4f3d3d | ||
![]() |
959fd06c37 | ||
![]() |
ca5dd74086 | ||
![]() |
74691c5a03 | ||
![]() |
803ff2ccf2 | ||
![]() |
de70b0d5b0 | ||
![]() |
696dc036ac | ||
![]() |
097d23f306 | ||
![]() |
02a2502fa5 |
4525 changed files with 137588 additions and 20953 deletions
8
.gitattributes
vendored
8
.gitattributes
vendored
|
@ -1,3 +1,7 @@
|
|||
* text=auto
|
||||
* text=auto eol=lf
|
||||
*.sh text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.png binary
|
||||
*.xcf binary
|
||||
*.xcf binary
|
||||
*.properties linguist-generated
|
||||
|
||||
|
|
10
.github/dependabot.yml
vendored
Normal file
10
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: "daily"
|
28
.github/workflows/build.yml
vendored
28
.github/workflows/build.yml
vendored
|
@ -1,28 +0,0 @@
|
|||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Git checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
- name: Set up GraalVM
|
||||
uses: graalvm/setup-graalvm@v1
|
||||
with:
|
||||
version: '22.3.0'
|
||||
java-version: '19'
|
||||
github-token: ${{ secrets.XPIPE_GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: Execute build
|
||||
run: ./gradlew clean build
|
39
.github/workflows/publish.yml
vendored
39
.github/workflows/publish.yml
vendored
|
@ -1,39 +0,0 @@
|
|||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Git checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
- name: Set up GraalVM
|
||||
uses: graalvm/setup-graalvm@v1
|
||||
with:
|
||||
version: '22.3.0'
|
||||
java-version: '19'
|
||||
github-token: ${{ secrets.XPIPE_GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: Publish
|
||||
run: ./gradlew publish
|
||||
env:
|
||||
GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }}
|
||||
GPG_KEY: ${{ secrets.GPG_KEY }}
|
||||
SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
|
||||
SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
|
||||
|
||||
- name: JReleaser
|
||||
run: ./gradlew jreleaserRelease
|
||||
env:
|
||||
XPIPE_GITHUB_TOKEN: ${{ secrets.XPIPE_GITHUB_TOKEN }}
|
||||
XPIPE_DISCORD_WEBHOOK: ${{ secrets.XPIPE_DISCORD_WEBHOOK }}
|
23
.gitignore
vendored
23
.gitignore
vendored
|
@ -1,11 +1,24 @@
|
|||
.gradle/
|
||||
build/
|
||||
.idea
|
||||
local/
|
||||
local_test/
|
||||
local_stage/
|
||||
.idea/*
|
||||
!.idea/codeStyles
|
||||
!.idea/inspectionProfiles
|
||||
lib/
|
||||
dev.properties
|
||||
extensions.txt
|
||||
dev_storage
|
||||
local/
|
||||
local*/
|
||||
local_*/
|
||||
.vs
|
||||
.vscode
|
||||
bin
|
||||
obj
|
||||
out
|
||||
bin
|
||||
.DS_Store
|
||||
ComponentsGenerated.wxs
|
||||
!dist/javafx/**/lib
|
||||
!dist/javafx/**/bin
|
||||
xcuserdata/
|
||||
*.dylib
|
||||
project.xcworkspace
|
||||
|
|
127
CODE_OF_CONDUCT.md
Normal file
127
CODE_OF_CONDUCT.md
Normal file
|
@ -0,0 +1,127 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement via [hello@xpipe.io](mailto:hello@xpipe.io).
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
117
CONTRIBUTING.md
Normal file
117
CONTRIBUTING.md
Normal file
|
@ -0,0 +1,117 @@
|
|||
# Development
|
||||
|
||||
Any contribution is welcomed!
|
||||
There are no real formal contribution guidelines right now, they will maybe come later.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- [core](core) - Shared core classes of the XPipe Java API, XPipe extensions, and the XPipe daemon implementation.
|
||||
This mainly concerns API classes not a lot of implementation.
|
||||
- [beacon](beacon) - The XPipe beacon component is responsible for handling all communications between the XPipe
|
||||
daemon and the client applications, for example APIs and the CLI
|
||||
- [app](app) - Contains the XPipe daemon implementation, the XPipe desktop application, and an
|
||||
API to create all different kinds of extensions for the XPipe platform
|
||||
- [dist](dist) - Tools to create a distributable package of XPipe
|
||||
- [ext](ext) - Available XPipe extensions. Essentially every concrete feature implementation is implemented as an extension
|
||||
|
||||
## Development Setup
|
||||
|
||||
You need to have an up-to-date version of XPipe installed on your local system in order to properly
|
||||
run XPipe in a development environment.
|
||||
This is due to the fact that some components are only included in the release version and not in this repository.
|
||||
XPipe is able to automatically detect your local installation and fetch the required
|
||||
components from it when it is run in a development environment.
|
||||
|
||||
Note that in case the current master branch is ahead of the latest release, it might happen that there are some incompatibilities when loading data from your local XPipe installation.
|
||||
You should therefore always check out the matching version tag for your local repository and local XPipe installation.
|
||||
You can find the available version tags at https://github.com/xpipe-io/xpipe/tags.
|
||||
So for example if you currently have XPipe `13.0` installed, you should run `git reset --hard 13.0` first to properly compile against it.
|
||||
|
||||
You need to have JDK for Java 22 installed to compile the project.
|
||||
If you are on Linux or macOS, you can easily accomplish that by running
|
||||
```bash
|
||||
curl -s "https://get.sdkman.io" | bash
|
||||
. "$HOME/.sdkman/bin/sdkman-init.sh"
|
||||
sdk install java 22.0.2-graalce
|
||||
sdk default java 22.0.2-graalce
|
||||
```
|
||||
.
|
||||
On Windows, you have to manually install a JDK, e.g. from [Adoptium](https://adoptium.net/temurin/releases/?version=21).
|
||||
|
||||
You can configure a few development options in the file `app/dev.properties` which will be automatically generated when gradle is first run.
|
||||
|
||||
## Building and Running
|
||||
|
||||
You can use the gradle wrapper to build and run the project:
|
||||
- `gradlew app:run` will run the desktop application. You can set various useful properties in `app/build.gradle`
|
||||
- `gradlew clean dist` will create a distributable production version in `dist/build/dist/base`.
|
||||
- `gradlew <project>:test` will run the tests of the specified project.
|
||||
|
||||
You are also able to properly debug the built production application through two different methods:
|
||||
- The `dist/build/dist/base/app/scripts/xpiped_debug` script will launch the application in debug mode and with a console attached to it
|
||||
- The `dist/build/dist/base/app/scripts/xpiped_debug_attach` script attaches a debugger with the help of [AttachMe](https://plugins.jetbrains.com/plugin/13263-attachme).
|
||||
Just make sure that the attachme process is running within IntelliJ, and the debugger should launch automatically once you start up the application.
|
||||
|
||||
Note that when any unit test is run using a debugger, the XPipe daemon process that is started will also attempt
|
||||
to connect to that debugger through [AttachMe](https://plugins.jetbrains.com/plugin/13263-attachme) as well.
|
||||
|
||||
## Modularity and IDEs
|
||||
|
||||
All XPipe components target [Java 22](https://openjdk.java.net/projects/jdk/22/) and make full use of the Java Module System (JPMS).
|
||||
All components are modularized, including all their dependencies.
|
||||
In case a dependency is (sadly) not modularized yet, module information is manually added using [extra-java-module-info](https://github.com/gradlex-org/extra-java-module-info).
|
||||
Further, note that as this is a pretty complicated Java project that fully utilizes modularity,
|
||||
many IDEs still have problems building this project properly.
|
||||
|
||||
For example, you can't build this project in eclipse or vscode as it will complain about missing modules.
|
||||
The tested and recommended IDE is IntelliJ.
|
||||
When setting up the project in IntelliJ, make sure that the correct JDK (Java 22)
|
||||
is selected both for the project and for gradle itself.
|
||||
|
||||
## Contributing guide
|
||||
|
||||
Especially when starting out, it might be a good idea to start with easy tasks first. Here's a selection of suitable common tasks that are very easy to implement:
|
||||
|
||||
### Interacting via the HTTP API
|
||||
|
||||
You can create clients that communicate with the XPipe daemon via its HTTP API.
|
||||
To get started, see the [OpenAPI spec](/openapi.yaml).
|
||||
|
||||
### Implementing support for a new editor
|
||||
|
||||
All code for handling external editors can be found [here](https://github.com/xpipe-io/xpipe/blob/master/app/src/main/java/io/xpipe/app/prefs/ExternalEditorType.java). There you will find plenty of working examples that you can use as a base for your own implementation.
|
||||
|
||||
### Implementing support for a new terminal
|
||||
|
||||
All code for handling external terminals can be found [here](https://github.com/xpipe-io/xpipe/blob/master/app/src/main/java/io/xpipe/app/terminal/). There you will find plenty of working examples that you can use as a base for your own implementation.
|
||||
|
||||
### Adding more context menu actions in the file browser
|
||||
|
||||
In case you want to implement your own actions for certain file types in the file browser, you can easily do so. You can find most existing actions [here](https://github.com/xpipe-io/xpipe/tree/master/ext/base/src/main/java/io/xpipe/ext/base/browser) to get some inspiration.
|
||||
Once you created your custom classes, you have to register them in your module info, just like [here](https://github.com/xpipe-io/xpipe/blob/master/ext/base/src/main/java/module-info.java).
|
||||
|
||||
### Implementing custom actions for the connection hub
|
||||
|
||||
All actions that you can perform for certain connections in the connection overview tab are implemented using an [Action API](https://github.com/xpipe-io/xpipe/blob/master/app/src/main/java/io/xpipe/app/ext/ActionProvider.java). You can find a sample implementation [here](https://github.com/xpipe-io/xpipe/blob/master/ext/base/src/main/java/io/xpipe/ext/base/action/SampleAction.java) and many common action implementations [here](https://github.com/xpipe-io/xpipe/tree/master/ext/base/src/main/java/io/xpipe/ext/base/action).
|
||||
|
||||
### Adding more predefined scripts
|
||||
|
||||
You can add custom script definitions [here](https://github.com/xpipe-io/xpipe/tree/master/ext/base/src/main/java/io/xpipe/ext/base/script/PredefinedScriptStore.java) and [here](https://github.com/xpipe-io/xpipe/tree/master/ext/base/src/main/resources/io/xpipe/ext/base/resources/scripts).
|
||||
|
||||
### Adding more system icons for system autodetection
|
||||
|
||||
You can register new system types [here](https://github.com/xpipe-io/xpipe/blob/master/app/src/main/java/io/xpipe/app/resources/SystemIcons.java) and add the respective icons [here](https://github.com/xpipe-io/xpipe/tree/master/app/src/main/resources/io/xpipe/app/resources/img/system).
|
||||
|
||||
### Adding more file icons for specific types
|
||||
|
||||
You can register file types [here](https://github.com/xpipe-io/xpipe/blob/master/app/src/main/resources/io/xpipe/app/resources/file_list.txt) and add the respective icons [here](https://github.com/xpipe-io/xpipe/tree/master/app/src/main/resources/io/xpipe/app/resources/img/browser).
|
||||
|
||||
The existing file list and icons are taken from the [vscode-icons](https://github.com/vscode-icons/vscode-icons) project. Due to limitations in the file definition list compatibility, some file types might not be listed by their proper extension and are therefore not being applied correctly even though the images and definitions exist already.
|
||||
|
||||
### Implementing something else
|
||||
|
||||
if you want to work on something that was not listed here, you can still do so of course. You can reach out on the [Discord server](https://discord.gg/8y89vS8cRb) to discuss any development plans and get you started.
|
||||
|
||||
### Adding translations
|
||||
|
||||
See the [translation guide](/lang) for details.
|
7
LICENSE
7
LICENSE
|
@ -1,7 +0,0 @@
|
|||
Copyright 2022 Christopher Schnick
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
203
LICENSE.md
Normal file
203
LICENSE.md
Normal file
|
@ -0,0 +1,203 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2023 Christopher Schnick
|
||||
Copyright 2023 XPipe UG (haftungsbeschränkt)
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
213
README.md
213
README.md
|
@ -1,37 +1,196 @@
|
|||
[](https://github.com/xpipe-io/xpipe_java/actions/workflows/build.yml)
|
||||
[](https://github.com/xpipe-io/xpipe_java/actions/workflows/publish.yml)
|
||||
<p align="center">
|
||||
<a href="https://xpipe.io" target="_blank" rel="noopener">
|
||||
<img src="https://github.com/xpipe-io/.github/raw/main/img/banner.png" alt="XPipe Banner" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## X-Pipe Java
|
||||
<h1></h1>
|
||||
|
||||
The fundamental components of the [X-Pipe project](https://xpipe.io).
|
||||
This repository contains the following four modules:
|
||||
## About
|
||||
|
||||
- Core - Shared core classes of the X-Pipe Java API, X-Pipe extensions, and the X-Pipe daemon implementation
|
||||
- API - The API that can be used to interact with X-Pipe from any JVM-based language10
|
||||
- Beacon - The X-Pipe beacon component is responsible for handling all communications between the X-Pipe daemon
|
||||
and the client applications, for example the various programming language APIs and the CLI
|
||||
- Extension - An API to create all different kinds of extensions for the X-Pipe platform
|
||||
XPipe is a new type of shell connection hub and remote file manager that allows you to access your entire server infrastructure from your local machine. It works on top of your installed command-line programs and does not require any setup on your remote systems. So if you normally use CLI tools like `ssh`, `docker`, `kubectl`, etc. to connect to your servers, you can just use XPipe on top of that.
|
||||
|
||||
## Installation / Usage
|
||||
XPipe fully integrates with your tools such as your favourite text/code editors, terminals, shells, command-line tools and more. The platform is designed to be extensible, allowing anyone to add easily support for more tools or to implement custom functionality through a modular extension system.
|
||||
|
||||
The *core* and *extension* modules are used in X-Pipe extension development.
|
||||
For setup instructions, see the [X-Pipe extension development](https://xpipe-io.readthedocs.io/en/latest/dev/extensions/index.html) section.
|
||||
It currently supports:
|
||||
|
||||
The *beacon* module handles all communication and serves as a
|
||||
reference when implementing the communication of an API or program that interacts with the X-Pipe daemon.
|
||||
- [SSH](https://docs.xpipe.io/guide/ssh) connections, config files, and tunnels
|
||||
- [Docker](https://docs.xpipe.io/guide/docker), [Podman](https://docs.xpipe.io/guide/podman), [LXD](https://docs.xpipe.io/guide/lxc), and [incus](https://docs.xpipe.io/guide/lxc) containers
|
||||
- [Proxmox PVE](https://docs.xpipe.io/guide/proxmox) virtual machines and containers
|
||||
- [Hyper-V](https://docs.xpipe.io/guide/hyperv), [KVM](https://docs.xpipe.io/guide/kvm), [VMware Player/Workstation/Fusion](https://docs.xpipe.io/guide/vmware) virtual machines
|
||||
- [Kubernetes](https://docs.xpipe.io/guide/kubernetes) clusters, pods, and containers
|
||||
- [Tailscale](https://docs.xpipe.io/guide/tailscale) and [Teleport](https://docs.xpipe.io/guide/teleport) connections
|
||||
- Windows Subsystem for Linux, Cygwin, and MSYS2 environments
|
||||
- Powershell Remote Sessions
|
||||
- RDP and VNC connections
|
||||
|
||||
The *api* module serves as a reference implementation for other potential X-Pipe APIs
|
||||
and can also be used to access X-Pipe functionalities from your Java programs.
|
||||
For setup instructions, see the [X-Pipe Java API Usage](https://xpipe-io.readthedocs.io/en/latest/dev/api/java/index.html) section.
|
||||
## Connection hub
|
||||
|
||||
## Development Notes
|
||||
- Easily connect to and access all kinds of remote connections in one place
|
||||
- Organize all your connections in hierarchical categories so you can keep an overview hundreds of connections
|
||||
- Create specific login environments on any system to instantly jump into a properly set up environment for every use case
|
||||
- Quickly perform various commonly used actions like starting/stopping containers, establishing tunnels, and more
|
||||
- Create desktop shortcuts that automatically open remote connections in your terminal without having to open any GUI
|
||||
|
||||
All X-Pipe components target [JDK 17](https://openjdk.java.net/projects/jdk/17/) and make full use of the Java Module System (JPMS).
|
||||
All components are modularized, including all their dependencies.
|
||||
In case a dependency is (sadly) not modularized yet, module information is manually added using [moditect](https://github.com/moditect/moditect-gradle-plugin).
|
||||
These dependency generation rules are accumulated in the [X-Pipe dependencies](https://github.com/xpipe-io/xpipe_java_deps)
|
||||
repository, which is shared between all components and integrated as a git submodule.
|
||||

|
||||
|
||||
Some unit tests depend on a connection to an X-Pipe daemon to properly function.
|
||||
To launch the installed daemon, it is required that you either have X-Pipe
|
||||
installed or have set the `XPIPE_HOME` environment variable in case you are using a portable version.
|
||||
## Powerful file management
|
||||
|
||||
- Interact with the file system of any remote system using a workflow optimized for professionals
|
||||
- Quickly open a terminal session into any directory in your favourite terminal emulator
|
||||
- Utilize your entire arsenal of locally installed programs to open and edit remote files
|
||||
- Dynamically elevate sessions with sudo when required without having to restart the session
|
||||
- Seamlessly transfer files from and to your system desktop environment
|
||||
- Work and perform transfers on multiple systems at the same time with the built-in tabbed multitasking
|
||||
|
||||

|
||||
|
||||
## Terminal launcher
|
||||
|
||||
- Boots you into a shell session in your favourite terminal with one click. Automatically fills password prompts and more
|
||||
- Comes with support for all commonly used terminal emulators across all operating systems
|
||||
- Supports opening custom terminal emulators as well via a custom command-line spec
|
||||
- Works with all command shells such as bash, zsh, cmd, PowerShell, and more, locally and remote
|
||||
- Connects to a system while the terminal is still starting up, allowing for faster connections than otherwise possible
|
||||
|
||||

|
||||
|
||||
<br>
|
||||
<p align="center">
|
||||
<img src="https://github.com/xpipe-io/.github/raw/main/img/terminal.gif" alt="Terminal launcher"/>
|
||||
</p>
|
||||
<br>
|
||||
|
||||
## Versatile scripting system
|
||||
|
||||
- Create reusable simple shell scripts, templates, and groups to run on connected remote systems
|
||||
- Automatically make your scripts available in the PATH on any remote system without any setup
|
||||
- Setup shell init environments for connections to fully customize your work environment for every purpose
|
||||
- Open custom shells and custom remote connections by providing your own commands
|
||||
|
||||

|
||||
|
||||
## Secure vault
|
||||
|
||||
- All data is stored exclusively on your local system in a cryptographically secure vault. You can also choose to increase security by using a custom master passphrase for further encryption
|
||||
- XPipe is able to retrieve secrets automatically from your password manager via it's command-line interface.
|
||||
- There are no servers involved, all your information stays on your systems. The XPipe application does not send any personal or sensitive information to outside services.
|
||||
- Vault changes can be pushed and pulled from your own remote git repository by multiple team members across many systems
|
||||
|
||||
# Downloads
|
||||
|
||||
Note that this is a desktop application that should be run on your local desktop workstation, not on any server or containers. It will be able to connect to your server infrastructure from there.
|
||||
|
||||
## Windows
|
||||
|
||||
Installers are the easiest way to get started and come with an optional automatic update functionality:
|
||||
|
||||
- [Windows .msi Installer (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-windows-x86_64.msi)
|
||||
|
||||
If you don't like installers, you can also use a portable version that is packaged as an archive:
|
||||
|
||||
- [Windows .zip Portable (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-windows-x86_64.zip)
|
||||
|
||||
Alternatively, you can also use the following package managers:
|
||||
- [choco](https://community.chocolatey.org/packages/xpipe) to install it with `choco install xpipe`.
|
||||
- [winget](https://github.com/microsoft/winget-cli) to install it with `winget install xpipe-io.xpipe --source winget`.
|
||||
|
||||
## macOS
|
||||
|
||||
Installers are the easiest way to get started and come with an optional automatic update functionality:
|
||||
|
||||
- [MacOS .pkg Installer (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-macos-x86_64.pkg)
|
||||
- [MacOS .pkg Installer (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-macos-arm64.pkg)
|
||||
|
||||
If you don't like installers, you can also use a portable version that is packaged as an archive:
|
||||
|
||||
- [MacOS .dmg Portable (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-macos-x86_64.dmg)
|
||||
- [MacOS .dmg Portable (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-macos-arm64.dmg)
|
||||
|
||||
Alternatively, you can also use [Homebrew](https://github.com/xpipe-io/homebrew-tap) to install XPipe with `brew install --cask xpipe-io/tap/xpipe`.
|
||||
|
||||
## Linux
|
||||
|
||||
You can install XPipe the fastest by pasting the installation command into your terminal. This will perform the setup automatically.
|
||||
The script supports installation via `apt`, `dnf`, `yum`, `zypper`, `rpm`, and `pacman` on Linux:
|
||||
|
||||
```
|
||||
bash <(curl -sL https://github.com/xpipe-io/xpipe/raw/master/get-xpipe.sh)
|
||||
```
|
||||
|
||||
Of course, there are also other installation methods available.
|
||||
|
||||
### Debian-based distros
|
||||
|
||||
The following debian installers are available:
|
||||
|
||||
- [Linux .deb Installer (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-linux-x86_64.deb)
|
||||
- [Linux .deb Installer (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-linux-arm64.deb)
|
||||
|
||||
Note that you should use apt to install the package with `sudo apt install <file>` as other package managers, for example dpkg,
|
||||
are not able to resolve and install any dependency packages.
|
||||
|
||||
### RHEL-based distros
|
||||
|
||||
The rpm releases are signed with the GPG key https://xpipe.io/signatures/crschnick.asc.
|
||||
You can import it via `rpm --import https://xpipe.io/signatures/crschnick.asc` to allow your rpm-based package manager to verify the release signature.
|
||||
|
||||
The following rpm installers are available:
|
||||
|
||||
- [Linux .rpm Installer (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-linux-x86_64.rpm)
|
||||
- [Linux .rpm Installer (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-linux-arm64.rpm)
|
||||
|
||||
The same applies here, you should use a package manager that supports resolving and installing required dependencies if needed.
|
||||
|
||||
### Arch
|
||||
|
||||
There is an official [AUR package](https://aur.archlinux.org/packages/xpipe) available that you can either install manually or via an AUR helper such as with `yay -S xpipe`.
|
||||
|
||||
### NixOS
|
||||
|
||||
There's an official [xpipe nixpkg](https://search.nixos.org/packages?channel=unstable&show=xpipe&from=0&size=50&sort=relevance&type=packages&query=xpipe) available that you can install with `nix-env -iA nixos.xpipe`. This one is however not always up to date.
|
||||
|
||||
There is also a custom repository that contains the latest up-to-date releases: https://github.com/xpipe-io/nixpkg.
|
||||
You can install XPipe by following the instructions in the linked repository.
|
||||
|
||||
### Portable
|
||||
|
||||
In case you prefer to use an archive version that you can extract anywhere, you can use these:
|
||||
|
||||
- [Linux .tar.gz Portable (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-linux-x86_64.tar.gz)
|
||||
- [Linux .tar.gz Portable (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-linux-arm64.tar.gz)
|
||||
|
||||
Alternatively, there are also AppImages available:
|
||||
|
||||
- [Linux .AppImage Portable (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-linux-x86_64.AppImage)
|
||||
- [Linux .AppImage Portable (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-linux-arm64.AppImage)
|
||||
|
||||
Note that the portable version assumes that you have some basic packages for graphical systems already installed
|
||||
as it is not a perfect standalone version. It should however run on most systems.
|
||||
|
||||
## Docker container
|
||||
|
||||
XPipe is a desktop application first and foremost. It requires a full desktop environment to function with various installed applications such as terminals, editors, shells, CLI tools, and more. So there is no true web-based interface for XPipe.
|
||||
|
||||
Since it might make sense however to access your XPipe environment from the web, there is also a so-called webtop docker container image for XPipe. [XPipe Webtop](https://github.com/xpipe-io/xpipe-webtop) is a web-based desktop environment that can be run in a container and accessed from a browser via KasmVNC. The desktop environment comes with XPipe and various terminals and editors preinstalled and configured.
|
||||
|
||||
# Further information
|
||||
|
||||
## Open source model
|
||||
|
||||
XPipe follows an open core model, which essentially means that the main application is open source while certain other components are not. This mainly concerns the features only available in the homelab/professional plan and the shell handling library implementation. Furthermore, some CI pipelines and tests that run on private servers are also not included in the open repository.
|
||||
|
||||
The distributed XPipe application consists out of two parts:
|
||||
- The open-source core that you can find this repository. It is licensed under the [Apache License 2.0](/LICENSE.md).
|
||||
- The closed-source extensions, mostly for homelab/professional plan features, which are not included in this repository
|
||||
|
||||
Additional features are available in the homelab/professional plan . For more details see https://xpipe.io/pricing.
|
||||
If your enterprise puts great emphasis on having access to the full source code, there are also full source-available enterprise options available.
|
||||
|
||||
## Documentation
|
||||
|
||||
You can find the documentation at https://docs.xpipe.io.
|
||||
|
||||
## Discord
|
||||
|
||||
[](https://discord.gg/8y89vS8cRb)
|
||||
|
|
7
SECURITY.md
Normal file
7
SECURITY.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
# Security
|
||||
|
||||
Due to its nature, XPipe has to handle a lot of sensitive information. Therefore, the security, integrity, and privacy of your data has topmost priority.
|
||||
|
||||
More information about the security approach of the XPipe application can be found on the documentation website at https://docs.xpipe.io/reference/security.
|
||||
|
||||
You can report security vulnerabilities in this GitHub repository in a confidential manner. We will get back to you as soon as possible if you do.
|
|
@ -1,20 +0,0 @@
|
|||
[](https://maven-badges.herokuapp.com/maven-central/io.xpipe/xpipe-api)
|
||||
[](https://javadoc.io/doc/io.xpipe/xpipe-api)
|
||||
|
||||
## X-Pipe Java API
|
||||
|
||||
The X-Pipe API for Java allows you to use most of the X-Pipe functionality from Java applications:
|
||||
|
||||
- Create data stores and data sources
|
||||
- Query and work with the contents of data sources
|
||||
- Write data to data sources
|
||||
|
||||
## Setup
|
||||
|
||||
Either install the [maven dependency](https://maven-badges.herokuapp.com/maven-central/io.xpipe/xpipe-api) from Maven Central
|
||||
using your favourite build tool or alternatively download the `xpipe-api.jar`, `xpipe-core.jar`, and `xpipe-beacon.jar`
|
||||
from the [releases page](https://github.com/xpipe-io/xpipe_java/releases/latest) and add them to the classpath.
|
||||
|
||||
## Usage
|
||||
|
||||
See [the API documentation](https://xpipe-io.readthedocs.io/en/latest/dev/api/java/index.html).
|
|
@ -1,38 +0,0 @@
|
|||
plugins {
|
||||
id 'java-library'
|
||||
id 'maven-publish'
|
||||
id 'signing'
|
||||
id "org.moditect.gradleplugin" version "1.0.0-rc3"
|
||||
}
|
||||
|
||||
apply from: "$projectDir/../gradle_scripts/java.gradle"
|
||||
apply from: "$projectDir/../gradle_scripts/junit.gradle"
|
||||
|
||||
System.setProperty('excludeExtensionLibrary', 'true')
|
||||
apply from: "$projectDir/../gradle_scripts/extension_test.gradle"
|
||||
|
||||
version = file('../misc/version').text
|
||||
group = 'io.xpipe'
|
||||
archivesBaseName = 'xpipe-api'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
test {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api project(':core')
|
||||
implementation project(':beacon')
|
||||
implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: "2.13.0"
|
||||
}
|
||||
|
||||
configurations {
|
||||
testImplementation.extendsFrom(dep)
|
||||
}
|
||||
|
||||
|
||||
apply from: 'publish.gradle'
|
||||
apply from: "$projectDir/../gradle_scripts/publish-base.gradle"
|
|
@ -1,40 +0,0 @@
|
|||
publishing {
|
||||
publications {
|
||||
mavenJava(MavenPublication) {
|
||||
artifactId = project.archivesBaseName
|
||||
|
||||
from components.java
|
||||
|
||||
pom.withXml {
|
||||
def pomNode = asNode()
|
||||
pomNode.dependencies.'*'.findAll().each() {
|
||||
it.scope*.value = 'compile'
|
||||
}
|
||||
}
|
||||
|
||||
pom {
|
||||
name = 'X-Pipe Java API'
|
||||
description = 'Contains everything necessary to interact with X-Pipe from Java applications.'
|
||||
url = 'https://github.com/xpipe-io/xpipe_java/api'
|
||||
licenses {
|
||||
license {
|
||||
name = 'The MIT License (MIT)'
|
||||
url = 'https://github.com/xpipe-io/xpipe_java/LICENSE.md'
|
||||
}
|
||||
}
|
||||
developers {
|
||||
developer {
|
||||
id = 'crschnick'
|
||||
name = 'Christopher Schnick'
|
||||
email = 'crschnick@xpipe.io'
|
||||
}
|
||||
}
|
||||
scm {
|
||||
connection = 'scm:git:git://github.com/xpipe-io/xpipe_java.git'
|
||||
developerConnection = 'scm:git:ssh://github.com/xpipe-io/xpipe_java.git'
|
||||
url = 'https://github.com/xpipe-io/xpipe_java'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
package io.xpipe.api;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
public interface DataRaw extends DataSource {
|
||||
|
||||
InputStream open();
|
||||
|
||||
byte[] readAll();
|
||||
|
||||
byte[] read(int maxBytes);
|
||||
}
|
|
@ -1,229 +0,0 @@
|
|||
package io.xpipe.api;
|
||||
|
||||
import io.xpipe.api.impl.DataSourceImpl;
|
||||
import io.xpipe.core.source.DataSourceId;
|
||||
import io.xpipe.core.source.DataSourceReference;
|
||||
import io.xpipe.core.source.DataSourceType;
|
||||
import io.xpipe.core.store.DataStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Represents a reference to a data source that is managed by X-Pipe.
|
||||
* <p>
|
||||
* The actual data is only queried when required and is not cached.
|
||||
* Therefore, the queried data is always up-to-date at the point of calling a method that queries the data.
|
||||
* <p>
|
||||
* As soon a data source reference is created, the data source is locked
|
||||
* within X-Pipe to prevent concurrent modification and the problems that can arise from it.
|
||||
* By default, the lock is held until the calling program terminates and prevents
|
||||
* other applications from modifying the data source in any way.
|
||||
* To unlock the data source earlier, you can make use the {@link #unlock()} method.
|
||||
*/
|
||||
public interface DataSource {
|
||||
|
||||
/**
|
||||
* NOT YET IMPLEMENTED!
|
||||
* <p>
|
||||
* Creates a new supplier data source that will be interpreted as the generated data source.
|
||||
* In case this program should be a data source generator, this method has to be called at
|
||||
* least once to register that it actually generates a data source.
|
||||
* <p>
|
||||
* All content that is written to this data source until the generator program terminates is
|
||||
* will be available later on when the data source is used as a supplier later on.
|
||||
* <p>
|
||||
* In case this method is called multiple times, the same data source is returned.
|
||||
*
|
||||
* @return the generator data source
|
||||
*/
|
||||
static DataSource drain() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT YET IMPLEMENTED!
|
||||
* <p>
|
||||
* Creates a data source sink that will block with any read operations
|
||||
* until an external data producer routes the output into this sink.
|
||||
*/
|
||||
static DataSource sink() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for {@link #get(DataSourceReference)}.
|
||||
*
|
||||
* @throws IllegalArgumentException if {@code id} is not a valid data source id
|
||||
*/
|
||||
static DataSource getById(String id) {
|
||||
return get(DataSourceReference.id(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for {@link #get(DataSourceReference)} using the latest reference.
|
||||
*/
|
||||
static DataSource getLatest() {
|
||||
return get(DataSourceReference.latest());
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for {@link #get(DataSourceReference)} using a name reference.
|
||||
*/
|
||||
static DataSource getByName(String name) {
|
||||
return get(DataSourceReference.name(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the data source for a given reference.
|
||||
*
|
||||
* @param ref the data source reference
|
||||
*/
|
||||
static DataSource get(DataSourceReference ref) {
|
||||
return DataSourceImpl.get(ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the lock held by this program for this data source such
|
||||
* that other applications can modify the data source again.
|
||||
*/
|
||||
static void unlock() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for {@link #create(DataSourceId, String, InputStream)} that creates an anonymous data source.
|
||||
*/
|
||||
public static DataSource createAnonymous(String type, Path path) {
|
||||
return create(null, type, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for {@link #create(DataSourceId, String, InputStream)}.
|
||||
*/
|
||||
public static DataSource create(DataSourceId id, String type, Path path) {
|
||||
try (var in = Files.newInputStream(path)) {
|
||||
return create(id, type, in);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for {@link #create(DataSourceId, String, InputStream)} that creates an anonymous data source.
|
||||
*/
|
||||
public static DataSource createAnonymous(String type, URL url) {
|
||||
return create(null, type, url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for {@link #create(DataSourceId, String, InputStream)}.
|
||||
*/
|
||||
public static DataSource create(DataSourceId id, String type, URL url) {
|
||||
try (var in = url.openStream()) {
|
||||
return create(id, type, in);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for {@link #create(DataSourceId, String, InputStream)} that creates an anonymous data source.
|
||||
*/
|
||||
public static DataSource createAnonymous(String type, InputStream in) {
|
||||
return create(null, type, in);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new data source from an input stream.
|
||||
*
|
||||
* @param id the data source id
|
||||
* @param type the data source type
|
||||
* @param in the input stream to read
|
||||
* @return a {@link DataSource} instances that can be used to access the underlying data
|
||||
*/
|
||||
public static DataSource create(DataSourceId id, String type, InputStream in) {
|
||||
return DataSourceImpl.create(id, type, in);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new data source from an input stream.
|
||||
*
|
||||
* @param id the data source id
|
||||
* @return a {@link DataSource} instances that can be used to access the underlying data
|
||||
*/
|
||||
public static DataSource create(DataSourceId id, io.xpipe.core.source.DataSource<?> source) {
|
||||
return DataSourceImpl.create(id, source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new data source from an input stream.
|
||||
* 1
|
||||
*
|
||||
* @param id the data source id
|
||||
* @param type the data source type
|
||||
* @param in the data store to add
|
||||
* @return a {@link DataSource} instances that can be used to access the underlying data
|
||||
*/
|
||||
public static DataSource create(DataSourceId id, String type, DataStore in) {
|
||||
return DataSourceImpl.create(id, type, in);
|
||||
}
|
||||
|
||||
void forwardTo(DataSource target);
|
||||
|
||||
void appendTo(DataSource target);
|
||||
|
||||
public io.xpipe.core.source.DataSource<?> getInternalSource();
|
||||
|
||||
/**
|
||||
* Returns the id of this data source.
|
||||
*/
|
||||
DataSourceId getId();
|
||||
|
||||
/**
|
||||
* Returns the type of this data source.
|
||||
*/
|
||||
DataSourceType getType();
|
||||
|
||||
DataSourceConfig getConfig();
|
||||
|
||||
/**
|
||||
* Attempts to cast this object to a {@link DataTable}.
|
||||
*
|
||||
* @throws UnsupportedOperationException if the data source is not a table
|
||||
*/
|
||||
default DataTable asTable() {
|
||||
throw new UnsupportedOperationException("Data source is not a table");
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to cast this object to a {@link DataStructure}.
|
||||
*
|
||||
* @throws UnsupportedOperationException if the data source is not a structure
|
||||
*/
|
||||
default DataStructure asStructure() {
|
||||
throw new UnsupportedOperationException("Data source is not a structure");
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to cast this object to a {@link DataText}.
|
||||
*
|
||||
* @throws UnsupportedOperationException if the data source is not a text
|
||||
*/
|
||||
default DataText asText() {
|
||||
throw new UnsupportedOperationException("Data source is not a text");
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to cast this object to a {@link DataRaw}.
|
||||
*
|
||||
* @throws UnsupportedOperationException if the data source is not raw
|
||||
*/
|
||||
default DataRaw asRaw() {
|
||||
throw new UnsupportedOperationException("Data source is not raw");
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
package io.xpipe.api;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Represents the current configuration of a data source.
|
||||
*/
|
||||
public final class DataSourceConfig {
|
||||
|
||||
/**
|
||||
* The data source provider id.
|
||||
*/
|
||||
private final String provider;
|
||||
|
||||
/**
|
||||
* The set configuration parameters.
|
||||
*/
|
||||
private final Map<String, String> configInstance;
|
||||
|
||||
public DataSourceConfig(String provider, Map<String, String> configInstance) {
|
||||
this.provider = provider;
|
||||
this.configInstance = configInstance;
|
||||
}
|
||||
|
||||
public String getProvider() {
|
||||
return provider;
|
||||
}
|
||||
|
||||
public Map<String, String> getConfig() {
|
||||
return configInstance;
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
package io.xpipe.api;
|
||||
|
||||
import io.xpipe.api.connector.XPipeApiConnection;
|
||||
import io.xpipe.beacon.exchange.cli.StoreAddExchange;
|
||||
import io.xpipe.beacon.util.QuietDialogHandler;
|
||||
import io.xpipe.core.store.DataStore;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class DataStores {
|
||||
|
||||
public static void addNamedStore(DataStore store, String name) {
|
||||
XPipeApiConnection.execute(con -> {
|
||||
var req = StoreAddExchange.Request.builder()
|
||||
.storeInput(store)
|
||||
.name(name)
|
||||
.build();
|
||||
StoreAddExchange.Response res = con.performSimpleExchange(req);
|
||||
|
||||
new QuietDialogHandler(res.getConfig(), con, Map.of()).handle();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package io.xpipe.api;
|
||||
|
||||
import io.xpipe.core.data.node.DataStructureNode;
|
||||
|
||||
public interface DataStructure extends DataSource {
|
||||
DataStructureNode read();
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
package io.xpipe.api;
|
||||
|
||||
import io.xpipe.core.data.node.ArrayNode;
|
||||
import io.xpipe.core.data.node.TupleNode;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public interface DataTable extends Iterable<TupleNode>, DataSource {
|
||||
|
||||
Stream<TupleNode> stream();
|
||||
|
||||
ArrayNode readAll();
|
||||
|
||||
ArrayNode read(int maxRows);
|
||||
|
||||
default int countAndDiscard() {
|
||||
AtomicInteger count = new AtomicInteger();
|
||||
try (var stream = stream()) {
|
||||
stream.forEach(dataStructureNodes -> {
|
||||
count.getAndIncrement();
|
||||
});
|
||||
}
|
||||
return count.get();
|
||||
}
|
||||
}
|
|
@ -1,52 +0,0 @@
|
|||
package io.xpipe.api;
|
||||
|
||||
import io.xpipe.api.impl.DataTableAccumulatorImpl;
|
||||
import io.xpipe.core.data.node.DataStructureNode;
|
||||
import io.xpipe.core.data.node.DataStructureNodeAcceptor;
|
||||
import io.xpipe.core.data.type.TupleType;
|
||||
import io.xpipe.core.source.DataSourceId;
|
||||
|
||||
/**
|
||||
* An accumulator for table data.
|
||||
* <p>
|
||||
* This class can be used to construct new table data sources by
|
||||
* accumulating the rows using {@link #add(DataStructureNode)} or {@link #acceptor()} and then calling
|
||||
* {@link #finish(DataSourceId)} to complete the construction process and create a new data source.
|
||||
*/
|
||||
public interface DataTableAccumulator {
|
||||
|
||||
public static DataTableAccumulator create(TupleType type) {
|
||||
return new DataTableAccumulatorImpl(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for {@link #finish(DataSourceId)}.
|
||||
*/
|
||||
default DataTable finish(String id) {
|
||||
return finish(DataSourceId.fromString(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes the construction process and returns the data source reference.
|
||||
*
|
||||
* @param id the data source id to assign
|
||||
*/
|
||||
DataTable finish(DataSourceId id);
|
||||
|
||||
/**
|
||||
* Adds a row to the table.
|
||||
*
|
||||
* @param row the row to add
|
||||
*/
|
||||
void add(DataStructureNode row);
|
||||
|
||||
/**
|
||||
* Creates a tuple acceptor that adds all accepted tuples to the table.
|
||||
*/
|
||||
DataStructureNodeAcceptor<DataStructureNode> acceptor();
|
||||
|
||||
/**
|
||||
* Returns the current amount of rows added to the table.
|
||||
*/
|
||||
int getCurrentRows();
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
package io.xpipe.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public interface DataText extends DataSource {
|
||||
|
||||
List<String> readAllLines();
|
||||
|
||||
List<String> readLines(int maxLines);
|
||||
|
||||
Stream<String> lines();
|
||||
|
||||
String readAll();
|
||||
|
||||
String read(int maxCharacters);
|
||||
}
|
|
@ -1,151 +0,0 @@
|
|||
package io.xpipe.api.connector;
|
||||
|
||||
import io.xpipe.beacon.BeaconClient;
|
||||
import io.xpipe.beacon.BeaconConnection;
|
||||
import io.xpipe.beacon.BeaconException;
|
||||
import io.xpipe.beacon.BeaconServer;
|
||||
import io.xpipe.beacon.exchange.cli.DialogExchange;
|
||||
import io.xpipe.core.dialog.DialogReference;
|
||||
import io.xpipe.core.util.XPipeDaemonMode;
|
||||
import io.xpipe.core.util.XPipeInstallation;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public final class XPipeApiConnection extends BeaconConnection {
|
||||
|
||||
private XPipeApiConnection() {
|
||||
}
|
||||
|
||||
public static XPipeApiConnection open() {
|
||||
var con = new XPipeApiConnection();
|
||||
con.constructSocket();
|
||||
return con;
|
||||
}
|
||||
|
||||
public static void finishDialog(DialogReference reference) {
|
||||
try (var con = new XPipeApiConnection()) {
|
||||
con.constructSocket();
|
||||
var element = reference.getStart();
|
||||
while (true) {
|
||||
if (element != null && element.requiresExplicitUserInput()) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
|
||||
DialogExchange.Response response = con.performSimpleExchange(DialogExchange.Request.builder()
|
||||
.dialogKey(reference.getDialogId())
|
||||
.build());
|
||||
element = response.getElement();
|
||||
if (response.getElement() == null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (BeaconException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BeaconException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void execute(Handler handler) {
|
||||
try (var con = new XPipeApiConnection()) {
|
||||
con.constructSocket();
|
||||
handler.handle(con);
|
||||
} catch (BeaconException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BeaconException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> T execute(Mapper<T> mapper) {
|
||||
try (var con = new XPipeApiConnection()) {
|
||||
con.constructSocket();
|
||||
return mapper.handle(con);
|
||||
} catch (BeaconException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BeaconException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static Optional<BeaconClient> waitForStartup(Process process) {
|
||||
for (int i = 0; i < 160; i++) {
|
||||
if (process != null && !process.isAlive() && process.exitValue() != 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
|
||||
var s = BeaconClient.tryConnect(BeaconClient.ApiClientInformation.builder()
|
||||
.version("?")
|
||||
.language("Java")
|
||||
.build());
|
||||
if (s.isPresent()) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static void waitForShutdown() {
|
||||
for (int i = 0; i < 40; i++) {
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
|
||||
var r = BeaconServer.isRunning();
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void constructSocket() {
|
||||
if (!BeaconServer.isRunning()) {
|
||||
try {
|
||||
start();
|
||||
} catch (Exception ex) {
|
||||
throw new BeaconException("Unable to start xpipe daemon", ex);
|
||||
}
|
||||
|
||||
var r = waitForStartup(null);
|
||||
if (r.isEmpty()) {
|
||||
throw new BeaconException("Wait for xpipe daemon timed out");
|
||||
} else {
|
||||
beaconClient = r.get();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
beaconClient = BeaconClient.connect(BeaconClient.ApiClientInformation.builder()
|
||||
.version("?")
|
||||
.language("Java")
|
||||
.build());
|
||||
} catch (Exception ex) {
|
||||
throw new BeaconException("Unable to connect to running xpipe daemon", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void start() throws Exception {
|
||||
var installation = XPipeInstallation.getLocalDefaultInstallationBasePath(true);
|
||||
BeaconServer.start(installation, XPipeDaemonMode.BACKGROUND);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public static interface Handler {
|
||||
|
||||
void handle(BeaconConnection con) throws Exception;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public static interface Mapper<T> {
|
||||
|
||||
T handle(BeaconConnection con) throws Exception;
|
||||
}
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
package io.xpipe.api.impl;
|
||||
|
||||
import io.xpipe.api.DataRaw;
|
||||
import io.xpipe.api.DataSourceConfig;
|
||||
import io.xpipe.core.source.DataSourceId;
|
||||
import io.xpipe.core.source.DataSourceType;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
public class DataRawImpl extends DataSourceImpl implements DataRaw {
|
||||
|
||||
public DataRawImpl(
|
||||
DataSourceId sourceId,
|
||||
DataSourceConfig sourceConfig,
|
||||
io.xpipe.core.source.DataSource<?> internalSource
|
||||
) {
|
||||
super(sourceId, sourceConfig, internalSource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream open() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] readAll() {
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] read(int maxBytes) {
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataSourceType getType() {
|
||||
return DataSourceType.RAW;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataRaw asRaw() {
|
||||
return this;
|
||||
}
|
||||
}
|
|
@ -1,164 +0,0 @@
|
|||
package io.xpipe.api.impl;
|
||||
|
||||
import io.xpipe.api.DataSource;
|
||||
import io.xpipe.api.DataSourceConfig;
|
||||
import io.xpipe.api.connector.XPipeApiConnection;
|
||||
import io.xpipe.beacon.exchange.*;
|
||||
import io.xpipe.core.source.DataSourceId;
|
||||
import io.xpipe.core.source.DataSourceReference;
|
||||
import io.xpipe.core.store.DataStore;
|
||||
import io.xpipe.core.store.StreamDataStore;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
public abstract class DataSourceImpl implements DataSource {
|
||||
|
||||
private final DataSourceId sourceId;
|
||||
private final DataSourceConfig config;
|
||||
private final io.xpipe.core.source.DataSource<?> internalSource;
|
||||
|
||||
public DataSourceImpl(
|
||||
DataSourceId sourceId, DataSourceConfig config, io.xpipe.core.source.DataSource<?> internalSource
|
||||
) {
|
||||
this.sourceId = sourceId;
|
||||
this.config = config;
|
||||
this.internalSource = internalSource;
|
||||
}
|
||||
|
||||
public static DataSource get(DataSourceReference ds) {
|
||||
return XPipeApiConnection.execute(con -> {
|
||||
var req = QueryDataSourceExchange.Request.builder().ref(ds).build();
|
||||
QueryDataSourceExchange.Response res = con.performSimpleExchange(req);
|
||||
var config = new DataSourceConfig(res.getProvider(), res.getConfig());
|
||||
return switch (res.getType()) {
|
||||
case TABLE -> {
|
||||
yield new DataTableImpl(res.getId(), config, res.getInternalSource());
|
||||
}
|
||||
case STRUCTURE -> {
|
||||
yield new DataStructureImpl(res.getId(), config, res.getInternalSource());
|
||||
}
|
||||
case TEXT -> {
|
||||
yield new DataTextImpl(res.getId(), config, res.getInternalSource());
|
||||
}
|
||||
case RAW -> {
|
||||
yield new DataRawImpl(res.getId(), config, res.getInternalSource());
|
||||
}
|
||||
case COLLECTION -> throw new UnsupportedOperationException("Unimplemented case: " + res.getType());
|
||||
default -> throw new IllegalArgumentException("Unexpected value: " + res.getType());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static DataSource create(DataSourceId id, io.xpipe.core.source.DataSource<?> source) {
|
||||
var startReq =
|
||||
AddSourceExchange.Request.builder().source(source).target(id).build();
|
||||
var returnedId = XPipeApiConnection.execute(con -> {
|
||||
AddSourceExchange.Response r = con.performSimpleExchange(startReq);
|
||||
return r.getId();
|
||||
});
|
||||
|
||||
var ref = DataSourceReference.id(returnedId);
|
||||
return get(ref);
|
||||
}
|
||||
|
||||
public static DataSource create(DataSourceId id, String type, DataStore store) {
|
||||
if (store instanceof StreamDataStore s && s.isContentExclusivelyAccessible()) {
|
||||
store = XPipeApiConnection.execute(con -> {
|
||||
var internal = con.createInternalStreamStore();
|
||||
var req = WriteStreamExchange.Request.builder()
|
||||
.name(internal.getUuid().toString())
|
||||
.build();
|
||||
con.performOutputExchange(req, out -> {
|
||||
try (InputStream inputStream = s.openInput()) {
|
||||
inputStream.transferTo(out);
|
||||
}
|
||||
});
|
||||
return internal;
|
||||
});
|
||||
}
|
||||
|
||||
var startReq = ReadExchange.Request.builder()
|
||||
.provider(type)
|
||||
.store(store)
|
||||
.target(id)
|
||||
.configureAll(false)
|
||||
.build();
|
||||
var startRes = XPipeApiConnection.execute(con -> {
|
||||
ReadExchange.Response r = con.performSimpleExchange(startReq);
|
||||
return r;
|
||||
});
|
||||
|
||||
var configInstance = startRes.getConfig();
|
||||
XPipeApiConnection.finishDialog(configInstance);
|
||||
|
||||
var ref = id != null ? DataSourceReference.id(id) : DataSourceReference.latest();
|
||||
return get(ref);
|
||||
}
|
||||
|
||||
public static DataSource create(DataSourceId id, String type, InputStream in) {
|
||||
var store = XPipeApiConnection.execute(con -> {
|
||||
var internal = con.createInternalStreamStore();
|
||||
var req = WriteStreamExchange.Request.builder()
|
||||
.name(internal.getUuid().toString())
|
||||
.build();
|
||||
con.performOutputExchange(req, out -> {
|
||||
in.transferTo(out);
|
||||
});
|
||||
return internal;
|
||||
});
|
||||
|
||||
var startReq = ReadExchange.Request.builder()
|
||||
.provider(type)
|
||||
.store(store)
|
||||
.target(id)
|
||||
.configureAll(false)
|
||||
.build();
|
||||
var startRes = XPipeApiConnection.execute(con -> {
|
||||
ReadExchange.Response r = con.performSimpleExchange(startReq);
|
||||
return r;
|
||||
});
|
||||
|
||||
var configInstance = startRes.getConfig();
|
||||
XPipeApiConnection.finishDialog(configInstance);
|
||||
|
||||
var ref = id != null ? DataSourceReference.id(id) : DataSourceReference.latest();
|
||||
return get(ref);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void forwardTo(DataSource target) {
|
||||
XPipeApiConnection.execute(con -> {
|
||||
var req = ForwardExchange.Request.builder()
|
||||
.source(DataSourceReference.id(sourceId))
|
||||
.target(DataSourceReference.id(target.getId()))
|
||||
.build();
|
||||
ForwardExchange.Response res = con.performSimpleExchange(req);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void appendTo(DataSource target) {
|
||||
XPipeApiConnection.execute(con -> {
|
||||
var req = ForwardExchange.Request.builder()
|
||||
.source(DataSourceReference.id(sourceId))
|
||||
.target(DataSourceReference.id(target.getId()))
|
||||
.append(true)
|
||||
.build();
|
||||
ForwardExchange.Response res = con.performSimpleExchange(req);
|
||||
});
|
||||
}
|
||||
|
||||
public io.xpipe.core.source.DataSource<?> getInternalSource() {
|
||||
return internalSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataSourceId getId() {
|
||||
return sourceId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataSourceConfig getConfig() {
|
||||
return config;
|
||||
}
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
package io.xpipe.api.impl;
|
||||
|
||||
import io.xpipe.api.DataSourceConfig;
|
||||
import io.xpipe.api.DataStructure;
|
||||
import io.xpipe.core.data.node.DataStructureNode;
|
||||
import io.xpipe.core.source.DataSourceId;
|
||||
import io.xpipe.core.source.DataSourceType;
|
||||
|
||||
public class DataStructureImpl extends DataSourceImpl implements DataStructure {
|
||||
|
||||
DataStructureImpl(
|
||||
DataSourceId sourceId,
|
||||
DataSourceConfig sourceConfig,
|
||||
io.xpipe.core.source.DataSource<?> internalSource
|
||||
) {
|
||||
super(sourceId, sourceConfig, internalSource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataSourceType getType() {
|
||||
return DataSourceType.STRUCTURE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataStructure asStructure() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataStructureNode read() {
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -1,110 +0,0 @@
|
|||
package io.xpipe.api.impl;
|
||||
|
||||
import io.xpipe.api.DataSource;
|
||||
import io.xpipe.api.DataTable;
|
||||
import io.xpipe.api.DataTableAccumulator;
|
||||
import io.xpipe.api.connector.XPipeApiConnection;
|
||||
import io.xpipe.api.util.TypeDescriptor;
|
||||
import io.xpipe.beacon.BeaconException;
|
||||
import io.xpipe.beacon.exchange.ReadExchange;
|
||||
import io.xpipe.beacon.exchange.WriteStreamExchange;
|
||||
import io.xpipe.beacon.exchange.cli.StoreAddExchange;
|
||||
import io.xpipe.beacon.util.QuietDialogHandler;
|
||||
import io.xpipe.core.data.node.DataStructureNode;
|
||||
import io.xpipe.core.data.node.DataStructureNodeAcceptor;
|
||||
import io.xpipe.core.data.node.TupleNode;
|
||||
import io.xpipe.core.data.type.TupleType;
|
||||
import io.xpipe.core.data.typed.TypedDataStreamWriter;
|
||||
import io.xpipe.core.impl.InternalStreamStore;
|
||||
import io.xpipe.core.source.DataSourceId;
|
||||
import io.xpipe.core.source.DataSourceReference;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class DataTableAccumulatorImpl implements DataTableAccumulator {
|
||||
|
||||
private final XPipeApiConnection connection;
|
||||
private final TupleType type;
|
||||
private int rows;
|
||||
private InternalStreamStore store;
|
||||
private TupleType writtenDescriptor;
|
||||
private OutputStream bodyOutput;
|
||||
|
||||
public DataTableAccumulatorImpl(TupleType type) {
|
||||
this.type = type;
|
||||
connection = XPipeApiConnection.open();
|
||||
|
||||
store = new InternalStreamStore();
|
||||
var addReq = StoreAddExchange.Request.builder().storeInput(store).name(store.getUuid().toString()).build();
|
||||
StoreAddExchange.Response addRes = connection.performSimpleExchange(addReq);
|
||||
QuietDialogHandler.handle(addRes.getConfig(), connection);
|
||||
|
||||
connection.sendRequest(WriteStreamExchange.Request.builder().name(store.getUuid().toString()).build());
|
||||
bodyOutput = connection.sendBody();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized DataTable finish(DataSourceId id) {
|
||||
try {
|
||||
bodyOutput.close();
|
||||
} catch (IOException e) {
|
||||
throw new BeaconException(e);
|
||||
}
|
||||
|
||||
WriteStreamExchange.Response res = connection.receiveResponse();
|
||||
connection.close();
|
||||
|
||||
var req = ReadExchange.Request.builder()
|
||||
.target(id)
|
||||
.store(store)
|
||||
.provider("xpbt")
|
||||
.configureAll(false)
|
||||
.build();
|
||||
ReadExchange.Response response = XPipeApiConnection.execute(con -> {
|
||||
return con.performSimpleExchange(req);
|
||||
});
|
||||
|
||||
var configInstance = response.getConfig();
|
||||
XPipeApiConnection.finishDialog(configInstance);
|
||||
|
||||
return DataSource.get(DataSourceReference.id(id)).asTable();
|
||||
}
|
||||
|
||||
private void writeDescriptor() {
|
||||
if (writtenDescriptor != null) {
|
||||
return;
|
||||
}
|
||||
writtenDescriptor = TupleType.tableType(type.getNames());
|
||||
|
||||
connection.withOutputStream(out -> {
|
||||
out.write((TypeDescriptor.create(type.getNames())).getBytes(StandardCharsets.UTF_8));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void add(DataStructureNode row) {
|
||||
TupleNode toUse = type.matches(row)
|
||||
? row.asTuple()
|
||||
: type.convert(row).orElseThrow().asTuple();
|
||||
connection.withOutputStream(out -> {
|
||||
writeDescriptor();
|
||||
TypedDataStreamWriter.writeStructure(out, toUse, writtenDescriptor);
|
||||
rows++;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized DataStructureNodeAcceptor<DataStructureNode> acceptor() {
|
||||
return node -> {
|
||||
add(node);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized int getCurrentRows() {
|
||||
return rows;
|
||||
}
|
||||
}
|
|
@ -1,128 +0,0 @@
|
|||
package io.xpipe.api.impl;
|
||||
|
||||
import io.xpipe.api.DataSourceConfig;
|
||||
import io.xpipe.api.DataTable;
|
||||
import io.xpipe.api.connector.XPipeApiConnection;
|
||||
import io.xpipe.beacon.BeaconConnection;
|
||||
import io.xpipe.beacon.BeaconException;
|
||||
import io.xpipe.beacon.exchange.api.QueryTableDataExchange;
|
||||
import io.xpipe.core.data.node.ArrayNode;
|
||||
import io.xpipe.core.data.node.DataStructureNode;
|
||||
import io.xpipe.core.data.node.TupleNode;
|
||||
import io.xpipe.core.data.typed.TypedAbstractReader;
|
||||
import io.xpipe.core.data.typed.TypedDataStreamParser;
|
||||
import io.xpipe.core.data.typed.TypedDataStructureNodeReader;
|
||||
import io.xpipe.core.source.DataSourceId;
|
||||
import io.xpipe.core.source.DataSourceReference;
|
||||
import io.xpipe.core.source.DataSourceType;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
public class DataTableImpl extends DataSourceImpl implements DataTable {
|
||||
|
||||
DataTableImpl(
|
||||
DataSourceId id,
|
||||
DataSourceConfig sourceConfig,
|
||||
io.xpipe.core.source.DataSource<?> internalSource
|
||||
) {
|
||||
super(id, sourceConfig, internalSource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataTable asTable() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Stream<TupleNode> stream() {
|
||||
var iterator = new TableIterator();
|
||||
return StreamSupport.stream(Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false)
|
||||
.onClose(iterator::finish);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataSourceType getType() {
|
||||
return DataSourceType.TABLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ArrayNode readAll() {
|
||||
return read(Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ArrayNode read(int maxRows) {
|
||||
List<DataStructureNode> nodes = new ArrayList<>();
|
||||
XPipeApiConnection.execute(con -> {
|
||||
var req = QueryTableDataExchange.Request.builder()
|
||||
.ref(DataSourceReference.id(getId()))
|
||||
.maxRows(maxRows)
|
||||
.build();
|
||||
con.performInputExchange(req, (QueryTableDataExchange.Response res, InputStream in) -> {
|
||||
var r = new TypedDataStreamParser(res.getDataType());
|
||||
|
||||
r.parseStructures(in, TypedDataStructureNodeReader.of(res.getDataType()), nodes::add);
|
||||
});
|
||||
});
|
||||
return ArrayNode.of(nodes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<TupleNode> iterator() {
|
||||
return new TableIterator();
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
private class TableIterator implements Iterator<TupleNode> {
|
||||
|
||||
private final BeaconConnection connection;
|
||||
private final TypedDataStreamParser parser;
|
||||
private final TypedAbstractReader nodeReader;
|
||||
private TupleNode node;
|
||||
|
||||
{
|
||||
connection = XPipeApiConnection.open();
|
||||
var req = QueryTableDataExchange.Request.builder()
|
||||
.ref(DataSourceReference.id(getId()))
|
||||
.maxRows(Integer.MAX_VALUE)
|
||||
.build();
|
||||
connection.sendRequest(req);
|
||||
QueryTableDataExchange.Response response = connection.receiveResponse();
|
||||
|
||||
nodeReader = TypedDataStructureNodeReader.of(response.getDataType());
|
||||
parser = new TypedDataStreamParser(response.getDataType());
|
||||
|
||||
connection.receiveBody();
|
||||
}
|
||||
|
||||
private void finish() {
|
||||
connection.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
connection.checkClosed();
|
||||
|
||||
try {
|
||||
node = (TupleNode) parser.parseStructure(connection.getInputStream(), nodeReader);
|
||||
} catch (IOException e) {
|
||||
throw new BeaconException(e);
|
||||
}
|
||||
if (node == null) {
|
||||
// finish();
|
||||
}
|
||||
return node != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TupleNode next() {
|
||||
connection.checkClosed();
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,122 +0,0 @@
|
|||
package io.xpipe.api.impl;
|
||||
|
||||
import io.xpipe.api.DataSourceConfig;
|
||||
import io.xpipe.api.DataText;
|
||||
import io.xpipe.api.connector.XPipeApiConnection;
|
||||
import io.xpipe.beacon.BeaconConnection;
|
||||
import io.xpipe.beacon.BeaconException;
|
||||
import io.xpipe.beacon.exchange.api.QueryTextDataExchange;
|
||||
import io.xpipe.core.source.DataSourceId;
|
||||
import io.xpipe.core.source.DataSourceReference;
|
||||
import io.xpipe.core.source.DataSourceType;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Spliterator;
|
||||
import java.util.Spliterators;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
public class DataTextImpl extends DataSourceImpl implements DataText {
|
||||
|
||||
DataTextImpl(
|
||||
DataSourceId sourceId,
|
||||
DataSourceConfig sourceConfig,
|
||||
io.xpipe.core.source.DataSource<?> internalSource
|
||||
) {
|
||||
super(sourceId, sourceConfig, internalSource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataSourceType getType() {
|
||||
return DataSourceType.TEXT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataText asText() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> readAllLines() {
|
||||
return readLines(Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> readLines(int maxLines) {
|
||||
try (Stream<String> lines = lines()) {
|
||||
return lines.limit(maxLines).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<String> lines() {
|
||||
var iterator = new Iterator<String>() {
|
||||
|
||||
private final BeaconConnection connection;
|
||||
private final BufferedReader reader;
|
||||
private String nextValue;
|
||||
|
||||
{
|
||||
connection = XPipeApiConnection.open();
|
||||
var req = QueryTextDataExchange.Request.builder()
|
||||
.ref(DataSourceReference.id(getId()))
|
||||
.maxLines(-1)
|
||||
.build();
|
||||
connection.sendRequest(req);
|
||||
connection.receiveResponse();
|
||||
reader = new BufferedReader(new InputStreamReader(connection.receiveBody(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private void close() {
|
||||
connection.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
connection.checkClosed();
|
||||
|
||||
try {
|
||||
nextValue = reader.readLine();
|
||||
} catch (IOException e) {
|
||||
throw new BeaconException(e);
|
||||
}
|
||||
return nextValue != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String next() {
|
||||
return nextValue;
|
||||
}
|
||||
};
|
||||
|
||||
return StreamSupport.stream(Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false)
|
||||
.onClose(iterator::close);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String readAll() {
|
||||
try (Stream<String> lines = lines()) {
|
||||
return lines.collect(Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String read(int maxCharacters) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
lines().takeWhile(s -> {
|
||||
if (builder.length() > maxCharacters) {
|
||||
return false;
|
||||
}
|
||||
|
||||
builder.append(s);
|
||||
return true;
|
||||
});
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
package io.xpipe.api.util;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class TypeDescriptor {
|
||||
|
||||
public static String create(List<String> names) {
|
||||
return "[" + names.stream().map(n -> n != null ? "\"" + n + "\"" : null).collect(Collectors.joining(","))
|
||||
+ "]\n";
|
||||
}
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
module io.xpipe.api {
|
||||
exports io.xpipe.api;
|
||||
exports io.xpipe.api.connector;
|
||||
exports io.xpipe.api.util;
|
||||
|
||||
requires transitive io.xpipe.core;
|
||||
requires io.xpipe.beacon;
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
package io.xpipe.api.test;
|
||||
|
||||
import io.xpipe.beacon.BeaconDaemonController;
|
||||
import io.xpipe.core.util.XPipeDaemonMode;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
|
||||
public class ApiTest {
|
||||
|
||||
@BeforeAll
|
||||
public static void setup() throws Exception {
|
||||
BeaconDaemonController.start(XPipeDaemonMode.TRAY);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
public static void teardown() throws Exception {
|
||||
BeaconDaemonController.stop();
|
||||
}
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
package io.xpipe.api.test;
|
||||
|
||||
import io.xpipe.api.DataTableAccumulator;
|
||||
import io.xpipe.core.data.node.TupleNode;
|
||||
import io.xpipe.core.data.node.ValueNode;
|
||||
import io.xpipe.core.data.type.TupleType;
|
||||
import io.xpipe.core.data.type.ValueType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class DataTableAccumulatorTest extends ApiTest {
|
||||
|
||||
@Test
|
||||
public void test() {
|
||||
var type = TupleType.of(List.of("col1", "col2"), List.of(ValueType.of(), ValueType.of()));
|
||||
var acc = DataTableAccumulator.create(type);
|
||||
|
||||
var val = type.convert(TupleNode.of(List.of(ValueNode.of("val1"), ValueNode.of("val2"))))
|
||||
.orElseThrow();
|
||||
acc.add(val);
|
||||
var table = acc.finish(":test");
|
||||
|
||||
// Assertions.assertEquals(table.getInfo().getDataType(), TupleType.tableType(List.of("col1", "col2")));
|
||||
// Assertions.assertEquals(table.getInfo().getRowCountIfPresent(), OptionalInt.empty());
|
||||
// var read = table.read(1).at(0);
|
||||
// Assertions.assertEquals(val, read);
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
package io.xpipe.api.test;
|
||||
|
||||
import io.xpipe.api.DataSource;
|
||||
import io.xpipe.core.source.DataSourceId;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class DataTableTest extends ApiTest {
|
||||
|
||||
@BeforeAll
|
||||
public static void setupStorage() throws Exception {
|
||||
DataSource.create(
|
||||
DataSourceId.fromString(":usernames"), "csv", DataTableTest.class.getResource("username.csv"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGet() {
|
||||
var table = DataSource.getById(":usernames").asTable();
|
||||
var r = table.read(2);
|
||||
var a = 0;
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
module io.xpipe.api.test {
|
||||
requires io.xpipe.api;
|
||||
requires io.xpipe.beacon;
|
||||
requires org.junit.jupiter.api;
|
||||
|
||||
opens io.xpipe.api.test;
|
||||
|
||||
exports io.xpipe.api.test;
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
Username;Identifier ;First name;Last name
|
||||
booker12;9012;Rachel;Booker
|
||||
grey07;2070;Laura;Grey
|
||||
johnson81;4081;Craig;Johnson
|
||||
jenkins46;9346;Mary;Jenkins
|
||||
smith79;5079;Jamie;Smith
|
|
182
app/build.gradle
Normal file
182
app/build.gradle
Normal file
|
@ -0,0 +1,182 @@
|
|||
plugins {
|
||||
id 'application'
|
||||
id 'jvm-test-suite'
|
||||
id 'java-library'
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
apply from: "$rootDir/gradle/gradle_scripts/java.gradle"
|
||||
apply from: "$rootDir/gradle/gradle_scripts/javafx.gradle"
|
||||
apply from: "$rootDir/gradle/gradle_scripts/jna.gradle"
|
||||
apply from: "$rootDir/gradle/gradle_scripts/lombok.gradle"
|
||||
|
||||
configurations {
|
||||
implementation.extendsFrom(javafx)
|
||||
api.extendsFrom(jna)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api project(':core')
|
||||
api project(':beacon')
|
||||
|
||||
compileOnly 'org.hamcrest:hamcrest:3.0'
|
||||
compileOnly 'org.junit.jupiter:junit-jupiter-api:5.11.4'
|
||||
compileOnly 'org.junit.jupiter:junit-jupiter-params:5.11.4'
|
||||
|
||||
api 'com.vladsch.flexmark:flexmark:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-util:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-util-options:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-util-data:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-util-ast:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-util-builder:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-util-sequence:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-util-misc:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-util-dependency:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-util-collection:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-util-format:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-util-html:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-util-visitor:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-ext-tables:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-ext-gfm-tasklist:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-ext-footnotes:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-ext-definition:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-ext-anchorlink:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-ext-yaml-front-matter:0.64.8'
|
||||
api 'com.vladsch.flexmark:flexmark-ext-toc:0.64.8'
|
||||
|
||||
api("com.github.weisj:jsvg:1.7.1")
|
||||
api files("$rootDir/gradle/gradle_scripts/markdowngenerator-1.3.1.1.jar")
|
||||
api files("$rootDir/gradle/gradle_scripts/vernacular-1.16.jar")
|
||||
api 'org.bouncycastle:bcprov-jdk18on:1.80'
|
||||
api 'info.picocli:picocli:4.7.6'
|
||||
api ('org.kohsuke:github-api:1.326') {
|
||||
exclude group: 'org.apache.commons', module: 'commons-lang3'
|
||||
}
|
||||
api 'org.apache.commons:commons-lang3:3.17.0'
|
||||
api 'io.sentry:sentry:7.20.0'
|
||||
api 'commons-io:commons-io:2.18.0'
|
||||
api group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: "2.18.2"
|
||||
api group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: "2.18.2"
|
||||
api group: 'org.kordamp.ikonli', name: 'ikonli-material2-pack', version: "12.2.0"
|
||||
api group: 'org.kordamp.ikonli', name: 'ikonli-materialdesign2-pack', version: "12.2.0"
|
||||
api group: 'org.kordamp.ikonli', name: 'ikonli-javafx', version: "12.2.0"
|
||||
api group: 'org.kordamp.ikonli', name: 'ikonli-material-pack', version: "12.2.0"
|
||||
api group: 'org.kordamp.ikonli', name: 'ikonli-feather-pack', version: "12.2.0"
|
||||
api group: 'org.slf4j', name: 'slf4j-api', version: '2.0.16'
|
||||
api group: 'org.slf4j', name: 'slf4j-jdk-platform-logging', version: '2.0.16'
|
||||
api 'io.xpipe:modulefs:0.1.6'
|
||||
api 'net.synedra:validatorfx:0.4.2'
|
||||
api files("$rootDir/gradle/gradle_scripts/atlantafx-base-2.0.2.jar")
|
||||
}
|
||||
|
||||
apply from: "$rootDir/gradle/gradle_scripts/local_junit_suite.gradle"
|
||||
|
||||
def extensionJarDepList = project.allExtensions.stream().map(p -> p.getTasksByName('jar', true)).toList();
|
||||
jar {
|
||||
finalizedBy(extensionJarDepList)
|
||||
}
|
||||
|
||||
application {
|
||||
mainModule = 'io.xpipe.app'
|
||||
mainClass = 'io.xpipe.app.Main'
|
||||
applicationDefaultJvmArgs = jvmRunArgs
|
||||
}
|
||||
|
||||
run {
|
||||
systemProperty 'io.xpipe.app.useVirtualThreads', 'false'
|
||||
systemProperty 'io.xpipe.app.mode', 'gui'
|
||||
systemProperty 'io.xpipe.app.writeLogs', "true"
|
||||
systemProperty 'io.xpipe.app.writeSysOut', "true"
|
||||
systemProperty 'io.xpipe.app.developerMode', "true"
|
||||
systemProperty 'io.xpipe.app.logLevel', "trace"
|
||||
systemProperty 'io.xpipe.app.fullVersion', rootProject.fullVersion
|
||||
systemProperty 'io.xpipe.app.staging', isStage
|
||||
// systemProperty 'io.xpipe.beacon.port', "30000"
|
||||
|
||||
// Apply passed xpipe properties
|
||||
for (final def e in System.getProperties().entrySet()) {
|
||||
if (e.getKey().toString().contains("xpipe")) {
|
||||
systemProperty e.getKey().toString(), e.getValue()
|
||||
}
|
||||
}
|
||||
|
||||
workingDir = rootDir
|
||||
jvmArgs += ['-XX:+EnableDynamicAgentLoading']
|
||||
|
||||
def exts = files(project.allExtensions.stream().map(p -> p.getTasksByName('jar', true)[0].outputs.files.singleFile).toList());
|
||||
classpath += exts
|
||||
|
||||
dependsOn(project.allExtensions.stream().map(p -> p.getTasksByName('jar', true)[0]).toList())
|
||||
}
|
||||
|
||||
task runAttachedDebugger(type: JavaExec) {
|
||||
workingDir = rootDir
|
||||
classpath = run.classpath
|
||||
mainModule = 'io.xpipe.app'
|
||||
mainClass = 'io.xpipe.app.Main'
|
||||
modularity.inferModulePath = true
|
||||
jvmArgs += jvmRunArgs
|
||||
jvmArgs += List.of(
|
||||
"-javaagent:${System.getProperty("user.home")}/.attachme/attachme-agent-1.2.9.jar=port:7857,host:localhost".toString(),
|
||||
"-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0"
|
||||
)
|
||||
jvmArgs += ['-XX:+EnableDynamicAgentLoading']
|
||||
systemProperties run.systemProperties
|
||||
|
||||
def exts = files(project.allExtensions.stream().map(p -> p.getTasksByName('jar', true)[0].outputs.files.singleFile).toList());
|
||||
classpath += exts
|
||||
dependsOn(project.allExtensions.stream().map(p -> p.getTasksByName('jar', true)[0]).toList())
|
||||
|
||||
}
|
||||
|
||||
processResources {
|
||||
doLast {
|
||||
def cssFiles = fileTree(dir: "$sourceSets.main.output.resourcesDir/io/xpipe/app/resources/style")
|
||||
cssFiles.include "**/*.css"
|
||||
cssFiles.each { css ->
|
||||
logger.info("converting CSS to BSS ${css}");
|
||||
|
||||
javaexec {
|
||||
workingDir = project.projectDir
|
||||
jvmArgs += "--module-path=${configurations.javafx.asFileTree.asPath},"
|
||||
jvmArgs += "--add-modules=javafx.graphics"
|
||||
main = "com.sun.javafx.css.parser.Css2Bin"
|
||||
args css
|
||||
}
|
||||
|
||||
delete css
|
||||
}
|
||||
}
|
||||
|
||||
doLast {
|
||||
def resourcesDir = new File(sourceSets.main.output.resourcesDir, "io/xpipe/app/resources/third-party")
|
||||
resourcesDir.mkdirs()
|
||||
copy {
|
||||
from "$rootDir/dist/licenses"
|
||||
into resourcesDir
|
||||
}
|
||||
}
|
||||
|
||||
doLast {
|
||||
copy {
|
||||
from file("$rootDir/openapi.yaml")
|
||||
into file("${sourceSets.main.output.resourcesDir}/io/xpipe/app/resources/misc");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
distTar {
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
distZip {
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
assembleDist {
|
||||
enabled = false;
|
||||
}
|
4
app/src/localTest/java/module-info.java
Normal file
4
app/src/localTest/java/module-info.java
Normal file
|
@ -0,0 +1,4 @@
|
|||
open module io.xpipe.app.localTest {
|
||||
requires org.junit.jupiter.api;
|
||||
requires io.xpipe.app;
|
||||
}
|
13
app/src/localTest/java/test/Test.java
Normal file
13
app/src/localTest/java/test/Test.java
Normal file
|
@ -0,0 +1,13 @@
|
|||
package test;
|
||||
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.test.LocalExtensionTest;
|
||||
|
||||
public class Test extends LocalExtensionTest {
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
public void test() {
|
||||
System.out.println("a");
|
||||
System.out.println(DataStorage.get().getStoreEntries());
|
||||
}
|
||||
}
|
29
app/src/main/java/io/xpipe/app/Main.java
Normal file
29
app/src/main/java/io/xpipe/app/Main.java
Normal file
|
@ -0,0 +1,29 @@
|
|||
package io.xpipe.app;
|
||||
|
||||
import io.xpipe.app.core.AppProperties;
|
||||
import io.xpipe.app.core.mode.OperationMode;
|
||||
|
||||
public class Main {
|
||||
|
||||
public static void main(String[] args) {
|
||||
if (args.length == 1 && args[0].equals("version")) {
|
||||
AppProperties.init(args);
|
||||
System.out.println(AppProperties.get().getVersion());
|
||||
return;
|
||||
}
|
||||
|
||||
// Since this is not marked as a console application, it will not print anything when you run it in a console on
|
||||
// Windows
|
||||
if (args.length == 1 && args[0].equals("--help")) {
|
||||
System.out.println(
|
||||
"""
|
||||
The daemon executable xpiped does not accept any command-line arguments.
|
||||
|
||||
For a reference on how to use xpipe from the command-line, take a look at https://docs.xpipe.io/cli.
|
||||
""");
|
||||
return;
|
||||
}
|
||||
|
||||
OperationMode.init(args);
|
||||
}
|
||||
}
|
26
app/src/main/java/io/xpipe/app/beacon/AppBeaconCache.java
Normal file
26
app/src/main/java/io/xpipe/app/beacon/AppBeaconCache.java
Normal file
|
@ -0,0 +1,26 @@
|
|||
package io.xpipe.app.beacon;
|
||||
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Value
|
||||
public class AppBeaconCache {
|
||||
|
||||
Set<BeaconShellSession> shellSessions = new HashSet<>();
|
||||
|
||||
public BeaconShellSession getShellSession(UUID uuid) throws BeaconClientException {
|
||||
var found = shellSessions.stream()
|
||||
.filter(beaconShellSession ->
|
||||
beaconShellSession.getEntry().getUuid().equals(uuid))
|
||||
.findFirst();
|
||||
if (found.isEmpty()) {
|
||||
throw new BeaconClientException("No active shell session known for id " + uuid);
|
||||
}
|
||||
return found.get();
|
||||
}
|
||||
}
|
221
app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java
Normal file
221
app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java
Normal file
|
@ -0,0 +1,221 @@
|
|||
package io.xpipe.app.beacon;
|
||||
|
||||
import io.xpipe.app.issue.ErrorEvent;
|
||||
import io.xpipe.app.issue.TrackEvent;
|
||||
import io.xpipe.app.resources.AppResources;
|
||||
import io.xpipe.app.util.MarkdownHelper;
|
||||
import io.xpipe.beacon.BeaconConfig;
|
||||
import io.xpipe.beacon.BeaconInterface;
|
||||
import io.xpipe.core.process.OsType;
|
||||
import io.xpipe.core.util.XPipeInstallation;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.Inet4Address;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class AppBeaconServer {
|
||||
|
||||
private static AppBeaconServer INSTANCE;
|
||||
|
||||
@Getter
|
||||
private final int port;
|
||||
|
||||
@Getter
|
||||
private final boolean propertyPort;
|
||||
|
||||
private boolean running;
|
||||
private ExecutorService executor;
|
||||
private HttpServer server;
|
||||
|
||||
@Getter
|
||||
private final Set<BeaconSession> sessions = new HashSet<>();
|
||||
|
||||
@Getter
|
||||
private final AppBeaconCache cache = new AppBeaconCache();
|
||||
|
||||
@Getter
|
||||
private String localAuthSecret;
|
||||
|
||||
private String notFoundHtml;
|
||||
private final Map<String, String> resources = new HashMap<>();
|
||||
|
||||
public static void setupPort() {
|
||||
int port;
|
||||
boolean propertyPort;
|
||||
if (System.getProperty(BeaconConfig.BEACON_PORT_PROP) != null) {
|
||||
port = BeaconConfig.getUsedPort();
|
||||
propertyPort = true;
|
||||
} else {
|
||||
port = XPipeInstallation.getDefaultBeaconPort();
|
||||
propertyPort = false;
|
||||
}
|
||||
INSTANCE = new AppBeaconServer(port, propertyPort);
|
||||
}
|
||||
|
||||
private AppBeaconServer(int port, boolean propertyPort) {
|
||||
this.port = port;
|
||||
this.propertyPort = propertyPort;
|
||||
}
|
||||
|
||||
public static void init() {
|
||||
try {
|
||||
INSTANCE.initAuthSecret();
|
||||
INSTANCE.start();
|
||||
TrackEvent.withInfo("Started http server")
|
||||
.tag("port", INSTANCE.getPort())
|
||||
.build()
|
||||
.handle();
|
||||
} catch (Exception ex) {
|
||||
// Not terminal!
|
||||
// We can still continue without the running server
|
||||
ErrorEvent.fromThrowable("Unable to start local http server on port " + INSTANCE.getPort(), ex)
|
||||
.build()
|
||||
.handle();
|
||||
}
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
if (INSTANCE != null) {
|
||||
INSTANCE.stop();
|
||||
INSTANCE.deleteAuthSecret();
|
||||
INSTANCE = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void addSession(BeaconSession session) {
|
||||
this.sessions.add(session);
|
||||
}
|
||||
|
||||
public static AppBeaconServer get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private void stop() {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
|
||||
running = false;
|
||||
server.stop(0);
|
||||
executor.shutdown();
|
||||
try {
|
||||
executor.awaitTermination(30, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException ignored) {}
|
||||
}
|
||||
|
||||
private void initAuthSecret() throws IOException {
|
||||
var file = XPipeInstallation.getLocalBeaconAuthFile();
|
||||
var id = UUID.randomUUID().toString();
|
||||
Files.writeString(file, id);
|
||||
if (OsType.getLocal() != OsType.WINDOWS) {
|
||||
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-rw----"));
|
||||
}
|
||||
localAuthSecret = id;
|
||||
}
|
||||
|
||||
private void deleteAuthSecret() {
|
||||
var file = XPipeInstallation.getLocalBeaconAuthFile();
|
||||
try {
|
||||
Files.delete(file);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void start() throws IOException {
|
||||
executor = Executors.newFixedThreadPool(5, r -> {
|
||||
Thread t = Executors.defaultThreadFactory().newThread(r);
|
||||
t.setDaemon(true);
|
||||
t.setName("http handler");
|
||||
t.setUncaughtExceptionHandler((t1, e) -> {
|
||||
ErrorEvent.fromThrowable(e).handle();
|
||||
});
|
||||
return t;
|
||||
});
|
||||
server = HttpServer.create(
|
||||
new InetSocketAddress(Inet4Address.getByAddress(new byte[] {0x7f, 0x00, 0x00, 0x01}), port), 10);
|
||||
BeaconInterface.getAll().forEach(beaconInterface -> {
|
||||
server.createContext(beaconInterface.getPath(), new BeaconRequestHandler<>(beaconInterface));
|
||||
});
|
||||
server.setExecutor(executor);
|
||||
|
||||
var resourceMap = Map.of(
|
||||
"openapi.yaml", "misc/openapi.yaml",
|
||||
"markdown.css", "misc/github-markdown-dark.css",
|
||||
"highlight.min.js", "misc/highlight.min.js",
|
||||
"github-dark.min.css", "misc/github-dark.min.css");
|
||||
resourceMap.forEach((s, s2) -> {
|
||||
server.createContext("/" + s, exchange -> {
|
||||
handleResource(exchange, s2);
|
||||
});
|
||||
});
|
||||
|
||||
server.createContext("/", exchange -> {
|
||||
handleCatchAll(exchange);
|
||||
});
|
||||
|
||||
server.start();
|
||||
running = true;
|
||||
}
|
||||
|
||||
private void handleResource(HttpExchange exchange, String resource) throws IOException {
|
||||
if (!resources.containsKey(resource)) {
|
||||
AppResources.with(AppResources.XPIPE_MODULE, resource, file -> {
|
||||
resources.put(resource, Files.readString(file));
|
||||
});
|
||||
}
|
||||
var body = resources.get(resource).getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
try (var out = exchange.getResponseBody()) {
|
||||
out.write(body);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCatchAll(HttpExchange exchange) throws IOException {
|
||||
if (notFoundHtml == null) {
|
||||
AppResources.with(AppResources.XPIPE_MODULE, "misc/api.md", file -> {
|
||||
var md = Files.readString(file);
|
||||
md = md.replaceAll(
|
||||
Pattern.quote(
|
||||
"""
|
||||
> 400 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "string"
|
||||
}
|
||||
```
|
||||
"""),
|
||||
"");
|
||||
notFoundHtml = MarkdownHelper.toHtml(
|
||||
md,
|
||||
head -> {
|
||||
return head + "\n" + "<link rel=\"stylesheet\" href=\"markdown.css\">"
|
||||
+ "\n" + "<link rel=\"stylesheet\" href=\"github-dark.min.css\">"
|
||||
+ "\n" + "<script src=\"highlight.min.js\"></script>"
|
||||
+ "\n" + "<script>hljs.highlightAll();</script>";
|
||||
},
|
||||
s -> {
|
||||
return "<div style=\"max-width: 800px;margin: auto;\">" + s + "</div>";
|
||||
},
|
||||
"standalone");
|
||||
});
|
||||
}
|
||||
var body = notFoundHtml.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
try (var out = exchange.getResponseBody()) {
|
||||
out.write(body);
|
||||
}
|
||||
}
|
||||
}
|
210
app/src/main/java/io/xpipe/app/beacon/BeaconRequestHandler.java
Normal file
210
app/src/main/java/io/xpipe/app/beacon/BeaconRequestHandler.java
Normal file
|
@ -0,0 +1,210 @@
|
|||
package io.xpipe.app.beacon;
|
||||
|
||||
import io.xpipe.app.core.mode.OperationMode;
|
||||
import io.xpipe.app.issue.ErrorEvent;
|
||||
import io.xpipe.app.issue.TrackEvent;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import io.xpipe.beacon.*;
|
||||
import io.xpipe.core.util.JacksonMapper;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
public class BeaconRequestHandler<T> implements HttpHandler {
|
||||
|
||||
private final BeaconInterface<T> beaconInterface;
|
||||
|
||||
public BeaconRequestHandler(BeaconInterface<T> beaconInterface) {
|
||||
this.beaconInterface = beaconInterface;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) {
|
||||
if (OperationMode.isInShutdown() && !beaconInterface.acceptInShutdown()) {
|
||||
writeError(exchange, new BeaconClientErrorResponse("Daemon is currently in shutdown"), 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (beaconInterface.requiresCompletedStartup()) {
|
||||
while (OperationMode.isInStartup()) {
|
||||
ThreadHelper.sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
if (beaconInterface.requiresEnabledApi()
|
||||
&& !AppPrefs.get().enableHttpApi().get()) {
|
||||
var ex = new BeaconServerException("HTTP API is not enabled in the settings menu");
|
||||
writeError(exchange, ex, 403);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AppPrefs.get().disableApiAuthentication().get() && beaconInterface.requiresAuthentication()) {
|
||||
var auth = exchange.getRequestHeaders().getFirst("Authorization");
|
||||
if (auth == null) {
|
||||
writeError(exchange, new BeaconClientErrorResponse("Missing Authorization header"), 401);
|
||||
return;
|
||||
}
|
||||
|
||||
var token = auth.replace("Bearer ", "");
|
||||
var session = AppBeaconServer.get().getSessions().stream()
|
||||
.filter(s -> s.getToken().equals(token))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (session == null) {
|
||||
writeError(exchange, new BeaconClientErrorResponse("Unknown token"), 403);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleAuthenticatedRequest(exchange);
|
||||
}
|
||||
|
||||
private void handleAuthenticatedRequest(HttpExchange exchange) {
|
||||
T object;
|
||||
Object response;
|
||||
try {
|
||||
if (beaconInterface.readRawRequestBody()) {
|
||||
object = createDefaultRequest(beaconInterface);
|
||||
} else {
|
||||
try (InputStream is = exchange.getRequestBody()) {
|
||||
var read = is.readAllBytes();
|
||||
var rawDataRequestClass = beaconInterface.getRequestClass().getDeclaredFields().length == 1
|
||||
&& beaconInterface
|
||||
.getRequestClass()
|
||||
.getDeclaredFields()[0]
|
||||
.getType()
|
||||
.equals(byte[].class);
|
||||
if (!new String(read, StandardCharsets.US_ASCII).trim().startsWith("{") && rawDataRequestClass) {
|
||||
object = createRawDataRequest(beaconInterface, read);
|
||||
} else {
|
||||
var tree = JacksonMapper.getDefault().readTree(read);
|
||||
TrackEvent.trace("Parsed raw request:\n" + tree.toPrettyString());
|
||||
var emptyRequestClass = tree.isEmpty()
|
||||
&& beaconInterface.getRequestClass().getDeclaredFields().length == 0;
|
||||
object = emptyRequestClass
|
||||
? createDefaultRequest(beaconInterface)
|
||||
: JacksonMapper.getDefault().treeToValue(tree, beaconInterface.getRequestClass());
|
||||
TrackEvent.trace("Parsed request object:\n" + object);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sync = beaconInterface.getSynchronizationObject();
|
||||
if (sync != null) {
|
||||
synchronized (sync) {
|
||||
response = beaconInterface.handle(exchange, object);
|
||||
}
|
||||
} else {
|
||||
response = beaconInterface.handle(exchange, object);
|
||||
}
|
||||
} catch (BeaconClientException clientException) {
|
||||
ErrorEvent.fromThrowable(clientException).omit().expected().handle();
|
||||
writeError(exchange, new BeaconClientErrorResponse(clientException.getMessage()), 400);
|
||||
return;
|
||||
} catch (BeaconServerException serverException) {
|
||||
var cause = serverException.getCause() != null ? serverException.getCause() : serverException;
|
||||
ErrorEvent.fromThrowable(cause).omit().handle();
|
||||
writeError(exchange, new BeaconServerErrorResponse(cause), 500);
|
||||
return;
|
||||
} catch (IOException ex) {
|
||||
// Handle serialization errors as normal exceptions and other IO exceptions as assuming that the connection
|
||||
// is broken
|
||||
if (!ex.getClass().getName().contains("jackson")) {
|
||||
ErrorEvent.fromThrowable(ex).omit().expected().handle();
|
||||
} else {
|
||||
ErrorEvent.fromThrowable(ex).omit().expected().handle();
|
||||
// Make deserialization error message more readable
|
||||
var message = ex.getMessage()
|
||||
.replace("$RequestBuilder", "")
|
||||
.replace("Exchange$Request", "Request")
|
||||
.replace("at [Source: UNKNOWN; byte offset: #UNKNOWN]", "")
|
||||
.replaceAll("(\\w+) is marked non-null but is null", "field $1 is missing from object")
|
||||
.trim();
|
||||
writeError(exchange, new BeaconClientErrorResponse(message), 400);
|
||||
}
|
||||
return;
|
||||
} catch (Throwable other) {
|
||||
ErrorEvent.fromThrowable(other).omit().expected().handle();
|
||||
writeError(exchange, new BeaconServerErrorResponse(other), 500);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var emptyResponseClass = beaconInterface.getResponseClass().getDeclaredFields().length == 0;
|
||||
if (!emptyResponseClass && response != null) {
|
||||
TrackEvent.trace("Sending response:\n" + response);
|
||||
TrackEvent.trace("Sending raw response:\n"
|
||||
+ JacksonMapper.getCensored().valueToTree(response).toPrettyString());
|
||||
var bytes = JacksonMapper.getDefault()
|
||||
.valueToTree(response)
|
||||
.toPrettyString()
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, bytes.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(bytes);
|
||||
}
|
||||
} else {
|
||||
exchange.sendResponseHeaders(200, -1);
|
||||
}
|
||||
} catch (IOException ioException) {
|
||||
// The exchange implementation might have already sent a response manually
|
||||
if (!"headers already sent".equals(ioException.getMessage())) {
|
||||
ErrorEvent.fromThrowable(ioException).omit().expected().handle();
|
||||
}
|
||||
} catch (Throwable other) {
|
||||
ErrorEvent.fromThrowable(other).handle();
|
||||
writeError(exchange, new BeaconServerErrorResponse(other), 500);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeError(HttpExchange exchange, Object errorMessage, int code) {
|
||||
try {
|
||||
var bytes =
|
||||
JacksonMapper.getDefault().writeValueAsString(errorMessage).getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(code, bytes.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(bytes);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
ErrorEvent.fromThrowable(ex).omit().expected().handle();
|
||||
}
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
@SuppressWarnings("unchecked")
|
||||
private <REQ> REQ createDefaultRequest(BeaconInterface<?> beaconInterface) {
|
||||
var c = beaconInterface.getRequestClass().getDeclaredMethod("builder");
|
||||
c.setAccessible(true);
|
||||
var b = c.invoke(null);
|
||||
var m = b.getClass().getDeclaredMethod("build");
|
||||
m.setAccessible(true);
|
||||
return (REQ) beaconInterface.getRequestClass().cast(m.invoke(b));
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
@SuppressWarnings("unchecked")
|
||||
private <REQ> REQ createRawDataRequest(BeaconInterface<?> beaconInterface, byte[] s) {
|
||||
var c = beaconInterface.getRequestClass().getDeclaredMethod("builder");
|
||||
c.setAccessible(true);
|
||||
|
||||
var b = c.invoke(null);
|
||||
var setMethod = Arrays.stream(b.getClass().getDeclaredMethods())
|
||||
.filter(method -> method.getParameterCount() == 1
|
||||
&& method.getParameters()[0].getType().equals(byte[].class))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
setMethod.invoke(b, (Object) s);
|
||||
|
||||
var m = b.getClass().getDeclaredMethod("build");
|
||||
m.setAccessible(true);
|
||||
return (REQ) beaconInterface.getRequestClass().cast(m.invoke(b));
|
||||
}
|
||||
}
|
12
app/src/main/java/io/xpipe/app/beacon/BeaconSession.java
Normal file
12
app/src/main/java/io/xpipe/app/beacon/BeaconSession.java
Normal file
|
@ -0,0 +1,12 @@
|
|||
package io.xpipe.app.beacon;
|
||||
|
||||
import io.xpipe.beacon.BeaconClientInformation;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
public class BeaconSession {
|
||||
|
||||
BeaconClientInformation clientInformation;
|
||||
String token;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package io.xpipe.app.beacon;
|
||||
|
||||
import io.xpipe.app.storage.DataStoreEntry;
|
||||
import io.xpipe.core.process.ShellControl;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
public class BeaconShellSession {
|
||||
|
||||
DataStoreEntry entry;
|
||||
ShellControl control;
|
||||
}
|
82
app/src/main/java/io/xpipe/app/beacon/BlobManager.java
Normal file
82
app/src/main/java/io/xpipe/app/beacon/BlobManager.java
Normal file
|
@ -0,0 +1,82 @@
|
|||
package io.xpipe.app.beacon;
|
||||
|
||||
import io.xpipe.app.issue.ErrorEvent;
|
||||
import io.xpipe.app.util.ShellTemp;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class BlobManager {
|
||||
|
||||
private static final Path TEMP = ShellTemp.getLocalTempDataDirectory("blob");
|
||||
private static BlobManager INSTANCE;
|
||||
private final Map<UUID, byte[]> memoryBlobs = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Path> fileBlobs = new ConcurrentHashMap<>();
|
||||
|
||||
public static BlobManager get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public static void init() {
|
||||
INSTANCE = new BlobManager();
|
||||
try {
|
||||
FileUtils.forceMkdir(TEMP.toFile());
|
||||
try {
|
||||
// Remove old files in dir
|
||||
FileUtils.cleanDirectory(TEMP.toFile());
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
} catch (IOException e) {
|
||||
ErrorEvent.fromThrowable(e).handle();
|
||||
}
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
try {
|
||||
FileUtils.cleanDirectory(TEMP.toFile());
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
INSTANCE = null;
|
||||
}
|
||||
|
||||
public Path newBlobFile() throws IOException {
|
||||
var file = TEMP.resolve(UUID.randomUUID().toString());
|
||||
FileUtils.forceMkdir(file.getParent().toFile());
|
||||
return file;
|
||||
}
|
||||
|
||||
public void store(UUID uuid, byte[] blob) {
|
||||
memoryBlobs.put(uuid, blob);
|
||||
}
|
||||
|
||||
public void store(UUID uuid, InputStream blob) throws IOException {
|
||||
var file = TEMP.resolve(uuid.toString());
|
||||
try (var fileOut = Files.newOutputStream(file)) {
|
||||
blob.transferTo(fileOut);
|
||||
}
|
||||
fileBlobs.put(uuid, file);
|
||||
}
|
||||
|
||||
public InputStream getBlob(UUID uuid) throws Exception {
|
||||
var memory = memoryBlobs.get(uuid);
|
||||
if (memory != null) {
|
||||
return new ByteArrayInputStream(memory);
|
||||
}
|
||||
|
||||
var found = fileBlobs.get(uuid);
|
||||
if (found == null) {
|
||||
throw new BeaconClientException("No saved data known for id " + uuid);
|
||||
}
|
||||
|
||||
return Files.newInputStream(found);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.prefs.ExternalApplicationType;
|
||||
import io.xpipe.app.terminal.TerminalView;
|
||||
import io.xpipe.app.util.AskpassAlert;
|
||||
import io.xpipe.app.util.SecretManager;
|
||||
import io.xpipe.app.util.SecretQueryState;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.AskpassExchange;
|
||||
import io.xpipe.core.process.OsType;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class AskpassExchangeImpl extends AskpassExchange {
|
||||
|
||||
@Override
|
||||
public boolean requiresCompletedStartup() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException {
|
||||
if (msg.getRequest() == null) {
|
||||
var r = AskpassAlert.queryRaw(msg.getPrompt(), null);
|
||||
return Response.builder().value(r.getSecret()).build();
|
||||
}
|
||||
|
||||
var found = msg.getSecretId() != null
|
||||
? SecretManager.getProgress(msg.getRequest(), msg.getSecretId())
|
||||
: SecretManager.getProgress(msg.getRequest());
|
||||
if (found.isEmpty()) {
|
||||
throw new BeaconClientException("Unknown askpass request");
|
||||
}
|
||||
|
||||
var p = found.get();
|
||||
var secret = p.process(msg.getPrompt());
|
||||
if (p.getState() != SecretQueryState.NORMAL) {
|
||||
throw new BeaconClientException(SecretQueryState.toErrorMessage(p.getState()));
|
||||
}
|
||||
focusTerminalIfNeeded(msg.getPid());
|
||||
return Response.builder().value(secret.inPlace()).build();
|
||||
}
|
||||
|
||||
private void focusTerminalIfNeeded(long pid) {
|
||||
if (TerminalView.get() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var found = TerminalView.get().findSession(pid);
|
||||
if (found.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var term = TerminalView.get().getTerminalInstances().stream()
|
||||
.filter(instance -> instance.equals(found.get().getTerminal()))
|
||||
.findFirst();
|
||||
if (term.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var control = term.get().controllable();
|
||||
if (control.isPresent()) {
|
||||
control.get().focus();
|
||||
} else {
|
||||
if (OsType.getLocal() == OsType.MACOS) {
|
||||
// Just focus the app, this is correct most of the time
|
||||
var terminalType = AppPrefs.get().terminalType().getValue();
|
||||
if (terminalType instanceof ExternalApplicationType.MacApplication m) {
|
||||
m.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.storage.DataStoreCategory;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.CategoryAddExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class CategoryAddExchangeImpl extends CategoryAddExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws Throwable {
|
||||
if (DataStorage.get().getStoreCategoryIfPresent(msg.getParent()).isEmpty()) {
|
||||
throw new BeaconClientException("Parent category with id " + msg.getParent() + " does not exist");
|
||||
}
|
||||
|
||||
var found = DataStorage.get().getStoreCategories().stream()
|
||||
.filter(dataStoreCategory -> msg.getParent().equals(dataStoreCategory.getParentCategory())
|
||||
&& msg.getName().equals(dataStoreCategory.getName()))
|
||||
.findAny();
|
||||
if (found.isPresent()) {
|
||||
return Response.builder().category(found.get().getUuid()).build();
|
||||
}
|
||||
|
||||
var cat = DataStoreCategory.createNew(msg.getParent(), msg.getName());
|
||||
DataStorage.get().addStoreCategory(cat);
|
||||
return Response.builder().category(cat.getUuid()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getSynchronizationObject() {
|
||||
return DataStorage.get();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.issue.ErrorEvent;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.storage.DataStoreEntry;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.ConnectionAddExchange;
|
||||
import io.xpipe.core.util.ValidationException;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class ConnectionAddExchangeImpl extends ConnectionAddExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws Throwable {
|
||||
var found = DataStorage.get().getStoreEntryIfPresent(msg.getData(), false);
|
||||
if (found.isPresent()) {
|
||||
return Response.builder().connection(found.get().getUuid()).build();
|
||||
}
|
||||
|
||||
if (msg.getCategory() != null
|
||||
&& DataStorage.get()
|
||||
.getStoreCategoryIfPresent(msg.getCategory())
|
||||
.isEmpty()) {
|
||||
throw new BeaconClientException("Category with id " + msg.getCategory() + " does not exist");
|
||||
}
|
||||
|
||||
var entry = DataStoreEntry.createNew(msg.getName(), msg.getData());
|
||||
if (msg.getCategory() != null) {
|
||||
entry.setCategoryUuid(msg.getCategory());
|
||||
}
|
||||
try {
|
||||
DataStorage.get().addStoreEntryInProgress(entry);
|
||||
if (msg.getValidate()) {
|
||||
entry.validateOrThrow();
|
||||
}
|
||||
} catch (Throwable ex) {
|
||||
if (ex instanceof ValidationException) {
|
||||
ErrorEvent.expected(ex);
|
||||
} else if (ex instanceof StackOverflowError) {
|
||||
// Cycles in connection graphs can fail hard but are expected
|
||||
ErrorEvent.expected(ex);
|
||||
}
|
||||
throw ex;
|
||||
} finally {
|
||||
DataStorage.get().removeStoreEntryInProgress(entry);
|
||||
}
|
||||
DataStorage.get().addStoreEntryIfNotPresent(entry);
|
||||
|
||||
// Explicitly assign category
|
||||
if (msg.getCategory() != null) {
|
||||
DataStorage.get()
|
||||
.moveEntryToCategory(
|
||||
entry,
|
||||
DataStorage.get()
|
||||
.getStoreCategoryIfPresent(msg.getCategory())
|
||||
.orElseThrow());
|
||||
}
|
||||
|
||||
return Response.builder().connection(entry.getUuid()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getSynchronizationObject() {
|
||||
return DataStorage.get();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.browser.BrowserFullSessionModel;
|
||||
import io.xpipe.app.core.AppLayoutModel;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.ConnectionBrowseExchange;
|
||||
import io.xpipe.core.store.FileSystemStore;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class ConnectionBrowseExchangeImpl extends ConnectionBrowseExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws Exception {
|
||||
var e = DataStorage.get()
|
||||
.getStoreEntryIfPresent(msg.getConnection())
|
||||
.orElseThrow(() -> new BeaconClientException("Unknown connection: " + msg.getConnection()));
|
||||
if (!(e.getStore() instanceof FileSystemStore)) {
|
||||
throw new BeaconClientException("Not a file system connection");
|
||||
}
|
||||
BrowserFullSessionModel.DEFAULT.openFileSystemSync(
|
||||
e.ref(), msg.getDirectory() != null ? ignored -> msg.getDirectory() : null, null, true);
|
||||
AppLayoutModel.get().selectBrowser();
|
||||
return Response.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getSynchronizationObject() {
|
||||
return DataStorage.get();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.ConnectionInfoExchange;
|
||||
import io.xpipe.core.store.StorePath;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import org.apache.commons.lang3.ClassUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ConnectionInfoExchangeImpl extends ConnectionInfoExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException {
|
||||
var list = new ArrayList<InfoResponse>();
|
||||
for (UUID uuid : msg.getConnections()) {
|
||||
var e = DataStorage.get()
|
||||
.getStoreEntryIfPresent(uuid)
|
||||
.orElseThrow(() -> new BeaconClientException("Unknown connection: " + uuid));
|
||||
|
||||
var names = DataStorage.get()
|
||||
.getStorePath(DataStorage.get()
|
||||
.getStoreCategoryIfPresent(e.getCategoryUuid())
|
||||
.orElseThrow())
|
||||
.getNames();
|
||||
var cat = new StorePath(names.subList(1, names.size()));
|
||||
var cache = e.getStoreCache().entrySet().stream()
|
||||
.filter(stringObjectEntry -> {
|
||||
return stringObjectEntry.getValue() != null
|
||||
&& (ClassUtils.isPrimitiveOrWrapper(
|
||||
stringObjectEntry.getValue().getClass())
|
||||
|| stringObjectEntry.getValue() instanceof String);
|
||||
})
|
||||
.collect(Collectors.toMap(
|
||||
stringObjectEntry -> stringObjectEntry.getKey(),
|
||||
stringObjectEntry -> stringObjectEntry.getValue()));
|
||||
|
||||
var apply = InfoResponse.builder()
|
||||
.lastModified(e.getLastModified())
|
||||
.lastUsed(e.getLastUsed())
|
||||
.connection(e.getUuid())
|
||||
.category(cat)
|
||||
.name(DataStorage.get().getStorePath(e))
|
||||
.rawData(e.getStore())
|
||||
.usageCategory(e.getProvider().getUsageCategory())
|
||||
.type(e.getProvider().getId())
|
||||
.state(e.getStorePersistentState() != null ? e.getStorePersistentState() : new Object())
|
||||
.cache(cache)
|
||||
.build();
|
||||
list.add(apply);
|
||||
}
|
||||
return Response.builder().infos(list).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getSynchronizationObject() {
|
||||
return DataStorage.get();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.storage.DataStorageQuery;
|
||||
import io.xpipe.app.storage.DataStoreEntry;
|
||||
import io.xpipe.beacon.api.ConnectionQueryExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class ConnectionQueryExchangeImpl extends ConnectionQueryExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) {
|
||||
var found = DataStorageQuery.query(msg.getCategoryFilter(), msg.getConnectionFilter(), msg.getTypeFilter());
|
||||
return Response.builder()
|
||||
.found(found.stream().map(entry -> entry.getUuid()).toList())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getSynchronizationObject() {
|
||||
return DataStorage.get();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.util.FixedHierarchyStore;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.ConnectionRefreshExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class ConnectionRefreshExchangeImpl extends ConnectionRefreshExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws Throwable {
|
||||
var e = DataStorage.get()
|
||||
.getStoreEntryIfPresent(msg.getConnection())
|
||||
.orElseThrow(() -> new BeaconClientException("Unknown connection: " + msg.getConnection()));
|
||||
if (e.getStore() instanceof FixedHierarchyStore) {
|
||||
DataStorage.get().refreshChildren(e, true);
|
||||
} else {
|
||||
e.validateOrThrow();
|
||||
}
|
||||
return Response.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getSynchronizationObject() {
|
||||
return DataStorage.get();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.storage.DataStoreEntry;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.ConnectionRemoveExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
|
||||
public class ConnectionRemoveExchangeImpl extends ConnectionRemoveExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException {
|
||||
var entries = new ArrayList<DataStoreEntry>();
|
||||
for (UUID uuid : msg.getConnections()) {
|
||||
var e = DataStorage.get()
|
||||
.getStoreEntryIfPresent(uuid)
|
||||
.orElseThrow(() -> new BeaconClientException("Unknown connection: " + uuid));
|
||||
entries.add(e);
|
||||
}
|
||||
DataStorage.get().deleteWithChildren(entries.toArray(DataStoreEntry[]::new));
|
||||
return Response.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getSynchronizationObject() {
|
||||
return DataStorage.get();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.ext.ShellStore;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.terminal.TerminalLauncher;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.ConnectionTerminalExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class ConnectionTerminalExchangeImpl extends ConnectionTerminalExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws Exception {
|
||||
var e = DataStorage.get()
|
||||
.getStoreEntryIfPresent(msg.getConnection())
|
||||
.orElseThrow(() -> new BeaconClientException("Unknown connection: " + msg.getConnection()));
|
||||
if (!(e.getStore() instanceof ShellStore shellStore)) {
|
||||
throw new BeaconClientException("Not a shell connection");
|
||||
}
|
||||
var sc = shellStore.getOrStartSession();
|
||||
TerminalLauncher.open(e, e.getName(), msg.getDirectory(), sc);
|
||||
return Response.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getSynchronizationObject() {
|
||||
return DataStorage.get();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.ConnectionToggleExchange;
|
||||
import io.xpipe.core.store.SingletonSessionStore;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class ConnectionToggleExchangeImpl extends ConnectionToggleExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws Exception {
|
||||
var e = DataStorage.get()
|
||||
.getStoreEntryIfPresent(msg.getConnection())
|
||||
.orElseThrow(() -> new BeaconClientException("Unknown connection: " + msg.getConnection()));
|
||||
if (!(e.getStore() instanceof SingletonSessionStore<?> singletonSessionStore)) {
|
||||
throw new BeaconClientException("Not a toggleable connection");
|
||||
}
|
||||
if (msg.getState()) {
|
||||
singletonSessionStore.startSessionIfNeeded();
|
||||
} else {
|
||||
singletonSessionStore.stopSessionIfNeeded();
|
||||
}
|
||||
return Response.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getSynchronizationObject() {
|
||||
return DataStorage.get();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.core.mode.OperationMode;
|
||||
import io.xpipe.app.core.window.AppMainWindow;
|
||||
import io.xpipe.beacon.api.DaemonFocusExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class DaemonFocusExchangeImpl extends DaemonFocusExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) {
|
||||
OperationMode.switchUp(OperationMode.GUI);
|
||||
var w = AppMainWindow.getInstance();
|
||||
if (w != null) {
|
||||
w.focus();
|
||||
}
|
||||
return Response.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.core.mode.OperationMode;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.DaemonModeExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class DaemonModeExchangeImpl extends DaemonModeExchange {
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException {
|
||||
var mode = OperationMode.map(msg.getMode());
|
||||
if (!mode.isSupported()) {
|
||||
throw new BeaconClientException("Unsupported mode: " + msg.getMode().getDisplayName() + ". Supported: "
|
||||
+ String.join(
|
||||
", ",
|
||||
OperationMode.getAll().stream()
|
||||
.filter(OperationMode::isSupported)
|
||||
.map(OperationMode::getId)
|
||||
.toList()));
|
||||
}
|
||||
|
||||
OperationMode.switchToSyncIfPossible(mode);
|
||||
return DaemonModeExchange.Response.builder()
|
||||
.usedMode(OperationMode.map(OperationMode.get()))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.core.AppOpenArguments;
|
||||
import io.xpipe.app.core.mode.OperationMode;
|
||||
import io.xpipe.app.util.PlatformInit;
|
||||
import io.xpipe.beacon.BeaconServerException;
|
||||
import io.xpipe.beacon.api.DaemonOpenExchange;
|
||||
import io.xpipe.core.process.OsType;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class DaemonOpenExchangeImpl extends DaemonOpenExchange {
|
||||
|
||||
private int openCounter = 0;
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws BeaconServerException {
|
||||
if (msg.getArguments().isEmpty()) {
|
||||
try {
|
||||
// At this point we are already loading this on another thread
|
||||
// so this call will only perform the waiting
|
||||
PlatformInit.init(true);
|
||||
} catch (Throwable t) {
|
||||
throw new BeaconServerException(t);
|
||||
}
|
||||
|
||||
// The open command is used as a default opener on Linux
|
||||
// We don't want to overwrite the default startup mode
|
||||
if (OsType.getLocal() == OsType.LINUX && openCounter++ == 0) {
|
||||
return Response.builder().build();
|
||||
}
|
||||
|
||||
OperationMode.switchToAsync(OperationMode.GUI);
|
||||
} else {
|
||||
AppOpenArguments.handle(msg.getArguments());
|
||||
}
|
||||
return Response.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresCompletedStartup() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.core.mode.OperationMode;
|
||||
import io.xpipe.beacon.api.DaemonStatusExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class DaemonStatusExchangeImpl extends DaemonStatusExchange {
|
||||
|
||||
@Override
|
||||
public boolean requiresCompletedStartup() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request body) {
|
||||
String mode;
|
||||
if (OperationMode.get() == null) {
|
||||
mode = "none";
|
||||
} else {
|
||||
mode = OperationMode.get().getId();
|
||||
}
|
||||
|
||||
return Response.builder().mode(mode).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.core.mode.OperationMode;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import io.xpipe.beacon.api.DaemonStopExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class DaemonStopExchangeImpl extends DaemonStopExchange {
|
||||
|
||||
@Override
|
||||
public boolean requiresCompletedStartup() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) {
|
||||
ThreadHelper.runAsync(() -> {
|
||||
ThreadHelper.sleep(1000);
|
||||
OperationMode.close();
|
||||
});
|
||||
return Response.builder().success(true).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.core.AppProperties;
|
||||
import io.xpipe.app.core.AppVersion;
|
||||
import io.xpipe.app.util.LicenseProvider;
|
||||
import io.xpipe.beacon.api.DaemonVersionExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class DaemonVersionExchangeImpl extends DaemonVersionExchange {
|
||||
|
||||
@Override
|
||||
public boolean requiresCompletedStartup() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) {
|
||||
var jvmVersion = System.getProperty("java.vm.vendor") + " "
|
||||
+ System.getProperty("java.vm.name") + " ("
|
||||
+ System.getProperty("java.vm.version") + ")";
|
||||
var version = AppProperties.get().getVersion();
|
||||
var pro = LicenseProvider.get().hasPaidLicense();
|
||||
return Response.builder()
|
||||
.version(version)
|
||||
.canonicalVersion(AppVersion.parse(version)
|
||||
.map(appVersion -> appVersion.toString())
|
||||
.orElse("?"))
|
||||
.buildVersion(AppProperties.get().getBuild())
|
||||
.jvmVersion(jvmVersion)
|
||||
.pro(pro)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.beacon.BlobManager;
|
||||
import io.xpipe.beacon.api.FsBlobExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class FsBlobExchangeImpl extends FsBlobExchange {
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public Object handle(HttpExchange exchange, Request msg) {
|
||||
var id = UUID.randomUUID();
|
||||
|
||||
var size = exchange.getRequestBody().available();
|
||||
if (size > 100_000_000) {
|
||||
BlobManager.get().store(id, exchange.getRequestBody());
|
||||
} else {
|
||||
BlobManager.get().store(id, exchange.getRequestBody().readAllBytes());
|
||||
}
|
||||
return Response.builder().blob(id).build();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.beacon.AppBeaconServer;
|
||||
import io.xpipe.app.beacon.BlobManager;
|
||||
import io.xpipe.app.ext.ConnectionFileSystem;
|
||||
import io.xpipe.app.util.FixedSizeInputStream;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.FsReadExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
|
||||
public class FsReadExchangeImpl extends FsReadExchange {
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public Object handle(HttpExchange exchange, Request msg) {
|
||||
var shell = AppBeaconServer.get().getCache().getShellSession(msg.getConnection());
|
||||
var fs = new ConnectionFileSystem(shell.getControl());
|
||||
|
||||
if (!fs.fileExists(msg.getPath().toString())) {
|
||||
throw new BeaconClientException("File does not exist");
|
||||
}
|
||||
|
||||
var size = fs.getFileSize(msg.getPath().toString());
|
||||
if (size > 100_000_000) {
|
||||
var file = BlobManager.get().newBlobFile();
|
||||
try (var in = fs.openInput(msg.getPath().toString())) {
|
||||
var fixedIn = new FixedSizeInputStream(new BufferedInputStream(in), size);
|
||||
try (var fileOut =
|
||||
Files.newOutputStream(file.resolve(msg.getPath().getFileName()))) {
|
||||
fixedIn.transferTo(fileOut);
|
||||
}
|
||||
in.transferTo(OutputStream.nullOutputStream());
|
||||
}
|
||||
|
||||
exchange.sendResponseHeaders(200, size);
|
||||
try (var fileIn = Files.newInputStream(file);
|
||||
var out = exchange.getResponseBody()) {
|
||||
fileIn.transferTo(out);
|
||||
}
|
||||
} else {
|
||||
byte[] bytes;
|
||||
try (var in = fs.openInput(msg.getPath().toString())) {
|
||||
var fixedIn = new FixedSizeInputStream(new BufferedInputStream(in), size);
|
||||
bytes = fixedIn.readAllBytes();
|
||||
in.transferTo(OutputStream.nullOutputStream());
|
||||
}
|
||||
exchange.sendResponseHeaders(200, bytes.length);
|
||||
try (var out = exchange.getResponseBody()) {
|
||||
out.write(bytes);
|
||||
}
|
||||
}
|
||||
return Response.builder().build();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.beacon.AppBeaconServer;
|
||||
import io.xpipe.app.beacon.BlobManager;
|
||||
import io.xpipe.app.util.ScriptHelper;
|
||||
import io.xpipe.beacon.api.FsScriptExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class FsScriptExchangeImpl extends FsScriptExchange {
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public Object handle(HttpExchange exchange, Request msg) {
|
||||
var shell = AppBeaconServer.get().getCache().getShellSession(msg.getConnection());
|
||||
String data;
|
||||
try (var in = BlobManager.get().getBlob(msg.getBlob())) {
|
||||
data = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
data = shell.getControl().getShellDialect().prepareScriptContent(data);
|
||||
var file = ScriptHelper.getExecScriptFile(shell.getControl());
|
||||
shell.getControl().view().writeScriptFile(file, data);
|
||||
file = ScriptHelper.fixScriptPermissions(shell.getControl(), file);
|
||||
return Response.builder().path(file).build();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.beacon.AppBeaconServer;
|
||||
import io.xpipe.app.beacon.BlobManager;
|
||||
import io.xpipe.app.ext.ConnectionFileSystem;
|
||||
import io.xpipe.beacon.api.FsWriteExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
public class FsWriteExchangeImpl extends FsWriteExchange {
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public Object handle(HttpExchange exchange, Request msg) {
|
||||
var shell = AppBeaconServer.get().getCache().getShellSession(msg.getConnection());
|
||||
var fs = new ConnectionFileSystem(shell.getControl());
|
||||
try (var in = BlobManager.get().getBlob(msg.getBlob());
|
||||
var os = fs.openOutput(msg.getPath().toString(), in.available())) {
|
||||
in.transferTo(os);
|
||||
}
|
||||
return Response.builder().build();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.beacon.AppBeaconServer;
|
||||
import io.xpipe.app.beacon.BeaconSession;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.beacon.BeaconAuthMethod;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.HandshakeExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class HandshakeExchangeImpl extends HandshakeExchange {
|
||||
|
||||
@Override
|
||||
public boolean requiresCompletedStartup() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request body) throws BeaconClientException {
|
||||
if (!checkAuth(body.getAuth())) {
|
||||
throw new BeaconClientException("Authentication failed");
|
||||
}
|
||||
|
||||
var session = new BeaconSession(body.getClient(), UUID.randomUUID().toString());
|
||||
AppBeaconServer.get().addSession(session);
|
||||
return Response.builder().sessionToken(session.getToken()).build();
|
||||
}
|
||||
|
||||
private boolean checkAuth(BeaconAuthMethod authMethod) {
|
||||
if (authMethod instanceof BeaconAuthMethod.Local local) {
|
||||
var c = local.getAuthFileContent().trim();
|
||||
return AppBeaconServer.get().getLocalAuthSecret().equals(c);
|
||||
}
|
||||
|
||||
if (authMethod instanceof BeaconAuthMethod.ApiKey key) {
|
||||
var c = key.getKey().trim();
|
||||
return AppPrefs.get().apiKey().get().equals(c);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.beacon.AppBeaconServer;
|
||||
import io.xpipe.beacon.api.ShellExecExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public class ShellExecExchangeImpl extends ShellExecExchange {
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public Object handle(HttpExchange exchange, Request msg) {
|
||||
var existing = AppBeaconServer.get().getCache().getShellSession(msg.getConnection());
|
||||
AtomicReference<String> out = new AtomicReference<>();
|
||||
AtomicReference<String> err = new AtomicReference<>();
|
||||
long exitCode;
|
||||
try (var command = existing.getControl().command(msg.getCommand()).start()) {
|
||||
var r = command.readStdoutAndStderr();
|
||||
out.set(r[0]);
|
||||
err.set(r[1]);
|
||||
command.close();
|
||||
exitCode = command.getExitCode();
|
||||
}
|
||||
return Response.builder()
|
||||
.stdout(out.get())
|
||||
.stderr(err.get())
|
||||
.exitCode(exitCode)
|
||||
.build();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.beacon.AppBeaconServer;
|
||||
import io.xpipe.app.beacon.BeaconShellSession;
|
||||
import io.xpipe.app.ext.ShellStore;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.ShellStartExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
public class ShellStartExchangeImpl extends ShellStartExchange {
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public Object handle(HttpExchange exchange, Request msg) {
|
||||
var e = DataStorage.get()
|
||||
.getStoreEntryIfPresent(msg.getConnection())
|
||||
.orElseThrow(() -> new BeaconClientException("Unknown connection"));
|
||||
if (!(e.getStore() instanceof ShellStore s)) {
|
||||
throw new BeaconClientException("Not a shell connection");
|
||||
}
|
||||
|
||||
var existing = AppBeaconServer.get().getCache().getShellSessions().stream()
|
||||
.filter(beaconShellSession -> beaconShellSession.getEntry().equals(e))
|
||||
.findFirst();
|
||||
var control = (existing.isPresent()
|
||||
? existing.get().getControl()
|
||||
: s.standaloneControl().start());
|
||||
control.setNonInteractive();
|
||||
control.start();
|
||||
|
||||
var d = control.getShellDialect().getDumbMode();
|
||||
if (!d.supportsAnyPossibleInteraction()) {
|
||||
control.close();
|
||||
d.throwIfUnsupported();
|
||||
}
|
||||
|
||||
if (existing.isEmpty()) {
|
||||
AppBeaconServer.get().getCache().getShellSessions().add(new BeaconShellSession(e, control));
|
||||
}
|
||||
return Response.builder()
|
||||
.shellDialect(control.getShellDialect())
|
||||
.osType(control.getOsType())
|
||||
.osName(control.getOsName())
|
||||
.temp(control.getSystemTemporaryDirectory())
|
||||
.ttyState(control.getTtyState())
|
||||
.build();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.beacon.AppBeaconServer;
|
||||
import io.xpipe.beacon.api.ShellStopExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
public class ShellStopExchangeImpl extends ShellStopExchange {
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public Object handle(HttpExchange exchange, Request msg) {
|
||||
var e = AppBeaconServer.get().getCache().getShellSession(msg.getConnection());
|
||||
e.getControl().close();
|
||||
AppBeaconServer.get().getCache().getShellSessions().remove(e);
|
||||
return Response.builder().build();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.terminal.TerminalLauncherManager;
|
||||
import io.xpipe.beacon.api.SshLaunchExchange;
|
||||
import io.xpipe.core.process.ShellDialects;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class SshLaunchExchangeImpl extends SshLaunchExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws Exception {
|
||||
if ("echo $SHELL".equals(msg.getArguments())) {
|
||||
return Response.builder().command(List.of("echo", "/bin/bash")).build();
|
||||
}
|
||||
|
||||
var usedDialect = ShellDialects.getStartableDialects().stream()
|
||||
.filter(dialect -> dialect.getExecutableName().equalsIgnoreCase(msg.getArguments()))
|
||||
.findFirst();
|
||||
if (msg.getArguments() != null
|
||||
&& usedDialect.isEmpty()
|
||||
&& !msg.getArguments().contains("SSH_ORIGINAL_COMMAND")) {
|
||||
return Response.builder().command(List.of()).build();
|
||||
}
|
||||
|
||||
// There are sometimes multiple requests by a terminal client (e.g. Termius)
|
||||
// This might fail sometimes, but it is expected
|
||||
var r = TerminalLauncherManager.sshLaunchExchange();
|
||||
var c = ProcessControlProvider.get()
|
||||
.getEffectiveLocalDialect()
|
||||
.getOpenScriptCommand(r.toString())
|
||||
.buildBaseParts(null);
|
||||
return Response.builder().command(c).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import atlantafx.base.layout.ModalBox;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import io.xpipe.app.comp.base.ModalOverlay;
|
||||
import io.xpipe.app.core.AppCache;
|
||||
import io.xpipe.app.core.window.AppDialog;
|
||||
import io.xpipe.app.ext.ShellStore;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.storage.DataStorageQuery;
|
||||
import io.xpipe.app.storage.DataStoreEntry;
|
||||
import io.xpipe.app.terminal.TerminalLauncherManager;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.BeaconServerException;
|
||||
import io.xpipe.beacon.api.TerminalExternalLaunchExchange;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class TerminalExternalLaunchExchangeImpl extends TerminalExternalLaunchExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException, BeaconServerException {
|
||||
var found = DataStorageQuery.queryUserInput(msg.getConnection());
|
||||
if (found.isEmpty()) {
|
||||
throw new BeaconClientException("No connection found for input " + msg.getConnection());
|
||||
}
|
||||
|
||||
if (found.size() > 1) {
|
||||
throw new BeaconServerException("Multiple connections found: " + found.stream().map(DataStoreEntry::getName).toList());
|
||||
}
|
||||
|
||||
var e = found.getFirst();
|
||||
var isShell = e.getStore() instanceof ShellStore;
|
||||
if (!isShell) {
|
||||
throw new BeaconClientException("Connection " + DataStorage.get().getStorePath(e).toString() + " is not a shell connection");
|
||||
}
|
||||
|
||||
if (!checkPermission()) {
|
||||
return Response.builder().command(List.of()).build();
|
||||
}
|
||||
|
||||
var r = TerminalLauncherManager.externalExchange(e.ref(), msg.getArguments());
|
||||
return Response.builder().command(r).build();
|
||||
}
|
||||
|
||||
private boolean checkPermission() {
|
||||
var cache = AppCache.getBoolean("externalLaunchPermitted", false);
|
||||
if (cache) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var r = AppDialog.confirm("externalLaunch");
|
||||
if (r) {
|
||||
AppCache.update("externalLaunchPermitted", true);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getSynchronizationObject() {
|
||||
return DataStorage.get();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.terminal.TerminalLauncherManager;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.BeaconServerException;
|
||||
import io.xpipe.beacon.api.TerminalLaunchExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class TerminalLaunchExchangeImpl extends TerminalLaunchExchange {
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException, BeaconServerException {
|
||||
var r = TerminalLauncherManager.launchExchange(msg.getRequest());
|
||||
return Response.builder().targetFile(r).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.terminal.TerminalLauncherManager;
|
||||
import io.xpipe.app.terminal.TerminalView;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.api.TerminalPrepareExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class TerminalPrepareExchangeImpl extends TerminalPrepareExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException {
|
||||
TerminalView.get().open(msg.getRequest(), msg.getPid());
|
||||
TerminalLauncherManager.registerPid(msg.getRequest(), msg.getPid());
|
||||
var term = AppPrefs.get().terminalType().getValue();
|
||||
var unicode = term.supportsUnicode();
|
||||
var escapes = term.supportsEscapes();
|
||||
return Response.builder()
|
||||
.supportsUnicode(unicode)
|
||||
.supportsEscapeSequences(escapes)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package io.xpipe.app.beacon.impl;
|
||||
|
||||
import io.xpipe.app.terminal.TerminalLauncherManager;
|
||||
import io.xpipe.beacon.BeaconClientException;
|
||||
import io.xpipe.beacon.BeaconServerException;
|
||||
import io.xpipe.beacon.api.TerminalWaitExchange;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
public class TerminalWaitExchangeImpl extends TerminalWaitExchange {
|
||||
|
||||
@Override
|
||||
public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException, BeaconServerException {
|
||||
TerminalLauncherManager.waitExchange(msg.getRequest());
|
||||
return Response.builder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresEnabledApi() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
package io.xpipe.app.browser;
|
||||
|
||||
import io.xpipe.app.util.BooleanScope;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
public class BrowserAbstractSessionModel<T extends BrowserSessionTab> {
|
||||
|
||||
protected final ObservableList<T> sessionEntries = FXCollections.observableArrayList();
|
||||
protected final Property<T> selectedEntry = new SimpleObjectProperty<>();
|
||||
protected final BooleanProperty busy = new SimpleBooleanProperty();
|
||||
|
||||
public void closeAsync(BrowserSessionTab e) {
|
||||
ThreadHelper.runAsync(() -> {
|
||||
// This is a bit ugly
|
||||
// If we die on tab init, wait a bit with closing to avoid removal while it is still being inited/added
|
||||
ThreadHelper.sleep(100);
|
||||
closeSync(e);
|
||||
});
|
||||
}
|
||||
|
||||
public void openSync(T e, BooleanProperty externalBusy) throws Exception {
|
||||
try (var b = new BooleanScope(externalBusy != null ? externalBusy : new SimpleBooleanProperty()).start()) {
|
||||
e.init();
|
||||
// Prevent multiple calls from interfering with each other
|
||||
synchronized (this) {
|
||||
sessionEntries.add(e);
|
||||
// The tab pane doesn't automatically select new tabs
|
||||
selectedEntry.setValue(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void closeSync(BrowserSessionTab e) {
|
||||
e.close();
|
||||
synchronized (BrowserAbstractSessionModel.this) {
|
||||
this.sessionEntries.remove(e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<T> getSessionEntriesSnapshot() {
|
||||
synchronized (this) {
|
||||
return new ArrayList<>(sessionEntries);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,195 @@
|
|||
package io.xpipe.app.browser;
|
||||
|
||||
import io.xpipe.app.browser.file.BrowserConnectionListComp;
|
||||
import io.xpipe.app.browser.file.BrowserConnectionListFilterComp;
|
||||
import io.xpipe.app.browser.file.BrowserEntry;
|
||||
import io.xpipe.app.browser.file.BrowserFileSystemTabComp;
|
||||
import io.xpipe.app.browser.file.BrowserFileSystemTabModel;
|
||||
import io.xpipe.app.comp.Comp;
|
||||
import io.xpipe.app.comp.base.DialogComp;
|
||||
import io.xpipe.app.comp.base.LeftSplitPaneComp;
|
||||
import io.xpipe.app.comp.base.StackComp;
|
||||
import io.xpipe.app.comp.base.VerticalComp;
|
||||
import io.xpipe.app.comp.store.StoreEntryWrapper;
|
||||
import io.xpipe.app.core.AppLayoutModel;
|
||||
import io.xpipe.app.ext.ShellStore;
|
||||
import io.xpipe.app.storage.DataStoreEntryRef;
|
||||
import io.xpipe.app.util.BindingsHelper;
|
||||
import io.xpipe.app.util.FileReference;
|
||||
import io.xpipe.app.util.PlatformThread;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import io.xpipe.core.store.FileSystemStore;
|
||||
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.Window;
|
||||
import javafx.stage.WindowEvent;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public class BrowserFileChooserSessionComp extends DialogComp {
|
||||
|
||||
private final Stage stage;
|
||||
private final BrowserFileChooserSessionModel model;
|
||||
|
||||
public BrowserFileChooserSessionComp(Stage stage, BrowserFileChooserSessionModel model) {
|
||||
this.stage = stage;
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public static void openSingleFile(
|
||||
Supplier<DataStoreEntryRef<? extends FileSystemStore>> store, Consumer<FileReference> file, boolean save) {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
var lastWindow = Window.getWindows().stream()
|
||||
.filter(window -> window.isFocused())
|
||||
.findFirst();
|
||||
var model = new BrowserFileChooserSessionModel(BrowserFileSystemTabModel.SelectionMode.SINGLE_FILE);
|
||||
DialogComp.showWindow(save ? "saveFileTitle" : "openFileTitle", stage -> {
|
||||
stage.addEventFilter(WindowEvent.WINDOW_HIDDEN, event -> {
|
||||
lastWindow.ifPresent(window -> window.requestFocus());
|
||||
});
|
||||
var comp = new BrowserFileChooserSessionComp(stage, model);
|
||||
comp.apply(struc -> struc.get().setPrefSize(1200, 700))
|
||||
.styleClass("browser")
|
||||
.styleClass("chooser");
|
||||
return comp;
|
||||
});
|
||||
model.setOnFinish(fileStores -> {
|
||||
file.accept(fileStores.size() > 0 ? fileStores.getFirst() : null);
|
||||
});
|
||||
ThreadHelper.runAsync(() -> {
|
||||
model.openFileSystemAsync(store.get(), null, null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String finishKey() {
|
||||
return "select";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Comp<?> pane(Comp<?> content) {
|
||||
return content;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void finish() {
|
||||
stage.close();
|
||||
model.finishChooser();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void discard() {
|
||||
model.finishWithoutChoice();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Comp<?> content() {
|
||||
Predicate<StoreEntryWrapper> applicable = storeEntryWrapper -> {
|
||||
return (storeEntryWrapper.getEntry().getStore() instanceof ShellStore)
|
||||
&& storeEntryWrapper.getEntry().getValidity().isUsable();
|
||||
};
|
||||
BiConsumer<StoreEntryWrapper, BooleanProperty> action = (w, busy) -> {
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
var entry = w.getEntry();
|
||||
if (!entry.getValidity().isUsable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't open same system again
|
||||
var current = model.getSelectedEntry().getValue();
|
||||
if (current != null && entry.ref().equals(current.getEntry())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.getStore() instanceof ShellStore) {
|
||||
model.openFileSystemAsync(entry.ref(), null, busy);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var bookmarkTopBar = new BrowserConnectionListFilterComp();
|
||||
var bookmarksList = new BrowserConnectionListComp(
|
||||
BindingsHelper.map(
|
||||
model.getSelectedEntry(), v -> v != null ? v.getEntry().get() : null),
|
||||
applicable,
|
||||
action,
|
||||
bookmarkTopBar.getCategory(),
|
||||
bookmarkTopBar.getFilter());
|
||||
var bookmarksContainer = new StackComp(List.of(bookmarksList)).styleClass("bookmarks-container");
|
||||
bookmarksContainer
|
||||
.apply(struc -> {
|
||||
var rec = new Rectangle();
|
||||
rec.widthProperty().bind(struc.get().widthProperty());
|
||||
rec.heightProperty().bind(struc.get().heightProperty());
|
||||
rec.setArcHeight(7);
|
||||
rec.setArcWidth(7);
|
||||
struc.get().getChildren().getFirst().setClip(rec);
|
||||
})
|
||||
.vgrow();
|
||||
|
||||
var stack = Comp.of(() -> {
|
||||
var s = new StackPane();
|
||||
model.getSelectedEntry().subscribe(selected -> {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
if (selected != null) {
|
||||
s.getChildren().setAll(new BrowserFileSystemTabComp(selected, false).createRegion());
|
||||
} else {
|
||||
s.getChildren().clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
return s;
|
||||
});
|
||||
|
||||
var vertical = new VerticalComp(List.of(bookmarkTopBar, bookmarksContainer)).styleClass("left");
|
||||
var splitPane = new LeftSplitPaneComp(vertical, stack)
|
||||
.withInitialWidth(AppLayoutModel.get().getSavedState().getBrowserConnectionsWidth())
|
||||
.withOnDividerChange(AppLayoutModel.get().getSavedState()::setBrowserConnectionsWidth)
|
||||
.styleClass("background")
|
||||
.apply(struc -> {
|
||||
struc.getLeft().setMinWidth(200);
|
||||
struc.getLeft().setMaxWidth(500);
|
||||
});
|
||||
return splitPane;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Comp<?> bottom() {
|
||||
return Comp.of(() -> {
|
||||
var selected = new HBox();
|
||||
selected.setAlignment(Pos.CENTER_LEFT);
|
||||
model.getFileSelection().addListener((ListChangeListener<? super BrowserEntry>) c -> {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
selected.getChildren()
|
||||
.setAll(c.getList().stream()
|
||||
.map(s -> {
|
||||
var field = new TextField(
|
||||
s.getRawFileEntry().getPath());
|
||||
field.setEditable(false);
|
||||
field.getStyleClass().add("chooser-selection");
|
||||
HBox.setHgrow(field, Priority.ALWAYS);
|
||||
return field;
|
||||
})
|
||||
.toList());
|
||||
});
|
||||
});
|
||||
var bottomBar = new HBox(selected);
|
||||
HBox.setHgrow(selected, Priority.ALWAYS);
|
||||
bottomBar.setAlignment(Pos.CENTER);
|
||||
return bottomBar;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
package io.xpipe.app.browser;
|
||||
|
||||
import io.xpipe.app.browser.file.BrowserEntry;
|
||||
import io.xpipe.app.browser.file.BrowserFileSystemTabModel;
|
||||
import io.xpipe.app.storage.DataStoreEntryRef;
|
||||
import io.xpipe.app.util.BooleanScope;
|
||||
import io.xpipe.app.util.DerivedObservableList;
|
||||
import io.xpipe.app.util.FileReference;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import io.xpipe.core.store.FileNames;
|
||||
import io.xpipe.core.store.FileSystemStore;
|
||||
import io.xpipe.core.util.FailableFunction;
|
||||
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@Getter
|
||||
public class BrowserFileChooserSessionModel extends BrowserAbstractSessionModel<BrowserFileSystemTabModel> {
|
||||
|
||||
private final BrowserFileSystemTabModel.SelectionMode selectionMode;
|
||||
private final ObservableList<BrowserEntry> fileSelection = FXCollections.observableArrayList();
|
||||
|
||||
@Setter
|
||||
private Consumer<List<FileReference>> onFinish;
|
||||
|
||||
public BrowserFileChooserSessionModel(BrowserFileSystemTabModel.SelectionMode selectionMode) {
|
||||
this.selectionMode = selectionMode;
|
||||
selectedEntry.addListener((observable, oldValue, newValue) -> {
|
||||
if (newValue == null) {
|
||||
fileSelection.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var l = new DerivedObservableList<>(fileSelection, true);
|
||||
l.bindContent(newValue.getFileList().getSelection());
|
||||
});
|
||||
}
|
||||
|
||||
public void finishChooser() {
|
||||
var chosen = new ArrayList<>(fileSelection);
|
||||
|
||||
synchronized (BrowserFileChooserSessionModel.this) {
|
||||
var open = selectedEntry.getValue();
|
||||
if (open != null) {
|
||||
ThreadHelper.runAsync(() -> {
|
||||
open.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var stores = chosen.stream()
|
||||
.map(entry -> new FileReference(
|
||||
selectedEntry.getValue().getEntry(),
|
||||
entry.getRawFileEntry().getPath()))
|
||||
.toList();
|
||||
onFinish.accept(stores);
|
||||
}
|
||||
|
||||
public void finishWithoutChoice() {
|
||||
synchronized (BrowserFileChooserSessionModel.this) {
|
||||
var open = selectedEntry.getValue();
|
||||
if (open != null) {
|
||||
ThreadHelper.runAsync(() -> {
|
||||
open.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void openFileSystemAsync(
|
||||
DataStoreEntryRef<? extends FileSystemStore> store,
|
||||
FailableFunction<BrowserFileSystemTabModel, String, Exception> path,
|
||||
BooleanProperty externalBusy) {
|
||||
if (store == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
BrowserFileSystemTabModel model;
|
||||
|
||||
try (var b = new BooleanScope(externalBusy != null ? externalBusy : new SimpleBooleanProperty()).start()) {
|
||||
model = new BrowserFileSystemTabModel(this, store, selectionMode);
|
||||
model.init();
|
||||
// Prevent multiple calls from interfering with each other
|
||||
synchronized (BrowserFileChooserSessionModel.this) {
|
||||
selectedEntry.setValue(model);
|
||||
sessionEntries.add(model);
|
||||
}
|
||||
if (path != null) {
|
||||
model.initWithGivenDirectory(FileNames.toDirectory(path.apply(model)));
|
||||
} else {
|
||||
model.initWithDefaultDirectory();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,217 @@
|
|||
package io.xpipe.app.browser;
|
||||
|
||||
import io.xpipe.app.browser.file.BrowserConnectionListComp;
|
||||
import io.xpipe.app.browser.file.BrowserConnectionListFilterComp;
|
||||
import io.xpipe.app.browser.file.BrowserTransferComp;
|
||||
import io.xpipe.app.comp.Comp;
|
||||
import io.xpipe.app.comp.CompStructure;
|
||||
import io.xpipe.app.comp.SimpleComp;
|
||||
import io.xpipe.app.comp.base.AnchorComp;
|
||||
import io.xpipe.app.comp.base.LeftSplitPaneComp;
|
||||
import io.xpipe.app.comp.base.LoadingOverlayComp;
|
||||
import io.xpipe.app.comp.base.StackComp;
|
||||
import io.xpipe.app.comp.base.VerticalComp;
|
||||
import io.xpipe.app.comp.store.StoreEntryWrapper;
|
||||
import io.xpipe.app.core.AppLayoutModel;
|
||||
import io.xpipe.app.ext.ShellStore;
|
||||
import io.xpipe.app.util.BindingsHelper;
|
||||
import io.xpipe.app.util.PlatformThread;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public class BrowserFullSessionComp extends SimpleComp {
|
||||
|
||||
private final BrowserFullSessionModel model;
|
||||
|
||||
public BrowserFullSessionComp(BrowserFullSessionModel model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Region createSimple() {
|
||||
var vertical = createLeftSide();
|
||||
|
||||
var leftSplit = new SimpleDoubleProperty();
|
||||
var rightSplit = new SimpleDoubleProperty();
|
||||
var tabs = new BrowserSessionTabsComp(model, leftSplit, rightSplit);
|
||||
tabs.apply(struc -> {
|
||||
struc.get().setViewOrder(1);
|
||||
struc.get().setPickOnBounds(false);
|
||||
AnchorPane.setTopAnchor(struc.get(), 0.0);
|
||||
AnchorPane.setBottomAnchor(struc.get(), 0.0);
|
||||
AnchorPane.setLeftAnchor(struc.get(), 0.0);
|
||||
AnchorPane.setRightAnchor(struc.get(), 0.0);
|
||||
});
|
||||
|
||||
vertical.apply(struc -> {
|
||||
struc.get()
|
||||
.paddingProperty()
|
||||
.bind(Bindings.createObjectBinding(
|
||||
() -> new Insets(tabs.getHeaderHeight().get(), 0, 0, 0), tabs.getHeaderHeight()));
|
||||
});
|
||||
var loadingIndicator = LoadingOverlayComp.noProgress(Comp.empty(), model.getBusy())
|
||||
.apply(struc -> {
|
||||
AnchorPane.setTopAnchor(struc.get(), 3.0);
|
||||
AnchorPane.setRightAnchor(struc.get(), 0.0);
|
||||
})
|
||||
.styleClass("tab-loading-indicator");
|
||||
|
||||
var pinnedStack = createSplitStack(rightSplit, tabs);
|
||||
|
||||
var loadingStack = new AnchorComp(List.of(tabs, pinnedStack, loadingIndicator));
|
||||
loadingStack.apply(struc -> struc.get().setPickOnBounds(false));
|
||||
var splitPane = new LeftSplitPaneComp(vertical, loadingStack)
|
||||
.withInitialWidth(AppLayoutModel.get().getSavedState().getBrowserConnectionsWidth())
|
||||
.withOnDividerChange(d -> {
|
||||
AppLayoutModel.get().getSavedState().setBrowserConnectionsWidth(d);
|
||||
leftSplit.set(d);
|
||||
});
|
||||
splitPane.apply(struc -> {
|
||||
struc.getLeft().setMinWidth(200);
|
||||
struc.getLeft().setMaxWidth(500);
|
||||
struc.get().setPickOnBounds(false);
|
||||
});
|
||||
|
||||
splitPane.apply(struc -> {
|
||||
struc.get().skinProperty().subscribe(newValue -> {
|
||||
if (newValue != null) {
|
||||
Platform.runLater(() -> {
|
||||
struc.get().getChildrenUnmodifiable().forEach(node -> {
|
||||
node.setClip(null);
|
||||
node.setPickOnBounds(false);
|
||||
});
|
||||
struc.get().lookupAll(".split-pane-divider").forEach(node -> node.setViewOrder(-1));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
splitPane.styleClass("browser");
|
||||
var r = splitPane.createRegion();
|
||||
return r;
|
||||
}
|
||||
|
||||
private Comp<CompStructure<VBox>> createLeftSide() {
|
||||
Predicate<StoreEntryWrapper> applicable = storeEntryWrapper -> {
|
||||
if (!storeEntryWrapper.getEntry().getValidity().isUsable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (storeEntryWrapper.getEntry().getStore() instanceof ShellStore) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return storeEntryWrapper.getEntry().getProvider().browserAction(model, storeEntryWrapper.getEntry(), null)
|
||||
!= null;
|
||||
};
|
||||
BiConsumer<StoreEntryWrapper, BooleanProperty> action = (w, busy) -> {
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
var entry = w.getEntry();
|
||||
if (!entry.getValidity().isUsable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var a = entry.getProvider().browserAction(model, entry, busy);
|
||||
if (a != null) {
|
||||
a.execute();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var bookmarkTopBar = new BrowserConnectionListFilterComp();
|
||||
var bookmarksList = new BrowserConnectionListComp(
|
||||
BindingsHelper.map(
|
||||
model.getSelectedEntry(),
|
||||
v -> v instanceof BrowserStoreSessionTab<?> st
|
||||
? st.getEntry().get()
|
||||
: null),
|
||||
applicable,
|
||||
action,
|
||||
bookmarkTopBar.getCategory(),
|
||||
bookmarkTopBar.getFilter());
|
||||
var bookmarksContainer = new StackComp(List.of(bookmarksList)).styleClass("bookmarks-container");
|
||||
bookmarksContainer
|
||||
.apply(struc -> {
|
||||
var rec = new Rectangle();
|
||||
rec.widthProperty().bind(struc.get().widthProperty());
|
||||
rec.heightProperty().bind(struc.get().heightProperty());
|
||||
rec.setArcHeight(11);
|
||||
rec.setArcWidth(11);
|
||||
struc.get().getChildren().getFirst().setClip(rec);
|
||||
})
|
||||
.vgrow();
|
||||
var localDownloadStage = new BrowserTransferComp(model.getLocalTransfersStage())
|
||||
.hide(PlatformThread.sync(Bindings.createBooleanBinding(
|
||||
() -> {
|
||||
if (model.getSessionEntries().size() == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
model.getSessionEntries(),
|
||||
model.getSelectedEntry())));
|
||||
localDownloadStage.prefHeight(200);
|
||||
localDownloadStage.maxHeight(200);
|
||||
var vertical =
|
||||
new VerticalComp(List.of(bookmarkTopBar, bookmarksContainer, localDownloadStage)).styleClass("left");
|
||||
return vertical;
|
||||
}
|
||||
|
||||
private StackComp createSplitStack(SimpleDoubleProperty rightSplit, BrowserSessionTabsComp tabs) {
|
||||
var cache = new HashMap<BrowserSessionTab, Region>();
|
||||
var splitStack = new StackComp(List.of());
|
||||
splitStack.apply(struc -> struc.get().setPickOnBounds(false));
|
||||
splitStack.apply(struc -> {
|
||||
model.getEffectiveRightTab().subscribe((newValue) -> {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
var all = model.getAllTabs();
|
||||
cache.keySet().removeIf(browserSessionTab -> !all.contains(browserSessionTab));
|
||||
|
||||
if (newValue == null) {
|
||||
struc.get().getChildren().clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var cached = cache.containsKey(newValue);
|
||||
if (!cached) {
|
||||
cache.put(newValue, newValue.comp().createRegion());
|
||||
}
|
||||
var r = cache.get(newValue);
|
||||
struc.get().getChildren().clear();
|
||||
struc.get().getChildren().add(r);
|
||||
|
||||
struc.get().setMinWidth(rightSplit.get());
|
||||
struc.get().setPrefWidth(rightSplit.get());
|
||||
struc.get().setMaxWidth(rightSplit.get());
|
||||
});
|
||||
});
|
||||
|
||||
rightSplit.addListener((observable, oldValue, newValue) -> {
|
||||
struc.get().setMinWidth(newValue.doubleValue());
|
||||
struc.get().setPrefWidth(newValue.doubleValue());
|
||||
struc.get().setMaxWidth(newValue.doubleValue());
|
||||
});
|
||||
|
||||
AnchorPane.setBottomAnchor(struc.get(), 0.0);
|
||||
AnchorPane.setRightAnchor(struc.get(), 0.0);
|
||||
tabs.getHeaderHeight().subscribe(number -> {
|
||||
AnchorPane.setTopAnchor(struc.get(), number.doubleValue());
|
||||
});
|
||||
});
|
||||
return splitStack;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,251 @@
|
|||
package io.xpipe.app.browser;
|
||||
|
||||
import io.xpipe.app.browser.file.BrowserFileSystemTabModel;
|
||||
import io.xpipe.app.browser.file.BrowserHistorySavedState;
|
||||
import io.xpipe.app.browser.file.BrowserHistoryTabModel;
|
||||
import io.xpipe.app.browser.file.BrowserTransferModel;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.storage.DataStoreEntryRef;
|
||||
import io.xpipe.app.util.BooleanScope;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import io.xpipe.core.store.FileNames;
|
||||
import io.xpipe.core.store.FileSystemStore;
|
||||
import io.xpipe.core.util.FailableFunction;
|
||||
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableMap;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Getter
|
||||
public class BrowserFullSessionModel extends BrowserAbstractSessionModel<BrowserSessionTab> {
|
||||
|
||||
public static final BrowserFullSessionModel DEFAULT = new BrowserFullSessionModel();
|
||||
|
||||
@SneakyThrows
|
||||
public static void init() {
|
||||
DEFAULT.openSync(new BrowserHistoryTabModel(DEFAULT), null);
|
||||
if (AppPrefs.get().pinLocalMachineOnStartup().get()) {
|
||||
var tab = new BrowserFileSystemTabModel(
|
||||
DEFAULT, DataStorage.get().local().ref(), BrowserFileSystemTabModel.SelectionMode.ALL);
|
||||
DEFAULT.openSync(tab, null);
|
||||
DEFAULT.pinTab(tab);
|
||||
}
|
||||
}
|
||||
|
||||
private final BrowserTransferModel localTransfersStage = new BrowserTransferModel(this);
|
||||
private final Property<Boolean> draggingFiles = new SimpleBooleanProperty();
|
||||
private final Property<BrowserSessionTab> globalPinnedTab = new SimpleObjectProperty<>();
|
||||
private final ObservableMap<BrowserSessionTab, BrowserSessionTab> splits = FXCollections.observableHashMap();
|
||||
private final ObservableValue<BrowserSessionTab> effectiveRightTab = createEffectiveRightTab();
|
||||
private final SequencedSet<BrowserSessionTab> previousTabs = new LinkedHashSet<>();
|
||||
|
||||
private ObservableValue<BrowserSessionTab> createEffectiveRightTab() {
|
||||
return Bindings.createObjectBinding(
|
||||
() -> {
|
||||
var current = selectedEntry.getValue();
|
||||
if (current == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!current.isCloseable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var split = splits.get(current);
|
||||
if (split != null) {
|
||||
return split;
|
||||
}
|
||||
|
||||
var global = globalPinnedTab.getValue();
|
||||
if (global == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (global == selectedEntry.getValue()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return global;
|
||||
},
|
||||
globalPinnedTab,
|
||||
selectedEntry,
|
||||
splits);
|
||||
}
|
||||
|
||||
public BrowserFullSessionModel() {
|
||||
sessionEntries.addListener((ListChangeListener<? super BrowserSessionTab>) c -> {
|
||||
var v = globalPinnedTab.getValue();
|
||||
if (v != null && !c.getList().contains(v)) {
|
||||
globalPinnedTab.setValue(null);
|
||||
}
|
||||
|
||||
splits.keySet().removeIf(browserSessionTab -> !c.getList().contains(browserSessionTab));
|
||||
});
|
||||
|
||||
selectedEntry.addListener((observable, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
previousTabs.remove(newValue);
|
||||
previousTabs.add(newValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Set<BrowserSessionTab> getAllTabs() {
|
||||
var set = new HashSet<BrowserSessionTab>();
|
||||
set.addAll(sessionEntries);
|
||||
set.addAll(splits.values());
|
||||
if (globalPinnedTab.getValue() != null) {
|
||||
set.add(globalPinnedTab.getValue());
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
public void splitTab(BrowserSessionTab tab, BrowserSessionTab split) {
|
||||
if (splits.containsKey(tab)) {
|
||||
return;
|
||||
}
|
||||
|
||||
splits.put(tab, split);
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
split.init();
|
||||
});
|
||||
}
|
||||
|
||||
public void unsplitTab(BrowserSessionTab tab) {
|
||||
if (splits.values().remove(tab)) {
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
tab.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void pinTab(BrowserSessionTab tab) {
|
||||
if (tab.equals(globalPinnedTab.getValue())) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalPinnedTab.setValue(tab);
|
||||
|
||||
var previousOthers = previousTabs.stream()
|
||||
.filter(browserSessionTab -> browserSessionTab != tab && browserSessionTab.isCloseable())
|
||||
.toList();
|
||||
if (previousOthers.size() > 0) {
|
||||
var prev = previousOthers.getLast();
|
||||
getSelectedEntry().setValue(prev);
|
||||
}
|
||||
}
|
||||
|
||||
public void unpinTab(BrowserSessionTab tab) {
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
globalPinnedTab.setValue(null);
|
||||
});
|
||||
}
|
||||
|
||||
public void restoreState(BrowserHistorySavedState state) {
|
||||
ThreadHelper.runAsync(() -> {
|
||||
var l = new ArrayList<>(state.getEntries());
|
||||
l.forEach(e -> {
|
||||
restoreStateAsync(e, null);
|
||||
// Don't try to run everything in parallel as that can be taxing
|
||||
ThreadHelper.sleep(1000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public void restoreStateAsync(BrowserHistorySavedState.Entry e, BooleanProperty busy) {
|
||||
var storageEntry = DataStorage.get().getStoreEntryIfPresent(e.getUuid());
|
||||
storageEntry.ifPresent(entry -> {
|
||||
openFileSystemAsync(entry.ref(), model -> e.getPath(), busy);
|
||||
});
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
synchronized (BrowserFullSessionModel.this) {
|
||||
if (globalPinnedTab.getValue() != null) {
|
||||
globalPinnedTab.setValue(null);
|
||||
}
|
||||
|
||||
var all = new ArrayList<>(sessionEntries);
|
||||
for (var o : all) {
|
||||
// Don't close busy connections gracefully
|
||||
// as we otherwise might lock up
|
||||
if (!o.canImmediatelyClose()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prevent blocking of shutdown
|
||||
closeAsync(o);
|
||||
}
|
||||
if (all.size() > 0) {
|
||||
ThreadHelper.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all files
|
||||
localTransfersStage.clear(true);
|
||||
}
|
||||
|
||||
public void openFileSystemAsync(
|
||||
DataStoreEntryRef<? extends FileSystemStore> store,
|
||||
FailableFunction<BrowserFileSystemTabModel, String, Exception> path,
|
||||
BooleanProperty externalBusy) {
|
||||
if (store == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
openFileSystemSync(store, path, externalBusy, true);
|
||||
});
|
||||
}
|
||||
|
||||
public BrowserFileSystemTabModel openFileSystemSync(
|
||||
DataStoreEntryRef<? extends FileSystemStore> store,
|
||||
FailableFunction<BrowserFileSystemTabModel, String, Exception> path,
|
||||
BooleanProperty externalBusy,
|
||||
boolean select)
|
||||
throws Exception {
|
||||
BrowserFileSystemTabModel model;
|
||||
try (var b = new BooleanScope(externalBusy != null ? externalBusy : new SimpleBooleanProperty()).start()) {
|
||||
try (var sessionBusy = new BooleanScope(busy).exclusive().start()) {
|
||||
model = new BrowserFileSystemTabModel(this, store, BrowserFileSystemTabModel.SelectionMode.ALL);
|
||||
model.init();
|
||||
// Prevent multiple calls from interfering with each other
|
||||
synchronized (BrowserFullSessionModel.this) {
|
||||
sessionEntries.add(model);
|
||||
if (select) {
|
||||
// The tab pane doesn't automatically select new tabs
|
||||
selectedEntry.setValue(model);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (path != null) {
|
||||
model.initWithGivenDirectory(FileNames.toDirectory(path.apply(model)));
|
||||
} else {
|
||||
model.initWithDefaultDirectory();
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closeSync(BrowserSessionTab e) {
|
||||
var split = splits.get(e);
|
||||
if (split != null) {
|
||||
split.close();
|
||||
}
|
||||
previousTabs.remove(e);
|
||||
super.closeSync(e);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package io.xpipe.app.browser;
|
||||
|
||||
import io.xpipe.app.comp.Comp;
|
||||
import io.xpipe.app.storage.DataColor;
|
||||
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public abstract class BrowserSessionTab {
|
||||
|
||||
protected final BooleanProperty busy = new SimpleBooleanProperty();
|
||||
protected final BrowserAbstractSessionModel<?> browserModel;
|
||||
protected final Property<BrowserSessionTab> splitTab = new SimpleObjectProperty<>();
|
||||
|
||||
public BrowserSessionTab(BrowserAbstractSessionModel<?> browserModel) {
|
||||
this.browserModel = browserModel;
|
||||
}
|
||||
|
||||
public abstract Comp<?> comp();
|
||||
|
||||
public abstract boolean canImmediatelyClose();
|
||||
|
||||
public abstract void init() throws Exception;
|
||||
|
||||
public abstract void close();
|
||||
|
||||
public abstract ObservableValue<String> getName();
|
||||
|
||||
public abstract String getIcon();
|
||||
|
||||
public abstract DataColor getColor();
|
||||
|
||||
public boolean isCloseable() {
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,505 @@
|
|||
package io.xpipe.app.browser;
|
||||
|
||||
import io.xpipe.app.comp.Comp;
|
||||
import io.xpipe.app.comp.SimpleComp;
|
||||
import io.xpipe.app.comp.base.PrettyImageHelper;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.util.BooleanScope;
|
||||
import io.xpipe.app.util.ContextMenuHelper;
|
||||
import io.xpipe.app.util.LabelGraphic;
|
||||
import io.xpipe.app.util.PlatformThread;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.value.ObservableDoubleValue;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.control.skin.TabPaneSkin;
|
||||
import javafx.scene.input.*;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
import atlantafx.base.controls.RingProgressIndicator;
|
||||
import atlantafx.base.theme.Styles;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static atlantafx.base.theme.Styles.DENSE;
|
||||
import static atlantafx.base.theme.Styles.toggleStyleClass;
|
||||
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
|
||||
|
||||
public class BrowserSessionTabsComp extends SimpleComp {
|
||||
|
||||
private final BrowserFullSessionModel model;
|
||||
private final ObservableDoubleValue leftPadding;
|
||||
private final DoubleProperty rightPadding;
|
||||
|
||||
@Getter
|
||||
private final DoubleProperty headerHeight;
|
||||
|
||||
public BrowserSessionTabsComp(
|
||||
BrowserFullSessionModel model, ObservableDoubleValue leftPadding, DoubleProperty rightPadding) {
|
||||
this.model = model;
|
||||
this.leftPadding = leftPadding;
|
||||
this.rightPadding = rightPadding;
|
||||
this.headerHeight = new SimpleDoubleProperty();
|
||||
}
|
||||
|
||||
public Region createSimple() {
|
||||
var tabs = createTabPane();
|
||||
var topBackground = Comp.hspacer().styleClass("top-spacer").createRegion();
|
||||
leftPadding.subscribe(number -> {
|
||||
StackPane.setMargin(topBackground, new Insets(0, 0, 0, -number.doubleValue() - 3));
|
||||
});
|
||||
var stack = new StackPane(topBackground, tabs);
|
||||
stack.setAlignment(Pos.TOP_CENTER);
|
||||
topBackground.prefHeightProperty().bind(headerHeight);
|
||||
topBackground.maxHeightProperty().bind(topBackground.prefHeightProperty());
|
||||
topBackground.prefWidthProperty().bind(tabs.widthProperty());
|
||||
return stack;
|
||||
}
|
||||
|
||||
private TabPane createTabPane() {
|
||||
var tabs = new TabPane();
|
||||
tabs.setTabDragPolicy(TabPane.TabDragPolicy.REORDER);
|
||||
tabs.setTabMinWidth(Region.USE_PREF_SIZE);
|
||||
tabs.setTabMaxWidth(400);
|
||||
tabs.setTabClosingPolicy(ALL_TABS);
|
||||
tabs.setSkin(new TabPaneSkin(tabs));
|
||||
Styles.toggleStyleClass(tabs, TabPane.STYLE_CLASS_FLOATING);
|
||||
toggleStyleClass(tabs, DENSE);
|
||||
|
||||
setupCustomStyle(tabs);
|
||||
// Sync to guarantee that no external changes are made during this
|
||||
synchronized (model) {
|
||||
setupTabEntries(tabs);
|
||||
}
|
||||
setupKeyEvents(tabs);
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
private void setupTabEntries(TabPane tabs) {
|
||||
var map = new HashMap<BrowserSessionTab, Tab>();
|
||||
|
||||
// Restore state
|
||||
model.getSessionEntries().forEach(v -> {
|
||||
var t = createTab(tabs, v);
|
||||
map.put(v, t);
|
||||
tabs.getTabs().add(t);
|
||||
});
|
||||
tabs.getSelectionModel()
|
||||
.select(model.getSessionEntries()
|
||||
.indexOf(model.getSelectedEntry().getValue()));
|
||||
|
||||
// Used for ignoring changes by the tabpane when new tabs are added. We want to perform the selections manually!
|
||||
var addingTab = new SimpleBooleanProperty();
|
||||
|
||||
// Handle selection from platform
|
||||
tabs.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (addingTab.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue == null) {
|
||||
model.getSelectedEntry().setValue(null);
|
||||
return;
|
||||
}
|
||||
|
||||
var source = map.entrySet().stream()
|
||||
.filter(openFileSystemModelTabEntry ->
|
||||
openFileSystemModelTabEntry.getValue().equals(newValue))
|
||||
.findAny()
|
||||
.map(Map.Entry::getKey)
|
||||
.orElse(null);
|
||||
model.getSelectedEntry().setValue(source);
|
||||
});
|
||||
|
||||
// Handle selection from model
|
||||
model.getSelectedEntry().addListener((observable, oldValue, newValue) -> {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
if (newValue == null) {
|
||||
tabs.getSelectionModel().select(null);
|
||||
return;
|
||||
}
|
||||
|
||||
var toSelect = map.entrySet().stream()
|
||||
.filter(openFileSystemModelTabEntry ->
|
||||
openFileSystemModelTabEntry.getKey().equals(newValue))
|
||||
.findAny()
|
||||
.map(Map.Entry::getValue)
|
||||
.orElse(null);
|
||||
if (toSelect == null || !tabs.getTabs().contains(toSelect)) {
|
||||
tabs.getSelectionModel().select(null);
|
||||
return;
|
||||
}
|
||||
|
||||
tabs.getSelectionModel().select(toSelect);
|
||||
Platform.runLater(() -> {
|
||||
toSelect.getContent().requestFocus();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
model.getSessionEntries().addListener((ListChangeListener<? super BrowserSessionTab>) c -> {
|
||||
while (c.next()) {
|
||||
for (var r : c.getRemoved()) {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
var t = map.remove(r);
|
||||
tabs.getTabs().remove(t);
|
||||
});
|
||||
}
|
||||
|
||||
for (var a : c.getAddedSubList()) {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
try (var b = new BooleanScope(addingTab).start()) {
|
||||
var t = createTab(tabs, a);
|
||||
map.put(a, t);
|
||||
tabs.getTabs().add(t);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tabs.getTabs().addListener((ListChangeListener<? super Tab>) c -> {
|
||||
while (c.next()) {
|
||||
for (var r : c.getRemoved()) {
|
||||
var source = map.entrySet().stream()
|
||||
.filter(openFileSystemModelTabEntry ->
|
||||
openFileSystemModelTabEntry.getValue().equals(r))
|
||||
.findAny()
|
||||
.orElse(null);
|
||||
|
||||
// Only handle close events that are triggered from the platform
|
||||
if (source == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
model.closeAsync(source.getKey());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setupCustomStyle(TabPane tabs) {
|
||||
tabs.skinProperty().subscribe(newValue -> {
|
||||
if (newValue != null) {
|
||||
Platform.runLater(() -> {
|
||||
tabs.setClip(null);
|
||||
tabs.setPickOnBounds(false);
|
||||
tabs.lookupAll(".tab-header-area").forEach(node -> {
|
||||
node.setClip(null);
|
||||
node.setPickOnBounds(false);
|
||||
|
||||
var r = (Region) node;
|
||||
r.prefHeightProperty().bind(r.maxHeightProperty());
|
||||
r.setMinHeight(Region.USE_PREF_SIZE);
|
||||
});
|
||||
tabs.lookupAll(".headers-region").forEach(node -> {
|
||||
node.setClip(null);
|
||||
node.setPickOnBounds(false);
|
||||
|
||||
var r = (Region) node;
|
||||
r.prefHeightProperty().bind(r.maxHeightProperty());
|
||||
r.setMinHeight(Region.USE_PREF_SIZE);
|
||||
});
|
||||
|
||||
Region headerArea = (Region) tabs.lookup(".tab-header-area");
|
||||
headerArea
|
||||
.paddingProperty()
|
||||
.bind(Bindings.createObjectBinding(
|
||||
() -> new Insets(2, 0, 4, -leftPadding.get() + 3), leftPadding));
|
||||
tabs.setPadding(new Insets(0, 0, 0, -5));
|
||||
headerHeight.bind(headerArea.heightProperty());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void setupKeyEvents(TabPane tabs) {
|
||||
tabs.addEventHandler(KeyEvent.KEY_PRESSED, keyEvent -> {
|
||||
var current = tabs.getSelectionModel().getSelectedItem();
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN).match(keyEvent)) {
|
||||
tabs.getTabs().remove(current);
|
||||
keyEvent.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
if (new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN, KeyCombination.SHIFT_DOWN)
|
||||
.match(keyEvent)) {
|
||||
tabs.getTabs().clear();
|
||||
keyEvent.consume();
|
||||
}
|
||||
|
||||
if (keyEvent.getCode().isFunctionKey()) {
|
||||
var start = KeyCode.F1.getCode();
|
||||
var index = keyEvent.getCode().getCode() - start;
|
||||
if (index < tabs.getTabs().size()) {
|
||||
tabs.getSelectionModel().select(index);
|
||||
keyEvent.consume();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var forward = new KeyCodeCombination(KeyCode.TAB, KeyCombination.SHORTCUT_DOWN);
|
||||
if (forward.match(keyEvent)) {
|
||||
var next = (tabs.getSelectionModel().getSelectedIndex() + 1)
|
||||
% tabs.getTabs().size();
|
||||
tabs.getSelectionModel().select(next);
|
||||
keyEvent.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
var back = new KeyCodeCombination(KeyCode.TAB, KeyCombination.SHORTCUT_DOWN, KeyCombination.SHIFT_DOWN);
|
||||
if (back.match(keyEvent)) {
|
||||
var previous = (tabs.getTabs().size() + tabs.getSelectionModel().getSelectedIndex() - 1)
|
||||
% tabs.getTabs().size();
|
||||
tabs.getSelectionModel().select(previous);
|
||||
keyEvent.consume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ContextMenu createContextMenu(TabPane tabs, Tab tab, BrowserSessionTab tabModel) {
|
||||
var cm = ContextMenuHelper.create();
|
||||
|
||||
if (tabModel.isCloseable()) {
|
||||
var unpin = ContextMenuHelper.item(LabelGraphic.none(), "unpinTab");
|
||||
unpin.visibleProperty()
|
||||
.bind(PlatformThread.sync(Bindings.createBooleanBinding(
|
||||
() -> {
|
||||
return model.getGlobalPinnedTab().getValue() != null
|
||||
&& model.getGlobalPinnedTab().getValue().equals(tabModel);
|
||||
},
|
||||
model.getGlobalPinnedTab())));
|
||||
unpin.setOnAction(event -> {
|
||||
model.unpinTab(tabModel);
|
||||
event.consume();
|
||||
});
|
||||
cm.getItems().add(unpin);
|
||||
|
||||
var pin = ContextMenuHelper.item(LabelGraphic.none(), "pinTab");
|
||||
pin.visibleProperty()
|
||||
.bind(PlatformThread.sync(Bindings.createBooleanBinding(
|
||||
() -> {
|
||||
return model.getGlobalPinnedTab().getValue() == null;
|
||||
},
|
||||
model.getGlobalPinnedTab())));
|
||||
pin.setOnAction(event -> {
|
||||
model.pinTab(tabModel);
|
||||
event.consume();
|
||||
});
|
||||
cm.getItems().add(pin);
|
||||
}
|
||||
|
||||
var select = ContextMenuHelper.item(LabelGraphic.none(), "selectTab");
|
||||
select.acceleratorProperty()
|
||||
.bind(Bindings.createObjectBinding(
|
||||
() -> {
|
||||
var start = KeyCode.F1.getCode();
|
||||
var index = tabs.getTabs().indexOf(tab);
|
||||
var keyCode = Arrays.stream(KeyCode.values())
|
||||
.filter(code -> code.getCode() == start + index)
|
||||
.findAny()
|
||||
.orElse(null);
|
||||
return keyCode != null ? new KeyCodeCombination(keyCode) : null;
|
||||
},
|
||||
tabs.getTabs()));
|
||||
select.setOnAction(event -> {
|
||||
tabs.getSelectionModel().select(tab);
|
||||
event.consume();
|
||||
});
|
||||
cm.getItems().add(select);
|
||||
|
||||
cm.getItems().add(new SeparatorMenuItem());
|
||||
|
||||
var close = ContextMenuHelper.item(LabelGraphic.none(), "closeTab");
|
||||
close.setAccelerator(new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN));
|
||||
close.setOnAction(event -> {
|
||||
if (tab.isClosable()) {
|
||||
tabs.getTabs().remove(tab);
|
||||
}
|
||||
event.consume();
|
||||
});
|
||||
cm.getItems().add(close);
|
||||
|
||||
var closeOthers = ContextMenuHelper.item(LabelGraphic.none(), "closeOtherTabs");
|
||||
closeOthers.setOnAction(event -> {
|
||||
tabs.getTabs()
|
||||
.removeAll(tabs.getTabs().stream()
|
||||
.filter(t -> t != tab && t.isClosable())
|
||||
.toList());
|
||||
event.consume();
|
||||
});
|
||||
cm.getItems().add(closeOthers);
|
||||
|
||||
var closeLeft = ContextMenuHelper.item(LabelGraphic.none(), "closeLeftTabs");
|
||||
closeLeft.setOnAction(event -> {
|
||||
var index = tabs.getTabs().indexOf(tab);
|
||||
tabs.getTabs()
|
||||
.removeAll(tabs.getTabs().stream()
|
||||
.filter(t -> tabs.getTabs().indexOf(t) < index && t.isClosable())
|
||||
.toList());
|
||||
event.consume();
|
||||
});
|
||||
cm.getItems().add(closeLeft);
|
||||
|
||||
var closeRight = ContextMenuHelper.item(LabelGraphic.none(), "closeRightTabs");
|
||||
closeRight.setOnAction(event -> {
|
||||
var index = tabs.getTabs().indexOf(tab);
|
||||
tabs.getTabs()
|
||||
.removeAll(tabs.getTabs().stream()
|
||||
.filter(t -> tabs.getTabs().indexOf(t) > index && t.isClosable())
|
||||
.toList());
|
||||
event.consume();
|
||||
});
|
||||
cm.getItems().add(closeRight);
|
||||
|
||||
var closeAll = ContextMenuHelper.item(LabelGraphic.none(), "closeAllTabs");
|
||||
closeAll.setAccelerator(
|
||||
new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN, KeyCombination.SHIFT_DOWN));
|
||||
closeAll.setOnAction(event -> {
|
||||
tabs.getTabs()
|
||||
.removeAll(
|
||||
tabs.getTabs().stream().filter(t -> t.isClosable()).toList());
|
||||
event.consume();
|
||||
});
|
||||
cm.getItems().add(closeAll);
|
||||
|
||||
return cm;
|
||||
}
|
||||
|
||||
private Tab createTab(TabPane tabs, BrowserSessionTab tabModel) {
|
||||
var tab = new Tab();
|
||||
if (tabModel.isCloseable()) {
|
||||
tab.setContextMenu(createContextMenu(tabs, tab, tabModel));
|
||||
}
|
||||
|
||||
tab.setClosable(tabModel.isCloseable());
|
||||
// Prevent closing while busy
|
||||
tab.setOnCloseRequest(event -> {
|
||||
if (!tabModel.canImmediatelyClose()) {
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
|
||||
if (tabModel.getIcon() != null) {
|
||||
var ring = new RingProgressIndicator(0, false);
|
||||
ring.setMinSize(16, 16);
|
||||
ring.setPrefSize(16, 16);
|
||||
ring.setMaxSize(16, 16);
|
||||
ring.progressProperty()
|
||||
.bind(Bindings.createDoubleBinding(
|
||||
() -> tabModel.getBusy().get()
|
||||
&& !AppPrefs.get().performanceMode().get()
|
||||
? -1d
|
||||
: 0,
|
||||
PlatformThread.sync(tabModel.getBusy()),
|
||||
AppPrefs.get().performanceMode()));
|
||||
|
||||
var image = tabModel.getIcon();
|
||||
var logo = PrettyImageHelper.ofFixedSizeSquare(image, 16).createRegion();
|
||||
|
||||
tab.graphicProperty()
|
||||
.bind(Bindings.createObjectBinding(
|
||||
() -> {
|
||||
return tabModel.getBusy().get() ? ring : logo;
|
||||
},
|
||||
PlatformThread.sync(tabModel.getBusy())));
|
||||
}
|
||||
|
||||
if (tabModel.getBrowserModel() instanceof BrowserFullSessionModel sessionModel) {
|
||||
var global = PlatformThread.sync(sessionModel.getGlobalPinnedTab());
|
||||
tab.textProperty()
|
||||
.bind(Bindings.createStringBinding(
|
||||
() -> {
|
||||
var n = tabModel.getName().getValue();
|
||||
return (AppPrefs.get().censorMode().get() ? "*".repeat(n.length()) : n)
|
||||
+ (global.getValue() == tabModel ? " (" + AppI18n.get("pinned") + ")" : "");
|
||||
},
|
||||
tabModel.getName(),
|
||||
global,
|
||||
AppI18n.activeLanguage(),
|
||||
AppPrefs.get().censorMode()));
|
||||
} else {
|
||||
tab.textProperty().bind(tabModel.getName());
|
||||
}
|
||||
|
||||
Comp<?> comp = tabModel.comp();
|
||||
var compRegion = comp.createRegion();
|
||||
var empty = new StackPane();
|
||||
empty.setMinWidth(450);
|
||||
empty.widthProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (tabModel.isCloseable() && tabs.getSelectionModel().getSelectedItem() == tab) {
|
||||
rightPadding.setValue(newValue.doubleValue());
|
||||
}
|
||||
});
|
||||
var split = new SplitPane(compRegion);
|
||||
if (tabModel.isCloseable()) {
|
||||
split.getItems().add(empty);
|
||||
}
|
||||
tabs.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (tabModel.isCloseable() && newValue == tab) {
|
||||
rightPadding.setValue(empty.getWidth());
|
||||
}
|
||||
});
|
||||
model.getEffectiveRightTab().subscribe(browserSessionTab -> {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
if (browserSessionTab != null && split.getItems().size() > 1) {
|
||||
split.getItems().set(1, empty);
|
||||
} else if (browserSessionTab != null && split.getItems().size() == 1) {
|
||||
split.getItems().add(empty);
|
||||
} else if (browserSessionTab == null && split.getItems().size() > 1) {
|
||||
split.getItems().remove(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
tab.setContent(split);
|
||||
|
||||
var id = UUID.randomUUID().toString();
|
||||
tab.setId(id);
|
||||
|
||||
tabs.skinProperty().subscribe(newValue -> {
|
||||
if (newValue != null) {
|
||||
Platform.runLater(() -> {
|
||||
Label l = (Label) tabs.lookup("#" + id + " .tab-label");
|
||||
var w = l.maxWidthProperty();
|
||||
l.minWidthProperty().bind(w);
|
||||
l.prefWidthProperty().bind(w);
|
||||
if (!tabModel.isCloseable()) {
|
||||
l.pseudoClassStateChanged(PseudoClass.getPseudoClass("static"), true);
|
||||
}
|
||||
|
||||
var close = (StackPane) tabs.lookup("#" + id + " .tab-close-button");
|
||||
close.setPrefWidth(30);
|
||||
|
||||
StackPane c = (StackPane) tabs.lookup("#" + id + " .tab-container");
|
||||
c.getStyleClass().add("color-box");
|
||||
var color = tabModel.getColor();
|
||||
if (color != null) {
|
||||
c.getStyleClass().add(color.getId());
|
||||
}
|
||||
c.addEventHandler(
|
||||
DragEvent.DRAG_ENTERED,
|
||||
mouseEvent -> Platform.runLater(
|
||||
() -> tabs.getSelectionModel().select(tab)));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return tab;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package io.xpipe.app.browser;
|
||||
|
||||
import io.xpipe.app.comp.Comp;
|
||||
import io.xpipe.app.storage.DataColor;
|
||||
import io.xpipe.app.storage.DataStorage;
|
||||
import io.xpipe.app.storage.DataStoreEntryRef;
|
||||
import io.xpipe.core.store.DataStore;
|
||||
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public abstract class BrowserStoreSessionTab<T extends DataStore> extends BrowserSessionTab {
|
||||
|
||||
protected final DataStoreEntryRef<? extends T> entry;
|
||||
private final String name;
|
||||
|
||||
public BrowserStoreSessionTab(BrowserAbstractSessionModel<?> browserModel, DataStoreEntryRef<? extends T> entry) {
|
||||
super(browserModel);
|
||||
this.entry = entry;
|
||||
this.name = DataStorage.get().getStoreEntryDisplayName(entry.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObservableValue<String> getName() {
|
||||
return new SimpleStringProperty(name);
|
||||
}
|
||||
|
||||
public abstract Comp<?> comp();
|
||||
|
||||
public abstract boolean canImmediatelyClose();
|
||||
|
||||
public abstract void init() throws Exception;
|
||||
|
||||
public abstract void close();
|
||||
|
||||
@Override
|
||||
public String getIcon() {
|
||||
return entry.get().getEffectiveIconFile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataColor getColor() {
|
||||
return DataStorage.get().getEffectiveColor(entry.get());
|
||||
}
|
||||
}
|
118
app/src/main/java/io/xpipe/app/browser/action/BrowserAction.java
Normal file
118
app/src/main/java/io/xpipe/app/browser/action/BrowserAction.java
Normal file
|
@ -0,0 +1,118 @@
|
|||
package io.xpipe.app.browser.action;
|
||||
|
||||
import io.xpipe.app.browser.file.BrowserEntry;
|
||||
import io.xpipe.app.browser.file.BrowserFileSystemTabModel;
|
||||
import io.xpipe.app.issue.ErrorEvent;
|
||||
import io.xpipe.core.util.ModuleLayerLoader;
|
||||
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.input.KeyCombination;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.ServiceLoader;
|
||||
|
||||
public interface BrowserAction {
|
||||
|
||||
List<BrowserAction> ALL = new ArrayList<>();
|
||||
|
||||
static List<BrowserLeafAction> getFlattened(BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
|
||||
return ALL.stream()
|
||||
.map(browserAction -> getFlattened(browserAction, model, entries))
|
||||
.flatMap(List::stream)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static List<BrowserLeafAction> getFlattened(
|
||||
BrowserAction browserAction, BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
|
||||
return browserAction instanceof BrowserLeafAction
|
||||
? List.of((BrowserLeafAction) browserAction)
|
||||
: ((BrowserBranchAction) browserAction)
|
||||
.getBranchingActions(model, entries).stream()
|
||||
.map(action -> getFlattened(action, model, entries))
|
||||
.flatMap(List::stream)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static BrowserLeafAction byId(String id, BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
|
||||
return getFlattened(model, entries).stream()
|
||||
.filter(browserAction -> id.equals(browserAction.getId()))
|
||||
.findAny()
|
||||
.orElseThrow();
|
||||
}
|
||||
|
||||
default List<BrowserEntry> resolveFilesIfNeeded(List<BrowserEntry> selected) {
|
||||
return automaticallyResolveLinks()
|
||||
? selected.stream()
|
||||
.map(browserEntry ->
|
||||
new BrowserEntry(browserEntry.getRawFileEntry().resolved(), browserEntry.getModel()))
|
||||
.toList()
|
||||
: selected;
|
||||
}
|
||||
|
||||
MenuItem toMenuItem(BrowserFileSystemTabModel model, List<BrowserEntry> selected);
|
||||
|
||||
default void init(BrowserFileSystemTabModel model) throws Exception {}
|
||||
|
||||
default String getProFeatureId() {
|
||||
return null;
|
||||
}
|
||||
|
||||
default Node getIcon(BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
default Category getCategory() {
|
||||
return null;
|
||||
}
|
||||
|
||||
default KeyCombination getShortcut() {
|
||||
return null;
|
||||
}
|
||||
|
||||
default boolean acceptsEmptySelection() {
|
||||
return false;
|
||||
}
|
||||
|
||||
ObservableValue<String> getName(BrowserFileSystemTabModel model, List<BrowserEntry> entries);
|
||||
|
||||
default boolean isApplicable(BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
|
||||
return true;
|
||||
}
|
||||
|
||||
default boolean automaticallyResolveLinks() {
|
||||
return true;
|
||||
}
|
||||
|
||||
default boolean isActive(BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
|
||||
return true;
|
||||
}
|
||||
|
||||
enum Category {
|
||||
CUSTOM,
|
||||
OPEN,
|
||||
NATIVE,
|
||||
COPY_PASTE,
|
||||
MUTATION
|
||||
}
|
||||
|
||||
class Loader implements ModuleLayerLoader {
|
||||
|
||||
@Override
|
||||
public void init(ModuleLayer layer) {
|
||||
ALL.addAll(ServiceLoader.load(layer, BrowserAction.class).stream()
|
||||
.map(actionProviderProvider -> actionProviderProvider.get())
|
||||
.filter(provider -> {
|
||||
try {
|
||||
return true;
|
||||
} catch (Throwable e) {
|
||||
ErrorEvent.fromThrowable(e).handle();
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package io.xpipe.app.browser.action;
|
||||
|
||||
import io.xpipe.app.browser.file.BrowserEntry;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class BrowserActionFormatter {
|
||||
|
||||
public static String filesArgument(List<BrowserEntry> entries) {
|
||||
return entries.size() == 1 ? entries.getFirst().getFileName() : "(" + entries.size() + ")";
|
||||
}
|
||||
|
||||
public static String centerEllipsis(String input, int length) {
|
||||
if (input == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (input.length() <= length) {
|
||||
return input;
|
||||
}
|
||||
|
||||
var half = (length / 2) - 5;
|
||||
return input.substring(0, half) + " ... " + input.substring(input.length() - half);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package io.xpipe.app.browser.action;
|
||||
|
||||
import io.xpipe.app.browser.file.BrowserEntry;
|
||||
import io.xpipe.app.browser.file.BrowserFileSystemTabModel;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface BrowserApplicationPathAction extends BrowserAction {
|
||||
|
||||
String getExecutable();
|
||||
|
||||
@Override
|
||||
default void init(BrowserFileSystemTabModel model) {
|
||||
// Cache result for later calls
|
||||
model.getCache().isApplicationInPath(getExecutable());
|
||||
}
|
||||
|
||||
@Override
|
||||
default boolean isActive(BrowserFileSystemTabModel model, List<BrowserEntry> entries) {
|
||||
return model.getCache().isApplicationInPath(getExecutable());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package io.xpipe.app.browser.action;
|
||||
|
||||
import io.xpipe.app.browser.file.BrowserEntry;
|
||||
import io.xpipe.app.browser.file.BrowserFileSystemTabModel;
|
||||
import io.xpipe.app.util.LicenseProvider;
|
||||
|
||||
import javafx.scene.control.Menu;
|
||||
import javafx.scene.control.MenuItem;
|
||||
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface BrowserBranchAction extends BrowserAction {
|
||||
|
||||
default MenuItem toMenuItem(BrowserFileSystemTabModel model, List<BrowserEntry> selected) {
|
||||
var m = new Menu(getName(model, selected).getValue() + " ...");
|
||||
for (var sub : getBranchingActions(model, selected)) {
|
||||
var subselected = resolveFilesIfNeeded(selected);
|
||||
if (!sub.isApplicable(model, subselected)) {
|
||||
continue;
|
||||
}
|
||||
m.getItems().add(sub.toMenuItem(model, subselected));
|
||||
}
|
||||
var graphic = getIcon(model, selected);
|
||||
if (graphic != null) {
|
||||
m.setGraphic(graphic);
|
||||
}
|
||||
m.setDisable(!isActive(model, selected));
|
||||
|
||||
if (getProFeatureId() != null
|
||||
&& !LicenseProvider.get().getFeature(getProFeatureId()).isSupported()) {
|
||||
m.setDisable(true);
|
||||
m.setGraphic(new FontIcon("mdi2p-professional-hexagon"));
|
||||
}
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
List<? extends BrowserAction> getBranchingActions(BrowserFileSystemTabModel model, List<BrowserEntry> entries);
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
package io.xpipe.app.browser.action;
|
||||
|
||||
import io.xpipe.app.browser.file.BrowserEntry;
|
||||
import io.xpipe.app.browser.file.BrowserFileSystemTabModel;
|
||||
import io.xpipe.app.comp.base.TooltipAugment;
|
||||
import io.xpipe.app.util.BindingsHelper;
|
||||
import io.xpipe.app.util.BooleanScope;
|
||||
import io.xpipe.app.util.LicenseProvider;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.Region;
|
||||
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface BrowserLeafAction extends BrowserAction {
|
||||
|
||||
void execute(BrowserFileSystemTabModel model, List<BrowserEntry> entries) throws Exception;
|
||||
|
||||
default Button toButton(Region root, BrowserFileSystemTabModel model, List<BrowserEntry> selected) {
|
||||
var b = new Button();
|
||||
b.setOnAction(event -> {
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
BooleanScope.executeExclusive(model.getBusy(), () -> {
|
||||
if (model.getFileSystem() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start shell in case we exited
|
||||
model.getFileSystem().getShell().orElseThrow().start();
|
||||
execute(model, selected);
|
||||
});
|
||||
});
|
||||
event.consume();
|
||||
});
|
||||
var name = getName(model, selected);
|
||||
new TooltipAugment<>(name, getShortcut()).augment(b);
|
||||
var graphic = getIcon(model, selected);
|
||||
if (graphic != null) {
|
||||
b.setGraphic(graphic);
|
||||
}
|
||||
b.setMnemonicParsing(false);
|
||||
b.accessibleTextProperty().bind(name);
|
||||
root.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
|
||||
if (getShortcut() != null && getShortcut().match(event)) {
|
||||
b.fire();
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
|
||||
b.setDisable(!isActive(model, selected));
|
||||
model.getCurrentPath().addListener((observable, oldValue, newValue) -> {
|
||||
b.setDisable(!isActive(model, selected));
|
||||
});
|
||||
|
||||
if (getProFeatureId() != null
|
||||
&& !LicenseProvider.get().getFeature(getProFeatureId()).isSupported()) {
|
||||
b.setDisable(true);
|
||||
b.setGraphic(new FontIcon("mdi2p-professional-hexagon"));
|
||||
}
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
default MenuItem toMenuItem(BrowserFileSystemTabModel model, List<BrowserEntry> selected) {
|
||||
var name = getName(model, selected);
|
||||
var mi = new MenuItem();
|
||||
mi.textProperty().bind(BindingsHelper.map(name, s -> {
|
||||
if (getProFeatureId() != null) {
|
||||
return LicenseProvider.get().getFeature(getProFeatureId()).suffix(s);
|
||||
}
|
||||
return s;
|
||||
}));
|
||||
mi.setOnAction(event -> {
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
BooleanScope.executeExclusive(model.getBusy(), () -> {
|
||||
if (model.getFileSystem() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start shell in case we exited
|
||||
model.getFileSystem().getShell().orElseThrow().start();
|
||||
execute(model, selected);
|
||||
});
|
||||
});
|
||||
event.consume();
|
||||
});
|
||||
if (getShortcut() != null) {
|
||||
mi.setAccelerator(getShortcut());
|
||||
}
|
||||
var graphic = getIcon(model, selected);
|
||||
if (graphic != null) {
|
||||
mi.setGraphic(graphic);
|
||||
}
|
||||
mi.setMnemonicParsing(false);
|
||||
mi.setDisable(!isActive(model, selected));
|
||||
|
||||
if (getProFeatureId() != null
|
||||
&& !LicenseProvider.get().getFeature(getProFeatureId()).isSupported()) {
|
||||
mi.setDisable(true);
|
||||
}
|
||||
|
||||
return mi;
|
||||
}
|
||||
|
||||
default String getId() {
|
||||
return null;
|
||||
}
|
||||
}
|
116
app/src/main/java/io/xpipe/app/browser/file/BrowserAlerts.java
Normal file
116
app/src/main/java/io/xpipe/app/browser/file/BrowserAlerts.java
Normal file
|
@ -0,0 +1,116 @@
|
|||
package io.xpipe.app.browser.file;
|
||||
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.core.window.AppWindowHelper;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.core.store.FileEntry;
|
||||
import io.xpipe.core.store.FileKind;
|
||||
import io.xpipe.core.store.FilePath;
|
||||
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.ButtonBar;
|
||||
import javafx.scene.control.ButtonType;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class BrowserAlerts {
|
||||
|
||||
public static FileConflictChoice showFileConflictAlert(String file, boolean multiple) {
|
||||
var map = new LinkedHashMap<ButtonType, FileConflictChoice>();
|
||||
map.put(new ButtonType(AppI18n.get("cancel"), ButtonBar.ButtonData.CANCEL_CLOSE), FileConflictChoice.CANCEL);
|
||||
if (multiple) {
|
||||
map.put(new ButtonType(AppI18n.get("skip"), ButtonBar.ButtonData.OTHER), FileConflictChoice.SKIP);
|
||||
map.put(new ButtonType(AppI18n.get("skipAll"), ButtonBar.ButtonData.OTHER), FileConflictChoice.SKIP_ALL);
|
||||
}
|
||||
map.put(new ButtonType(AppI18n.get("replace"), ButtonBar.ButtonData.OTHER), FileConflictChoice.REPLACE);
|
||||
if (multiple) {
|
||||
map.put(
|
||||
new ButtonType(AppI18n.get("replaceAll"), ButtonBar.ButtonData.OTHER),
|
||||
FileConflictChoice.REPLACE_ALL);
|
||||
}
|
||||
map.put(new ButtonType(AppI18n.get("rename"), ButtonBar.ButtonData.OTHER), FileConflictChoice.RENAME);
|
||||
if (multiple) {
|
||||
map.put(
|
||||
new ButtonType(AppI18n.get("renameAll"), ButtonBar.ButtonData.OTHER),
|
||||
FileConflictChoice.RENAME_ALL);
|
||||
}
|
||||
var w = multiple ? 700 : 400;
|
||||
return AppWindowHelper.showBlockingAlert(alert -> {
|
||||
alert.setTitle(AppI18n.get("fileConflictAlertTitle"));
|
||||
alert.setHeaderText(AppI18n.get("fileConflictAlertHeader"));
|
||||
alert.setAlertType(Alert.AlertType.CONFIRMATION);
|
||||
alert.getButtonTypes().clear();
|
||||
alert.getDialogPane()
|
||||
.setContent(AppWindowHelper.alertContentText(
|
||||
AppI18n.get(
|
||||
multiple ? "fileConflictAlertContentMultiple" : "fileConflictAlertContent",
|
||||
file),
|
||||
w - 50));
|
||||
alert.getDialogPane().setMinWidth(w);
|
||||
alert.getDialogPane().setPrefWidth(w);
|
||||
alert.getDialogPane().setMaxWidth(w);
|
||||
map.sequencedKeySet()
|
||||
.forEach(buttonType -> alert.getButtonTypes().add(buttonType));
|
||||
})
|
||||
.map(map::get)
|
||||
.orElse(FileConflictChoice.CANCEL);
|
||||
}
|
||||
|
||||
public static boolean showMoveAlert(List<FileEntry> source, FileEntry target) {
|
||||
if (source.stream().noneMatch(entry -> entry.getKind() == FileKind.DIRECTORY)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return AppWindowHelper.showBlockingAlert(alert -> {
|
||||
alert.setTitle(AppI18n.get("moveAlertTitle"));
|
||||
alert.setHeaderText(AppI18n.get("moveAlertHeader", source.size(), target.getPath()));
|
||||
alert.getDialogPane()
|
||||
.setContent(AppWindowHelper.alertContentText(getSelectedElementsString(source)));
|
||||
alert.setAlertType(Alert.AlertType.CONFIRMATION);
|
||||
})
|
||||
.map(b -> b.getButtonData().isDefaultButton())
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
public static boolean showDeleteAlert(List<FileEntry> source) {
|
||||
if (!AppPrefs.get().confirmDeletions().get()
|
||||
&& source.stream().noneMatch(entry -> entry.getKind() == FileKind.DIRECTORY)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return AppWindowHelper.showBlockingAlert(alert -> {
|
||||
alert.setTitle(AppI18n.get("deleteAlertTitle"));
|
||||
alert.setHeaderText(AppI18n.get("deleteAlertHeader", source.size()));
|
||||
alert.getDialogPane()
|
||||
.setContent(AppWindowHelper.alertContentText(getSelectedElementsString(source)));
|
||||
alert.setAlertType(Alert.AlertType.CONFIRMATION);
|
||||
})
|
||||
.map(b -> b.getButtonData().isDefaultButton())
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
private static String getSelectedElementsString(List<FileEntry> source) {
|
||||
var namesHeader = AppI18n.get("selectedElements");
|
||||
var names = namesHeader + "\n"
|
||||
+ source.stream()
|
||||
.limit(10)
|
||||
.map(entry -> "- " + new FilePath(entry.getPath()).getFileName())
|
||||
.collect(Collectors.joining("\n"));
|
||||
if (source.size() > 10) {
|
||||
names += "\n+ " + (source.size() - 10) + " ...";
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
public enum FileConflictChoice {
|
||||
CANCEL,
|
||||
SKIP,
|
||||
SKIP_ALL,
|
||||
REPLACE,
|
||||
REPLACE_ALL,
|
||||
RENAME,
|
||||
RENAME_ALL
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
package io.xpipe.app.browser.file;
|
||||
|
||||
import io.xpipe.app.comp.SimpleComp;
|
||||
import io.xpipe.app.util.PlatformThread;
|
||||
import io.xpipe.core.store.FileNames;
|
||||
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonBase;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.util.Callback;
|
||||
|
||||
import atlantafx.base.controls.Breadcrumbs;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class BrowserBreadcrumbBar extends SimpleComp {
|
||||
|
||||
private final BrowserFileSystemTabModel model;
|
||||
|
||||
public BrowserBreadcrumbBar(BrowserFileSystemTabModel model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Region createSimple() {
|
||||
Callback<Breadcrumbs.BreadCrumbItem<String>, ButtonBase> crumbFactory = crumb -> {
|
||||
var name = crumb.getValue().equals("/") ? "/" : FileNames.getFileName(crumb.getValue());
|
||||
var btn = new Button(name, null);
|
||||
btn.setMnemonicParsing(false);
|
||||
btn.setFocusTraversable(false);
|
||||
return btn;
|
||||
};
|
||||
return createBreadcrumbs(crumbFactory, null);
|
||||
}
|
||||
|
||||
private Region createBreadcrumbs(
|
||||
Callback<Breadcrumbs.BreadCrumbItem<String>, ButtonBase> crumbFactory,
|
||||
Callback<Breadcrumbs.BreadCrumbItem<String>, ? extends Node> dividerFactory) {
|
||||
|
||||
var breadcrumbs = new Breadcrumbs<String>();
|
||||
breadcrumbs.setMinWidth(0);
|
||||
PlatformThread.sync(model.getCurrentPath()).subscribe(val -> {
|
||||
if (val == null) {
|
||||
breadcrumbs.setSelectedCrumb(null);
|
||||
return;
|
||||
}
|
||||
|
||||
var sc = model.getFileSystem().getShell();
|
||||
if (sc.isEmpty()) {
|
||||
breadcrumbs.setDividerFactory(item -> item != null && !item.isLast() ? new Label("/") : null);
|
||||
} else {
|
||||
breadcrumbs.setDividerFactory(item -> {
|
||||
if (item == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.isFirst() && item.getValue().equals("/")) {
|
||||
return new Label("");
|
||||
}
|
||||
|
||||
return new Label(sc.get().getOsType().getFileSystemSeparator());
|
||||
});
|
||||
}
|
||||
|
||||
var elements = FileNames.splitHierarchy(val);
|
||||
var modifiedElements = new ArrayList<>(elements);
|
||||
if (val.startsWith("/")) {
|
||||
modifiedElements.addFirst("/");
|
||||
}
|
||||
Breadcrumbs.BreadCrumbItem<String> items =
|
||||
Breadcrumbs.buildTreeModel(modifiedElements.toArray(String[]::new));
|
||||
breadcrumbs.setSelectedCrumb(items);
|
||||
});
|
||||
|
||||
if (crumbFactory != null) {
|
||||
breadcrumbs.setCrumbFactory(crumbFactory);
|
||||
}
|
||||
if (dividerFactory != null) {
|
||||
breadcrumbs.setDividerFactory(dividerFactory);
|
||||
}
|
||||
|
||||
breadcrumbs.selectedCrumbProperty().addListener((obs, old, val) -> {
|
||||
model.cdAsync(val != null ? val.getValue() : null);
|
||||
});
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
package io.xpipe.app.browser.file;
|
||||
|
||||
import io.xpipe.app.ext.ProcessControlProvider;
|
||||
import io.xpipe.app.issue.ErrorEvent;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import io.xpipe.core.store.FileEntry;
|
||||
import io.xpipe.core.util.FailableRunnable;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import javafx.scene.input.DataFormat;
|
||||
import javafx.scene.input.Dragboard;
|
||||
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.Value;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.datatransfer.Clipboard;
|
||||
import java.awt.datatransfer.DataFlavor;
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class BrowserClipboard {
|
||||
|
||||
public static final Property<Instance> currentCopyClipboard = new SimpleObjectProperty<>();
|
||||
public static Instance currentDragClipboard;
|
||||
private static final DataFormat DATA_FORMAT = new DataFormat("application/xpipe-file-list");
|
||||
|
||||
static {
|
||||
Toolkit.getDefaultToolkit()
|
||||
.getSystemClipboard()
|
||||
.addFlavorListener(e -> ThreadHelper.runFailableAsync(new FailableRunnable<>() {
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void run() {
|
||||
Clipboard clipboard = (Clipboard) e.getSource();
|
||||
try {
|
||||
if (!clipboard.isDataFlavorAvailable(DataFlavor.javaFileListFlavor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<File> data = (List<File>) clipboard.getData(DataFlavor.javaFileListFlavor);
|
||||
// Sometimes file data can contain invalid chars. Why?
|
||||
var files = data.stream()
|
||||
.filter(file ->
|
||||
file.toString().chars().noneMatch(value -> Character.isISOControl(value)))
|
||||
.map(f -> f.toPath())
|
||||
.toList();
|
||||
if (files.size() == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var entries = new ArrayList<BrowserEntry>();
|
||||
for (Path file : files) {
|
||||
entries.add(BrowserLocalFileSystem.getLocalBrowserEntry(file));
|
||||
}
|
||||
|
||||
currentCopyClipboard.setValue(
|
||||
new Instance(UUID.randomUUID(), null, entries, BrowserFileTransferMode.COPY));
|
||||
} catch (Exception e) {
|
||||
ErrorEvent.fromThrowable(e).expected().omit().handle();
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
public static ClipboardContent startDrag(
|
||||
FileEntry base, List<BrowserEntry> selected, BrowserFileTransferMode mode) {
|
||||
if (selected.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = new ClipboardContent();
|
||||
var id = UUID.randomUUID();
|
||||
currentDragClipboard = new Instance(id, base, new ArrayList<>(selected), mode);
|
||||
content.put(DATA_FORMAT, currentDragClipboard.toClipboardString());
|
||||
return content;
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
public static void startCopy(FileEntry base, List<BrowserEntry> selected) {
|
||||
if (selected.isEmpty()) {
|
||||
currentCopyClipboard.setValue(null);
|
||||
return;
|
||||
}
|
||||
|
||||
var id = UUID.randomUUID();
|
||||
currentCopyClipboard.setValue(new Instance(id, base, new ArrayList<>(selected), BrowserFileTransferMode.COPY));
|
||||
}
|
||||
|
||||
public static Instance retrieveCopy() {
|
||||
return currentCopyClipboard.getValue();
|
||||
}
|
||||
|
||||
public static Instance retrieveDrag(Dragboard dragboard) {
|
||||
if (currentDragClipboard == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
var s = dragboard.getContent(DATA_FORMAT);
|
||||
if (s != null && s.equals(currentDragClipboard.toClipboardString())) {
|
||||
var current = currentDragClipboard;
|
||||
currentDragClipboard = null;
|
||||
return current;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Value
|
||||
public static class Instance {
|
||||
UUID uuid;
|
||||
FileEntry baseDirectory;
|
||||
List<BrowserEntry> entries;
|
||||
BrowserFileTransferMode mode;
|
||||
|
||||
public String toClipboardString() {
|
||||
return entries.stream()
|
||||
.map(fileEntry -> "\"" + fileEntry.getRawFileEntry().getPath() + "\"")
|
||||
.collect(Collectors.joining(ProcessControlProvider.get()
|
||||
.getEffectiveLocalDialect()
|
||||
.getNewLine()
|
||||
.getNewLineString()));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
package io.xpipe.app.browser.file;
|
||||
|
||||
import io.xpipe.app.comp.Comp;
|
||||
import io.xpipe.app.comp.CompStructure;
|
||||
import io.xpipe.app.comp.SimpleComp;
|
||||
import io.xpipe.app.comp.store.*;
|
||||
import io.xpipe.app.storage.DataStoreEntry;
|
||||
import io.xpipe.app.util.PlatformThread;
|
||||
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.layout.Region;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public final class BrowserConnectionListComp extends SimpleComp {
|
||||
|
||||
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
|
||||
private final ObservableValue<DataStoreEntry> selected;
|
||||
private final Predicate<StoreEntryWrapper> applicable;
|
||||
private final BiConsumer<StoreEntryWrapper, BooleanProperty> action;
|
||||
private final Property<StoreCategoryWrapper> category;
|
||||
private final Property<String> filter;
|
||||
|
||||
public BrowserConnectionListComp(
|
||||
ObservableValue<DataStoreEntry> selected,
|
||||
Predicate<StoreEntryWrapper> applicable,
|
||||
BiConsumer<StoreEntryWrapper, BooleanProperty> action,
|
||||
Property<StoreCategoryWrapper> category,
|
||||
Property<String> filter) {
|
||||
this.selected = selected;
|
||||
this.applicable = applicable;
|
||||
this.action = action;
|
||||
this.category = category;
|
||||
this.filter = filter;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Region createSimple() {
|
||||
var busyEntries = FXCollections.<StoreSection>observableSet(new HashSet<>());
|
||||
BiConsumer<StoreSection, Comp<CompStructure<Button>>> augment = (s, comp) -> {
|
||||
comp.disable(Bindings.createBooleanBinding(
|
||||
() -> {
|
||||
return busyEntries.contains(s) || !applicable.test(s.getWrapper());
|
||||
},
|
||||
busyEntries));
|
||||
comp.apply(struc -> {
|
||||
selected.addListener((observable, oldValue, newValue) -> {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
struc.get()
|
||||
.pseudoClassStateChanged(
|
||||
SELECTED,
|
||||
newValue != null
|
||||
&& newValue.equals(
|
||||
s.getWrapper().getEntry()));
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var section = new StoreSectionMiniComp(
|
||||
StoreSection.createTopLevel(
|
||||
StoreViewState.get().getAllEntries(),
|
||||
this::filter,
|
||||
filter,
|
||||
category,
|
||||
StoreViewState.get().getEntriesListUpdateObservable()),
|
||||
augment,
|
||||
selectedAction -> {
|
||||
BooleanProperty busy = new SimpleBooleanProperty(false);
|
||||
action.accept(selectedAction.getWrapper(), busy);
|
||||
busy.addListener((observable, oldValue, newValue) -> {
|
||||
if (newValue) {
|
||||
busyEntries.add(selectedAction);
|
||||
} else {
|
||||
busyEntries.remove(selectedAction);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var r = section.vgrow().createRegion();
|
||||
r.getStyleClass().add("bookmark-list");
|
||||
return r;
|
||||
}
|
||||
|
||||
private boolean filter(StoreEntryWrapper w) {
|
||||
return applicable.test(w);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package io.xpipe.app.browser.file;
|
||||
|
||||
import io.xpipe.app.comp.SimpleComp;
|
||||
import io.xpipe.app.comp.base.FilterComp;
|
||||
import io.xpipe.app.comp.base.HorizontalComp;
|
||||
import io.xpipe.app.comp.store.StoreCategoryWrapper;
|
||||
import io.xpipe.app.comp.store.StoreViewState;
|
||||
import io.xpipe.app.core.AppFontSizes;
|
||||
import io.xpipe.app.util.DataStoreCategoryChoiceComp;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.scene.layout.Region;
|
||||
|
||||
import atlantafx.base.theme.Styles;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
public final class BrowserConnectionListFilterComp extends SimpleComp {
|
||||
|
||||
private final Property<StoreCategoryWrapper> category =
|
||||
new SimpleObjectProperty<>(StoreViewState.get().getActiveCategory().getValue());
|
||||
private final Property<String> filter = new SimpleStringProperty();
|
||||
|
||||
@Override
|
||||
protected Region createSimple() {
|
||||
var category = new DataStoreCategoryChoiceComp(
|
||||
StoreViewState.get().getAllConnectionsCategory(),
|
||||
StoreViewState.get().getActiveCategory(),
|
||||
this.category)
|
||||
.styleClass(Styles.LEFT_PILL)
|
||||
.apply(struc -> {
|
||||
AppFontSizes.base(struc.get());
|
||||
});
|
||||
var filter = new FilterComp(this.filter)
|
||||
.styleClass(Styles.RIGHT_PILL)
|
||||
.minWidth(0)
|
||||
.hgrow()
|
||||
.apply(struc -> {
|
||||
AppFontSizes.base(struc.get());
|
||||
});
|
||||
|
||||
var top = new HorizontalComp(List.of(category, filter))
|
||||
.apply(struc -> struc.get().setFillHeight(true))
|
||||
.apply(struc -> {
|
||||
var first = ((Region) struc.get().getChildren().get(0));
|
||||
var second = ((Region) struc.get().getChildren().get(1));
|
||||
first.prefHeightProperty().bind(second.heightProperty());
|
||||
first.minHeightProperty().bind(second.heightProperty());
|
||||
first.maxHeightProperty().bind(second.heightProperty());
|
||||
AppFontSizes.xl(struc.get());
|
||||
})
|
||||
.styleClass("bookmarks-header")
|
||||
.createRegion();
|
||||
return top;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
package io.xpipe.app.browser.file;
|
||||
|
||||
import io.xpipe.app.browser.action.BrowserAction;
|
||||
import io.xpipe.app.core.AppFontSizes;
|
||||
import io.xpipe.app.util.InputHelper;
|
||||
|
||||
import javafx.scene.control.ContextMenu;
|
||||
import javafx.scene.control.SeparatorMenuItem;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class BrowserContextMenu extends ContextMenu {
|
||||
|
||||
private final BrowserFileSystemTabModel model;
|
||||
private final BrowserEntry source;
|
||||
private final boolean quickAccess;
|
||||
|
||||
public BrowserContextMenu(BrowserFileSystemTabModel model, BrowserEntry source, boolean quickAccess) {
|
||||
this.model = model;
|
||||
this.source = source;
|
||||
this.quickAccess = quickAccess;
|
||||
createMenu();
|
||||
}
|
||||
|
||||
private void createMenu() {
|
||||
AppFontSizes.lg(getStyleableNode());
|
||||
|
||||
InputHelper.onLeft(this, false, e -> {
|
||||
hide();
|
||||
e.consume();
|
||||
});
|
||||
|
||||
var empty = source == null;
|
||||
var selected = new ArrayList<>(
|
||||
empty
|
||||
? List.of(new BrowserEntry(model.getCurrentDirectory(), model.getFileList()))
|
||||
: quickAccess ? List.of() : model.getFileList().getSelection());
|
||||
if (source != null && !selected.contains(source)) {
|
||||
selected.add(source);
|
||||
}
|
||||
|
||||
if (model.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (BrowserAction.Category cat : BrowserAction.Category.values()) {
|
||||
var all = BrowserAction.ALL.stream()
|
||||
.filter(browserAction -> browserAction.getCategory() == cat)
|
||||
.filter(browserAction -> {
|
||||
if (model.isClosed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var used = browserAction.resolveFilesIfNeeded(selected);
|
||||
if (!browserAction.isApplicable(model, used)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!browserAction.acceptsEmptySelection() && empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.toList();
|
||||
if (all.size() == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (getItems().size() > 0) {
|
||||
getItems().add(new SeparatorMenuItem());
|
||||
}
|
||||
|
||||
for (BrowserAction a : all) {
|
||||
if (model.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var used = a.resolveFilesIfNeeded(selected);
|
||||
getItems().add(a.toMenuItem(model, used));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package io.xpipe.app.browser.file;
|
||||
|
||||
import io.xpipe.app.browser.icon.BrowserIconDirectoryType;
|
||||
import io.xpipe.app.browser.icon.BrowserIconFileType;
|
||||
import io.xpipe.core.store.FileEntry;
|
||||
import io.xpipe.core.store.FileKind;
|
||||
import io.xpipe.core.store.FileNames;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class BrowserEntry {
|
||||
|
||||
private final BrowserFileListModel model;
|
||||
private final FileEntry rawFileEntry;
|
||||
private final BrowserIconFileType fileType;
|
||||
private final BrowserIconDirectoryType directoryType;
|
||||
|
||||
public BrowserEntry(FileEntry rawFileEntry, BrowserFileListModel model) {
|
||||
this.rawFileEntry = rawFileEntry;
|
||||
this.model = model;
|
||||
this.fileType = fileType(rawFileEntry);
|
||||
this.directoryType = directoryType(rawFileEntry);
|
||||
}
|
||||
|
||||
private static BrowserIconFileType fileType(FileEntry rawFileEntry) {
|
||||
if (rawFileEntry == null) {
|
||||
return null;
|
||||
}
|
||||
rawFileEntry = rawFileEntry.resolved();
|
||||
|
||||
if (rawFileEntry.getKind() != FileKind.FILE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var f : BrowserIconFileType.getAll()) {
|
||||
if (f.matches(rawFileEntry)) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static BrowserIconDirectoryType directoryType(FileEntry rawFileEntry) {
|
||||
if (rawFileEntry == null) {
|
||||
return null;
|
||||
}
|
||||
rawFileEntry = rawFileEntry.resolved();
|
||||
|
||||
if (rawFileEntry.getKind() != FileKind.DIRECTORY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var f : BrowserIconDirectoryType.getAll()) {
|
||||
if (f.matches(rawFileEntry)) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getIcon() {
|
||||
if (fileType != null) {
|
||||
return fileType.getIcon();
|
||||
} else if (directoryType != null) {
|
||||
return directoryType.getIcon(rawFileEntry);
|
||||
} else {
|
||||
return rawFileEntry != null && rawFileEntry.resolved().getKind() == FileKind.DIRECTORY
|
||||
? "browser/default_folder.svg"
|
||||
: "browser/default_file.svg";
|
||||
}
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return FileNames.getFileName(getRawFileEntry().getPath());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,639 @@
|
|||
package io.xpipe.app.browser.file;
|
||||
|
||||
import io.xpipe.app.browser.action.BrowserAction;
|
||||
import io.xpipe.app.comp.SimpleComp;
|
||||
import io.xpipe.app.core.AppI18n;
|
||||
import io.xpipe.app.util.*;
|
||||
import io.xpipe.core.process.OsType;
|
||||
import io.xpipe.core.store.FileEntry;
|
||||
import io.xpipe.core.store.FileInfo;
|
||||
import io.xpipe.core.store.FileKind;
|
||||
import io.xpipe.core.store.FileNames;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Bounds;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.control.skin.TableViewSkin;
|
||||
import javafx.scene.control.skin.VirtualFlow;
|
||||
import javafx.scene.input.*;
|
||||
import javafx.scene.layout.Region;
|
||||
|
||||
import atlantafx.base.theme.Styles;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static io.xpipe.app.util.HumanReadableFormat.byteCount;
|
||||
import static javafx.scene.control.TableColumn.SortType.ASCENDING;
|
||||
|
||||
public final class BrowserFileListComp extends SimpleComp {
|
||||
|
||||
private static final PseudoClass HIDDEN = PseudoClass.getPseudoClass("hidden");
|
||||
private static final PseudoClass EMPTY = PseudoClass.getPseudoClass("empty");
|
||||
private static final PseudoClass FILE = PseudoClass.getPseudoClass("file");
|
||||
private static final PseudoClass FOLDER = PseudoClass.getPseudoClass("folder");
|
||||
private static final PseudoClass DRAG = PseudoClass.getPseudoClass("drag");
|
||||
private static final PseudoClass DRAG_OVER = PseudoClass.getPseudoClass("drag-over");
|
||||
private static final PseudoClass DRAG_INTO_CURRENT = PseudoClass.getPseudoClass("drag-into-current");
|
||||
|
||||
private final BrowserFileListModel fileList;
|
||||
private final StringProperty typedSelection = new SimpleStringProperty("");
|
||||
private final DoubleProperty ownerWidth = new SimpleDoubleProperty();
|
||||
|
||||
public BrowserFileListComp(BrowserFileListModel fileList) {
|
||||
this.fileList = fileList;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Region createSimple() {
|
||||
return createTable();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private TableView<BrowserEntry> createTable() {
|
||||
var filenameCol = new TableColumn<BrowserEntry, String>();
|
||||
filenameCol.textProperty().bind(AppI18n.observable("name"));
|
||||
filenameCol.setCellValueFactory(param -> new SimpleStringProperty(
|
||||
param.getValue() != null
|
||||
? FileNames.getFileName(
|
||||
param.getValue().getRawFileEntry().getPath())
|
||||
: null));
|
||||
filenameCol.setComparator(Comparator.comparing(String::toLowerCase));
|
||||
filenameCol.setSortType(ASCENDING);
|
||||
filenameCol.setCellFactory(col ->
|
||||
new BrowserFileListNameCell(fileList, typedSelection, fileList.getEditing(), col.getTableView()));
|
||||
filenameCol.setReorderable(false);
|
||||
filenameCol.setResizable(false);
|
||||
|
||||
var sizeCol = new TableColumn<BrowserEntry, Number>();
|
||||
sizeCol.textProperty().bind(AppI18n.observable("size"));
|
||||
sizeCol.setCellValueFactory(param -> new SimpleLongProperty(
|
||||
param.getValue().getRawFileEntry().resolved().getSize()));
|
||||
sizeCol.setCellFactory(col -> new FileSizeCell());
|
||||
sizeCol.setResizable(false);
|
||||
sizeCol.setPrefWidth(120);
|
||||
sizeCol.setReorderable(false);
|
||||
|
||||
var mtimeCol = new TableColumn<BrowserEntry, Instant>();
|
||||
mtimeCol.textProperty().bind(AppI18n.observable("modified"));
|
||||
mtimeCol.setCellValueFactory(param -> new SimpleObjectProperty<>(
|
||||
param.getValue().getRawFileEntry().resolved().getDate()));
|
||||
mtimeCol.setCellFactory(col -> new FileTimeCell());
|
||||
mtimeCol.setResizable(false);
|
||||
mtimeCol.setPrefWidth(150);
|
||||
mtimeCol.setReorderable(false);
|
||||
|
||||
var modeCol = new TableColumn<BrowserEntry, String>();
|
||||
modeCol.textProperty().bind(AppI18n.observable("attributes"));
|
||||
modeCol.setCellValueFactory(param -> new SimpleObjectProperty<>(
|
||||
param.getValue().getRawFileEntry().resolved().getInfo() instanceof FileInfo.Unix u
|
||||
? u.getPermissions()
|
||||
: null));
|
||||
modeCol.setCellFactory(col -> new FileModeCell());
|
||||
modeCol.setResizable(false);
|
||||
modeCol.setPrefWidth(120);
|
||||
modeCol.setSortable(false);
|
||||
modeCol.setReorderable(false);
|
||||
|
||||
var ownerCol = new TableColumn<BrowserEntry, String>();
|
||||
ownerCol.textProperty().bind(AppI18n.observable("owner"));
|
||||
ownerCol.setCellValueFactory(param -> {
|
||||
return new SimpleObjectProperty<>(formatOwner(param.getValue()));
|
||||
});
|
||||
ownerCol.setCellFactory(col -> new FileOwnerCell());
|
||||
ownerCol.setSortable(false);
|
||||
ownerCol.setReorderable(false);
|
||||
ownerCol.setResizable(false);
|
||||
|
||||
var table = new TableView<BrowserEntry>();
|
||||
table.setSkin(new TableViewSkin<>(table));
|
||||
table.setAccessibleText("Directory contents");
|
||||
table.setPlaceholder(new Region());
|
||||
table.getStyleClass().add(Styles.STRIPED);
|
||||
table.getColumns().setAll(filenameCol, mtimeCol, modeCol, ownerCol, sizeCol);
|
||||
table.getSortOrder().add(filenameCol);
|
||||
table.setFocusTraversable(true);
|
||||
table.setSortPolicy(param -> {
|
||||
fileList.setComparator(table.getComparator());
|
||||
return true;
|
||||
});
|
||||
table.setFixedCellSize(30.0);
|
||||
|
||||
prepareColumnVisibility(table, ownerCol, filenameCol);
|
||||
prepareTableScrollFix(table);
|
||||
prepareTableSelectionModel(table);
|
||||
prepareTableShortcuts(table);
|
||||
prepareTableEntries(table);
|
||||
prepareTableChanges(table, filenameCol, mtimeCol, modeCol, ownerCol);
|
||||
prepareTypedSelectionModel(table);
|
||||
return table;
|
||||
}
|
||||
|
||||
private static void prepareTableScrollFix(TableView<BrowserEntry> table) {
|
||||
table.lookupAll(".scroll-bar").stream()
|
||||
.filter(node -> node.getPseudoClassStates().contains(PseudoClass.getPseudoClass("horizontal")))
|
||||
.findFirst()
|
||||
.ifPresent(node -> {
|
||||
Region region = (Region) node;
|
||||
region.setMinHeight(0);
|
||||
region.setPrefHeight(0);
|
||||
region.setMaxHeight(0);
|
||||
});
|
||||
}
|
||||
|
||||
private void prepareColumnVisibility(
|
||||
TableView<BrowserEntry> table,
|
||||
TableColumn<BrowserEntry, String> ownerCol,
|
||||
TableColumn<BrowserEntry, String> filenameCol) {
|
||||
var os = fileList.getFileSystemModel()
|
||||
.getFileSystem()
|
||||
.getShell()
|
||||
.map(shellControl -> shellControl.getOsType())
|
||||
.orElse(null);
|
||||
table.widthProperty().subscribe((newValue) -> {
|
||||
if (os != OsType.WINDOWS && os != OsType.MACOS) {
|
||||
ownerCol.setVisible(newValue.doubleValue() > 1000);
|
||||
}
|
||||
var width = getFilenameWidth(table);
|
||||
filenameCol.setPrefWidth(width);
|
||||
});
|
||||
}
|
||||
|
||||
private double getFilenameWidth(TableView<?> tableView) {
|
||||
var sum = tableView.getColumns().stream()
|
||||
.filter(tableColumn -> tableColumn.isVisible()
|
||||
&& tableView.getColumns().indexOf(tableColumn) != 0)
|
||||
.mapToDouble(value -> value.getPrefWidth())
|
||||
.sum()
|
||||
+ 7;
|
||||
return tableView.getWidth() - sum;
|
||||
}
|
||||
|
||||
private String formatOwner(BrowserEntry param) {
|
||||
FileInfo.Unix unix = param.getRawFileEntry().resolved().getInfo() instanceof FileInfo.Unix u ? u : null;
|
||||
if (unix == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var m = fileList.getFileSystemModel();
|
||||
var user = unix.getUser() != null
|
||||
? unix.getUser()
|
||||
: m.getCache().getUsers().getOrDefault(unix.getUid(), "?");
|
||||
var group = unix.getGroup() != null
|
||||
? unix.getGroup()
|
||||
: m.getCache().getGroups().getOrDefault(unix.getGid(), "?");
|
||||
var uid = String.valueOf(
|
||||
unix.getUid() != null ? unix.getUid() : m.getCache().getUidForUser(user));
|
||||
var gid = String.valueOf(
|
||||
unix.getGid() != null ? unix.getGid() : m.getCache().getGidForGroup(group));
|
||||
if (uid.equals(gid) && user.equals(group)) {
|
||||
return user + " [" + uid + "]";
|
||||
}
|
||||
return user + " [" + uid + "] / " + group + " [" + gid + "]";
|
||||
}
|
||||
|
||||
private void prepareTypedSelectionModel(TableView<BrowserEntry> table) {
|
||||
AtomicReference<Instant> lastFail = new AtomicReference<>();
|
||||
table.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
|
||||
updateTypedSelection(table, lastFail, event, false);
|
||||
});
|
||||
|
||||
table.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> {
|
||||
typedSelection.set("");
|
||||
lastFail.set(null);
|
||||
});
|
||||
|
||||
fileList.getFileSystemModel().getCurrentPath().addListener((observable, oldValue, newValue) -> {
|
||||
typedSelection.set("");
|
||||
lastFail.set(null);
|
||||
});
|
||||
|
||||
table.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
|
||||
if (event.getCode() == KeyCode.ESCAPE) {
|
||||
typedSelection.set("");
|
||||
lastFail.set(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateTypedSelection(
|
||||
TableView<BrowserEntry> table, AtomicReference<Instant> lastType, KeyEvent event, boolean recursive) {
|
||||
var typed = event.getText();
|
||||
if (typed.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = typedSelection.get() + typed;
|
||||
var found = fileList.getShown().getValue().stream()
|
||||
.filter(browserEntry -> browserEntry.getFileName().toLowerCase().startsWith(updated.toLowerCase()))
|
||||
.findFirst();
|
||||
if (found.isEmpty()) {
|
||||
if (typedSelection.get().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var inCooldown = lastType.get() != null
|
||||
&& Duration.between(lastType.get(), Instant.now()).toMillis() < 1000;
|
||||
if (inCooldown) {
|
||||
lastType.set(Instant.now());
|
||||
event.consume();
|
||||
} else {
|
||||
lastType.set(null);
|
||||
typedSelection.set("");
|
||||
table.getSelectionModel().clearSelection();
|
||||
if (!recursive) {
|
||||
updateTypedSelection(table, lastType, event, true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lastType.set(Instant.now());
|
||||
typedSelection.set(updated);
|
||||
table.scrollTo(found.get());
|
||||
table.getSelectionModel().clearAndSelect(fileList.getShown().getValue().indexOf(found.get()));
|
||||
event.consume();
|
||||
}
|
||||
|
||||
private void prepareTableSelectionModel(TableView<BrowserEntry> table) {
|
||||
if (!fileList.getSelectionMode().isMultiple()) {
|
||||
table.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
||||
} else {
|
||||
table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
}
|
||||
table.getSelectionModel().setCellSelectionEnabled(false);
|
||||
|
||||
var updateFromModel = new BooleanScope(new SimpleBooleanProperty());
|
||||
table.getSelectionModel().getSelectedItems().addListener((ListChangeListener<? super BrowserEntry>) c -> {
|
||||
if (updateFromModel.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (var ignored = updateFromModel) {
|
||||
// Attempt to preserve ordering. Works at least when selecting single entries
|
||||
var existing = new HashSet<>(fileList.getSelection());
|
||||
c.getList().forEach(browserEntry -> {
|
||||
if (!existing.contains(browserEntry)) {
|
||||
fileList.getSelection().add(browserEntry);
|
||||
}
|
||||
});
|
||||
fileList.getSelection().removeIf(browserEntry -> !c.getList().contains(browserEntry));
|
||||
}
|
||||
});
|
||||
|
||||
fileList.getSelection().addListener((ListChangeListener<? super BrowserEntry>) c -> {
|
||||
var existing = new HashSet<>(table.getSelectionModel().getSelectedItems());
|
||||
var toApply = new HashSet<>(c.getList());
|
||||
if (existing.equals(toApply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Platform.runLater(() -> {
|
||||
var tableIndices = table.getSelectionModel().getSelectedItems().stream()
|
||||
.mapToInt(entry -> table.getItems().indexOf(entry))
|
||||
.toArray();
|
||||
var indices = c.getList().stream()
|
||||
.mapToInt(entry -> table.getItems().indexOf(entry))
|
||||
.toArray();
|
||||
if (Arrays.equals(indices, tableIndices)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (indices.length == 0) {
|
||||
table.getSelectionModel().clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (indices.length == 1) {
|
||||
table.getSelectionModel().clearAndSelect(indices[0]);
|
||||
} else {
|
||||
table.getSelectionModel().clearSelection();
|
||||
table.getSelectionModel().selectIndices(indices[0], indices);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void prepareTableShortcuts(TableView<BrowserEntry> table) {
|
||||
table.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
|
||||
// Prevent post close events
|
||||
if (fileList.getFileSystemModel().isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var selected = fileList.getSelection();
|
||||
var action = BrowserAction.getFlattened(fileList.getFileSystemModel(), selected).stream()
|
||||
.filter(browserAction -> browserAction.isApplicable(fileList.getFileSystemModel(), selected)
|
||||
&& browserAction.isActive(fileList.getFileSystemModel(), selected))
|
||||
.filter(browserAction -> browserAction.getShortcut() != null)
|
||||
.filter(browserAction -> browserAction.getShortcut().match(event))
|
||||
.findAny();
|
||||
action.ifPresent(browserAction -> {
|
||||
// Prevent concurrent modification by creating copy on platform thread
|
||||
var selectionCopy = new ArrayList<>(selected);
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
browserAction.execute(fileList.getFileSystemModel(), selectionCopy);
|
||||
});
|
||||
event.consume();
|
||||
});
|
||||
if (action.isPresent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getCode() == KeyCode.ESCAPE) {
|
||||
table.getSelectionModel().clearSelection();
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void prepareTableEntries(TableView<BrowserEntry> table) {
|
||||
var emptyEntry = new BrowserFileListCompEntry(table, table, null, fileList);
|
||||
table.setOnMouseClicked(e -> {
|
||||
emptyEntry.onMouseClick(e);
|
||||
});
|
||||
table.setOnMouseDragEntered(event -> {
|
||||
emptyEntry.onMouseDragEntered(event);
|
||||
});
|
||||
table.setOnDragOver(event -> {
|
||||
emptyEntry.onDragOver(event);
|
||||
});
|
||||
table.setOnDragEntered(event -> {
|
||||
emptyEntry.onDragEntered(event);
|
||||
});
|
||||
table.setOnDragDetected(event -> {
|
||||
emptyEntry.startDrag(event);
|
||||
});
|
||||
table.setOnDragExited(event -> {
|
||||
emptyEntry.onDragExited(event);
|
||||
});
|
||||
table.setOnDragDropped(event -> {
|
||||
emptyEntry.onDragDrop(event);
|
||||
});
|
||||
table.setOnDragDone(event -> {
|
||||
emptyEntry.onDragDone(event);
|
||||
});
|
||||
|
||||
// Don't let the list view see this event
|
||||
// otherwise it unselects everything as it doesn't understand shift clicks
|
||||
table.addEventFilter(MouseEvent.MOUSE_CLICKED, t -> {
|
||||
if (t.getButton() == MouseButton.PRIMARY && t.isShiftDown() && t.getClickCount() == 1) {
|
||||
t.consume();
|
||||
}
|
||||
});
|
||||
|
||||
table.setRowFactory(param -> {
|
||||
TableRow<BrowserEntry> row = new TableRow<>();
|
||||
row.accessibleTextProperty()
|
||||
.bind(Bindings.createStringBinding(
|
||||
() -> {
|
||||
return row.getItem() != null ? row.getItem().getFileName() : null;
|
||||
},
|
||||
row.itemProperty()));
|
||||
row.focusTraversableProperty()
|
||||
.bind(Bindings.createBooleanBinding(
|
||||
() -> {
|
||||
return row.getItem() != null;
|
||||
},
|
||||
row.itemProperty()));
|
||||
var listEntry = Bindings.createObjectBinding(
|
||||
() -> new BrowserFileListCompEntry(table, row, row.getItem(), fileList), row.itemProperty());
|
||||
|
||||
// Don't let the list view see this event
|
||||
// otherwise it unselects everything as it doesn't understand shift clicks
|
||||
row.addEventFilter(MouseEvent.MOUSE_PRESSED, t -> {
|
||||
if (t.getButton() == MouseButton.PRIMARY && t.isShiftDown()) {
|
||||
listEntry.get().onMouseShiftClick(t);
|
||||
}
|
||||
});
|
||||
|
||||
row.itemProperty().addListener((observable, oldValue, newValue) -> {
|
||||
row.pseudoClassStateChanged(DRAG, false);
|
||||
row.pseudoClassStateChanged(DRAG_OVER, false);
|
||||
});
|
||||
|
||||
row.itemProperty().addListener((observable, oldValue, newValue) -> {
|
||||
row.pseudoClassStateChanged(EMPTY, newValue == null);
|
||||
row.pseudoClassStateChanged(
|
||||
FILE, newValue != null && newValue.getRawFileEntry().getKind() != FileKind.DIRECTORY);
|
||||
row.pseudoClassStateChanged(
|
||||
FOLDER, newValue != null && newValue.getRawFileEntry().getKind() == FileKind.DIRECTORY);
|
||||
});
|
||||
|
||||
fileList.getDraggedOverDirectory().addListener((observable, oldValue, newValue) -> {
|
||||
row.pseudoClassStateChanged(DRAG_OVER, newValue != null && newValue == row.getItem());
|
||||
});
|
||||
|
||||
fileList.getDraggedOverEmpty().addListener((observable, oldValue, newValue) -> {
|
||||
table.pseudoClassStateChanged(DRAG_INTO_CURRENT, newValue);
|
||||
});
|
||||
|
||||
row.setOnMouseClicked(e -> {
|
||||
listEntry.get().onMouseClick(e);
|
||||
});
|
||||
row.setOnMouseDragEntered(event -> {
|
||||
listEntry.get().onMouseDragEntered(event);
|
||||
});
|
||||
row.setOnDragEntered(event -> {
|
||||
listEntry.get().onDragEntered(event);
|
||||
});
|
||||
row.setOnDragOver(event -> {
|
||||
borderScroll(table, event);
|
||||
listEntry.get().onDragOver(event);
|
||||
});
|
||||
row.setOnDragDetected(event -> {
|
||||
listEntry.get().startDrag(event);
|
||||
});
|
||||
row.setOnDragExited(event -> {
|
||||
listEntry.get().onDragExited(event);
|
||||
});
|
||||
row.setOnDragDropped(event -> {
|
||||
listEntry.get().onDragDrop(event);
|
||||
});
|
||||
row.setOnDragDone(event -> {
|
||||
listEntry.get().onDragDone(event);
|
||||
});
|
||||
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
private void prepareTableChanges(
|
||||
TableView<BrowserEntry> table,
|
||||
TableColumn<BrowserEntry, String> filenameCol,
|
||||
TableColumn<BrowserEntry, Instant> mtimeCol,
|
||||
TableColumn<BrowserEntry, String> modeCol,
|
||||
TableColumn<BrowserEntry, String> ownerCol) {
|
||||
var lastDir = new SimpleObjectProperty<FileEntry>();
|
||||
Runnable updateHandler = () -> {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
var newItems = new ArrayList<>(fileList.getShown().getValue());
|
||||
table.getItems().clear();
|
||||
|
||||
var hasModifiedDate = newItems.size() == 0
|
||||
|| newItems.stream()
|
||||
.anyMatch(entry -> entry.getRawFileEntry().getDate() != null);
|
||||
if (!hasModifiedDate) {
|
||||
mtimeCol.setVisible(false);
|
||||
} else {
|
||||
mtimeCol.setVisible(true);
|
||||
}
|
||||
|
||||
ownerWidth.set(fileList.getAll().getValue().stream()
|
||||
.map(browserEntry -> formatOwner(browserEntry))
|
||||
.map(s -> s != null ? s.length() * 9 : 0)
|
||||
.max(Comparator.naturalOrder())
|
||||
.orElse(150));
|
||||
ownerCol.setPrefWidth(ownerWidth.get());
|
||||
|
||||
if (fileList.getFileSystemModel().getFileSystem() != null) {
|
||||
var shell = fileList.getFileSystemModel()
|
||||
.getFileSystem()
|
||||
.getShell()
|
||||
.orElseThrow();
|
||||
if (OsType.WINDOWS.equals(shell.getOsType()) || OsType.MACOS.equals(shell.getOsType())) {
|
||||
modeCol.setVisible(false);
|
||||
ownerCol.setVisible(false);
|
||||
} else {
|
||||
modeCol.setVisible(true);
|
||||
if (table.getWidth() > 1000) {
|
||||
ownerCol.setVisible(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the list ourselves as sorting the table would incur a lot of cell updates
|
||||
var obs = FXCollections.observableList(newItems);
|
||||
table.getItems().setAll(obs);
|
||||
|
||||
var width = getFilenameWidth(table);
|
||||
filenameCol.setPrefWidth(width);
|
||||
|
||||
TableViewSkin<?> skin = (TableViewSkin<?>) table.getSkin();
|
||||
var currentDirectory = fileList.getFileSystemModel().getCurrentDirectory();
|
||||
if (skin != null && !Objects.equals(lastDir.get(), currentDirectory)) {
|
||||
VirtualFlow<?> flow = (VirtualFlow<?>) skin.getChildren().get(1);
|
||||
ScrollBar vbar = (ScrollBar) flow.getChildrenUnmodifiable().get(2);
|
||||
if (vbar.getValue() != 0.0) {
|
||||
table.scrollTo(0);
|
||||
}
|
||||
}
|
||||
lastDir.setValue(currentDirectory);
|
||||
});
|
||||
};
|
||||
|
||||
updateHandler.run();
|
||||
fileList.getShown().addListener((observable, oldValue, newValue) -> {
|
||||
// Delay to prevent internal tableview exceptions when sorting
|
||||
Platform.runLater(updateHandler);
|
||||
});
|
||||
fileList.getFileSystemModel().getCurrentPath().addListener((observable, oldValue, newValue) -> {
|
||||
if (oldValue == null) {
|
||||
updateHandler.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void borderScroll(TableView<?> tableView, DragEvent event) {
|
||||
TableViewSkin<?> skin = (TableViewSkin<?>) tableView.getSkin();
|
||||
if (skin == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
VirtualFlow<?> flow = (VirtualFlow<?>) skin.getChildren().get(1);
|
||||
ScrollBar vbar = (ScrollBar) flow.getChildrenUnmodifiable().get(2);
|
||||
|
||||
if (!vbar.isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
double proximity = 100;
|
||||
Bounds tableBounds = tableView.localToScene(tableView.getBoundsInLocal());
|
||||
double dragY = event.getSceneY();
|
||||
// Include table header as well in calculations
|
||||
double topYProximity = tableBounds.getMinY() + proximity + 20;
|
||||
double bottomYProximity = tableBounds.getMaxY() - proximity;
|
||||
|
||||
// clamp new values between 0 and 1 to prevent scrollbar flicking around at the edges
|
||||
if (dragY < topYProximity) {
|
||||
var scrollValue = Math.min(topYProximity - dragY, 100) / 10000.0;
|
||||
vbar.setValue(Math.max(vbar.getValue() - scrollValue, 0));
|
||||
} else if (dragY > bottomYProximity) {
|
||||
var scrollValue = Math.min(dragY - bottomYProximity, 100) / 10000.0;
|
||||
vbar.setValue(Math.min(vbar.getValue() + scrollValue, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
private static class FileSizeCell extends TableCell<BrowserEntry, Number> {
|
||||
|
||||
@Override
|
||||
protected void updateItem(Number fileSize, boolean empty) {
|
||||
super.updateItem(fileSize, empty);
|
||||
if (empty || getTableRow() == null || getTableRow().getItem() == null) {
|
||||
setText(null);
|
||||
} else {
|
||||
var path = getTableRow().getItem();
|
||||
if (path.getRawFileEntry().resolved().getKind() == FileKind.DIRECTORY) {
|
||||
setText("");
|
||||
} else {
|
||||
setText(byteCount(fileSize.longValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class FileModeCell extends TableCell<BrowserEntry, String> {
|
||||
|
||||
@Override
|
||||
protected void updateItem(String mode, boolean empty) {
|
||||
super.updateItem(mode, empty);
|
||||
if (empty || getTableRow() == null || getTableRow().getItem() == null) {
|
||||
setText(null);
|
||||
} else {
|
||||
setText(mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class FileOwnerCell extends TableCell<BrowserEntry, String> {
|
||||
|
||||
public FileOwnerCell() {
|
||||
setTextOverrun(OverrunStyle.CENTER_ELLIPSIS);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(String owner, boolean empty) {
|
||||
super.updateItem(owner, empty);
|
||||
if (empty || getTableRow() == null || getTableRow().getItem() == null) {
|
||||
setText(null);
|
||||
} else {
|
||||
setText(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class FileTimeCell extends TableCell<BrowserEntry, Instant> {
|
||||
|
||||
@Override
|
||||
protected void updateItem(Instant fileTime, boolean empty) {
|
||||
super.updateItem(fileTime, empty);
|
||||
if (empty) {
|
||||
setText(null);
|
||||
} else {
|
||||
setText(
|
||||
fileTime != null
|
||||
? HumanReadableFormat.date(
|
||||
fileTime.atZone(ZoneId.systemDefault()).toLocalDateTime())
|
||||
: "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,338 @@
|
|||
package io.xpipe.app.browser.file;
|
||||
|
||||
import io.xpipe.app.browser.BrowserFullSessionModel;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.app.util.BooleanScope;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import io.xpipe.core.store.FileKind;
|
||||
|
||||
import javafx.geometry.Point2D;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ContextMenu;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.input.*;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Objects;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
@Getter
|
||||
public class BrowserFileListCompEntry {
|
||||
|
||||
public static final Timer DROP_TIMER = new Timer("dnd", true);
|
||||
|
||||
private final TableView<BrowserEntry> tv;
|
||||
private final Node row;
|
||||
private final BrowserEntry item;
|
||||
private final BrowserFileListModel model;
|
||||
|
||||
private Point2D lastOver = new Point2D(-1, -1);
|
||||
private TimerTask activeTask;
|
||||
private ContextMenu lastContextMenu;
|
||||
|
||||
public BrowserFileListCompEntry(
|
||||
TableView<BrowserEntry> tv, Node row, BrowserEntry item, BrowserFileListModel model) {
|
||||
this.tv = tv;
|
||||
this.row = row;
|
||||
this.item = item;
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public void onMouseClick(MouseEvent t) {
|
||||
if (lastContextMenu != null) {
|
||||
lastContextMenu.hide();
|
||||
lastContextMenu = null;
|
||||
}
|
||||
|
||||
if (showContextMenu(t)) {
|
||||
var cm = new BrowserContextMenu(model.getFileSystemModel(), item, false);
|
||||
cm.show(row, t.getScreenX(), t.getScreenY());
|
||||
lastContextMenu = cm;
|
||||
t.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
if (t.getButton() == MouseButton.BACK) {
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
BooleanScope.executeExclusive(model.getFileSystemModel().getBusy(), () -> {
|
||||
model.getFileSystemModel().backSync(1);
|
||||
});
|
||||
});
|
||||
t.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
if (t.getButton() == MouseButton.FORWARD) {
|
||||
ThreadHelper.runFailableAsync(() -> {
|
||||
BooleanScope.executeExclusive(model.getFileSystemModel().getBusy(), () -> {
|
||||
model.getFileSystemModel().forthSync(1);
|
||||
});
|
||||
});
|
||||
t.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item == null) {
|
||||
// Only clear for normal clicks
|
||||
if (t.isStillSincePress()) {
|
||||
model.getSelection().clear();
|
||||
if (tv != null) {
|
||||
tv.requestFocus();
|
||||
}
|
||||
}
|
||||
t.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
row.requestFocus();
|
||||
if (t.getClickCount() == 2 && t.getButton() == MouseButton.PRIMARY) {
|
||||
model.onDoubleClick(item);
|
||||
t.consume();
|
||||
}
|
||||
|
||||
t.consume();
|
||||
}
|
||||
|
||||
private boolean showContextMenu(MouseEvent event) {
|
||||
if (item == null) {
|
||||
return event.getButton() == MouseButton.SECONDARY;
|
||||
}
|
||||
|
||||
if (item.getRawFileEntry().resolved().getKind() == FileKind.DIRECTORY) {
|
||||
return event.getButton() == MouseButton.SECONDARY;
|
||||
}
|
||||
|
||||
if (item.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY) {
|
||||
return event.getButton() == MouseButton.SECONDARY
|
||||
|| !AppPrefs.get().editFilesWithDoubleClick().get()
|
||||
&& event.getButton() == MouseButton.PRIMARY
|
||||
&& event.getClickCount() == 2;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void onMouseShiftClick(MouseEvent t) {
|
||||
if (t.getButton() != MouseButton.PRIMARY) {
|
||||
return;
|
||||
}
|
||||
|
||||
var all = tv.getItems();
|
||||
var index = item != null ? all.indexOf(item) : all.size() - 1;
|
||||
var min = Math.min(
|
||||
index,
|
||||
tv.getSelectionModel().getSelectedIndices().stream()
|
||||
.mapToInt(value -> value)
|
||||
.min()
|
||||
.orElse(1));
|
||||
var max = Math.max(
|
||||
index,
|
||||
tv.getSelectionModel().getSelectedIndices().stream()
|
||||
.mapToInt(value -> value)
|
||||
.max()
|
||||
.orElse(all.indexOf(item)));
|
||||
|
||||
var toSelect = new ArrayList<BrowserEntry>();
|
||||
for (int i = min; i <= max; i++) {
|
||||
if (!model.getSelection().contains(model.getShown().getValue().get(i))) {
|
||||
toSelect.add(model.getShown().getValue().get(i));
|
||||
}
|
||||
}
|
||||
model.getSelection().addAll(toSelect);
|
||||
t.consume();
|
||||
}
|
||||
|
||||
private boolean acceptsDrop(DragEvent event) {
|
||||
// Accept drops from outside the app window
|
||||
if (event.getGestureSource() == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
BrowserClipboard.Instance cb = BrowserClipboard.currentDragClipboard;
|
||||
if (cb == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (model.getFileSystemModel().getCurrentDirectory() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Objects.equals(
|
||||
model.getFileSystemModel().getFileSystem(),
|
||||
cb.getEntries().getFirst().getRawFileEntry().getFileSystem())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prevent drag and drops of files into the current directory
|
||||
if (cb.getBaseDirectory() != null
|
||||
&& cb.getBaseDirectory()
|
||||
.getPath()
|
||||
.equals(model.getFileSystemModel().getCurrentDirectory().getPath())
|
||||
&& (item == null || item.getRawFileEntry().getKind() != FileKind.DIRECTORY)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent dropping items onto themselves
|
||||
if (item != null && cb.getEntries().contains(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void onDragDrop(DragEvent event) {
|
||||
model.getDraggedOverEmpty().setValue(false);
|
||||
model.getDraggedOverDirectory().setValue(null);
|
||||
|
||||
// Accept drops from outside the app window
|
||||
if (event.getGestureSource() == null && event.getDragboard().hasFiles()) {
|
||||
Dragboard db = event.getDragboard();
|
||||
var list = db.getFiles().stream().map(File::toPath).toList();
|
||||
var target = item != null && item.getRawFileEntry().getKind() == FileKind.DIRECTORY
|
||||
? item.getRawFileEntry()
|
||||
: model.getFileSystemModel().getCurrentDirectory();
|
||||
model.getFileSystemModel().dropLocalFilesIntoAsync(target, list);
|
||||
event.setDropCompleted(true);
|
||||
event.consume();
|
||||
}
|
||||
|
||||
// Accept drops from inside the app window
|
||||
if (event.getGestureSource() != null) {
|
||||
var db = BrowserClipboard.retrieveDrag(event.getDragboard());
|
||||
if (db == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var files = db.getEntries();
|
||||
var target = item != null && item.getRawFileEntry().getKind() == FileKind.DIRECTORY
|
||||
? item.getRawFileEntry()
|
||||
: model.getFileSystemModel().getCurrentDirectory();
|
||||
model.getFileSystemModel()
|
||||
.dropFilesIntoAsync(
|
||||
target,
|
||||
files.stream()
|
||||
.map(browserEntry -> browserEntry.getRawFileEntry())
|
||||
.toList(),
|
||||
db.getMode());
|
||||
event.setDropCompleted(true);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
|
||||
public void onDragExited(DragEvent event) {
|
||||
if (item != null && item.getRawFileEntry().getKind() == FileKind.DIRECTORY) {
|
||||
model.getDraggedOverDirectory().setValue(null);
|
||||
} else {
|
||||
model.getDraggedOverEmpty().setValue(false);
|
||||
}
|
||||
event.consume();
|
||||
}
|
||||
|
||||
public void startDrag(MouseEvent event) {
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getButton() != MouseButton.PRIMARY) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.getFileSystemModel().getBrowserModel() instanceof BrowserFullSessionModel sessionModel) {
|
||||
sessionModel.getDraggingFiles().setValue(true);
|
||||
}
|
||||
var selected = model.getSelection();
|
||||
Dragboard db = row.startDragAndDrop(TransferMode.COPY);
|
||||
db.setContent(BrowserClipboard.startDrag(
|
||||
model.getFileSystemModel().getCurrentDirectory(),
|
||||
selected,
|
||||
event.isAltDown() ? BrowserFileTransferMode.MOVE : BrowserFileTransferMode.NORMAL));
|
||||
|
||||
Image image = BrowserFileSelectionListComp.snapshot(selected);
|
||||
db.setDragView(image, -20, 15);
|
||||
|
||||
event.setDragDetect(true);
|
||||
event.consume();
|
||||
}
|
||||
|
||||
public void onDragDone(DragEvent event) {
|
||||
if (model.getFileSystemModel().getBrowserModel() instanceof BrowserFullSessionModel sessionModel) {
|
||||
sessionModel.getDraggingFiles().setValue(false);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
|
||||
private void acceptDrag(DragEvent event) {
|
||||
model.getDraggedOverEmpty()
|
||||
.setValue(item == null || item.getRawFileEntry().getKind() != FileKind.DIRECTORY);
|
||||
model.getDraggedOverDirectory().setValue(item);
|
||||
event.acceptTransferModes(TransferMode.COPY_OR_MOVE);
|
||||
}
|
||||
|
||||
private void handleHoverTimer(DragEvent event) {
|
||||
if (item == null || item.getRawFileEntry().getKind() != FileKind.DIRECTORY) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastOver.getX() == event.getX() && lastOver.getY() == event.getY()) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastOver = (new Point2D(event.getX(), event.getY()));
|
||||
activeTask = new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (activeTask != this) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item != model.getDraggedOverDirectory().getValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.getFileSystemModel().cdAsync(item.getRawFileEntry().getPath());
|
||||
}
|
||||
};
|
||||
DROP_TIMER.schedule(activeTask, 1200);
|
||||
}
|
||||
|
||||
public void onDragEntered(DragEvent event) {
|
||||
event.consume();
|
||||
if (!acceptsDrop(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
acceptDrag(event);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public void onMouseDragEntered(MouseDragEvent event) {
|
||||
event.consume();
|
||||
|
||||
if (model.getFileSystemModel().getCurrentDirectory() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var tv = ((TableView<BrowserEntry>)
|
||||
row.getParent().getParent().getParent().getParent());
|
||||
tv.getSelectionModel().select(item);
|
||||
}
|
||||
|
||||
public void onDragOver(DragEvent event) {
|
||||
event.consume();
|
||||
if (!acceptsDrop(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
acceptDrag(event);
|
||||
handleHoverTimer(event);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
package io.xpipe.app.browser.file;
|
||||
|
||||
import io.xpipe.app.comp.Comp;
|
||||
import io.xpipe.app.comp.CompStructure;
|
||||
import io.xpipe.app.comp.base.TextFieldComp;
|
||||
import io.xpipe.app.comp.base.TooltipAugment;
|
||||
import io.xpipe.app.util.InputHelper;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyCodeCombination;
|
||||
import javafx.scene.input.KeyCombination;
|
||||
import javafx.scene.layout.HBox;
|
||||
|
||||
import atlantafx.base.theme.Styles;
|
||||
import org.kordamp.ikonli.javafx.FontIcon;
|
||||
|
||||
public class BrowserFileListFilterComp extends Comp<BrowserFileListFilterComp.Structure> {
|
||||
|
||||
private final BrowserFileSystemTabModel model;
|
||||
private final Property<String> filterString;
|
||||
|
||||
public BrowserFileListFilterComp(BrowserFileSystemTabModel model, Property<String> filterString) {
|
||||
this.model = model;
|
||||
this.filterString = filterString;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Structure createBase() {
|
||||
var expanded = new SimpleBooleanProperty();
|
||||
var text = new TextFieldComp(filterString, false).createStructure().get();
|
||||
var button = new Button();
|
||||
button.minWidthProperty().bind(button.heightProperty());
|
||||
button.setFocusTraversable(true);
|
||||
InputHelper.onExactKeyCode(text, KeyCode.ESCAPE, true, keyEvent -> {
|
||||
if (!expanded.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
text.clear();
|
||||
button.fire();
|
||||
keyEvent.consume();
|
||||
});
|
||||
new TooltipAugment<>("app.search", new KeyCodeCombination(KeyCode.F, KeyCombination.SHORTCUT_DOWN))
|
||||
.augment(button);
|
||||
text.focusedProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (!newValue && filterString.getValue() == null) {
|
||||
if (button.isFocused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
expanded.set(false);
|
||||
}
|
||||
});
|
||||
filterString.addListener((observable, oldValue, newValue) -> {
|
||||
if (newValue == null && !text.isFocused()) {
|
||||
expanded.set(false);
|
||||
}
|
||||
});
|
||||
text.setMinWidth(0);
|
||||
Styles.toggleStyleClass(text, Styles.LEFT_PILL);
|
||||
|
||||
filterString.subscribe(val -> {
|
||||
if (val == null) {
|
||||
text.getStyleClass().remove(Styles.SUCCESS);
|
||||
} else {
|
||||
text.getStyleClass().add(Styles.SUCCESS);
|
||||
}
|
||||
});
|
||||
|
||||
var fi = new FontIcon("mdi2m-magnify");
|
||||
button.setGraphic(fi);
|
||||
button.setOnAction(event -> {
|
||||
if (model.getCurrentDirectory() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expanded.get()) {
|
||||
if (filterString.getValue() == null) {
|
||||
expanded.set(false);
|
||||
}
|
||||
event.consume();
|
||||
} else {
|
||||
expanded.set(true);
|
||||
text.requestFocus();
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
|
||||
var box = new HBox(text, button);
|
||||
box.getStyleClass().add("browser-filter");
|
||||
box.setAlignment(Pos.CENTER);
|
||||
|
||||
text.setPrefWidth(0);
|
||||
text.setFocusTraversable(false);
|
||||
button.getStyleClass().add(Styles.FLAT);
|
||||
button.disableProperty().bind(model.getInOverview());
|
||||
expanded.addListener((observable, oldValue, val) -> {
|
||||
if (val) {
|
||||
text.setPrefWidth(250);
|
||||
text.setFocusTraversable(true);
|
||||
button.getStyleClass().add(Styles.RIGHT_PILL);
|
||||
button.getStyleClass().remove(Styles.FLAT);
|
||||
} else {
|
||||
text.setPrefWidth(0);
|
||||
text.setFocusTraversable(false);
|
||||
button.getStyleClass().remove(Styles.RIGHT_PILL);
|
||||
button.getStyleClass().add(Styles.FLAT);
|
||||
}
|
||||
});
|
||||
button.minHeightProperty().bind(text.heightProperty());
|
||||
button.minWidthProperty().bind(text.heightProperty());
|
||||
button.maxHeightProperty().bind(text.heightProperty());
|
||||
button.maxWidthProperty().bind(text.heightProperty());
|
||||
return new Structure(box, text, button);
|
||||
}
|
||||
|
||||
public record Structure(HBox box, TextField textField, Button toggleButton) implements CompStructure<HBox> {
|
||||
|
||||
@Override
|
||||
public HBox get() {
|
||||
return box;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,158 @@
|
|||
package io.xpipe.app.browser.file;
|
||||
|
||||
import io.xpipe.app.issue.ErrorEvent;
|
||||
import io.xpipe.app.prefs.AppPrefs;
|
||||
import io.xpipe.core.process.OsType;
|
||||
import io.xpipe.core.store.FileEntry;
|
||||
import io.xpipe.core.store.FileKind;
|
||||
import io.xpipe.core.store.FileNames;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Getter
|
||||
public final class BrowserFileListModel {
|
||||
|
||||
static final Comparator<BrowserEntry> FILE_TYPE_COMPARATOR =
|
||||
Comparator.comparing(path -> path.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY);
|
||||
|
||||
private final BrowserFileSystemTabModel.SelectionMode selectionMode;
|
||||
|
||||
private final BrowserFileSystemTabModel fileSystemModel;
|
||||
private final Property<Comparator<BrowserEntry>> comparatorProperty =
|
||||
new SimpleObjectProperty<>(FILE_TYPE_COMPARATOR);
|
||||
private final Property<List<BrowserEntry>> all = new SimpleObjectProperty<>(new ArrayList<>());
|
||||
private final Property<List<BrowserEntry>> shown = new SimpleObjectProperty<>(new ArrayList<>());
|
||||
private final ObservableList<BrowserEntry> selection = FXCollections.observableArrayList();
|
||||
|
||||
private final Property<BrowserEntry> draggedOverDirectory = new SimpleObjectProperty<>();
|
||||
private final Property<Boolean> draggedOverEmpty = new SimpleBooleanProperty();
|
||||
private final Property<BrowserEntry> editing = new SimpleObjectProperty<>();
|
||||
|
||||
public BrowserFileListModel(
|
||||
BrowserFileSystemTabModel.SelectionMode selectionMode, BrowserFileSystemTabModel fileSystemModel) {
|
||||
this.selectionMode = selectionMode;
|
||||
this.fileSystemModel = fileSystemModel;
|
||||
|
||||
fileSystemModel.getFilter().addListener((observable, oldValue, newValue) -> {
|
||||
refreshShown();
|
||||
});
|
||||
}
|
||||
|
||||
public void setAll(Stream<FileEntry> newFiles) {
|
||||
try (var s = newFiles) {
|
||||
var l = s.filter(entry -> entry != null)
|
||||
.map(entry -> new BrowserEntry(entry, this))
|
||||
.toList();
|
||||
all.setValue(l);
|
||||
refreshShown();
|
||||
}
|
||||
}
|
||||
|
||||
public void setComparator(Comparator<BrowserEntry> comparator) {
|
||||
comparatorProperty.setValue(comparator);
|
||||
refreshShown();
|
||||
}
|
||||
|
||||
private void refreshShown() {
|
||||
List<BrowserEntry> filtered = fileSystemModel.getFilter().getValue() != null
|
||||
? all.getValue().stream()
|
||||
.filter(entry -> {
|
||||
var name = FileNames.getFileName(
|
||||
entry.getRawFileEntry().getPath())
|
||||
.toLowerCase(Locale.ROOT);
|
||||
var filterString =
|
||||
fileSystemModel.getFilter().getValue().toLowerCase(Locale.ROOT);
|
||||
return name.contains(filterString);
|
||||
})
|
||||
.toList()
|
||||
: all.getValue();
|
||||
|
||||
var listCopy = new ArrayList<>(filtered);
|
||||
listCopy.sort(order());
|
||||
shown.setValue(listCopy);
|
||||
}
|
||||
|
||||
public Comparator<BrowserEntry> order() {
|
||||
var dirsFirst = Comparator.<BrowserEntry, Boolean>comparing(
|
||||
path -> path.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY);
|
||||
var comp = comparatorProperty.getValue();
|
||||
|
||||
Comparator<BrowserEntry> us = comp != null ? dirsFirst.thenComparing(comp) : dirsFirst;
|
||||
return us;
|
||||
}
|
||||
|
||||
public BrowserEntry rename(BrowserEntry old, String newName) {
|
||||
if (old == null
|
||||
|| newName == null
|
||||
|| fileSystemModel == null
|
||||
|| fileSystemModel.isClosed()
|
||||
|| fileSystemModel.getCurrentPath().get() == null) {
|
||||
return old;
|
||||
}
|
||||
|
||||
var fullPath = FileNames.join(fileSystemModel.getCurrentPath().get(), old.getFileName());
|
||||
var newFullPath = FileNames.join(fileSystemModel.getCurrentPath().get(), newName);
|
||||
|
||||
// This check will fail on case-insensitive file systems when changing the case of the file
|
||||
// So skip it in this case
|
||||
var skipExistCheck =
|
||||
fileSystemModel.getFileSystem().getShell().orElseThrow().getOsType() == OsType.WINDOWS
|
||||
&& old.getFileName().equalsIgnoreCase(newName);
|
||||
if (!skipExistCheck) {
|
||||
boolean exists;
|
||||
try {
|
||||
exists = fileSystemModel.getFileSystem().fileExists(newFullPath)
|
||||
|| fileSystemModel.getFileSystem().directoryExists(newFullPath);
|
||||
} catch (Exception e) {
|
||||
ErrorEvent.fromThrowable(e).handle();
|
||||
return old;
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
ErrorEvent.fromMessage("Target " + newFullPath + " does already exist")
|
||||
.expected()
|
||||
.handle();
|
||||
fileSystemModel.refresh();
|
||||
return old;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fileSystemModel.getFileSystem().move(fullPath, newFullPath);
|
||||
fileSystemModel.refresh();
|
||||
var b = all.getValue().stream()
|
||||
.filter(browserEntry ->
|
||||
browserEntry.getRawFileEntry().getPath().equals(newFullPath))
|
||||
.findFirst()
|
||||
.orElse(old);
|
||||
return b;
|
||||
} catch (Exception e) {
|
||||
ErrorEvent.fromThrowable(e).handle();
|
||||
return old;
|
||||
}
|
||||
}
|
||||
|
||||
public void onDoubleClick(BrowserEntry entry) {
|
||||
var r = entry.getRawFileEntry().resolved();
|
||||
if (r.getKind() == FileKind.DIRECTORY) {
|
||||
fileSystemModel.cdAsync(r.getPath());
|
||||
}
|
||||
|
||||
if (AppPrefs.get().editFilesWithDoubleClick().get() && r.getKind() == FileKind.FILE) {
|
||||
var selection = new LinkedHashSet<>(getSelection());
|
||||
selection.add(entry);
|
||||
for (BrowserEntry e : selection) {
|
||||
BrowserFileOpener.openInTextEditor(getFileSystemModel(), e.getRawFileEntry());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,202 @@
|
|||
package io.xpipe.app.browser.file;
|
||||
|
||||
import io.xpipe.app.comp.base.LazyTextFieldComp;
|
||||
import io.xpipe.app.comp.base.PrettyImageHelper;
|
||||
import io.xpipe.app.util.BooleanScope;
|
||||
import io.xpipe.app.util.ContextMenuHelper;
|
||||
import io.xpipe.app.util.InputHelper;
|
||||
import io.xpipe.app.util.PlatformThread;
|
||||
import io.xpipe.app.util.ThreadHelper;
|
||||
import io.xpipe.core.store.FileKind;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableStringValue;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.geometry.Side;
|
||||
import javafx.scene.AccessibleRole;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ButtonBase;
|
||||
import javafx.scene.control.TableCell;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
|
||||
import atlantafx.base.controls.Spacer;
|
||||
|
||||
class BrowserFileListNameCell extends TableCell<BrowserEntry, String> {
|
||||
|
||||
private final BrowserFileListModel fileList;
|
||||
private final ObservableStringValue typedSelection;
|
||||
private final StringProperty img = new SimpleStringProperty();
|
||||
private final StringProperty text = new SimpleStringProperty();
|
||||
|
||||
private final BooleanProperty updating = new SimpleBooleanProperty();
|
||||
|
||||
public BrowserFileListNameCell(
|
||||
BrowserFileListModel fileList,
|
||||
ObservableStringValue typedSelection,
|
||||
Property<BrowserEntry> editing,
|
||||
TableView<BrowserEntry> tableView) {
|
||||
this.fileList = fileList;
|
||||
this.typedSelection = typedSelection;
|
||||
|
||||
accessibleTextProperty()
|
||||
.bind(Bindings.createStringBinding(
|
||||
() -> {
|
||||
return getItem() != null ? getItem() : null;
|
||||
},
|
||||
itemProperty()));
|
||||
setAccessibleRole(AccessibleRole.TEXT);
|
||||
|
||||
var textField = new LazyTextFieldComp(text)
|
||||
.minWidth(USE_PREF_SIZE)
|
||||
.createStructure()
|
||||
.get();
|
||||
var quickAccess = createQuickAccessButton();
|
||||
setupShortcuts(tableView, (ButtonBase) quickAccess);
|
||||
setupRename(fileList, textField, editing);
|
||||
|
||||
Node imageView = PrettyImageHelper.ofFixedSize(img, 24, 24).createRegion();
|
||||
HBox graphic = new HBox(imageView, new Spacer(5), quickAccess, new Spacer(1), textField);
|
||||
quickAccess.prefHeightProperty().bind(graphic.heightProperty());
|
||||
graphic.setAlignment(Pos.CENTER_LEFT);
|
||||
graphic.setPrefHeight(34);
|
||||
HBox.setHgrow(textField, Priority.ALWAYS);
|
||||
graphic.setAlignment(Pos.CENTER_LEFT);
|
||||
setGraphic(graphic);
|
||||
}
|
||||
|
||||
private Region createQuickAccessButton() {
|
||||
var quickAccess = new BrowserQuickAccessButtonComp(() -> getTableRow().getItem(), fileList.getFileSystemModel())
|
||||
.hide(Bindings.createBooleanBinding(
|
||||
() -> {
|
||||
if (getTableRow() == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var item = getTableRow().getItem();
|
||||
var notDir = item.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY;
|
||||
var isParentLink = item.getRawFileEntry()
|
||||
.equals(fileList.getFileSystemModel().getCurrentParentDirectory());
|
||||
return notDir || isParentLink;
|
||||
},
|
||||
itemProperty()))
|
||||
.focusTraversable(false)
|
||||
.createRegion();
|
||||
return quickAccess;
|
||||
}
|
||||
|
||||
private void setupShortcuts(TableView<BrowserEntry> tableView, ButtonBase quickAccess) {
|
||||
InputHelper.onExactKeyCode(tableView, KeyCode.RIGHT, false, event -> {
|
||||
var selected = fileList.getSelection();
|
||||
if (selected.size() == 1 && selected.getFirst() == getTableRow().getItem()) {
|
||||
quickAccess.fire();
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
InputHelper.onExactKeyCode(tableView, KeyCode.SPACE, true, event -> {
|
||||
var selection = typedSelection.get() + " ";
|
||||
var found = fileList.getShown().getValue().stream()
|
||||
.filter(browserEntry ->
|
||||
browserEntry.getFileName().toLowerCase().startsWith(selection))
|
||||
.findFirst();
|
||||
// Ugly fix to prevent space from showing the menu when there is a file matching
|
||||
// Due to the table view input map, these events always get sent and consumed, not allowing us to
|
||||
// differentiate between these cases
|
||||
if (found.isPresent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var selected = fileList.getSelection();
|
||||
// Only show one menu across all selected entries
|
||||
if (selected.size() > 0 && selected.getLast() == getTableRow().getItem()) {
|
||||
var cm = new BrowserContextMenu(
|
||||
fileList.getFileSystemModel(), getTableRow().getItem(), false);
|
||||
ContextMenuHelper.toggleShow(cm, this, Side.RIGHT);
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setupRename(BrowserFileListModel fileList, TextField textField, Property<BrowserEntry> editing) {
|
||||
ChangeListener<String> listener = (observable, oldValue, newValue) -> {
|
||||
if (updating.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
getTableRow().requestFocus();
|
||||
var it = getTableRow().getItem();
|
||||
editing.setValue(null);
|
||||
ThreadHelper.runAsync(() -> {
|
||||
if (it == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var r = fileList.rename(it, newValue);
|
||||
Platform.runLater(() -> {
|
||||
updateItem(getItem(), isEmpty());
|
||||
fileList.getSelection().setAll(r);
|
||||
getTableView().scrollTo(r);
|
||||
});
|
||||
});
|
||||
};
|
||||
text.addListener(listener);
|
||||
|
||||
editing.addListener((observable, oldValue, newValue) -> {
|
||||
if (getTableRow().getItem() != null && getTableRow().getItem().equals(newValue)) {
|
||||
PlatformThread.runLaterIfNeeded(() -> {
|
||||
textField.setDisable(false);
|
||||
textField.requestFocus();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(String newName, boolean empty) {
|
||||
if (updating.get()) {
|
||||
super.updateItem(newName, empty);
|
||||
return;
|
||||
}
|
||||
|
||||
try (var ignored = new BooleanScope(updating).start()) {
|
||||
super.updateItem(newName, empty);
|
||||
if (empty || newName == null || getTableRow().getItem() == null) {
|
||||
// Don't set image as that would trigger image comp update
|
||||
// and cells are emptied on each change, leading to unnecessary changes
|
||||
// img.set(null);
|
||||
|
||||
// Visibility seems to be bugged, so use opacity
|
||||
setOpacity(0.0);
|
||||
} else {
|
||||
img.set(getTableRow().getItem().getIcon());
|
||||
|
||||
var isDirectory = getTableRow().getItem().getRawFileEntry().getKind() == FileKind.DIRECTORY;
|
||||
pseudoClassStateChanged(PseudoClass.getPseudoClass("folder"), isDirectory);
|
||||
|
||||
var normalName = getTableRow().getItem().getRawFileEntry().getKind() == FileKind.LINK
|
||||
? getTableRow().getItem().getFileName() + " -> "
|
||||
+ getTableRow()
|
||||
.getItem()
|
||||
.getRawFileEntry()
|
||||
.resolved()
|
||||
.getPath()
|
||||
: getTableRow().getItem().getFileName();
|
||||
var fileName = normalName;
|
||||
var hidden = getTableRow().getItem().getRawFileEntry().getInfo().explicitlyHidden()
|
||||
|| fileName.startsWith(".");
|
||||
getTableRow().pseudoClassStateChanged(PseudoClass.getPseudoClass("hidden"), hidden);
|
||||
text.set(fileName);
|
||||
// Visibility seems to be bugged, so use opacity
|
||||
setOpacity(1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue