Compare commits
1211 Commits
v0.2026041
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16e5dd466a | ||
|
|
2c910779bf | ||
|
|
61309fef4b | ||
|
|
3b5435da90 | ||
|
|
78fd4bb015 | ||
|
|
e8417906c4 | ||
|
|
2fc9b4c31f | ||
|
|
db1a99bc44 | ||
|
|
71d814700f | ||
|
|
50f48b4379 | ||
|
|
f471a9a0f1 | ||
|
|
cf0df62a16 | ||
|
|
cfa07a82cc | ||
|
|
6b1a44cdfd | ||
|
|
c2366f2a3e | ||
|
|
2d90b61a70 | ||
|
|
0cf2467fbc | ||
|
|
4f54849991 | ||
|
|
b0950c92c5 | ||
|
|
58dfb039ca | ||
|
|
c19cefe989 | ||
|
|
e1771ed7e6 | ||
|
|
9308493326 | ||
|
|
951b15d603 | ||
|
|
93ef104413 | ||
|
|
dd18bac5ec | ||
|
|
11f2dec3e9 | ||
|
|
b54a5a5ad2 | ||
|
|
2bc560468d | ||
|
|
66a9127430 | ||
|
|
478ed789f8 | ||
|
|
95d75f7a7a | ||
|
|
c56277add0 | ||
|
|
8df4730db6 | ||
|
|
e5f3bae8f3 | ||
|
|
1f39501ce5 | ||
|
|
10024dbf53 | ||
|
|
e52d07affc | ||
|
|
0d9af0fb0f | ||
|
|
1ca2662a4b | ||
|
|
eb2758c154 | ||
|
|
7d308a65e0 | ||
|
|
74850b5b69 | ||
|
|
e486e863ce | ||
|
|
132618ebd3 | ||
|
|
d29d5a03ae | ||
|
|
084c58fed8 | ||
|
|
63d85bf67f | ||
|
|
17d67013dc | ||
|
|
458898f632 | ||
|
|
89a605262c | ||
|
|
e097c5b717 | ||
|
|
c0df2124ff | ||
|
|
7f11f3e3ea | ||
|
|
f02bb68021 | ||
|
|
80914e1e3c | ||
|
|
94d07f0d47 | ||
|
|
ef0709a795 | ||
|
|
eac3b123c4 | ||
|
|
25d9da5440 | ||
|
|
e366c84b65 | ||
|
|
7daf6898c1 | ||
|
|
377a3f8d0e | ||
|
|
901326d06f | ||
|
|
d378e488f1 | ||
|
|
e581ad3691 | ||
|
|
09f7498e86 | ||
|
|
7443b9a468 | ||
|
|
12f8fad0f0 | ||
|
|
c90b1d6694 | ||
|
|
806b552d47 | ||
|
|
2cd192fae6 | ||
|
|
46106da47a | ||
|
|
0051559b8e | ||
|
|
3f19528cb6 | ||
|
|
24e5b537d6 | ||
|
|
f8f6e3d003 | ||
|
|
ba9b5c7c31 | ||
|
|
1930e7132b | ||
|
|
8a51ff5f3c | ||
|
|
56cb878d0b | ||
|
|
52a673e16e | ||
|
|
fe44153ac6 | ||
|
|
088e0bca8e | ||
|
|
45249bade4 | ||
|
|
917773bc4c | ||
|
|
d39d691ccd | ||
|
|
98effa9637 | ||
|
|
e966f3a14b | ||
|
|
4ac2d994c1 | ||
|
|
043766f86e | ||
|
|
09e8d03851 | ||
|
|
9fa1c52a96 | ||
|
|
d32b11f02e | ||
|
|
6b2d34fec4 | ||
|
|
6693615791 | ||
|
|
e47c653b8b | ||
|
|
60ecc4dd0e | ||
|
|
5323ec6ede | ||
|
|
a007b37089 | ||
|
|
3c4f6ccabd | ||
|
|
313b11fb0e | ||
|
|
df1cff97b9 | ||
|
|
2b419b48a0 | ||
|
|
f8d080e678 | ||
|
|
9f905169cb | ||
|
|
ef0288268c | ||
|
|
1d3927ca2b | ||
|
|
2feff35764 | ||
|
|
6f66cf0d9c | ||
|
|
aaa46f8d20 | ||
|
|
f384b6fad3 | ||
|
|
de122ad045 | ||
|
|
c0a64e181b | ||
|
|
fe85d1f679 | ||
|
|
9db5f5cbed | ||
|
|
13c0802395 | ||
|
|
4c44f8e966 | ||
|
|
2b135d208e | ||
|
|
076860632c | ||
|
|
520ec504fe | ||
|
|
cdb3eeea4c | ||
|
|
f68436c2e0 | ||
|
|
7c03c50e25 | ||
|
|
24911b6bb7 | ||
|
|
51914db534 | ||
|
|
e4ffeeccc0 | ||
|
|
8ec33b99b5 | ||
|
|
638678184e | ||
|
|
62af7a3d6e | ||
|
|
ef9f3ec21f | ||
|
|
0d91e29e82 | ||
|
|
77d59e3ee0 | ||
|
|
f37de38d21 | ||
|
|
51ce27f781 | ||
|
|
4a48833dbc | ||
|
|
d21eef8aa6 | ||
|
|
13f503691e | ||
|
|
17a327173b | ||
|
|
6eb4963952 | ||
|
|
b51b464605 | ||
|
|
a3b5b2244e | ||
|
|
38f83b55e0 | ||
|
|
a5652b3f4d | ||
|
|
56faffdcc8 | ||
|
|
c01f340851 | ||
|
|
a0fc97e5cf | ||
|
|
3172425a16 | ||
|
|
9b93a17cec | ||
|
|
aec4e9e992 | ||
|
|
1bca32a263 | ||
|
|
d5c3e1858e | ||
|
|
ab3de7eecd | ||
|
|
188cd7af8b | ||
|
|
248ec02dee | ||
|
|
9f2bd669fe | ||
|
|
b786b2a4cb | ||
|
|
83dad79692 | ||
|
|
e7ea808f2b | ||
|
|
96df0e6796 | ||
|
|
2bec65a445 | ||
|
|
4fe6f15aa1 | ||
|
|
a093ff4631 | ||
|
|
3ed1fb4ec4 | ||
|
|
7ed0787990 | ||
|
|
56ddaba105 | ||
|
|
6e5652487d | ||
|
|
dab5f3bc41 | ||
|
|
c662b541c7 | ||
|
|
b467fd8446 | ||
|
|
4d6f713a59 | ||
|
|
284af17483 | ||
|
|
2a21dc4b08 | ||
|
|
6946aad139 | ||
|
|
4ae07446a8 | ||
|
|
88a7926a48 | ||
|
|
fd642889c8 | ||
|
|
e2d0a46608 | ||
|
|
990470e5b4 | ||
|
|
33db64822d | ||
|
|
931fed879b | ||
|
|
f71e899b90 | ||
|
|
9960fd6139 | ||
|
|
e3923fe123 | ||
|
|
375baa4e1a | ||
|
|
321a518a1f | ||
|
|
ed549a160c | ||
|
|
3de211df96 | ||
|
|
f84faac7c3 | ||
|
|
ee69e30b6b | ||
|
|
e823243a3b | ||
|
|
85e8eb7041 | ||
|
|
6640e74a74 | ||
|
|
be98fd51e5 | ||
|
|
b1aaae82df | ||
|
|
2f658425df | ||
|
|
5ce1431522 | ||
|
|
36febb75da | ||
|
|
8a923a95cf | ||
|
|
c0fed9c5c0 | ||
|
|
4d787d6f84 | ||
|
|
3bc824940a | ||
|
|
cebeca678f | ||
|
|
dd59ee072d | ||
|
|
828d5f84a9 | ||
|
|
459ee8c7e3 | ||
|
|
62e1dbb173 | ||
|
|
f15dc0e516 | ||
|
|
491e5d3962 | ||
|
|
1199840fdc | ||
|
|
39db25a39a | ||
|
|
213e51c135 | ||
|
|
9a253392e5 | ||
|
|
c4001ec3f6 | ||
|
|
e3e60a2815 | ||
|
|
e43e2a7549 | ||
|
|
517f1c04f5 | ||
|
|
635d793d32 | ||
|
|
093f1bc9dc | ||
|
|
7dc7897367 | ||
|
|
46a08c6f43 | ||
|
|
eb7a45adf9 | ||
|
|
e89dc65c22 | ||
|
|
ce4736b619 | ||
|
|
7d94bb26bb | ||
|
|
b78e42272e | ||
|
|
4d0e7469b9 | ||
|
|
c29206da3b | ||
|
|
6764fd04a1 | ||
|
|
59ed6897c1 | ||
|
|
9b59c269d8 | ||
|
|
ff1f166ca6 | ||
|
|
289ab82ed1 | ||
|
|
94da70ba30 | ||
|
|
bd130171df | ||
|
|
2045e13404 | ||
|
|
d83121bc83 | ||
|
|
acfceb3335 | ||
|
|
2dd6a94ef8 | ||
|
|
296d474732 | ||
|
|
98a98ab024 | ||
|
|
2b85640521 | ||
|
|
c3b397f900 | ||
|
|
6e0b578590 | ||
|
|
860efc1f42 | ||
|
|
d05bc271a8 | ||
|
|
7f0134a99c | ||
|
|
8fbf035d46 | ||
|
|
859795879c | ||
|
|
0ee4862508 | ||
|
|
e1def7f8bb | ||
|
|
e697b4b5e5 | ||
|
|
6b0bb5173c | ||
|
|
81f986a065 | ||
|
|
564ca50206 | ||
|
|
6d405a763d | ||
|
|
7c9bc3d640 | ||
|
|
d316539a91 | ||
|
|
0f22475c20 | ||
|
|
797c7b66b6 | ||
|
|
af7d79fe44 | ||
|
|
14b5c34b94 | ||
|
|
a7a61d9751 | ||
|
|
68066b857f | ||
|
|
858468aec6 | ||
|
|
67ac8db888 | ||
|
|
1ae1377b2d | ||
|
|
adfceb3c70 | ||
|
|
46856b4dc2 | ||
|
|
2746fb88ad | ||
|
|
52d66048d6 | ||
|
|
1a90679f62 | ||
|
|
59773725e1 | ||
|
|
d9254ffaf5 | ||
|
|
85b545a0bc | ||
|
|
e46b9ecb1b | ||
|
|
0488a3c505 | ||
|
|
a59640634e | ||
|
|
89f53a1214 | ||
|
|
3749770598 | ||
|
|
d7f18f79c1 | ||
|
|
f80339a0ed | ||
|
|
93d5d582ef | ||
|
|
1ba74d4b1d | ||
|
|
5c0e7b2987 | ||
|
|
bcd4b8fd99 | ||
|
|
2dd68c170e | ||
|
|
4eca4cb545 | ||
|
|
1ebd5163f1 | ||
|
|
f61fc57aa6 | ||
|
|
e2788d65b5 | ||
|
|
42b76d85d0 | ||
|
|
dc57984600 | ||
|
|
bb1eebbd6f | ||
|
|
341590cfd9 | ||
|
|
bc08345021 | ||
|
|
99ae8260cb | ||
|
|
c9c8380d6c | ||
|
|
80313376f8 | ||
|
|
b36b45057b | ||
|
|
f1e0afb715 | ||
|
|
5464da9c6e | ||
|
|
41edd75837 | ||
|
|
c4960e8ee7 | ||
|
|
c13a5fe3b0 | ||
|
|
0ded9ee871 | ||
|
|
cf2bc61ce5 | ||
|
|
f7f669774e | ||
|
|
af9d858bb3 | ||
|
|
31d85a0223 | ||
|
|
3cb55fe752 | ||
|
|
91854f8bae | ||
|
|
a05a9339c1 | ||
|
|
f47557ccb5 | ||
|
|
e96f266c2b | ||
|
|
181c9fe114 | ||
|
|
b2e99d2da9 | ||
|
|
df7761b759 | ||
|
|
94112ffcd8 | ||
|
|
ec6d490025 | ||
|
|
d108aa01e8 | ||
|
|
259d5b6489 | ||
|
|
86a9f65f88 | ||
|
|
660208a6fd | ||
|
|
6665d646fb | ||
|
|
1eecae0add | ||
|
|
5c7693902d | ||
|
|
47b5e45696 | ||
|
|
1de22b04b8 | ||
|
|
2be961c53c | ||
|
|
198ea1fcc9 | ||
|
|
1b547d4191 | ||
|
|
59ca7a7b41 | ||
|
|
af147c4cf0 | ||
|
|
97126c8a0e | ||
|
|
c4136d69b4 | ||
|
|
8b723b36d9 | ||
|
|
e27b29eec9 | ||
|
|
8207ee4569 | ||
|
|
2fdc122d73 | ||
|
|
60d3b48ea6 | ||
|
|
ecbb94ae83 | ||
|
|
50ad7e0e8c | ||
|
|
ea8d847d46 | ||
|
|
845181d002 | ||
|
|
35c62583d9 | ||
|
|
a6b2454184 | ||
|
|
a74f76fdf1 | ||
|
|
c6fa1f48cb | ||
|
|
8f8954a6f7 | ||
|
|
99f5716508 | ||
|
|
b8f29c9530 | ||
|
|
33ae00a558 | ||
|
|
ac02de88e6 | ||
|
|
fb8208cfa0 | ||
|
|
66e29b70b8 | ||
|
|
d1b358f76a | ||
|
|
c2ce67c300 | ||
|
|
07522e984c | ||
|
|
36f43c1ae0 | ||
|
|
1b478d0fc1 | ||
|
|
8646be6b8d | ||
|
|
fd9b4fe5e7 | ||
|
|
d52365882c | ||
|
|
135fe62d21 | ||
|
|
99aee0f67b | ||
|
|
a369b3d93e | ||
|
|
4e88cf71b1 | ||
|
|
35bbe221b8 | ||
|
|
05dca72ef3 | ||
|
|
57a66e4788 | ||
|
|
7b0b31455b | ||
|
|
7c16ebd065 | ||
|
|
5df4a7a3ad | ||
|
|
ca41008850 | ||
|
|
badbf141dd | ||
|
|
bc55231baa | ||
|
|
24da33e7cd | ||
|
|
004502ae76 | ||
|
|
d4b0cd5cc2 | ||
|
|
975931ec6d | ||
|
|
748bf732a1 | ||
|
|
70ab40538f | ||
|
|
2ca49b8526 | ||
|
|
11c04f0b31 | ||
|
|
e1e489fbc7 | ||
|
|
e5177f5905 | ||
|
|
210f2f6916 | ||
|
|
9b92cf40c4 | ||
|
|
e8ace69bb0 | ||
|
|
8cfe7de66a | ||
|
|
47a11df4a4 | ||
|
|
27dd42810a | ||
|
|
7eaa284d4e | ||
|
|
c559188f5a | ||
|
|
9a17e3ad24 | ||
|
|
e3d3331bbc | ||
|
|
888ceb256d | ||
|
|
4600d0038b | ||
|
|
6cf889df6c | ||
|
|
c85e3b4f2f | ||
|
|
ecd941c54e | ||
|
|
e1c545220f | ||
|
|
e6b3278fea | ||
|
|
76de05e9b3 | ||
|
|
f7ab10222a | ||
|
|
b5a54c7f16 | ||
|
|
414cb12c7d | ||
|
|
3a24178759 | ||
|
|
e01403ea10 | ||
|
|
a0874b2232 | ||
|
|
a1688f8488 | ||
|
|
56b923c650 | ||
|
|
dc6edd74af | ||
|
|
50dec4bcf3 | ||
|
|
a808880e32 | ||
|
|
1e3ddb231c | ||
|
|
d07a592750 | ||
|
|
90dfbb2893 | ||
|
|
412a1e02c7 | ||
|
|
b8ae75648f | ||
|
|
7620fe61df | ||
|
|
08493f8217 | ||
|
|
6dc6b5e515 | ||
|
|
beb49b6e40 | ||
|
|
fa49251459 | ||
|
|
67dbda8f24 | ||
|
|
17f94647b7 | ||
|
|
97513929a3 | ||
|
|
01d7a2058d | ||
|
|
19bbba9a27 | ||
|
|
4d13212ff5 | ||
|
|
2f028404b1 | ||
|
|
1550c02e4a | ||
|
|
19d53da8fb | ||
|
|
389424ff84 | ||
|
|
15a747094b | ||
|
|
d1bbacafb3 | ||
|
|
7b6d8773d4 | ||
|
|
4c6b0430f4 | ||
|
|
7a341c2a5e | ||
|
|
3f5ac7a9a9 | ||
|
|
85fa0b8680 | ||
|
|
08668854b9 | ||
|
|
5a081b06d4 | ||
|
|
fa74009877 | ||
|
|
f89b199b79 | ||
|
|
6a30016e6b | ||
|
|
e7a9581e63 | ||
|
|
a2071354e2 | ||
|
|
6237efe02f | ||
|
|
aad863ebe3 | ||
|
|
38021d231d | ||
|
|
a88c9fc2d0 | ||
|
|
40bf800c39 | ||
|
|
5838f4104b | ||
|
|
23b632ac9f | ||
|
|
5cfc80dad5 | ||
|
|
298c82b9b8 | ||
|
|
0bf2ea6cb7 | ||
|
|
ee556aa004 | ||
|
|
8684b36bba | ||
|
|
899e786fdc | ||
|
|
b03173058c | ||
|
|
b3126044e8 | ||
|
|
fc1826b0e2 | ||
|
|
17d8618c16 | ||
|
|
698c4cece1 | ||
|
|
acd048b144 | ||
|
|
9ce74e7081 | ||
|
|
deec1be7e0 | ||
|
|
b2bb7cf661 | ||
|
|
112f68c66d | ||
|
|
28fa9673b7 | ||
|
|
18e173faca | ||
|
|
a16b477878 | ||
|
|
7bcbf87067 | ||
|
|
a23264eacb | ||
|
|
18b65f1e59 | ||
|
|
52d68aa506 | ||
|
|
8cb2194842 | ||
|
|
66090688f9 | ||
|
|
a15f36ec6a | ||
|
|
8137125569 | ||
|
|
9891a29f7f | ||
|
|
6c9b39c0f0 | ||
|
|
3207b0b10e | ||
|
|
088c495520 | ||
|
|
70f984c399 | ||
|
|
0d6cce1588 | ||
|
|
73278f3baf | ||
|
|
93dc454a8a | ||
|
|
1b7b7f3fde | ||
|
|
7e20a36469 | ||
|
|
61a145c49b | ||
|
|
9a7369c799 | ||
|
|
13d3b2d375 | ||
|
|
9994f2386c | ||
|
|
a496a115bf | ||
|
|
ed4926d59f | ||
|
|
c158cbccff | ||
|
|
12416b99bc | ||
|
|
a25f9ee1fc | ||
|
|
8a48c21445 | ||
|
|
7c72494efb | ||
|
|
10b4e6d038 | ||
|
|
55519e53ad | ||
|
|
fafa4e394b | ||
|
|
ab02aa5e96 | ||
|
|
4dd27cf0c3 | ||
|
|
c499ef30f0 | ||
|
|
b3bf2bf76e | ||
|
|
13622bc236 | ||
|
|
80ad5cfad7 | ||
|
|
068d70c264 | ||
|
|
86ffb43eb7 | ||
|
|
3504bb221a | ||
|
|
f2136f17ef | ||
|
|
75ca18d4d0 | ||
|
|
47c408dc50 | ||
|
|
96d7df368a | ||
|
|
c7b0c15537 | ||
|
|
0ee6e76d10 | ||
|
|
25c44910d1 | ||
|
|
61760c4a41 | ||
|
|
8104a8380c | ||
|
|
593a0d3d54 | ||
|
|
a50aae70e8 | ||
|
|
2387b9a637 | ||
|
|
27452515d7 | ||
|
|
14a4d371e6 | ||
|
|
9199ceaa35 | ||
|
|
6af18655de | ||
|
|
90bf73524c | ||
|
|
c1f7f7ec6f | ||
|
|
d1021b9131 | ||
|
|
b9139e2d57 | ||
|
|
c692c5d067 | ||
|
|
b86f6d5b88 | ||
|
|
3426cbc882 | ||
|
|
57ff0f18f8 | ||
|
|
abf6b51369 | ||
|
|
02c784b286 | ||
|
|
fc4ba24c4e | ||
|
|
eaeb6e5d40 | ||
|
|
64fe0f1c25 | ||
|
|
474353718a | ||
|
|
a933e6a787 | ||
|
|
79689839b2 | ||
|
|
b10e9facad | ||
|
|
2a32d2b5ad | ||
|
|
11a4e1593b | ||
|
|
080e5ff62d | ||
|
|
60fd4d9ade | ||
|
|
3583cb9518 | ||
|
|
1f497e4b18 | ||
|
|
13b325217b | ||
|
|
1714da402e | ||
|
|
7b75cb79c4 | ||
|
|
a66eedbecd | ||
|
|
dc92fd1f57 | ||
|
|
4012a65a73 | ||
|
|
b2da813923 | ||
|
|
e461a91721 | ||
|
|
edf24898ae | ||
|
|
1c8542bd01 | ||
|
|
aef98f17eb | ||
|
|
5c85bc41f6 | ||
|
|
60f3139b3e | ||
|
|
e40c09a2ef | ||
|
|
f90f703096 | ||
|
|
eaf31ff61c | ||
|
|
db50f779c9 | ||
|
|
e228bd3a52 | ||
|
|
3a0fd0620f | ||
|
|
e2489b8957 | ||
|
|
11f8731d32 | ||
|
|
6f7a7d71d8 | ||
|
|
aef1924407 | ||
|
|
2173df6f0d | ||
|
|
98cad76aa0 | ||
|
|
f076c71cc1 | ||
|
|
44221e50d4 | ||
|
|
b46c71c76f | ||
|
|
06c01539af | ||
|
|
fddc323d1e | ||
|
|
23b63bb583 | ||
|
|
c858cf8d3b | ||
|
|
0dc684453d | ||
|
|
aafe69b573 | ||
|
|
a3c53c19d1 | ||
|
|
5185f363e6 | ||
|
|
3915363026 | ||
|
|
2249c4a450 | ||
|
|
6575ec2d1c | ||
|
|
1f08694e9d | ||
|
|
a99cb2af78 | ||
|
|
8eabcd9467 | ||
|
|
97112b9c84 | ||
|
|
a53819e56a | ||
|
|
41a2d25311 | ||
|
|
3a3d0bbcdf | ||
|
|
a4468289a2 | ||
|
|
bcfd37d481 | ||
|
|
f27ebe05c4 | ||
|
|
7470e4f4a7 | ||
|
|
0e503cb179 | ||
|
|
d83f04c6ff | ||
|
|
9ffa6930c5 | ||
|
|
88b20b83dc | ||
|
|
548e5694ac | ||
|
|
0cf8f55a8d | ||
|
|
72b88cef43 | ||
|
|
8db9f61d5c | ||
|
|
fb2067ec79 | ||
|
|
b60bdb685d | ||
|
|
83009d8fb9 | ||
|
|
068d434344 | ||
|
|
6b2a1b0659 | ||
|
|
bf5f5521af | ||
|
|
f4961d0bc3 | ||
|
|
058de96cbc | ||
|
|
d29f919182 | ||
|
|
42e37e035c | ||
|
|
0ad0fa9b6b | ||
|
|
c37c03d6a9 | ||
|
|
fcc264d7dc | ||
|
|
382ba0a6d4 | ||
|
|
4719810b10 | ||
|
|
20b4ba7a3b | ||
|
|
de00ab6794 | ||
|
|
5d8f514bea | ||
|
|
1fd3ea02ae | ||
|
|
8da1484ebf | ||
|
|
90ebc2e939 | ||
|
|
c14927df8f | ||
|
|
a6d60695a2 | ||
|
|
7ddc0c14bf | ||
|
|
2a00b8aac7 | ||
|
|
b7d2304282 | ||
|
|
50b5fa9c2e | ||
|
|
963e7cf111 | ||
|
|
c9a5d20c12 | ||
|
|
586e1fcde5 | ||
|
|
75d67623ce | ||
|
|
b9d94abae4 | ||
|
|
18b2aaedf6 | ||
|
|
1706300494 | ||
|
|
628ab76f09 | ||
|
|
d3896ddf01 | ||
|
|
7289a60db3 | ||
|
|
0cff626e48 | ||
|
|
5a4c986fe3 | ||
|
|
540b1400e2 | ||
|
|
826cda852a | ||
|
|
19583ea1f5 | ||
|
|
63eb4ff980 | ||
|
|
bbb29857b8 | ||
|
|
900ce7f66f | ||
|
|
eb55c5ec02 | ||
|
|
6f6e7d7cfe | ||
|
|
edcb306c7f | ||
|
|
97be1d1ca3 | ||
|
|
ea29a81d79 | ||
|
|
5b1fda2279 | ||
|
|
1cd596061a | ||
|
|
efb233b18f | ||
|
|
fd34df8db0 | ||
|
|
244deeb727 | ||
|
|
70f94d3a51 | ||
|
|
bb20ef17f6 | ||
|
|
15a1ba6829 | ||
|
|
e2c6669fd6 | ||
|
|
d4098d3308 | ||
|
|
1c3d677851 | ||
|
|
3da0b0e652 | ||
|
|
bc2f97d1d4 | ||
|
|
2fb6a30dff | ||
|
|
0206fb3720 | ||
|
|
51d1b28460 | ||
|
|
0183062467 | ||
|
|
1c244a85eb | ||
|
|
900755055b | ||
|
|
3af9a09d29 | ||
|
|
348b2654eb | ||
|
|
013cf0ffe1 | ||
|
|
418ea8a7a8 | ||
|
|
1e3c296787 | ||
|
|
32b4a90ae5 | ||
|
|
25260c7d58 | ||
|
|
ff3e7af65a | ||
|
|
75878c8b64 | ||
|
|
8f0c6e04fe | ||
|
|
adf45a51b5 | ||
|
|
96bc4e935a | ||
|
|
eab457b388 | ||
|
|
89da970455 | ||
|
|
6a57e83c99 | ||
|
|
d41e4ea34a | ||
|
|
c371e26dcb | ||
|
|
c78018b92a | ||
|
|
5e84ebc28a | ||
|
|
5775cb0c96 | ||
|
|
7ced48d001 | ||
|
|
533c9da4d0 | ||
|
|
e1afaaa5b6 | ||
|
|
914bcfdafd | ||
|
|
cfb047cb22 | ||
|
|
55ff9e6f5d | ||
|
|
d3ea632673 | ||
|
|
6d3d752fd5 | ||
|
|
26181b57b6 | ||
|
|
7efcaa11c4 | ||
|
|
197aad0e97 | ||
|
|
db47ffe454 | ||
|
|
008f067bf7 | ||
|
|
55a2509658 | ||
|
|
790f2ea85c | ||
|
|
ed5e6d6820 | ||
|
|
feb97caa87 | ||
|
|
5bcd344d5f | ||
|
|
816e3ca8bd | ||
|
|
ef148be94e | ||
|
|
ceee8b04ea | ||
|
|
ba7d2f1acc | ||
|
|
7e3c8630a4 | ||
|
|
d5b621e174 | ||
|
|
24c4a5a823 | ||
|
|
ea61ded4af | ||
|
|
3a623d48bb | ||
|
|
3fcb06396a | ||
|
|
17eeac75cd | ||
|
|
2dad764ea7 | ||
|
|
c3fa296b99 | ||
|
|
6370b66e05 | ||
|
|
1ec27dd264 | ||
|
|
10e6d7b366 | ||
|
|
0c87e51037 | ||
|
|
7df1961172 | ||
|
|
ec74f86d53 | ||
|
|
d86dfbfcb6 | ||
|
|
e77208ec34 | ||
|
|
818707603e | ||
|
|
fa3f9adccc | ||
|
|
bf6312000e | ||
|
|
832181b9a9 | ||
|
|
bc011692cd | ||
|
|
0604a13cdd | ||
|
|
eb77607401 | ||
|
|
e305c29e6e | ||
|
|
5b1804e5e1 | ||
|
|
2c5cfe2923 | ||
|
|
35144aedfb | ||
|
|
4d7252c78f | ||
|
|
eb9a3d889f | ||
|
|
6d2988b722 | ||
|
|
a33a000c57 | ||
|
|
f0b456bb8c | ||
|
|
b489fa8e3e | ||
|
|
1a92b4694c | ||
|
|
6b9ff9a4a2 | ||
|
|
97d2182c0e | ||
|
|
80baa74175 | ||
|
|
f71e6d07e7 | ||
|
|
b8a0702e3c | ||
|
|
c48f337c4d | ||
|
|
b32d46f482 | ||
|
|
fd100e1c3b | ||
|
|
249db95c9e | ||
|
|
3c27b63908 | ||
|
|
a246de4483 | ||
|
|
2793024904 | ||
|
|
f0ef9cacec | ||
|
|
d2538e121f | ||
|
|
531666b031 | ||
|
|
e239d71a48 | ||
|
|
e2f1fe239e | ||
|
|
8495f0e2e6 | ||
|
|
19bc3c67e3 | ||
|
|
628d97d909 | ||
|
|
aa6b31317c | ||
|
|
b24ba8a4c6 | ||
|
|
b3f47f4507 | ||
|
|
6fcd0552d0 | ||
|
|
8747a84453 | ||
|
|
2eb3080050 | ||
|
|
99b64c0fac | ||
|
|
b2a68a9a67 | ||
|
|
f5bbcb81a4 | ||
|
|
db5e53f77e | ||
|
|
90e67adc41 | ||
|
|
185feb5a2c | ||
|
|
f04dfdbd37 | ||
|
|
3c27403f86 | ||
|
|
276b3c1a37 | ||
|
|
cadb3500f1 | ||
|
|
ee8f0d6bcd | ||
|
|
41d43501a0 | ||
|
|
32b8e2ee57 | ||
|
|
b1110ead87 | ||
|
|
b75b917538 | ||
|
|
24bb64841e | ||
|
|
df2452dcaf | ||
|
|
89d0e39ad2 | ||
|
|
e3977a6042 | ||
|
|
4cdc534c01 | ||
|
|
1a93acdce7 | ||
|
|
9c8f7907b8 | ||
|
|
8d9862ffef | ||
|
|
a5e4efcf86 | ||
|
|
e4bab72952 | ||
|
|
f08b807a0c | ||
|
|
b521736102 | ||
|
|
4d13628d0b | ||
|
|
2b6000f5d4 | ||
|
|
894cb45779 | ||
|
|
2a1b17f8c6 | ||
|
|
47ca3cb13c | ||
|
|
5fb059d2e9 | ||
|
|
bd7feb94e7 | ||
|
|
de7b624902 | ||
|
|
7e1057482e | ||
|
|
7aa4bf7efe | ||
|
|
6e99bf7d03 | ||
|
|
669d473a64 | ||
|
|
ba1404b808 | ||
|
|
98314c5106 | ||
|
|
bac5a911c1 | ||
|
|
67155db9ab | ||
|
|
b42a4d3608 | ||
|
|
e29be460c3 | ||
|
|
bcfbc8e00e | ||
|
|
95bcf3b25a | ||
|
|
3fff794d9b | ||
|
|
de6023547f | ||
|
|
0effc563dc | ||
|
|
37ac70f720 | ||
|
|
222f697873 | ||
|
|
681554af58 | ||
|
|
87bce9d4cc | ||
|
|
248774ed13 | ||
|
|
6363e84402 | ||
|
|
8106eb86a8 | ||
|
|
7feae25d45 | ||
|
|
cc08055e0a | ||
|
|
c789b49bef | ||
|
|
bccc0ceed4 | ||
|
|
b924ec9b95 | ||
|
|
c56a8c0e7f | ||
|
|
c5bcbecf1f | ||
|
|
5ef4f2d642 | ||
|
|
f52ce5c618 | ||
|
|
d83fef67f0 | ||
|
|
8a91d25395 | ||
|
|
77f76a33a4 | ||
|
|
d8aa615ea3 | ||
|
|
82dafe9334 | ||
|
|
aff56af6aa | ||
|
|
4e2fa0d374 | ||
|
|
465a80d3e1 | ||
|
|
ce12e3cd6f | ||
|
|
e67eb5e85e | ||
|
|
c11184b24a | ||
|
|
ee42731ecc | ||
|
|
3c57b0ff1a | ||
|
|
bd0576c593 | ||
|
|
57f0378bab | ||
|
|
1ff342ae60 | ||
|
|
da55318979 | ||
|
|
384cffec48 | ||
|
|
c0ce5064d8 | ||
|
|
e9c869075a | ||
|
|
f881c13b62 | ||
|
|
92c7b003d9 | ||
|
|
cafdc84260 | ||
|
|
d87c80ece4 | ||
|
|
a67e0bd221 | ||
|
|
ed8a51aa8f | ||
|
|
fb763f5338 | ||
|
|
a18a19166b | ||
|
|
1efdbfb978 | ||
|
|
233497cb5c | ||
|
|
6ff3a1929a | ||
|
|
42b463e07a | ||
|
|
a6b92d5248 | ||
|
|
8c574882e5 | ||
|
|
bb0a5c5c98 | ||
|
|
cd1246d43c | ||
|
|
92c8dbd1bf | ||
|
|
4672fcb5c0 | ||
|
|
d962cd9eae | ||
|
|
88a8035c45 | ||
|
|
035bd7f630 | ||
|
|
9ce890576b | ||
|
|
f569750666 | ||
|
|
9cfa8a7ca2 | ||
|
|
918b204d3d | ||
|
|
177e593e90 | ||
|
|
959e4975e3 | ||
|
|
a2f1476b98 | ||
|
|
9d5c90f5c0 | ||
|
|
e91a7ae7f6 | ||
|
|
47e38ba6b0 | ||
|
|
cbb88b34b8 | ||
|
|
52b1693f43 | ||
|
|
3d784ce740 | ||
|
|
480d124a0e | ||
|
|
b15a4d143c | ||
|
|
baca6b1098 | ||
|
|
8a38dfc3c7 | ||
|
|
464d143313 | ||
|
|
990bcfc83a | ||
|
|
35c5e0f2c3 | ||
|
|
1e2ed97630 | ||
|
|
49b8e1d817 | ||
|
|
a6140fe277 | ||
|
|
4474e635fd | ||
|
|
375e370a9e | ||
|
|
25b01f9501 | ||
|
|
c44f5887ae | ||
|
|
9523bbdb54 | ||
|
|
347a8aa487 | ||
|
|
c33e89556d | ||
|
|
be420adace | ||
|
|
b7d1bb18cf | ||
|
|
b05992e2b1 | ||
|
|
0ebb5033fa | ||
|
|
31c30133bd | ||
|
|
ba9a720c4f | ||
|
|
d6d1b87d0c | ||
|
|
58692882f2 | ||
|
|
a312a6982f | ||
|
|
5ef59a8716 | ||
|
|
4664f3360e | ||
|
|
1c2f0ee193 | ||
|
|
fa5bc3fac2 | ||
|
|
ecc6734881 | ||
|
|
f2eaa5c842 | ||
|
|
81990214af | ||
|
|
f9fadf2763 | ||
|
|
aaf9c9a330 | ||
|
|
68a7518b51 | ||
|
|
e3f159a3f4 | ||
|
|
2bac6a117f | ||
|
|
d6b7343dac | ||
|
|
9c81caca46 | ||
|
|
60704dc37b | ||
|
|
9da7cf5182 | ||
|
|
17e9cb1a8e | ||
|
|
5b6bc64cd6 | ||
|
|
807a4904a2 | ||
|
|
ee663df4fe | ||
|
|
9184fadde0 | ||
|
|
7534b8f20c | ||
|
|
d6feccb91f | ||
|
|
8b48babcff | ||
|
|
53e9c60bb1 | ||
|
|
72d21f6482 | ||
|
|
021597566d | ||
|
|
15b2042081 | ||
|
|
9a185e5003 | ||
|
|
45237251c6 | ||
|
|
99b4098f48 | ||
|
|
12e2859946 | ||
|
|
58881640a5 | ||
|
|
1a6d6b3446 | ||
|
|
561ae2d656 | ||
|
|
edba920623 | ||
|
|
292f105fae | ||
|
|
a9166bb50b | ||
|
|
813ad22595 | ||
|
|
e95e85ceb1 | ||
|
|
19f35728e7 | ||
|
|
c5ede71439 | ||
|
|
0b2c7daac6 | ||
|
|
a6b8602fff | ||
|
|
57b30d2328 | ||
|
|
2fe473ebbd | ||
|
|
b5f69fa58c | ||
|
|
af2932c918 | ||
|
|
f047f8e3c9 | ||
|
|
d2cb395f65 | ||
|
|
1d23c1ac05 | ||
|
|
d79b677119 | ||
|
|
6e038a5310 | ||
|
|
a2ed06dcf6 | ||
|
|
ae3657d19c | ||
|
|
2b8e1a5967 | ||
|
|
0fd56da5d6 | ||
|
|
7ebaf464cb | ||
|
|
6e14996301 | ||
|
|
6ac505c8c9 | ||
|
|
f7c4e16be9 | ||
|
|
62c8d0f7a8 | ||
|
|
b5eb7e4098 | ||
|
|
08ce58b8e5 | ||
|
|
a8a72b54d2 | ||
|
|
a24a9735c4 | ||
|
|
db87806ffc | ||
|
|
75f7e4fa4f | ||
|
|
1d0e1284ed | ||
|
|
49704bc76d | ||
|
|
49ebc3e71e | ||
|
|
0f4484b28a | ||
|
|
a98b4c6aad | ||
|
|
873fb4d434 | ||
|
|
8a08fb67a3 | ||
|
|
5d09e32b08 | ||
|
|
7736d20b9f | ||
|
|
9bcc1776cc | ||
|
|
576ce1f294 | ||
|
|
9a5b37f602 | ||
|
|
8b44ebbc22 | ||
|
|
3ea175eec5 | ||
|
|
2e2224865c | ||
|
|
7e586eccb0 | ||
|
|
a7abcc21b3 | ||
|
|
d65eba8096 | ||
|
|
45a0098f5c | ||
|
|
99535cc0da | ||
|
|
31d8bfb843 | ||
|
|
e7e4dccd3f | ||
|
|
9a8b2d930a | ||
|
|
bacd4e17a2 | ||
|
|
8276e4225d | ||
|
|
6a63768892 | ||
|
|
49a4ba2491 | ||
|
|
e607859c5b | ||
|
|
b4201487ec | ||
|
|
4c031bdb8b | ||
|
|
45b48e654c | ||
|
|
f90bb1f701 | ||
|
|
8a7fb09a46 | ||
|
|
8911aad930 | ||
|
|
56ed81ccb0 | ||
|
|
d127ece50d | ||
|
|
be4e3c72ae | ||
|
|
7219c0799f | ||
|
|
5d73655875 | ||
|
|
8cbf30358d | ||
|
|
ab87c046c2 | ||
|
|
fe55f9253d | ||
|
|
5d833b4203 | ||
|
|
1a87036087 | ||
|
|
2a9a3dd2f6 | ||
|
|
323c8e3cc2 | ||
|
|
ab3221dc13 | ||
|
|
4612fe43e0 | ||
|
|
f0eb9e7f22 | ||
|
|
7352d056bc | ||
|
|
ed26463231 | ||
|
|
cdd33cf41f | ||
|
|
c53b919151 | ||
|
|
71e9ad364f | ||
|
|
de5154e5f1 | ||
|
|
c4f6de9f49 | ||
|
|
8b708f2c81 | ||
|
|
32fd3ca656 | ||
|
|
060a9cf0fb | ||
|
|
209821ffdd | ||
|
|
8d19731e13 | ||
|
|
1fba2a042e | ||
|
|
79fe2d9e6d | ||
|
|
2268350cca | ||
|
|
e288ccc905 | ||
|
|
ec01b46cbd | ||
|
|
6eb01ac598 | ||
|
|
ce5f48bfbf | ||
|
|
8454ce7be5 | ||
|
|
d40c1b28d7 | ||
|
|
e51bafd4b7 | ||
|
|
beaf946f7d | ||
|
|
76a0076909 | ||
|
|
0794d8f8ac | ||
|
|
d17739179a | ||
|
|
c67c4a267e | ||
|
|
8bc67774eb | ||
|
|
53cba9f88d | ||
|
|
381a82475e | ||
|
|
437a35420c | ||
|
|
75929dd420 | ||
|
|
30dba89c89 | ||
|
|
ba874013fd | ||
|
|
13da213168 | ||
|
|
1bfeb78868 | ||
|
|
45e6c33de5 | ||
|
|
57b42d233d | ||
|
|
65ec00ae33 | ||
|
|
058276c6f4 | ||
|
|
747265b5a6 | ||
|
|
995b20d084 | ||
|
|
6b22197161 | ||
|
|
40fa39ca67 | ||
|
|
e2bc3ef4c0 | ||
|
|
ad41fbb9cd | ||
|
|
c0426c84c8 | ||
|
|
5f624d111a | ||
|
|
472882eb21 | ||
|
|
066bef8eba | ||
|
|
1c064731fc | ||
|
|
889d06b717 | ||
|
|
19b53a6f3c | ||
|
|
4c7ed07fee | ||
|
|
211c7e5d41 | ||
|
|
bf549d5605 | ||
|
|
3eef1e76d4 | ||
|
|
a5e465d0f9 | ||
|
|
afaa22966d | ||
|
|
7a8b8bc618 | ||
|
|
5aac07f7e2 | ||
|
|
53cf2bc4d6 | ||
|
|
42b543992c | ||
|
|
36dc6d4416 | ||
|
|
55d9e733ea | ||
|
|
61d279ba9e | ||
|
|
0f3b6801d1 | ||
|
|
1e1c177a54 | ||
|
|
f1398133ee | ||
|
|
64de189db7 | ||
|
|
504bfa4907 | ||
|
|
da0a113eec | ||
|
|
866d6fea75 | ||
|
|
3c0bed651a | ||
|
|
c5a76ed03c | ||
|
|
59b697fd16 | ||
|
|
8c89b5d7fe | ||
|
|
9f9cf7cec1 | ||
|
|
d10fd6bb25 | ||
|
|
e02e61ae01 | ||
|
|
9ec46676f0 | ||
|
|
e9ad3ff5cc | ||
|
|
4c7838522d | ||
|
|
d6198e981b | ||
|
|
c446d4c321 | ||
|
|
64907b83c6 | ||
|
|
f0434b32aa | ||
|
|
a485ca49c7 | ||
|
|
57a13bb4fe | ||
|
|
1b3e1d2f67 | ||
|
|
a0e888dffb | ||
|
|
7c19817d6e | ||
|
|
d217607407 | ||
|
|
c180b6851a | ||
|
|
74db63f1c8 | ||
|
|
d793093249 | ||
|
|
7dc8b317af | ||
|
|
db5d5c98d5 | ||
|
|
bd58557a71 | ||
|
|
800a0df61b | ||
|
|
5b91b25713 | ||
|
|
964daea45e | ||
|
|
9cfd956940 | ||
|
|
7140bf052d | ||
|
|
ca8637b155 | ||
|
|
4511951e81 | ||
|
|
21a624b633 | ||
|
|
c8e87f0ab9 | ||
|
|
9b26969d2f | ||
|
|
4447a8a873 | ||
|
|
d54c956082 | ||
|
|
e5612d933e | ||
|
|
93aa709217 | ||
|
|
ffa93ea511 | ||
|
|
e608d1f412 | ||
|
|
785720b3dd | ||
|
|
36f4f5048a | ||
|
|
e174c782cb | ||
|
|
2720757620 | ||
|
|
23ca40c19a | ||
|
|
6527db10a2 | ||
|
|
67b8d1830c | ||
|
|
9b44f5d564 | ||
|
|
0d4dba2161 | ||
|
|
0053d3b985 | ||
|
|
0e7865c667 | ||
|
|
23c6a5d0d2 | ||
|
|
0fbebfbfaa | ||
|
|
e3a88db08a | ||
|
|
81f7b163e4 | ||
|
|
ff9b98e14e | ||
|
|
1cccfd70cd | ||
|
|
b87ebe59a6 | ||
|
|
369792f738 | ||
|
|
8533984508 | ||
|
|
731a2e5188 | ||
|
|
66b9518613 | ||
|
|
5ebcf41d47 | ||
|
|
197a1b2428 | ||
|
|
001f2e728e | ||
|
|
624271ffab | ||
|
|
e4c0fe6b36 | ||
|
|
362f6c67a5 | ||
|
|
649d1ce2e0 | ||
|
|
5268168abb | ||
|
|
cae6fced35 | ||
|
|
efd79e9a3d | ||
|
|
f476897d5e | ||
|
|
0fcda11cf1 | ||
|
|
20ce35bb44 | ||
|
|
7f1f4d859b | ||
|
|
434395d602 | ||
|
|
17613b5c4e | ||
|
|
486c0faa6f | ||
|
|
b4cd95f0b4 | ||
|
|
158c5a0089 | ||
|
|
d88b16357b | ||
|
|
8b91db3466 | ||
|
|
163a5606b6 | ||
|
|
1230cc4e77 | ||
|
|
53c881278e | ||
|
|
e54d7552d9 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.7
|
||||
AVERAGE_THRESHOLD=9.36
|
||||
HOTSPOT_THRESHOLD=9.68
|
||||
AVERAGE_THRESHOLD=9.3
|
||||
|
||||
15
.github/workflows/release-canary.yml
vendored
@@ -172,9 +172,7 @@ jobs:
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
TAG="${{ needs.version.outputs.tag }}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
REPO="refactoringhq/laputa-app"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
@@ -182,7 +180,7 @@ jobs:
|
||||
cat > latest-canary.json << EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"notes": "Canary build. See ${PAGES_URL} for release notes.",
|
||||
"notes": "Canary build. See https://refactoringhq.github.io/laputa-app/ for release notes.",
|
||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
@@ -198,7 +196,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: Tolaria ${{ needs.version.outputs.version }} (Canary)
|
||||
name: Laputa ${{ needs.version.outputs.version }} (Canary)
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: true
|
||||
@@ -229,9 +227,8 @@ jobs:
|
||||
run: |
|
||||
mkdir -p _site
|
||||
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
# Download stable latest.json from existing GH Pages (preserve it)
|
||||
curl -fsSL "${PAGES_URL}/latest.json" -o _site/latest.json || echo '{}' > _site/latest.json
|
||||
curl -fsSL "https://refactoringhq.github.io/laputa-app/latest.json" -o _site/latest.json || echo '{}' > _site/latest.json
|
||||
# Copy canary latest.json from this release
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "latest-canary.json" --output _site/latest-canary.json || true
|
||||
cat > _site/index.html << 'HTMLEOF'
|
||||
@@ -240,7 +237,7 @@ jobs:
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tolaria — Release History</title>
|
||||
<title>Laputa — Release History</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #F7F6F3; color: #37352F; line-height: 1.6; padding: 2rem; max-width: 720px; margin: 0 auto; }
|
||||
@@ -258,7 +255,7 @@ jobs:
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Tolaria Release History</h1>
|
||||
<h1>Laputa Release History</h1>
|
||||
<p class="subtitle">Auto-updated on every release</p>
|
||||
<div id="releases"></div>
|
||||
<script>
|
||||
|
||||
15
.github/workflows/release.yml
vendored
@@ -173,9 +173,7 @@ jobs:
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
TAG="${{ needs.version.outputs.tag }}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
REPO="refactoringhq/laputa-app"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
@@ -183,7 +181,7 @@ jobs:
|
||||
cat > latest.json << EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"notes": "See ${PAGES_URL} for full release notes.",
|
||||
"notes": "See https://refactoringhq.github.io/laputa-app/ for full release notes.",
|
||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
@@ -199,7 +197,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: Tolaria ${{ needs.version.outputs.version }}
|
||||
name: Laputa ${{ needs.version.outputs.version }}
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -230,18 +228,17 @@ jobs:
|
||||
run: |
|
||||
mkdir -p _site
|
||||
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
# Copy latest.json to GitHub Pages for auto-updater endpoint
|
||||
gh release download --repo ${{ github.repository }} --pattern "latest.json" --output _site/latest.json || true
|
||||
# Preserve canary latest.json from existing GH Pages
|
||||
curl -fsSL "${PAGES_URL}/latest-canary.json" -o _site/latest-canary.json || true
|
||||
curl -fsSL "https://refactoringhq.github.io/laputa-app/latest-canary.json" -o _site/latest-canary.json || true
|
||||
cat > _site/index.html << 'HTMLEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tolaria — Release History</title>
|
||||
<title>Laputa — Release History</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #F7F6F3; color: #37352F; line-height: 1.6; padding: 2rem; max-width: 720px; margin: 0 auto; }
|
||||
@@ -258,7 +255,7 @@ jobs:
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Tolaria Release History</h1>
|
||||
<h1>Laputa Release History</h1>
|
||||
<p class="subtitle">Auto-updated on every release</p>
|
||||
<div id="releases"></div>
|
||||
<script>
|
||||
|
||||
19
AGENTS.md
@@ -1,4 +1,4 @@
|
||||
# AGENTS.md — Tolaria App
|
||||
# AGENTS.md — Laputa App
|
||||
|
||||
> Quick links: [Project Spec](docs/PROJECT-SPEC.md) · [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
|
||||
|
||||
@@ -41,8 +41,8 @@ BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
```bash
|
||||
pnpm tauri dev &
|
||||
sleep 10
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
```
|
||||
|
||||
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
|
||||
@@ -107,10 +107,7 @@ After any Tauri command, new component/hook, data model change, or new integrati
|
||||
|
||||
### User vault (`~/Laputa/`)
|
||||
|
||||
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
|
||||
- **Never commit or push** any test notes to the remote vault
|
||||
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
|
||||
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
|
||||
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never commit changes** — always run `cd ~/Laputa && git checkout -- . && git clean -fd` when done.
|
||||
|
||||
### UI design
|
||||
|
||||
@@ -133,7 +130,7 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
|
||||
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
|
||||
| Dialog/modal | `Dialog` from shadcn/ui |
|
||||
|
||||
**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Tolaria — if it looks like a browser default, it's wrong.
|
||||
**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Laputa — if it looks like a browser default, it's wrong.
|
||||
|
||||
---
|
||||
|
||||
@@ -148,9 +145,9 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
|
||||
### QA scripts
|
||||
|
||||
```bash
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/out.png
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
|
||||
```
|
||||
|
||||
### Diagrams
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Tolaria App
|
||||
# Laputa App
|
||||
|
||||
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Abstractions
|
||||
|
||||
Key abstractions and domain models in Tolaria.
|
||||
Key abstractions and domain models in Laputa.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
Tolaria's abstractions follow the **convention over configuration** principle: standard field names and folder structures have well-defined meanings and trigger UI behavior automatically. This makes vaults legible both to humans and to AI agents — the more a vault follows conventions, the less custom configuration an AI needs to navigate it correctly.
|
||||
Laputa's abstractions follow the **convention over configuration** principle: standard field names and folder structures have well-defined meanings and trigger UI behavior automatically. This makes vaults legible both to humans and to AI agents — the more a vault follows conventions, the less custom configuration an AI needs to navigate it correctly.
|
||||
|
||||
The full set of design principles is documented in [ARCHITECTURE.md](./ARCHITECTURE.md#design-principles).
|
||||
|
||||
## Semantic Field Names (conventions)
|
||||
|
||||
These frontmatter field names have special meaning in Tolaria's UI:
|
||||
These frontmatter field names have special meaning in Laputa's UI:
|
||||
|
||||
| Field | Meaning | UI behavior |
|
||||
|---|---|---|
|
||||
| `title:` | Legacy display-title fallback for older notes | Used only when a note has no H1; new notes do not write it automatically |
|
||||
| `title:` | Human-readable title (synced with filename) | Breadcrumb, sidebar. Filename = `slugify(title).md` |
|
||||
| `type:` | Entity type (Project, Person, Quarter…) | Type chip in note list + sidebar grouping |
|
||||
| `status:` | Lifecycle stage (active, done, blocked…) | Colored chip in note list + editor header |
|
||||
| `icon:` | Per-note icon (emoji, Phosphor name, or HTTP/HTTPS image URL) | Rendered on note title surfaces; editable from the Properties panel |
|
||||
@@ -35,7 +35,7 @@ Any frontmatter field whose name starts with `_` is a **system property**:
|
||||
- It is **not shown** in the Properties panel (neither for notes nor for Type notes)
|
||||
- It is **not exposed** as a user-visible property in search, filters, or the UI
|
||||
- It **is editable** directly in the raw editor (power users can access it if needed)
|
||||
- It is used by Tolaria internally for configuration, behavior, and UI preferences
|
||||
- It is used by Laputa internally for configuration, behavior, and UI preferences
|
||||
|
||||
Examples:
|
||||
```yaml
|
||||
@@ -233,21 +233,20 @@ All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex an
|
||||
|
||||
### Title / Filename Sync
|
||||
|
||||
Tolaria separates **display title** from the file identifier:
|
||||
Laputa separates **display title** from the file identifier:
|
||||
|
||||
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
|
||||
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
|
||||
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions update the filename and wikilinks across the vault. The editor body remains the title editing surface.
|
||||
- **On rename / explicit title edits** (`rename_note`): Laputa updates both filename and `title` frontmatter atomically, plus wikilinks across the vault.
|
||||
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
|
||||
|
||||
### Title Surface (UI)
|
||||
### Title Field (UI)
|
||||
|
||||
The BlockNote body is the only title editing surface:
|
||||
The dedicated `TitleField` is a fallback editing surface, not the canonical one:
|
||||
|
||||
- The first H1 is the canonical display title.
|
||||
- There is no separate title row above the editor, even when a note has no H1.
|
||||
- Notes without an H1 show the editor body and placeholder only.
|
||||
- Filename changes are explicit breadcrumb actions, not a dedicated title-input side effect.
|
||||
- If the note already has an H1, the editor body is the primary title surface and the dedicated title row is hidden.
|
||||
- If the note has no H1 and is not an untitled draft, `TitleField` appears above the editor and `onTitleSync` updates `title:` frontmatter plus the filename.
|
||||
- `TitleField` also responds to `laputa:focus-editor` events with `selectTitle: true` for new-note flows that start without an H1.
|
||||
|
||||
### Sidebar Selection
|
||||
|
||||
@@ -495,7 +494,7 @@ No indexing step required — search runs directly against the filesystem.
|
||||
### Vault Switching
|
||||
|
||||
`useVaultSwitcher` hook manages multiple vaults:
|
||||
- Persists vault list to `~/.config/com.tolaria.app/vaults.json` (reads legacy `com.laputa.app` on upgrade)
|
||||
- Persists vault list to `~/.config/com.laputa.app/vaults.json`
|
||||
- Switching closes all tabs and resets sidebar
|
||||
- Supports adding, removing, hiding/restoring vaults
|
||||
- Default vault: Getting Started demo vault
|
||||
@@ -504,7 +503,7 @@ No indexing step required — search runs directly against the filesystem.
|
||||
|
||||
Per-vault settings stored locally and scoped by vault path:
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides, explicit organization workflow toggle
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
### Getting Started / Onboarding
|
||||
@@ -512,28 +511,27 @@ Per-vault settings stored locally and scoped by vault path:
|
||||
`useOnboarding` hook detects first launch:
|
||||
- If vault path doesn't exist → show `WelcomeScreen`
|
||||
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen folder
|
||||
- Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback)
|
||||
- Welcome state tracked in localStorage (`laputa_welcome_dismissed`)
|
||||
|
||||
### Remote Git Operations
|
||||
### GitHub Integration
|
||||
|
||||
Tolaria delegates remote auth to the user's system git setup:
|
||||
- `CloneVaultModal` captures a remote URL and local destination
|
||||
- `clone_repo` shells out to system git for clone operations
|
||||
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors
|
||||
- No provider-specific token or username is stored in app settings
|
||||
Device Authorization Flow for GitHub-backed vaults:
|
||||
- `GitHubDeviceFlow` component handles OAuth
|
||||
- `GitHubVaultModal` for cloning existing repos or creating new ones
|
||||
- Token persisted in app settings for future git operations
|
||||
- `SettingsPanel` shows connection status with disconnect option
|
||||
|
||||
## Settings
|
||||
|
||||
App-level settings persisted at `~/.config/com.tolaria.app/settings.json` (reads legacy `com.laputa.app` on upgrade):
|
||||
App-level settings persisted at `~/.config/com.laputa.app/settings.json`:
|
||||
|
||||
```typescript
|
||||
interface Settings {
|
||||
openai_key: string | null
|
||||
google_key: string | null
|
||||
github_token: string | null
|
||||
github_username: string | null
|
||||
auto_pull_interval_minutes: number | null
|
||||
telemetry_consent: boolean | null
|
||||
crash_reporting_enabled: boolean | null
|
||||
analytics_enabled: boolean | null
|
||||
anonymous_id: string | null
|
||||
release_channel: string | null
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
Tolaria is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.
|
||||
Laputa is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.
|
||||
|
||||
## Design Principles
|
||||
|
||||
@@ -10,23 +10,23 @@ The vault is a folder of plain markdown files. The app never owns the data — i
|
||||
|
||||
### Convention over configuration
|
||||
|
||||
Tolaria is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `Belongs to:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them.
|
||||
Laputa is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `Belongs to:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them.
|
||||
|
||||
This principle directly serves AI-readability: the more structure comes from shared conventions rather than per-user custom configurations, the easier it is for an AI agent to understand and navigate the vault correctly — without needing bespoke instructions for every setup.
|
||||
|
||||
### Where to store state: vault vs. app settings
|
||||
|
||||
When deciding where to persist a piece of data, ask: **"Would the user want this to follow them across all their Tolaria installations — other devices, future platforms (tablet, web)?"**
|
||||
When deciding where to persist a piece of data, ask: **"Would the user want this to follow them across all their Laputa installations — other devices, future platforms (tablet, web)?"**
|
||||
|
||||
| Follows the vault | Stays with the installation |
|
||||
|-------------------|-----------------------------|
|
||||
| Type icon, type color | Editor zoom level |
|
||||
| Pinned properties per type | API keys (OpenAI, Google) |
|
||||
| Sidebar label overrides | Auto-sync interval |
|
||||
| Sidebar label overrides | GitHub token |
|
||||
| Property display order | Window size / position |
|
||||
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
|
||||
|
||||
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage.
|
||||
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.laputa.app/settings.json` or localStorage.
|
||||
|
||||
Examples:
|
||||
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
|
||||
@@ -124,10 +124,11 @@ flowchart TD
|
||||
end
|
||||
|
||||
subgraph RB["Rust Backend"]
|
||||
LIB["lib.rs → Tauri commands"]
|
||||
LIB["lib.rs → 64 Tauri commands"]
|
||||
VAULT["vault/"]
|
||||
FM["frontmatter/"]
|
||||
GIT["git/\n(commit, sync, clone)"]
|
||||
GIT["git/"]
|
||||
GH["github/"]
|
||||
SETTINGS["settings.rs"]
|
||||
SEARCH["search.rs"]
|
||||
CLI["claude_cli.rs"]
|
||||
@@ -136,15 +137,11 @@ flowchart TD
|
||||
subgraph EXT["External Services"]
|
||||
CCLI["Claude CLI\n(agent subprocess)"]
|
||||
MCP["MCP Server\n(ws://9710, 9711)"]
|
||||
GCLI["git CLI\n(system executable)"]
|
||||
REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"]
|
||||
GHAPI["GitHub API\n(OAuth, repos, clone)"]
|
||||
end
|
||||
|
||||
FE -->|"Tauri IPC"| RB
|
||||
CLI -->|"spawn subprocess"| CCLI
|
||||
LIB -->|"register / monitor"| MCP
|
||||
GIT -->|"clone / fetch / push / pull"| GCLI
|
||||
GCLI -->|"network auth via user config"| REMOTE
|
||||
FE -->|"Vite Proxy / WS"| EXT
|
||||
end
|
||||
|
||||
style FE fill:#e8f4fd,stroke:#2196f3,color:#000
|
||||
@@ -268,7 +265,7 @@ Token budget: 60% of 180k context limit (~108k tokens max). Active note gets pri
|
||||
|
||||
### Authentication
|
||||
|
||||
Claude CLI (agent mode) uses its own authentication — no API key configuration needed in Tolaria.
|
||||
Claude CLI (agent mode) uses its own authentication — no API key configuration needed in Laputa.
|
||||
|
||||
## MCP Server
|
||||
|
||||
@@ -288,7 +285,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
|
||||
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
|
||||
| `vault_context` | — | Get vault summary: entity types + 20 recent notes + configFiles |
|
||||
| `ui_open_note` | `path` | Open a note in the Tolaria UI editor |
|
||||
| `ui_open_note` | `path` | Open a note in the Laputa UI editor |
|
||||
| `ui_open_tab` | `path` | Open a note in a new UI tab |
|
||||
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
|
||||
| `ui_set_filter` | `type` | Set the sidebar filter to a specific type |
|
||||
@@ -296,13 +293,13 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
### Transports
|
||||
|
||||
- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`)
|
||||
- **WebSocket** — live bridge for Tolaria app integration:
|
||||
- **WebSocket** — live bridge for Laputa app integration:
|
||||
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
|
||||
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
|
||||
|
||||
### Auto-Registration
|
||||
|
||||
On app startup, Tolaria automatically registers itself as an MCP server in:
|
||||
On app startup, Laputa automatically registers itself as an MCP server in:
|
||||
- `~/.claude/mcp.json` (Claude Code)
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
|
||||
@@ -352,7 +349,7 @@ flowchart LR
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
|
||||
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code and Cursor MCP configs |
|
||||
| `register_mcp(vault_path)` | Writes Laputa entry to Claude Code and Cursor MCP configs |
|
||||
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
|
||||
@@ -403,7 +400,7 @@ The app uses a single light theme with no user-configurable theming (see [ADR-00
|
||||
|
||||
### Vault List
|
||||
|
||||
Persisted at `~/.config/com.tolaria.app/vaults.json` (reads legacy `com.laputa.app` on upgrade):
|
||||
Persisted at `~/.config/com.laputa.app/vaults.json`:
|
||||
```json
|
||||
{
|
||||
"vaults": [{ "label": "My Vault", "path": "/path/to/vault" }],
|
||||
@@ -423,7 +420,6 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
- `tag_colors`, `status_colors`: Custom color overrides
|
||||
- `property_display_modes`: Property display preferences
|
||||
- `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list
|
||||
- `inbox.explicitOrganization`: When `false`, hide Inbox and the organized toggle so the vault behaves like a plain note collection
|
||||
|
||||
### Getting Started Vault
|
||||
|
||||
@@ -432,23 +428,23 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
|
||||
- **Open an existing folder** → system file picker
|
||||
- **Get started with a template** → pick a folder, then call `create_getting_started_vault()` to clone the public starter repo at runtime
|
||||
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public starter repo URL and delegates the actual clone to the git backend.
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public GitHub URL and delegates the actual clone to the existing git backend.
|
||||
|
||||
### Remote Clone & Auth Model
|
||||
### GitHub OAuth Integration
|
||||
|
||||
Tolaria no longer implements provider-specific OAuth or remote-repository APIs. All remote git work goes through the user's existing system git configuration.
|
||||
Implements GitHub Device Authorization Flow for cloning/creating GitHub-backed vaults.
|
||||
|
||||
**Flow:**
|
||||
1. User opens `CloneVaultModal` from onboarding or the vault menu
|
||||
2. User pastes any git URL and chooses a local destination
|
||||
3. `clone_repo()` shells out to `git clone`
|
||||
4. `git_push()` / `git_pull()` continue to use the same system git path
|
||||
5. If auth fails, the raw git stderr is surfaced in the UI
|
||||
1. User clicks "Login with GitHub" in Settings panel
|
||||
2. `github_device_flow_start()` returns a user code + verification URL
|
||||
3. User authorizes at `github.com/login/device`
|
||||
4. App polls `github_device_flow_poll()` until authorized
|
||||
5. Token stored in `~/.config/com.laputa.app/settings.json`
|
||||
|
||||
**Auth model:**
|
||||
- SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup
|
||||
- No provider tokens are stored in Tolaria settings
|
||||
- The same flow works for GitHub, GitLab, Bitbucket, Gitea, and self-hosted remotes
|
||||
**Vault operations:**
|
||||
- `GitHubVaultModal`: Clone existing repo or create new private/public repo
|
||||
- `clone_repo()`: Clones with token-injected HTTPS URL
|
||||
- Token persists for future git push/pull operations
|
||||
|
||||
## Pulse View
|
||||
|
||||
@@ -568,7 +564,8 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|--------|---------|
|
||||
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
|
||||
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) |
|
||||
| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) |
|
||||
| `search.rs` | Keyword search — walkdir-based vault file scan |
|
||||
| `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing |
|
||||
| `mcp.rs` | MCP server spawning + config registration |
|
||||
@@ -578,7 +575,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `vault_list.rs` | Vault list persistence |
|
||||
| `menu.rs` | Native macOS menu bar |
|
||||
|
||||
## Tauri IPC Commands
|
||||
## Tauri IPC Commands (65 total)
|
||||
|
||||
### Vault Operations
|
||||
|
||||
@@ -622,7 +619,17 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `get_conflict_mode` | Get conflict resolution mode |
|
||||
| `get_vault_pulse` | Git activity feed (paginated) |
|
||||
| `get_last_commit_info` | Latest commit metadata |
|
||||
| `clone_repo` | Clone a remote repository into a local folder using system git |
|
||||
|
||||
### GitHub
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `github_device_flow_start` | Begin OAuth device flow |
|
||||
| `github_device_flow_poll` | Poll for authorization |
|
||||
| `github_get_user` | Get authenticated user info |
|
||||
| `github_list_repos` | List user's repos |
|
||||
| `github_create_repo` | Create new repo |
|
||||
| `clone_repo` | Clone repo with token auth |
|
||||
|
||||
### Search
|
||||
|
||||
@@ -663,7 +670,6 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `save_image` | Save base64 image to vault |
|
||||
| `copy_image_to_vault` | Copy image file to vault |
|
||||
| `update_menu_state` | Update native menu checkmarks and enabled/disabled state for selection-dependent actions |
|
||||
| `trigger_menu_command` | Emit a native menu command ID for deterministic shortcut QA |
|
||||
|
||||
## Mock Layer
|
||||
|
||||
@@ -697,9 +703,8 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
|
||||
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `useSettings` | App settings (telemetry, release channel, auto-sync interval) | Persistent settings |
|
||||
| `useSettings` | App settings (API keys, GitHub token) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
|
||||
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
|
||||
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
|
||||
|
||||
@@ -719,18 +724,6 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
|
||||
Selection-dependent note actions are wired through both the command palette and the native Note menu. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled.
|
||||
|
||||
Shortcut routing is explicit:
|
||||
|
||||
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
|
||||
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
|
||||
- macOS browser-reserved chords such as `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
|
||||
- `menu.rs` and `useMenuEvents` emit the same command IDs for native menu clicks and accelerators
|
||||
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
|
||||
- Deterministic QA uses two explicit proof paths from the shared manifest:
|
||||
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
|
||||
- native menu-command proof through `trigger_menu_command`
|
||||
- The browser harness is only a deterministic desktop command bridge; exact native accelerator delivery still requires real Tauri QA for commands flagged as manual-native-critical
|
||||
|
||||
## Auto-Release & In-App Updates
|
||||
|
||||
### Release Pipeline
|
||||
@@ -813,7 +806,7 @@ sequenceDiagram
|
||||
|
||||
### Updates
|
||||
|
||||
Tolaria uses the Tauri updater plugin for automatic updates:
|
||||
Laputa uses the Tauri updater plugin for automatic updates:
|
||||
|
||||
- Builds from `main` branch are published as GitHub Releases
|
||||
- `latest.json` is published to GitHub Pages for the updater plugin
|
||||
@@ -860,7 +853,7 @@ Desktop-only modules gated at the crate level:
|
||||
|
||||
Desktop-only features gated at the function level in `commands/`:
|
||||
- Git operations (commit, pull, push, status, history, diff, conflicts)
|
||||
- Clone-by-URL via system git (`clone_repo`)
|
||||
- GitHub operations (clone, list repos, device flow auth)
|
||||
- Claude CLI streaming (check, chat, agent)
|
||||
- MCP registration and status
|
||||
- Menu state updates
|
||||
|
||||
@@ -31,7 +31,7 @@ pnpm playwright:regression # Full Playwright regression suite
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
tolaria/
|
||||
laputa-app/
|
||||
├── src/ # React frontend
|
||||
│ ├── main.tsx # Entry point (renders <App />)
|
||||
│ ├── App.tsx # Root component — orchestrates layout + state
|
||||
@@ -63,7 +63,9 @@ tolaria/
|
||||
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
|
||||
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
|
||||
│ │ ├── WelcomeScreen.tsx # Onboarding screen
|
||||
│ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL
|
||||
│ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create
|
||||
│ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow
|
||||
│ │ ├── TitleField.tsx # Editable note title above editor
|
||||
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
|
||||
│ │ ├── CommitDialog.tsx # Git commit modal
|
||||
│ │ ├── CreateNoteDialog.tsx # New note modal
|
||||
@@ -95,8 +97,6 @@ tolaria/
|
||||
│ │ ├── useCommandRegistry.ts # Command palette registry
|
||||
│ │ ├── useAppCommands.ts # App-level commands
|
||||
│ │ ├── useAppKeyboard.ts # Keyboard shortcuts
|
||||
│ │ ├── appCommandCatalog.ts # Shortcut combos + command metadata
|
||||
│ │ ├── appCommandDispatcher.ts # Shared shortcut/menu command IDs + dispatch
|
||||
│ │ ├── useSettings.ts # App settings
|
||||
│ │ ├── useOnboarding.ts # First-launch flow
|
||||
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
|
||||
@@ -133,7 +133,7 @@ tolaria/
|
||||
│ ├── capabilities/ # Tauri v2 security capabilities
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # Entry point (calls lib::run())
|
||||
│ │ ├── lib.rs # Tauri setup + command registration
|
||||
│ │ ├── lib.rs # Tauri setup + command registration (61 commands)
|
||||
│ │ ├── commands/ # Tauri command handlers (split into modules)
|
||||
│ │ ├── vault/ # Vault module
|
||||
│ │ │ ├── mod.rs # Core types, parse_md_file, scan_vault
|
||||
@@ -146,8 +146,10 @@ tolaria/
|
||||
│ │ ├── frontmatter/ # Frontmatter module
|
||||
│ │ │ ├── mod.rs, yaml.rs, ops.rs
|
||||
│ │ ├── git/ # Git module
|
||||
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs, clone.rs
|
||||
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs
|
||||
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
|
||||
│ │ ├── github/ # GitHub module
|
||||
│ │ │ ├── mod.rs, auth.rs, api.rs, clone.rs
|
||||
│ │ ├── telemetry.rs # Sentry init + path scrubber
|
||||
│ │ ├── search.rs # Keyword search (walkdir-based)
|
||||
│ │ ├── claude_cli.rs # Claude CLI subprocess management
|
||||
@@ -209,7 +211,8 @@ tolaria/
|
||||
| `src-tauri/src/vault/mod.rs` | Vault scanning, frontmatter parsing, entity type inference, relationship extraction. |
|
||||
| `src-tauri/src/vault/cache.rs` | Git-based incremental caching — how large vaults load fast. |
|
||||
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse). |
|
||||
| `src-tauri/src/git/` | All git operations (commit, pull, push, conflicts, pulse). |
|
||||
| `src-tauri/src/github/` | GitHub OAuth device flow + repo clone/create. |
|
||||
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
|
||||
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
|
||||
|
||||
@@ -241,9 +244,9 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, auto-sync interval). |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, and the vault-level explicit organization toggle. |
|
||||
| `src/hooks/useSettings.ts` | App settings (API keys, GitHub token, sync interval). |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI including GitHub OAuth connection. |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
@@ -277,17 +280,10 @@ type SidebarSelection =
|
||||
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. The native macOS menu bar also triggers commands via `useMenuEvents`.
|
||||
|
||||
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
|
||||
|
||||
For automated shortcut QA, use the explicit proof path from `appCommandCatalog.ts`:
|
||||
|
||||
- `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage
|
||||
- `window.__laputaTest.triggerMenuCommand()` for deterministic native menu-command coverage
|
||||
|
||||
That browser harness is a deterministic desktop command bridge, not real native accelerator QA. For macOS browser-reserved chords, still perform native QA in the real Tauri app because the webview-init prevent-default layer is only active there. Do not treat flaky synthesized macOS keystrokes as proof that a shortcut works unless you also confirm the visible app behavior.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
@@ -341,7 +337,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
1. Register the command in `useAppCommands.ts` via the command registry
|
||||
2. Add a corresponding menu bar item in `menu.rs` for discoverability
|
||||
3. If it has a keyboard shortcut, register it in `appCommandCatalog.ts` with the canonical command ID, modifier rule, and deterministic QA mode, then wire the matching native menu item in `menu.rs` if it should also appear in the menu bar
|
||||
3. If it has a keyboard shortcut, register it in `useAppKeyboard.ts`
|
||||
4. If its enabled state depends on runtime selection (active note, deleted preview, Git status, etc.), thread that flag through `useMenuEvents.ts` and `update_menu_state` so the native menu enables/disables correctly
|
||||
|
||||
### Modify styling
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Tolaria — Product Vision
|
||||
# Laputa — Product Vision
|
||||
|
||||
*Written by Brian based on conversations with Luca Rossi, Feb–Mar 2026.*
|
||||
*This is a living document — update it as the vision evolves.*
|
||||
@@ -13,21 +13,21 @@ The best projects are built by people who have an unusually strong answer to "wh
|
||||
|
||||
**Luca Rossi** is a startup founder and former generalist CTO — someone who can build a product end-to-end across code, design, scope, and product. And for the last five years, full-time, he has run Refactoring: a technical newsletter with nearly 200,000 subscribers, for which he has written over 300 original articles. In word count, that's roughly two *Lord of the Rings* novels.
|
||||
|
||||
Personal knowledge management has been an obsession since university. But over the last five years it stopped being a hobby and became *table stakes* — the system that makes writing 300 articles possible. Tolaria is an attempt to bottle that system.
|
||||
Personal knowledge management has been an obsession since university. But over the last five years it stopped being a hobby and became *table stakes* — the system that makes writing 300 articles possible. Laputa is an attempt to bottle that system.
|
||||
|
||||
The credibility is real: if you wonder whether this person knows how to organize knowledge for sustained output, the output speaks for itself. The method inside Tolaria is not theorized — it's been battle-tested for years at scale.
|
||||
The credibility is real: if you wonder whether this person knows how to organize knowledge for sustained output, the output speaks for itself. The method inside Laputa is not theorized — it's been battle-tested for years at scale.
|
||||
|
||||
**The distribution is built in.** Refactoring reaches ~200,000 engineers, managers, and technical leaders — exactly the people most receptive to a tool like this. The audience already trusts the author on this topic, because they've been reading his writing about knowledge management and learning for years.
|
||||
|
||||
This is not a product looking for a market. It's a tool built by its first power user, for an audience that already knows and trusts him.
|
||||
|
||||
**Why Tolaria, in the context of Refactoring.**
|
||||
**Why Laputa, in the context of Refactoring.**
|
||||
|
||||
Refactoring is a newsletter about how software is built, how teams work, and how digital products are developed — written from Luca's experience and conversations with other tech leaders. A natural question follows: what is the author's own current experience building software with AI?
|
||||
|
||||
Tolaria answers that question directly and publicly. If it works — if it becomes a real product used by real people — it validates the author's capabilities and authority to write about these topics. Not as theory, but as demonstrated practice. Anyone can look at the GitHub repository, see 100 commits a day, and verify: this person actually does this.
|
||||
Laputa answers that question directly and publicly. If it works — if it becomes a real product used by real people — it validates the author's capabilities and authority to write about these topics. Not as theory, but as demonstrated practice. Anyone can look at the GitHub repository, see 100 commits a day, and verify: this person actually does this.
|
||||
|
||||
This is why Tolaria is **free and open source**: success becomes a reputation and acquisition channel for Refactoring. The attention and trust earned through a well-executed open source project converts — through sponsorships, paid subscriptions, and brand authority — into the business that Refactoring runs on.
|
||||
This is why Laputa is **free and open source**: success becomes a reputation and acquisition channel for Refactoring. The attention and trust earned through a well-executed open source project converts — through sponsorships, paid subscriptions, and brand authority — into the business that Refactoring runs on.
|
||||
|
||||
The strategy is coherent: build the tool you describe, make the work visible, let the product speak for the author.
|
||||
|
||||
@@ -45,7 +45,7 @@ The problem has two distinct layers:
|
||||
|
||||
2. **Methodological**: even with the right tool, most people don't know *how* to organize knowledge so it becomes useful over time — what to capture, how to connect things, how to turn raw notes into a system that works with you instead of against you.
|
||||
|
||||
Tolaria addresses both layers, together. That's what makes it different.
|
||||
Laputa addresses both layers, together. That's what makes it different.
|
||||
|
||||
---
|
||||
|
||||
@@ -53,11 +53,11 @@ Tolaria addresses both layers, together. That's what makes it different.
|
||||
|
||||
Most PKM tools give you a blank canvas and leave the rest to you. They solve the first problem (somewhere to put things) but not the second (how to organize them). The result is that sophisticated users build complex custom systems, while everyone else gives up.
|
||||
|
||||
Tolaria's position is different: **we ship the method alongside the tool.**
|
||||
Laputa's position is different: **we ship the method alongside the tool.**
|
||||
|
||||
The method is opinionated but not rigid. It tells you: here's how to think about your work, here's where different kinds of notes belong, here's how to connect them. If it fits your needs — great, start immediately. If your situation is different — customize it. The types, the relationships, the structure can all be changed. But you don't have to figure it out from scratch.
|
||||
|
||||
This combination — an opinionated method on top of a technically excellent foundation — is what makes Tolaria genuinely useful to people who are stuck, not just people who already know what they're doing.
|
||||
This combination — an opinionated method on top of a technically excellent foundation — is what makes Laputa genuinely useful to people who are stuck, not just people who already know what they're doing.
|
||||
|
||||
---
|
||||
|
||||
@@ -65,7 +65,7 @@ This combination — an opinionated method on top of a technically excellent fou
|
||||
|
||||
### The knowledge ontology
|
||||
|
||||
Tolaria organizes work around two axes:
|
||||
Laputa organizes work around two axes:
|
||||
|
||||
| | **One-time** | **Recurring** |
|
||||
|---|---|---|
|
||||
@@ -84,7 +84,7 @@ This ontology is not arbitrary. It maps cleanly to how both individuals and orga
|
||||
|
||||
### Knowledge has a purpose
|
||||
|
||||
A principle that underlies everything in Tolaria: **notes exist to get things done.** Not to be stored for some abstract future use. Not to show how organized you are. To do something.
|
||||
A principle that underlies everything in Laputa: **notes exist to get things done.** Not to be stored for some abstract future use. Not to show how organized you are. To do something.
|
||||
|
||||
This is the difference between a knowledge system that works over years and one that collapses after a few weeks. Without a real purpose, the maintenance cost of taking notes is never justified, and people stop. With a purpose — writing regularly, building things, making decisions — the system pays for itself.
|
||||
|
||||
@@ -120,7 +120,7 @@ This is convention *over* configuration — not convention *instead of* it.
|
||||
|
||||
## The foundation: architecture that earns trust
|
||||
|
||||
The method is only as good as the system it runs on. Tolaria's architecture is built around a single principle: **your knowledge is yours, permanently and unconditionally.**
|
||||
The method is only as good as the system it runs on. Laputa's architecture is built around a single principle: **your knowledge is yours, permanently and unconditionally.**
|
||||
|
||||
### Local files, version-controlled with Git
|
||||
|
||||
@@ -134,11 +134,11 @@ A vault of plain Markdown files, version-controlled with Git, is dramatically mo
|
||||
|
||||
An AI agent working on a local vault can read thousands of notes in seconds, understand their structure, write new ones, connect existing ones, and commit the changes back — all with full comprehension. Notion's AI can't do this. No SaaS-based AI can do this, because the architecture doesn't allow it.
|
||||
|
||||
More importantly: the more a vault follows Tolaria's conventions, the *less configuration an AI needs* to navigate it. Shared conventions make knowledge legible to both humans and AI without bespoke instructions for every setup. The method and the AI-native architecture reinforce each other.
|
||||
More importantly: the more a vault follows Laputa's conventions, the *less configuration an AI needs* to navigate it. Shared conventions make knowledge legible to both humans and AI without bespoke instructions for every setup. The method and the AI-native architecture reinforce each other.
|
||||
|
||||
### Open and exit-friendly
|
||||
|
||||
The trust between Tolaria and the user is earned daily, not enforced by format. If something better comes along, you take your Markdown files and leave. The exit door is always open.
|
||||
The trust between Laputa and the user is earned daily, not enforced by format. If something better comes along, you take your Markdown files and leave. The exit door is always open.
|
||||
|
||||
---
|
||||
|
||||
@@ -147,9 +147,9 @@ The trust between Tolaria and the user is earned daily, not enforced by format.
|
||||
Obsidian is the obvious comparison. The difference is philosophy:
|
||||
|
||||
- **Obsidian** is a blank canvas. Infinitely configurable via plugins and community extensions. Powerful for users who want to build their own system — and who have the time and patience to do so.
|
||||
- **Tolaria** is opinionated. It ships with a complete point of view: a knowledge framework, semantic conventions, and defaults that work immediately. No plugin hunting. No configuration required to get started.
|
||||
- **Laputa** is opinionated. It ships with a complete point of view: a knowledge framework, semantic conventions, and defaults that work immediately. No plugin hunting. No configuration required to get started.
|
||||
|
||||
Obsidian also treats Git as an afterthought — its business model is built around proprietary sync. In Tolaria, Git is a first-class citizen: the natural, obvious way to sync, collaborate, and maintain history.
|
||||
Obsidian also treats Git as an afterthought — its business model is built around proprietary sync. In Laputa, Git is a first-class citizen: the natural, obvious way to sync, collaborate, and maintain history.
|
||||
|
||||
---
|
||||
|
||||
@@ -157,7 +157,7 @@ Obsidian also treats Git as an afterthought — its business model is built arou
|
||||
|
||||
### Three stages of adoption
|
||||
|
||||
Tolaria is designed to grow through three natural stages — not pivots, but extensions of the same foundation:
|
||||
Laputa is designed to grow through three natural stages — not pivots, but extensions of the same foundation:
|
||||
|
||||
**Stage 1: Personal PKM + AI context** *(current)*
|
||||
A single person manages their knowledge, life, and work in a local vault. The primary collaborator is AI. The vault gives structure to one person's context, making it legible to an AI that can assist meaningfully across all areas of work and life. The method helps structure the knowledge; the AI helps use it.
|
||||
@@ -172,7 +172,7 @@ The ontology scales to organizations. Companies have projects, responsibilities,
|
||||
|
||||
### The right early adopters
|
||||
|
||||
The first users who will get the most from Tolaria are technically-minded individuals who:
|
||||
The first users who will get the most from Laputa are technically-minded individuals who:
|
||||
- Are frustrated with Notion's performance, complexity, or lock-in
|
||||
- Understand or are comfortable with Git
|
||||
- Want a system that's AI-native by design, not by bolted-on features
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0050"
|
||||
title: "Deterministic shortcut command routing"
|
||||
status: active
|
||||
date: 2026-04-11
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Laputa is keyboard-first, but shortcut execution had split ownership: `useAppKeyboard` handled some shortcuts in the renderer while `menu.rs` owned others as native Tauri menu accelerators. That split made QA unreliable. Browser tests could prove the renderer path, but not the native menu path, and flaky macOS key synthesis made `Cmd+Shift+L`, `Cmd+Shift+I`, and `Cmd+N` regressions easy to miss.
|
||||
|
||||
## Decision
|
||||
|
||||
**Keyboard shortcuts and native menu accelerators now dispatch through the same canonical app command IDs. Renderer-owned shortcuts call the shared dispatcher directly; native menu items emit the same IDs into the frontend, and tests get a deterministic menu-command trigger that exercises that route without relying on synthesized native keystrokes.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Shared command IDs plus deterministic menu-command trigger — keeps native desktop UX while making menu-owned commands testable in unit tests, Playwright, and native QA. Downside: one more command layer to maintain.
|
||||
- **Option B**: Move every shortcut to the renderer — simpler automated testing, but worse macOS menu-bar parity and weaker native UX.
|
||||
- **Option C**: Keep renderer and native shortcuts separate — lowest code churn, but continues to produce false confidence and shortcut regressions.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `appCommandDispatcher.ts` owns the canonical shortcut command IDs and the shared execution path used by `useAppKeyboard` and `useMenuEvents`.
|
||||
- Native menu routing remains explicit in `menu.rs`; adding or changing a native shortcut now requires wiring the accelerator and the matching command ID in one place.
|
||||
- Automated QA can trigger menu-owned commands deterministically through the shared `window.__laputaTest.triggerMenuCommand()` bridge in browser runs and through the native `trigger_menu_command` Tauri command in desktop runs.
|
||||
- Keyboard QA should prefer real menu selection or the deterministic menu-command trigger for native-owned shortcuts, and reserve synthesized keystrokes for renderer-owned shortcuts or true end-to-end spot checks.
|
||||
- This decision supersedes the blanket assumption in ADR 0020 that all shortcut verification can be treated as plain keyboard-event testing.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0051"
|
||||
title: "Shared shortcut manifest for testable routing"
|
||||
status: active
|
||||
date: 2026-04-11
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0050 moved renderer shortcuts and native menu events onto the same command dispatcher, but shortcut ownership still drifted across multiple places: `appKeyboardShortcuts.ts`, `appCommandDispatcher.ts`, command-palette metadata, and `menu.rs`. That made shortcut regressions easy to reintroduce because the same facts had to be updated manually in several files.
|
||||
|
||||
The riskiest failures were exactly the native-owned commands that matter most in a keyboard-first app: `Cmd+\` for raw editor, `Cmd+Shift+I` for properties, and `Cmd+Shift+L` for the AI panel. We need one declarative place that says which command owns which shortcut, whether the shortcut is renderer-owned or native-menu-owned, and how tests should trigger it.
|
||||
|
||||
## Decision
|
||||
|
||||
**Shortcut-capable app commands are now defined in a shared frontend manifest that owns command IDs, routing semantics, and shortcut ownership. Renderer keyboard handling resolves commands from that manifest, native menu routing dispatches the same command IDs, and deterministic QA for native-owned shortcuts targets those IDs rather than duplicating shortcut facts in ad hoc code paths.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Shared shortcut manifest plus shared dispatcher and deterministic menu-command QA. This reduces drift, improves CodeScene on the command router, and makes native-owned shortcuts provable without flaky macOS key synthesis. Downside: one more manifest to maintain.
|
||||
- **Option B**: Keep the shared dispatcher from ADR 0050 but continue storing shortcut ownership in separate key maps and menu lists. Lower churn, but it keeps the exact source of the regressions we reopened.
|
||||
- **Option C**: Move all shortcuts into renderer-only handlers. Easier to test, but weaker macOS menu-bar parity and worse native desktop UX.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `appCommandCatalog.ts` is now the frontend source of truth for shortcut-capable command IDs, ownership, modifier rules, and dispatch kind.
|
||||
- `appCommandDispatcher.ts` is reduced to route execution instead of carrying a large switch plus duplicated ownership metadata.
|
||||
- `useAppKeyboard.ts` resolves shortcuts from the shared manifest, including the distinction between `Cmd+Shift+L` (macOS-only) and `CmdOrCtrl+Shift+I/F/O`.
|
||||
- Native-menu smoke tests should use `window.__laputaTest.triggerMenuCommand()` or the Tauri `trigger_menu_command` bridge to prove the native command path. Renderer-only commands may still be proven with direct keyboard events.
|
||||
- This ADR supersedes ADR 0050 by replacing “shared command IDs are enough” with “shared command IDs plus shared shortcut ownership metadata are required.”
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0052"
|
||||
title: "Renderer-first shortcut execution with native-menu dedupe"
|
||||
status: active
|
||||
date: 2026-04-11
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0051 gave Laputa a shared shortcut manifest and shared command IDs, but it still treated many shortcuts as native-menu-owned at execution time. In practice that meant `useAppKeyboard` deferred commands like `Cmd+Shift+I`, `Cmd+Shift+L`, and `Cmd+\` whenever the app ran under Tauri, and automated QA had to prove those flows by injecting menu-command IDs instead of pressing the real keys.
|
||||
|
||||
That is not a strong enough QA story for a keyboard-first app. If a user presses a shortcut while the editor is focused, we need a deterministic way to prove the actual key combo works. At the same time, we still want a native macOS menu bar with working menu items and accelerators.
|
||||
|
||||
## Decision
|
||||
|
||||
**Renderer keyboard handling is now the primary execution path for all shortcut-capable app commands, including commands that also have native menu accelerators. Native menu clicks and accelerators still emit the same command IDs, but the shared dispatcher suppresses the duplicate native/renderer echo from a single keypress so the command runs exactly once.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Renderer-first shortcut execution plus native-menu dedupe. This keeps shortcuts testable with real key events in a Tauri-like environment while preserving menu-bar parity and clickable native menu items. Downside: the dispatcher has to understand and suppress paired native/renderer echoes.
|
||||
- **Option B**: Keep deferring native-owned shortcuts out of the renderer and prove them only through `trigger_menu_command`. Lower implementation churn, but it still leaves the real keystroke path unproven.
|
||||
- **Option C**: Remove native accelerators entirely and keep shortcuts renderer-only. Simplest to reason about, but weaker desktop UX and poorer macOS menu discoverability.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `appCommandCatalog.ts` remains the single manifest for command IDs and shortcut combos, but keyboard execution no longer depends on a separate owner flag.
|
||||
- `useAppKeyboard` handles the actual key event for every shortcut-capable command, even in Tauri mode.
|
||||
- `useMenuEvents` still handles menu clicks and test-triggered native command IDs, but shared dispatcher dedupe prevents a focused keypress from firing twice when the native menu accelerator also echoes back into the renderer.
|
||||
- Deterministic QA now has two complementary proofs:
|
||||
- real keyboard events in a Tauri-like environment for the actual shortcut combo
|
||||
- `trigger_menu_command` for the native menu click/accelerator command path
|
||||
- This ADR supersedes ADR 0051 by replacing “execution ownership lives in the manifest” with “shortcut combos live in the manifest, while execution is renderer-first and native menu dispatch is deduped.”
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0053"
|
||||
title: "Webview-init prevention for browser-reserved shortcuts"
|
||||
status: active
|
||||
date: 2026-04-11
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0052 made renderer-first shortcut handling the primary path for command execution, with native menu accelerators deduped afterward. That works for normal shortcuts, but native QA on macOS showed that `Cmd+Shift+L` still failed to reach the app even though the shared command path and the Note menu item both worked.
|
||||
|
||||
The gap is WKWebView itself: some browser-reserved chords are swallowed by the webview before the renderer-level shortcut listener can execute. That makes the shortcut untestable with the real native keypress even though the command bus is correct.
|
||||
|
||||
## Decision
|
||||
|
||||
**Laputa will keep renderer-first shortcut execution, but for macOS browser-reserved chords we will add a narrow Tauri webview-init prevention layer using `tauri-plugin-prevent-default` so the real keystroke reaches the shared command path.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Add a narrow `tauri-plugin-prevent-default` registration for only the known browser-reserved chords we actually use. This preserves ADR 0052, keeps the command bus unified, and fixes the real native keystroke path without broad shortcut capture.
|
||||
- **Option B**: Keep relying on renderer capture listeners alone. Simpler, but it fails for chords that WKWebView consumes before renderer code sees them.
|
||||
- **Option C**: Use a global shortcut plugin as the fallback path. This would catch the keystroke natively, but it reserves the chord outside Laputa and is too heavy for app-local shortcuts.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Shortcut ownership stays unified: command IDs and execution still live in the shared renderer/native command bus.
|
||||
- macOS-only browser-reserved chords now have one extra declaration point in `src-tauri/src/lib.rs`, and that list must stay intentionally small.
|
||||
- Native QA remains mandatory for any shortcut added to that list, because browser dev and mocked Tauri tests do not exercise the webview-init layer.
|
||||
- Re-evaluate this decision if Tauri/WKWebView exposes a better app-local native shortcut hook that does not require browser-reserved-key workarounds.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0054"
|
||||
title: "Deterministic shortcut QA matrix"
|
||||
status: active
|
||||
date: 2026-04-11
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0052 made renderer-first shortcut execution the primary runtime path, and ADR 0053 added a narrow macOS webview-init prevent-default layer for browser-reserved chords such as `Cmd+Shift+L`. Those decisions improved behavior, but the automated QA story was still muddy:
|
||||
|
||||
- browser smoke tests were describing a mocked desktop harness as if it were native Tauri QA
|
||||
- some tests used `page.keyboard.press()` for commands whose real desktop accelerators are intercepted or reserved by the browser shell
|
||||
- native menu command coverage existed, but the catalog did not declare which deterministic proof path each shortcut should use
|
||||
|
||||
That made it too easy to ship a shortcut with passing automation while overstating what the automation had actually proven.
|
||||
|
||||
## Decision
|
||||
|
||||
**Laputa will treat shortcut QA as an explicit part of the shared command manifest. Every shortcut-capable command must have a deterministic automated proof path, and the test harness must distinguish renderer shortcut-event proof from native menu-command proof instead of calling the browser harness “native Tauri QA”.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Add a deterministic shortcut QA matrix to the shared command catalog. Renderer shortcut handling can be exercised through synthetic `keydown` events generated from the manifest, while native menu commands are exercised through `trigger_menu_command`. Pros: deterministic, explicit, and honest about what is being proved. Cons: still requires real native QA for exact accelerator delivery on macOS.
|
||||
- **Option B**: Keep using ad hoc Playwright key presses and browser-side menu shims. Lower change cost, but still allows false claims about native coverage and still depends on browser-reserved shortcuts behaving nicely.
|
||||
- **Option C**: Block all shortcut work until full native Tauri automation exists. Strongest eventual guarantee, but it would leave the keyboard-first app without a usable deterministic QA strategy today.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `appCommandCatalog.ts` now owns not just command IDs and modifier rules, but also the deterministic QA mode for each shortcut-capable command.
|
||||
- Browser harness smoke tests must describe themselves as a desktop command bridge, not native app QA.
|
||||
- Renderer shortcut behavior can be verified deterministically without depending on browser chrome or flaky AppleScript key synthesis.
|
||||
- Native menu-command behavior can be verified deterministically through the Tauri command bridge.
|
||||
- Exact desktop accelerator delivery still requires real Tauri QA for commands flagged as needing manual native verification, especially browser-reserved macOS chords.
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0055"
|
||||
title: "H1 is the only editor title surface"
|
||||
status: active
|
||||
date: 2026-04-11
|
||||
supersedes: "0044"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0044 moved Laputa to H1-as-title, but the frontend still carried a legacy fallback: when a note had no H1, `TitleField` and the old title section could reappear above the editor. That left two competing title surfaces in the product and made it possible for deleting an H1 to resurrect UI that was supposed to be gone.
|
||||
|
||||
The result was both behavioral drift and stale tests: some code paths still treated the dedicated title row as a valid editing surface even though the product direction is now keyboard-first writing directly in the document body.
|
||||
|
||||
## Decision
|
||||
|
||||
**The editor body is now the only title surface. Laputa never renders a separate title section above the editor, regardless of whether a note currently has an H1.**
|
||||
|
||||
Display-title behavior stays:
|
||||
1. First H1 in the body
|
||||
2. Legacy frontmatter `title:`
|
||||
3. Filename-derived fallback
|
||||
|
||||
But the UI no longer exposes a dedicated title field for cases 2 or 3. When a note has no H1, the editor simply shows normal body content or the empty-editor placeholder.
|
||||
|
||||
Filename operations remain explicit:
|
||||
- untitled notes still auto-rename from H1 on save
|
||||
- manual filename rename/sync remains in the breadcrumb
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): remove the fallback title section entirely. This makes the editor honest, removes a stale code path, and keeps title editing aligned with the keyboard-first document model.
|
||||
- **Option B**: keep the fallback title field for non-H1 notes. This preserves an alternate rename path, but it reintroduces the exact dual-surface ambiguity that ADR-0044 tried to escape.
|
||||
- **Option C**: hide the title section with CSS only. Low churn, but it leaves dead render/state paths in place and makes regressions like “delete H1 and old title row returns” easy to reintroduce.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Deleting an H1 no longer reveals any legacy title UI; the user stays in the editor body.
|
||||
- `TitleField` and the title-section render path are removed from the frontend.
|
||||
- Breadcrumb filename controls are now the only explicit file-identifier editing surface outside the editor body.
|
||||
- Older tests that asserted title editing through `TitleField` are obsolete and should be replaced by H1-title or breadcrumb-filename coverage.
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0056"
|
||||
title: "System git auth only — no provider-specific OAuth or repo APIs"
|
||||
status: active
|
||||
date: 2026-04-12
|
||||
supersedes: "0019"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already uses the system `git` executable for the core remote workflow: commit, pull, push, status, history, and conflict resolution. The only provider-specific part left was GitHub authentication and repository management:
|
||||
|
||||
- GitHub Device Flow OAuth
|
||||
- persisted `github_token` / `github_username` settings
|
||||
- GitHub-only clone/create UI
|
||||
- GitHub API calls for repo listing and creation
|
||||
|
||||
That split made the product more complex than the actual user need. Tolaria's remote-sync users are developers who typically already have git configured via SSH keys, Git Credential Manager, Keychain helpers, or `gh auth`. The app was carrying a provider-specific auth stack even though the real transport path was already plain git CLI.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria does not implement provider-specific authentication or remote-repository APIs. All remote auth is delegated to the user's existing system git configuration, and cloning is a generic "paste any git URL" flow.**
|
||||
|
||||
Concretely:
|
||||
|
||||
- remove GitHub Device Flow commands and UI
|
||||
- remove persisted GitHub auth fields from app settings
|
||||
- remove GitHub repo list/create API integration
|
||||
- keep `clone_repo`, but make it a generic system-git clone command
|
||||
- keep commit / pull / push behavior unchanged apart from surfacing raw git errors directly
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A — Keep GitHub Device Flow OAuth** (ADR-0019, now superseded): polished GitHub-specific onboarding, but it preserves provider lock-in, token storage, and an entire second auth model beside system git.
|
||||
- **Option B — Replace OAuth with manual PAT entry**: smaller implementation than Device Flow, but still provider-specific, still stores credentials in app settings, and still teaches users the wrong abstraction.
|
||||
- **Option C — Pure system git auth** (chosen): one auth path, less code, works with any git host, and aligns the clone flow with the rest of Tolaria's git stack. Downside: users must already have git auth configured outside the app.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `CloneVaultModal` accepts any git URL and local destination path.
|
||||
- `clone_repo` shells out to system git without injecting provider tokens.
|
||||
- `git_push` / `git_pull` continue to rely on the same external git configuration; auth failures surface as raw git stderr.
|
||||
- `SettingsPanel` no longer contains a GitHub connection section.
|
||||
- Tolaria no longer stores git-provider credentials in `settings.json`.
|
||||
- GitHub, GitLab, Bitbucket, Gitea, and self-hosted remotes all work through the same product path.
|
||||
- Creating or listing remote repos from inside Tolaria is no longer supported; remote setup happens in the user's normal git tools.
|
||||
- The Getting Started vault still clones from a public remote URL, but it now goes through the same generic git clone path as every other vault import.
|
||||
|
||||
Re-evaluate if Tolaria later targets less technical users who cannot reasonably be expected to configure git outside the app.
|
||||
@@ -74,7 +74,7 @@ proposed → active → superseded
|
||||
| [0016](0016-sentry-posthog-telemetry.md) | Sentry + PostHog telemetry with consent | active |
|
||||
| [0017](0017-canary-release-channel.md) | Canary release channel and feature flags | active |
|
||||
| [0018](0018-codescene-code-health-gates.md) | CodeScene code health gates in CI | active |
|
||||
| [0019](0019-github-device-flow-oauth.md) | GitHub device flow OAuth for vault sync | superseded → [0056](0056-system-git-cli-auth-no-provider-oauth.md) |
|
||||
| [0019](0019-github-device-flow-oauth.md) | GitHub device flow OAuth for vault sync | active |
|
||||
| [0020](0020-keyboard-first-design.md) | Keyboard-first design principle | active |
|
||||
| [0021](0021-push-to-main-workflow.md) | Push directly to main (no PRs) | active |
|
||||
| [0022](0022-blocknote-rich-text-editor.md) | BlockNote as the rich text editor | active |
|
||||
@@ -99,16 +99,9 @@ proposed → active → superseded
|
||||
| [0041](0041-filekind-all-files-in-vault-scanner.md) | fileKind field — scan all vault files, not just markdown | active |
|
||||
| [0042](0042-trash-auto-purge-safety-model.md) | Trash auto-purge safety model | superseded → [0045](0045-permanent-delete-no-trash.md) |
|
||||
| [0043](0043-reactive-vault-state-on-save.md) | Reactive vault state: editor changes propagate immediately to all UI | active |
|
||||
| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | superseded → [0055](0055-h1-is-the-only-editor-title-surface.md) |
|
||||
| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | active |
|
||||
| [0045](0045-permanent-delete-no-trash.md) | Permanent delete with confirm modal — no Trash system | active |
|
||||
| [0046](0046-starter-vault-cloned-from-github.md) | Starter vault cloned from GitHub at runtime — no bundled content | active |
|
||||
| [0047](0047-regex-mode-for-view-filter-conditions.md) | Regex mode for view filter conditions | active |
|
||||
| [0048](0048-relative-date-expressions-in-view-filters.md) | Relative date expressions in view filter conditions | active |
|
||||
| [0049](0049-per-note-icon-property.md) | Per-note icon property (_icon on individual notes) | active |
|
||||
| [0050](0050-deterministic-shortcut-command-routing.md) | Deterministic shortcut command routing | superseded → [0051](0051-shared-shortcut-manifest-for-testable-routing.md) |
|
||||
| [0051](0051-shared-shortcut-manifest-for-testable-routing.md) | Shared shortcut manifest for testable routing | superseded → [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) |
|
||||
| [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) | Renderer-first shortcut execution with native-menu dedupe | active |
|
||||
| [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active |
|
||||
| [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | active |
|
||||
| [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | active |
|
||||
| [0056](0056-system-git-cli-auth-no-provider-oauth.md) | System git auth only — no provider-specific OAuth or repo APIs | active |
|
||||
|
||||
@@ -7,16 +7,12 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
|
||||
<title>Tolaria</title>
|
||||
<title>laputa-scaffold</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// Apply saved theme before React mounts to prevent flash
|
||||
var t = localStorage.getItem('tolaria-theme');
|
||||
if (t === null) {
|
||||
t = localStorage.getItem('laputa-theme');
|
||||
if (t !== null) localStorage.setItem('tolaria-theme', t);
|
||||
}
|
||||
var t = localStorage.getItem('laputa-theme');
|
||||
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tolaria MCP Server — lightweight vault tools for AI agents.
|
||||
* Laputa MCP Server — lightweight vault tools for AI agents.
|
||||
*
|
||||
* The agent has full shell access (bash, read, write, edit).
|
||||
* These MCP tools provide Tolaria-specific capabilities that
|
||||
* These MCP tools provide Laputa-specific capabilities that
|
||||
* native tools cannot replace:
|
||||
*
|
||||
* - search_notes: full-text search across vault notes
|
||||
* - get_vault_context: vault structure overview (types, note count, folders)
|
||||
* - get_note: parsed frontmatter + content (convenience over raw cat)
|
||||
* - open_note: signal Tolaria UI to open a note as a tab
|
||||
* - open_note: signal Laputa UI to open a note as a tab
|
||||
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
|
||||
* - refresh_vault: trigger vault rescan so new/modified files appear
|
||||
*/
|
||||
@@ -87,7 +87,7 @@ const TOOLS = [
|
||||
},
|
||||
{
|
||||
name: 'open_note',
|
||||
description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.',
|
||||
description: 'Open a note in the Laputa UI as a new tab. Use after creating or editing a note so the user can see it.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -98,7 +98,7 @@ const TOOLS = [
|
||||
},
|
||||
{
|
||||
name: 'highlight_editor',
|
||||
description: 'Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
|
||||
description: 'Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -110,7 +110,7 @@ const TOOLS = [
|
||||
},
|
||||
{
|
||||
name: 'refresh_vault',
|
||||
description: 'Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.',
|
||||
description: 'Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -152,7 +152,7 @@ function handleOpenNote(args) {
|
||||
// then signal the UI to open it in a tab.
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
broadcastUiAction('open_tab', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Opening ${args.path} in Tolaria` }] }
|
||||
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
|
||||
}
|
||||
|
||||
function handleHighlightEditor(args) {
|
||||
@@ -168,7 +168,7 @@ function handleRefreshVault(args) {
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'tolaria-mcp-server', version: '0.3.0' },
|
||||
{ name: 'laputa-mcp-server', version: '0.3.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
@@ -195,7 +195,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`)
|
||||
console.error(`Laputa MCP server running (vault: ${VAULT_PATH})`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
|
||||
4
mcp-server/package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "tolaria-mcp-server",
|
||||
"name": "laputa-mcp-server",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tolaria-mcp-server",
|
||||
"name": "laputa-mcp-server",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tolaria-mcp-server",
|
||||
"name": "laputa-mcp-server",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP server for Tolaria vault operations",
|
||||
"description": "MCP server for Laputa vault operations",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* WebSocket bridge for Tolaria MCP tools.
|
||||
* WebSocket bridge for Laputa MCP tools.
|
||||
*
|
||||
* Exposes vault operations over WebSocket so the Tolaria app frontend
|
||||
* Exposes vault operations over WebSocket so the Laputa app frontend
|
||||
* can invoke MCP tools in real-time without going through stdio.
|
||||
*
|
||||
* Port 9710: Tool bridge — Claude/AI clients call vault tools here.
|
||||
@@ -74,7 +74,7 @@ async function handleMessage(data) {
|
||||
/**
|
||||
* Attempt to start the UI bridge WebSocket server.
|
||||
* Returns a Promise that resolves to the WebSocketServer or null if the port
|
||||
* is unavailable (e.g. another Tolaria instance owns it).
|
||||
* is unavailable (e.g. another Laputa instance owns it).
|
||||
*/
|
||||
export function startUiBridge(port = WS_UI_PORT) {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "tolaria",
|
||||
"name": "laputa-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
@@ -13,7 +13,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||
index 6c65eb7d57e207a8cd1f2a2ae3bb99507c405cef..c163932957846385623eb2980bf1141eb6631db1 100644
|
||||
--- a/dist/index.cjs
|
||||
+++ b/dist/index.cjs
|
||||
@@ -2443,6 +2443,21 @@ function handleMouseLeave(view) {
|
||||
const pluginState = columnResizingPluginKey.getState(view.state);
|
||||
if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1);
|
||||
}
|
||||
+function safeDispatch(view, tr) {
|
||||
+ try {
|
||||
+ view.dispatch(tr);
|
||||
+ return true;
|
||||
+ } catch (_error) {
|
||||
+ return false;
|
||||
+ }
|
||||
+}
|
||||
+function safeDomAtPos(view, pos) {
|
||||
+ try {
|
||||
+ return view.domAtPos(pos);
|
||||
+ } catch (_error) {
|
||||
+ return null;
|
||||
+ }
|
||||
+}
|
||||
function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
|
||||
var _view$dom$ownerDocume;
|
||||
if (!view.editable) return false;
|
||||
@@ -2451,17 +2466,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
|
||||
if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) return false;
|
||||
const cell = view.state.doc.nodeAt(pluginState.activeHandle);
|
||||
const width = currentColWidth(view, pluginState.activeHandle, cell.attrs);
|
||||
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
|
||||
+ if (width == null) return false;
|
||||
+ if (!safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
|
||||
startX: event.clientX,
|
||||
startWidth: width
|
||||
- } }));
|
||||
+ } }))) return false;
|
||||
function finish(event$1) {
|
||||
win.removeEventListener("mouseup", finish);
|
||||
win.removeEventListener("mousemove", move);
|
||||
const pluginState$1 = columnResizingPluginKey.getState(view.state);
|
||||
if (pluginState$1 === null || pluginState$1 === void 0 ? void 0 : pluginState$1.dragging) {
|
||||
updateColumnWidth(view, pluginState$1.activeHandle, draggedWidth(pluginState$1.dragging, event$1, cellMinWidth));
|
||||
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
|
||||
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
|
||||
}
|
||||
}
|
||||
function move(event$1) {
|
||||
@@ -2482,15 +2498,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
|
||||
function currentColWidth(view, cellPos, { colspan, colwidth }) {
|
||||
const width = colwidth && colwidth[colwidth.length - 1];
|
||||
if (width) return width;
|
||||
- const dom = view.domAtPos(cellPos);
|
||||
- let domWidth = dom.node.childNodes[dom.offset].offsetWidth, parts = colspan;
|
||||
+ const dom = safeDomAtPos(view, cellPos);
|
||||
+ if (!dom) return null;
|
||||
+ const cellDom = dom.node && dom.node.childNodes ? dom.node.childNodes[dom.offset] : null;
|
||||
+ if (!cellDom || typeof cellDom.offsetWidth != "number") return null;
|
||||
+ let domWidth = cellDom.offsetWidth, parts = colspan;
|
||||
if (colwidth) {
|
||||
for (let i = 0; i < colspan; i++) if (colwidth[i]) {
|
||||
domWidth -= colwidth[i];
|
||||
parts--;
|
||||
}
|
||||
}
|
||||
- return domWidth / parts;
|
||||
+ return parts ? domWidth / parts : null;
|
||||
}
|
||||
function domCellAround(target) {
|
||||
while (target && target.nodeName != "TD" && target.nodeName != "TH") target = target.classList && target.classList.contains("ProseMirror") ? null : target.parentNode;
|
||||
@@ -2516,10 +2535,15 @@ function draggedWidth(dragging, event, resizeMinWidth) {
|
||||
return Math.max(resizeMinWidth, dragging.startWidth + offset);
|
||||
}
|
||||
function updateHandle(view, value) {
|
||||
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
|
||||
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
|
||||
}
|
||||
function updateColumnWidth(view, cell, width) {
|
||||
- const $cell = view.state.doc.resolve(cell);
|
||||
+ let $cell;
|
||||
+ try {
|
||||
+ $cell = view.state.doc.resolve(cell);
|
||||
+ } catch (_error) {
|
||||
+ return false;
|
||||
+ }
|
||||
const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1);
|
||||
const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
|
||||
const tr = view.state.tr;
|
||||
@@ -2537,16 +2561,24 @@ function updateColumnWidth(view, cell, width) {
|
||||
colwidth
|
||||
});
|
||||
}
|
||||
- if (tr.docChanged) view.dispatch(tr);
|
||||
+ if (tr.docChanged) return safeDispatch(view, tr);
|
||||
+ return true;
|
||||
}
|
||||
function displayColumnWidth(view, cell, width, defaultCellMinWidth) {
|
||||
- const $cell = view.state.doc.resolve(cell);
|
||||
+ let $cell;
|
||||
+ try {
|
||||
+ $cell = view.state.doc.resolve(cell);
|
||||
+ } catch (_error) {
|
||||
+ return false;
|
||||
+ }
|
||||
const table = $cell.node(-1), start = $cell.start(-1);
|
||||
const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
|
||||
- let dom = view.domAtPos($cell.start(-1)).node;
|
||||
+ const domAtPos = safeDomAtPos(view, $cell.start(-1));
|
||||
+ let dom = domAtPos && domAtPos.node;
|
||||
while (dom && dom.nodeName != "TABLE") dom = dom.parentNode;
|
||||
- if (!dom) return;
|
||||
+ if (!dom) return false;
|
||||
updateColumnsOnResize(table, dom.firstChild, dom, defaultCellMinWidth, col, width);
|
||||
+ return true;
|
||||
}
|
||||
function zeroes(n) {
|
||||
return Array(n).fill(0);
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 5b4ac25594ba5722409332b1e5c812f108c9bf11..5b811ee70ff2fcb0c7587f838f00bc6fc19e906a 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -2443,6 +2443,21 @@ function handleMouseLeave(view) {
|
||||
const pluginState = columnResizingPluginKey.getState(view.state);
|
||||
if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1);
|
||||
}
|
||||
+function safeDispatch(view, tr) {
|
||||
+ try {
|
||||
+ view.dispatch(tr);
|
||||
+ return true;
|
||||
+ } catch (_error) {
|
||||
+ return false;
|
||||
+ }
|
||||
+}
|
||||
+function safeDomAtPos(view, pos) {
|
||||
+ try {
|
||||
+ return view.domAtPos(pos);
|
||||
+ } catch (_error) {
|
||||
+ return null;
|
||||
+ }
|
||||
+}
|
||||
function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
|
||||
var _view$dom$ownerDocume;
|
||||
if (!view.editable) return false;
|
||||
@@ -2451,17 +2466,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
|
||||
if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) return false;
|
||||
const cell = view.state.doc.nodeAt(pluginState.activeHandle);
|
||||
const width = currentColWidth(view, pluginState.activeHandle, cell.attrs);
|
||||
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
|
||||
+ if (width == null) return false;
|
||||
+ if (!safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
|
||||
startX: event.clientX,
|
||||
startWidth: width
|
||||
- } }));
|
||||
+ } }))) return false;
|
||||
function finish(event$1) {
|
||||
win.removeEventListener("mouseup", finish);
|
||||
win.removeEventListener("mousemove", move);
|
||||
const pluginState$1 = columnResizingPluginKey.getState(view.state);
|
||||
if (pluginState$1 === null || pluginState$1 === void 0 ? void 0 : pluginState$1.dragging) {
|
||||
updateColumnWidth(view, pluginState$1.activeHandle, draggedWidth(pluginState$1.dragging, event$1, cellMinWidth));
|
||||
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
|
||||
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
|
||||
}
|
||||
}
|
||||
function move(event$1) {
|
||||
@@ -2482,15 +2498,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
|
||||
function currentColWidth(view, cellPos, { colspan, colwidth }) {
|
||||
const width = colwidth && colwidth[colwidth.length - 1];
|
||||
if (width) return width;
|
||||
- const dom = view.domAtPos(cellPos);
|
||||
- let domWidth = dom.node.childNodes[dom.offset].offsetWidth, parts = colspan;
|
||||
+ const dom = safeDomAtPos(view, cellPos);
|
||||
+ if (!dom) return null;
|
||||
+ const cellDom = dom.node && dom.node.childNodes ? dom.node.childNodes[dom.offset] : null;
|
||||
+ if (!cellDom || typeof cellDom.offsetWidth != "number") return null;
|
||||
+ let domWidth = cellDom.offsetWidth, parts = colspan;
|
||||
if (colwidth) {
|
||||
for (let i = 0; i < colspan; i++) if (colwidth[i]) {
|
||||
domWidth -= colwidth[i];
|
||||
parts--;
|
||||
}
|
||||
}
|
||||
- return domWidth / parts;
|
||||
+ return parts ? domWidth / parts : null;
|
||||
}
|
||||
function domCellAround(target) {
|
||||
while (target && target.nodeName != "TD" && target.nodeName != "TH") target = target.classList && target.classList.contains("ProseMirror") ? null : target.parentNode;
|
||||
@@ -2516,10 +2535,15 @@ function draggedWidth(dragging, event, resizeMinWidth) {
|
||||
return Math.max(resizeMinWidth, dragging.startWidth + offset);
|
||||
}
|
||||
function updateHandle(view, value) {
|
||||
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
|
||||
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
|
||||
}
|
||||
function updateColumnWidth(view, cell, width) {
|
||||
- const $cell = view.state.doc.resolve(cell);
|
||||
+ let $cell;
|
||||
+ try {
|
||||
+ $cell = view.state.doc.resolve(cell);
|
||||
+ } catch (_error) {
|
||||
+ return false;
|
||||
+ }
|
||||
const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1);
|
||||
const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
|
||||
const tr = view.state.tr;
|
||||
@@ -2537,16 +2561,24 @@ function updateColumnWidth(view, cell, width) {
|
||||
colwidth
|
||||
});
|
||||
}
|
||||
- if (tr.docChanged) view.dispatch(tr);
|
||||
+ if (tr.docChanged) return safeDispatch(view, tr);
|
||||
+ return true;
|
||||
}
|
||||
function displayColumnWidth(view, cell, width, defaultCellMinWidth) {
|
||||
- const $cell = view.state.doc.resolve(cell);
|
||||
+ let $cell;
|
||||
+ try {
|
||||
+ $cell = view.state.doc.resolve(cell);
|
||||
+ } catch (_error) {
|
||||
+ return false;
|
||||
+ }
|
||||
const table = $cell.node(-1), start = $cell.start(-1);
|
||||
const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
|
||||
- let dom = view.domAtPos($cell.start(-1)).node;
|
||||
+ const domAtPos = safeDomAtPos(view, $cell.start(-1));
|
||||
+ let dom = domAtPos && domAtPos.node;
|
||||
while (dom && dom.nodeName != "TABLE") dom = dom.parentNode;
|
||||
- if (!dom) return;
|
||||
+ if (!dom) return false;
|
||||
updateColumnsOnResize(table, dom.firstChild, dom, defaultCellMinWidth, col, width);
|
||||
+ return true;
|
||||
}
|
||||
function zeroes(n) {
|
||||
return Array(n).fill(0);
|
||||
9
pnpm-lock.yaml
generated
@@ -8,9 +8,6 @@ patchedDependencies:
|
||||
'@blocknote/core@0.46.2':
|
||||
hash: 9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec
|
||||
path: patches/@blocknote__core@0.46.2.patch
|
||||
prosemirror-tables@1.8.5:
|
||||
hash: cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b
|
||||
path: patches/prosemirror-tables@1.8.5.patch
|
||||
|
||||
importers:
|
||||
|
||||
@@ -4494,7 +4491,7 @@ snapshots:
|
||||
prosemirror-highlight: 0.13.1(@shikijs/types@3.22.0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.11.0)(prosemirror-view@1.41.6)
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b)
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
rehype-format: 5.0.1
|
||||
@@ -6248,7 +6245,7 @@ snapshots:
|
||||
prosemirror-schema-basic: 1.2.4
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b)
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
@@ -8102,7 +8099,7 @@ snapshots:
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
|
||||
prosemirror-tables@1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b):
|
||||
prosemirror-tables@1.8.5:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
@@ -6,4 +6,3 @@ ignoredBuiltDependencies:
|
||||
|
||||
patchedDependencies:
|
||||
'@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch
|
||||
prosemirror-tables@1.8.5: patches/prosemirror-tables@1.8.5.patch
|
||||
|
||||
285
src-tauri/Cargo.lock
generated
@@ -123,6 +123,16 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -609,6 +619,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -644,6 +663,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -667,7 +696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
@@ -680,7 +709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -983,12 +1012,6 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
@@ -1672,6 +1695,25 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1823,9 +1865,11 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@@ -1884,9 +1928,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2104,15 +2150,6 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -2219,6 +2256,36 @@ dependencies = [
|
||||
"selectors",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "laputa"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures-util",
|
||||
"gray_matter",
|
||||
"log",
|
||||
"mockito",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -2397,6 +2464,31 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mockito"
|
||||
version = "1.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"bytes",
|
||||
"colored",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"similar",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
@@ -3357,6 +3449,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -3377,6 +3479,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3395,6 +3507,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -3525,17 +3646,21 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -3546,12 +3671,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3590,7 +3717,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3746,7 +3873,7 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
@@ -3878,7 +4005,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -4252,6 +4379,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
@@ -4371,27 +4504,6 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4451,6 +4563,27 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -4472,7 +4605,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dispatch",
|
||||
@@ -4754,20 +4887,6 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-prevent-default"
|
||||
version = "4.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6260061932cad80647a823d0c5a3633f4eec62160a8f57bad0ab82b537477ef2"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"itertools",
|
||||
"serde",
|
||||
"strum",
|
||||
"tauri",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
@@ -5043,6 +5162,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
@@ -5093,35 +5213,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tolaria"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures-util",
|
||||
"gray_matter",
|
||||
"log",
|
||||
"regex",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-prevent-default",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.5.11"
|
||||
@@ -5692,6 +5783,19 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
@@ -5965,6 +6069,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "tolaria"
|
||||
name = "laputa"
|
||||
version = "0.1.0"
|
||||
description = "Personal knowledge and life management app"
|
||||
authors = ["you"]
|
||||
@@ -9,7 +9,7 @@ edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[lib]
|
||||
name = "tolaria_lib"
|
||||
name = "laputa_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
@@ -25,6 +25,7 @@ tauri-plugin-log = "2"
|
||||
gray_matter = "0.2"
|
||||
walkdir = "2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
futures-util = "0.3"
|
||||
base64 = "0.22"
|
||||
@@ -34,9 +35,9 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-updater = "2.10.0"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-prevent-default = "4.0.4"
|
||||
sentry = "0.37"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
mockito = "1"
|
||||
|
||||
@@ -28077,7 +28077,7 @@ var TOOLS = [
|
||||
},
|
||||
{
|
||||
name: "open_note",
|
||||
description: "Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.",
|
||||
description: "Open a note in the Laputa UI as a new tab. Use after creating or editing a note so the user can see it.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -28088,7 +28088,7 @@ var TOOLS = [
|
||||
},
|
||||
{
|
||||
name: "highlight_editor",
|
||||
description: "Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.",
|
||||
description: "Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -28100,7 +28100,7 @@ var TOOLS = [
|
||||
},
|
||||
{
|
||||
name: "refresh_vault",
|
||||
description: "Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.",
|
||||
description: "Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -28134,7 +28134,7 @@ async function handleGetNote(args) {
|
||||
function handleOpenNote(args) {
|
||||
broadcastUiAction("vault_changed", { path: args.path });
|
||||
broadcastUiAction("open_tab", { path: args.path });
|
||||
return { content: [{ type: "text", text: `Opening ${args.path} in Tolaria` }] };
|
||||
return { content: [{ type: "text", text: `Opening ${args.path} in Laputa` }] };
|
||||
}
|
||||
function handleHighlightEditor(args) {
|
||||
broadcastUiAction("highlight", { element: args.element, path: args.path });
|
||||
@@ -28145,7 +28145,7 @@ function handleRefreshVault(args) {
|
||||
return { content: [{ type: "text", text: "Vault refresh triggered" }] };
|
||||
}
|
||||
var server = new Server(
|
||||
{ name: "tolaria-mcp-server", version: "0.3.0" },
|
||||
{ name: "laputa-mcp-server", version: "0.3.0" },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
@@ -28169,7 +28169,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`);
|
||||
console.error(`Laputa MCP server running (vault: ${VAULT_PATH})`);
|
||||
}
|
||||
main().catch(console.error);
|
||||
/*! Bundled license information:
|
||||
|
||||
@@ -500,8 +500,8 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.tolaria;
|
||||
PRODUCT_NAME = Tolaria;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.laputa;
|
||||
PRODUCT_NAME = "laputa";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALID_ARCHS = arm64;
|
||||
@@ -531,8 +531,8 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.tolaria;
|
||||
PRODUCT_NAME = Tolaria;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.laputa;
|
||||
PRODUCT_NAME = "laputa";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALID_ARCHS = arm64;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Tolaria
|
||||
name: laputa
|
||||
options:
|
||||
bundleIdPrefix: club.refactoring.tolaria
|
||||
bundleIdPrefix: club.refactoring.laputa
|
||||
deploymentTarget:
|
||||
iOS: 14.0
|
||||
fileGroups: [../../src]
|
||||
@@ -10,8 +10,8 @@ configs:
|
||||
settingGroups:
|
||||
app:
|
||||
base:
|
||||
PRODUCT_NAME: Tolaria
|
||||
PRODUCT_BUNDLE_IDENTIFIER: club.refactoring.tolaria
|
||||
PRODUCT_NAME: laputa
|
||||
PRODUCT_BUNDLE_IDENTIFIER: club.refactoring.laputa
|
||||
targetTemplates:
|
||||
app:
|
||||
type: application
|
||||
@@ -85,4 +85,4 @@ targets:
|
||||
basedOnDependencyAnalysis: false
|
||||
outputFiles:
|
||||
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
|
||||
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
|
||||
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
|
||||
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 106 KiB |
@@ -208,7 +208,7 @@ fn build_mcp_config(vault_path: &str) -> Result<String, String> {
|
||||
let index_js = server_dir.join("index.js");
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"tolaria": {
|
||||
"laputa": {
|
||||
"command": "node",
|
||||
"args": [index_js.to_string_lossy()],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
@@ -508,9 +508,9 @@ mod tests {
|
||||
fn build_mcp_config_is_valid_json() {
|
||||
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&config_str).unwrap();
|
||||
assert!(parsed["mcpServers"]["tolaria"]["command"].is_string());
|
||||
assert!(parsed["mcpServers"]["laputa"]["command"].is_string());
|
||||
assert_eq!(
|
||||
parsed["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
|
||||
parsed["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
|
||||
"/tmp/test-vault"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,13 +151,6 @@ pub fn init_git_repo(vault_path: String) -> Result<(), String> {
|
||||
crate::git::init_repo(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: String, local_path: String) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
crate::git::clone_repo(&url, &local_path)
|
||||
}
|
||||
|
||||
// ── Git commands (mobile stubs) ─────────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -278,9 +271,3 @@ pub fn is_git_repo(_vault_path: String) -> bool {
|
||||
pub fn init_git_repo(_vault_path: String) -> Result<(), String> {
|
||||
Err("Git init is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(_url: String, _local_path: String) -> Result<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
|
||||
88
src-tauri/src/commands/github.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
|
||||
use super::expand_tilde;
|
||||
|
||||
// ── GitHub commands (desktop) ───────────────────────────────────────────────
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
crate::github::github_list_repos(&token).await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_create_repo(
|
||||
token: String,
|
||||
name: String,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
crate::github::github_create_repo(&token, &name, private).await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
crate::github::clone_repo(&url, &token, &local_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
crate::github::github_device_flow_start().await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
crate::github::github_device_flow_poll(&device_code).await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
|
||||
crate::github::github_get_user(&token).await
|
||||
}
|
||||
|
||||
// ── GitHub commands (mobile stubs) ──────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_list_repos(_token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_create_repo(
|
||||
_token: String,
|
||||
_name: String,
|
||||
_private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(_url: String, _token: String, _local_path: String) -> Result<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_poll(_device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_get_user(_token: String) -> Result<GitHubUser, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod ai;
|
||||
mod git;
|
||||
mod github;
|
||||
mod system;
|
||||
mod vault;
|
||||
|
||||
@@ -7,6 +8,7 @@ use std::borrow::Cow;
|
||||
|
||||
pub use ai::*;
|
||||
pub use git::*;
|
||||
pub use github::*;
|
||||
pub use system::*;
|
||||
pub use vault::*;
|
||||
|
||||
@@ -14,17 +16,16 @@ pub use vault::*;
|
||||
/// Returns the original string unchanged if it doesn't start with `~` or if the
|
||||
/// home directory cannot be determined.
|
||||
pub fn expand_tilde(path: &str) -> Cow<'_, str> {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Cow::Borrowed(path);
|
||||
};
|
||||
|
||||
match path {
|
||||
"~" => Cow::Owned(home.to_string_lossy().into_owned()),
|
||||
_ => path
|
||||
.strip_prefix("~/")
|
||||
.map(|rest| Cow::Owned(home.join(rest).to_string_lossy().into_owned()))
|
||||
.unwrap_or(Cow::Borrowed(path)),
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(home.to_string_lossy().into_owned());
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest));
|
||||
}
|
||||
}
|
||||
Cow::Borrowed(path)
|
||||
}
|
||||
|
||||
pub fn parse_build_label(version: &str) -> String {
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::menu;
|
||||
use crate::settings::Settings;
|
||||
use crate::vault_list;
|
||||
use crate::vault_list::VaultList;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::parse_build_label;
|
||||
|
||||
@@ -42,29 +41,23 @@ pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
|
||||
|
||||
// ── Menu commands ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MenuStateUpdate {
|
||||
has_active_note: bool,
|
||||
has_modified_files: Option<bool>,
|
||||
has_conflicts: Option<bool>,
|
||||
has_restorable_deleted_note: Option<bool>,
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn update_menu_state(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: MenuStateUpdate,
|
||||
has_active_note: bool,
|
||||
has_modified_files: Option<bool>,
|
||||
has_conflicts: Option<bool>,
|
||||
has_restorable_deleted_note: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
menu::set_note_items_enabled(&app_handle, state.has_active_note);
|
||||
if let Some(v) = state.has_modified_files {
|
||||
menu::set_note_items_enabled(&app_handle, has_active_note);
|
||||
if let Some(v) = has_modified_files {
|
||||
menu::set_git_commit_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = state.has_conflicts {
|
||||
if let Some(v) = has_conflicts {
|
||||
menu::set_git_conflict_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = state.has_restorable_deleted_note {
|
||||
if let Some(v) = has_restorable_deleted_note {
|
||||
menu::set_restore_deleted_item_enabled(&app_handle, v);
|
||||
}
|
||||
Ok(())
|
||||
@@ -74,23 +67,14 @@ pub fn update_menu_state(
|
||||
#[tauri::command]
|
||||
pub fn update_menu_state(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_state: MenuStateUpdate,
|
||||
_has_active_note: bool,
|
||||
_has_modified_files: Option<bool>,
|
||||
_has_conflicts: Option<bool>,
|
||||
_has_restorable_deleted_note: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn trigger_menu_command(app_handle: tauri::AppHandle, id: String) -> Result<(), String> {
|
||||
menu::emit_custom_menu_event(&app_handle, &id)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn trigger_menu_command(_app_handle: tauri::AppHandle, _id: String) -> Result<(), String> {
|
||||
Err("Native menu commands are not available on mobile".into())
|
||||
}
|
||||
|
||||
// ── Settings & config commands ──────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -45,17 +45,6 @@ pub fn rename_note(
|
||||
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_note_filename(
|
||||
vault_path: String,
|
||||
old_path: String,
|
||||
new_filename_stem: String,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let old_path = expand_tilde(&old_path);
|
||||
vault::rename_note_filename(&vault_path, &old_path, &new_filename_stem)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn auto_rename_untitled(
|
||||
vault_path: String,
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Clone a git repository to a local path using the system git configuration.
|
||||
pub fn clone_repo(url: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
prepare_clone_destination(dest)?;
|
||||
|
||||
if let Err(err) = run_clone(url, dest) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(format!("Cloned to {}", dest.display()))
|
||||
}
|
||||
|
||||
fn prepare_clone_destination(dest: &Path) -> Result<(), String> {
|
||||
if !dest.exists() {
|
||||
return ensure_parent_directory(dest);
|
||||
}
|
||||
|
||||
ensure_empty_directory(dest)
|
||||
}
|
||||
|
||||
fn ensure_empty_directory(dest: &Path) -> Result<(), String> {
|
||||
if !dest.is_dir() {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not a directory",
|
||||
dest.display()
|
||||
));
|
||||
}
|
||||
|
||||
if directory_has_entries(dest)? {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
dest.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_parent_directory(dest: &Path) -> Result<(), String> {
|
||||
let Some(parent) = dest.parent() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if parent.as_os_str().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create parent directory for '{}': {}",
|
||||
dest.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn directory_has_entries(dest: &Path) -> Result<bool, String> {
|
||||
dest.read_dir()
|
||||
.map_err(|e| format!("Failed to inspect destination '{}': {}", dest.display(), e))
|
||||
.map(|mut entries| entries.next().is_some())
|
||||
}
|
||||
|
||||
fn run_clone(url: &str, dest: &Path) -> Result<(), String> {
|
||||
let destination = dest
|
||||
.to_str()
|
||||
.ok_or_else(|| format!("Destination '{}' is not valid UTF-8", dest.display()))?;
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", url, destination])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("git clone failed: {}", stderr.trim()))
|
||||
}
|
||||
|
||||
fn cleanup_failed_clone(dest: &Path) {
|
||||
if dest.exists() && dest.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
fn init_source_repo(path: &Path) {
|
||||
fs::create_dir_all(path).unwrap();
|
||||
fs::write(path.join("welcome.md"), "# Welcome\n").unwrap();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.email", "tolaria@app.local"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", "Tolaria App"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_clones_local_repository() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("source");
|
||||
let dest = dir.path().join("dest");
|
||||
init_source_repo(&source);
|
||||
|
||||
let result = clone_repo(source.to_str().unwrap(), dest.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(result, format!("Cloned to {}", dest.to_string_lossy()));
|
||||
assert!(dest.join(".git").exists());
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_nonempty_dest() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
fs::write(dir.path().join("existing.txt"), "data").unwrap();
|
||||
|
||||
let result = clone_repo("https://example.com/repo.git", dir.path().to_str().unwrap());
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_empty_dest_allowed() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("empty-dir");
|
||||
fs::create_dir(&dest).unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://example.com/nonexistent/repo.git",
|
||||
dest.to_str().unwrap(),
|
||||
);
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
mod clone;
|
||||
mod commit;
|
||||
mod conflict;
|
||||
mod dates;
|
||||
@@ -10,7 +9,6 @@ mod status;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub use clone::clone_repo;
|
||||
pub use commit::git_commit;
|
||||
pub use conflict::{
|
||||
get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict,
|
||||
@@ -37,7 +35,7 @@ pub struct GitCommit {
|
||||
pub date: i64,
|
||||
}
|
||||
|
||||
const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never commit)\n\
|
||||
const DEFAULT_GITIGNORE: &str = "# Laputa app files (machine-specific, never commit)\n\
|
||||
.laputa/settings.json\n\
|
||||
\n\
|
||||
# macOS\n\
|
||||
@@ -103,10 +101,7 @@ fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
||||
|
||||
/// Set local user.name and user.email if not already configured.
|
||||
fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [
|
||||
("user.name", "Tolaria"),
|
||||
("user.email", "vault@tolaria.app"),
|
||||
] {
|
||||
for (key, fallback) in [("user.name", "Laputa"), ("user.email", "vault@laputa.app")] {
|
||||
let check = Command::new("git")
|
||||
.args(["config", key])
|
||||
.current_dir(dir)
|
||||
@@ -124,27 +119,28 @@ fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
/// Extract "owner/repo" from a GitHub remote URL.
|
||||
/// Supports HTTPS (https://github.com/owner/repo.git) and
|
||||
/// SSH (git@github.com:owner/repo.git) formats.
|
||||
fn normalize_github_repo_path(repo_path: &str) -> Option<String> {
|
||||
let repo_path = repo_path.strip_suffix(".git").unwrap_or(repo_path);
|
||||
repo_path.contains('/').then(|| repo_path.to_string())
|
||||
}
|
||||
|
||||
fn github_remote_suffix(url: &str) -> Option<&str> {
|
||||
const GITHUB_PREFIXES: [&str; 4] = [
|
||||
"git@github.com:",
|
||||
"https://github.com/",
|
||||
"http://github.com/",
|
||||
"ssh://git@github.com/",
|
||||
];
|
||||
|
||||
GITHUB_PREFIXES
|
||||
.iter()
|
||||
.find_map(|prefix| url.strip_prefix(prefix))
|
||||
.or_else(|| url.split_once("@github.com/").map(|(_, suffix)| suffix))
|
||||
}
|
||||
|
||||
fn parse_github_repo_path(url: &str) -> Option<String> {
|
||||
github_remote_suffix(url.trim()).and_then(normalize_github_repo_path)
|
||||
let trimmed = url.trim();
|
||||
|
||||
// SSH format: git@github.com:owner/repo.git
|
||||
if let Some(rest) = trimmed.strip_prefix("git@github.com:") {
|
||||
let path = rest.strip_suffix(".git").unwrap_or(rest);
|
||||
if path.contains('/') {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPS format: https://github.com/owner/repo.git
|
||||
// Also handle token-embedded URLs: https://token@github.com/owner/repo.git
|
||||
if trimmed.contains("github.com/") {
|
||||
let after = trimmed.split("github.com/").nth(1)?;
|
||||
let path = after.strip_suffix(".git").unwrap_or(after);
|
||||
if path.contains('/') {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -154,13 +150,6 @@ mod tests {
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn assert_repo_path(url: &str, expected: Option<&str>) {
|
||||
assert_eq!(
|
||||
parse_github_repo_path(url),
|
||||
expected.map(ToString::to_string)
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn setup_git_repo() -> TempDir {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
@@ -356,23 +345,42 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_variants() {
|
||||
for url in [
|
||||
"https://github.com/owner/repo.git",
|
||||
"https://github.com/owner/repo",
|
||||
"http://github.com/owner/repo.git",
|
||||
"git@github.com:owner/repo.git",
|
||||
"git@github.com:owner/repo",
|
||||
"ssh://git@github.com/owner/repo.git",
|
||||
"https://gho_abc123@github.com/owner/repo.git",
|
||||
] {
|
||||
assert_repo_path(url, Some("owner/repo"));
|
||||
}
|
||||
fn test_parse_github_repo_path_https() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://github.com/owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://github.com/owner/repo"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_ssh() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("git@github.com:owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_repo_path("git@github.com:owner/repo"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_token_embedded() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://gho_abc123@github.com/owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_non_github() {
|
||||
assert_repo_path("https://gitlab.com/owner/repo.git", None);
|
||||
assert_repo_path("owner/repo", None);
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://gitlab.com/owner/repo.git"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
326
src-tauri/src/github/api.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
use super::{GitHubUser, GithubRepo};
|
||||
|
||||
/// Lists the authenticated user's GitHub repositories.
|
||||
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
github_list_repos_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_list_repos_with_base(
|
||||
token: &str,
|
||||
api_base: &str,
|
||||
) -> Result<Vec<GithubRepo>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut all_repos: Vec<GithubRepo> = Vec::new();
|
||||
let mut page = 1u32;
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"{}/user/repos?per_page=100&sort=updated&page={}",
|
||||
api_base, page
|
||||
);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
let repos: Vec<GithubRepo> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
|
||||
|
||||
let count = repos.len();
|
||||
all_repos.extend(repos);
|
||||
|
||||
if count < 100 {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
if page > 10 {
|
||||
break; // safety limit: 1000 repos max
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_repos)
|
||||
}
|
||||
|
||||
/// Creates a new GitHub repository for the authenticated user.
|
||||
pub async fn github_create_repo(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github_create_repo_with_base(token, name, private, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_create_repo_with_base(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
api_base: &str,
|
||||
) -> Result<GithubRepo, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"private": private,
|
||||
"auto_init": true,
|
||||
"description": "Laputa vault"
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/user/repos", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 422 && body.contains("name already exists") {
|
||||
return Err("Repository name already exists on your account".to_string());
|
||||
}
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GithubRepo>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))
|
||||
}
|
||||
|
||||
/// Gets the authenticated GitHub user's profile.
|
||||
pub async fn github_get_user(token: &str) -> Result<GitHubUser, String> {
|
||||
github_get_user_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(format!("{}/user", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub user request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GitHubUser>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse user response: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn mock_json(
|
||||
method: &str,
|
||||
path: &str,
|
||||
status: usize,
|
||||
body: &str,
|
||||
) -> (mockito::ServerGuard, mockito::Mock) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock(method, path)
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
(server, mock)
|
||||
}
|
||||
|
||||
async fn mock_list_repos(status: usize, body: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
let (server, mock) = mock_json(
|
||||
"GET",
|
||||
"/user/repos?per_page=100&sort=updated&page=1",
|
||||
status,
|
||||
body,
|
||||
)
|
||||
.await;
|
||||
let result = github_list_repos_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_create_repo(status: usize, body: &str) -> Result<GithubRepo, String> {
|
||||
let (server, mock) = mock_json("POST", "/user/repos", status, body).await;
|
||||
let result = github_create_repo_with_base("token", "repo", false, &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_user(status: usize, body: &str) -> Result<GitHubUser, String> {
|
||||
let (server, mock) = mock_json("GET", "/user", status, body).await;
|
||||
let result = github_get_user_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_success() {
|
||||
let repos = mock_list_repos(
|
||||
200,
|
||||
r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].name, "my-repo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_empty() {
|
||||
let repos = mock_list_repos(200, "[]").await.unwrap();
|
||||
assert!(repos.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_auth_error() {
|
||||
let err = mock_list_repos(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_paginated() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let repos_page1: Vec<serde_json::Value> = (0..100)
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"name": format!("repo-{}", i),
|
||||
"full_name": format!("user/repo-{}", i),
|
||||
"description": null,
|
||||
"private": false,
|
||||
"clone_url": format!("https://github.com/user/repo-{}.git", i),
|
||||
"html_url": format!("https://github.com/user/repo-{}", i),
|
||||
"updated_at": null
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mock1 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::to_string(&repos_page1).unwrap())
|
||||
.create_async()
|
||||
.await;
|
||||
let mock2 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=2")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let repos = github_list_repos_with_base("token", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock1.assert_async().await;
|
||||
mock2.assert_async().await;
|
||||
|
||||
assert_eq!(repos.len(), 101);
|
||||
assert_eq!(repos[100].name, "extra-repo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_success() {
|
||||
let repo = mock_create_repo(
|
||||
201,
|
||||
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Laputa vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repo.name, "new-repo");
|
||||
assert!(repo.private);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_name_exists() {
|
||||
let err = mock_create_repo(
|
||||
422,
|
||||
r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Repository name already exists"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_server_error() {
|
||||
let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 500"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_success() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
user,
|
||||
GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_unauthorized() {
|
||||
let err = mock_user(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 401"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_null_name() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user.login, "bot-account");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
242
src-tauri/src/github/auth.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{DeviceFlowPollResult, DeviceFlowStart, GITHUB_CLIENT_ID};
|
||||
|
||||
/// Starts the GitHub OAuth device flow. Returns device code info for user authorization.
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github_device_flow_start_with_base("https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/device/code", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 404 {
|
||||
return Err(
|
||||
"GitHub device flow not available. Ensure a GitHub App is registered with \
|
||||
'Device authorization flow' enabled (Settings → Developer settings → GitHub Apps)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return Err(format!("Device flow start failed ({}): {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<DeviceFlowStart>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse device flow response: {}", e))
|
||||
}
|
||||
|
||||
/// Polls GitHub for the device flow authorization result.
|
||||
pub async fn github_device_flow_poll(device_code: &str) -> Result<DeviceFlowPollResult, String> {
|
||||
github_device_flow_poll_with_base(device_code, "https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_poll_with_base(
|
||||
device_code: &str,
|
||||
base_url: &str,
|
||||
) -> Result<DeviceFlowPollResult, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/oauth/access_token", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.form(&[
|
||||
("client_id", GITHUB_CLIENT_ID),
|
||||
("device_code", device_code),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow poll failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Device flow poll HTTP error: {}", body));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawResponse {
|
||||
access_token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
let raw: RawResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse poll response: {}", e))?;
|
||||
|
||||
if let Some(token) = raw.access_token {
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some(token),
|
||||
error: None,
|
||||
})
|
||||
} else {
|
||||
let error = raw.error.unwrap_or_else(|| "unknown".to_string());
|
||||
let status = match error.as_str() {
|
||||
"authorization_pending" | "slow_down" => "pending",
|
||||
"expired_token" => "expired",
|
||||
_ => "error",
|
||||
};
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: status.to_string(),
|
||||
access_token: None,
|
||||
error: Some(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn mock_json(
|
||||
method: &str,
|
||||
path: &str,
|
||||
status: usize,
|
||||
body: &str,
|
||||
) -> (mockito::ServerGuard, mockito::Mock) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock(method, path)
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
(server, mock)
|
||||
}
|
||||
|
||||
async fn mock_device_start(status: usize, body: &str) -> Result<DeviceFlowStart, String> {
|
||||
let (server, mock) = mock_json("POST", "/login/device/code", status, body).await;
|
||||
let result = github_device_flow_start_with_base(&server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_poll(body: &str) -> DeviceFlowPollResult {
|
||||
let (server, mock) = mock_json("POST", "/login/oauth/access_token", 200, body).await;
|
||||
let result = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_success() {
|
||||
let start = mock_device_start(
|
||||
200,
|
||||
r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
start,
|
||||
DeviceFlowStart {
|
||||
device_code: "dev_abc".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_error() {
|
||||
let err = mock_device_start(400, "bad request").await.unwrap_err();
|
||||
assert!(err.contains("Device flow start failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_404_gives_clear_message() {
|
||||
let err = mock_device_start(404, r#"{"error":"Not Found"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("device flow not available"));
|
||||
assert!(err.contains("Device authorization flow"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_complete() {
|
||||
let poll =
|
||||
mock_poll(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#)
|
||||
.await;
|
||||
assert_eq!(
|
||||
poll,
|
||||
DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_secret123".to_string()),
|
||||
error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_pending() {
|
||||
let poll = mock_poll(
|
||||
r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("authorization_pending".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_slow_down() {
|
||||
let poll = mock_poll(r#"{"error":"slow_down"}"#).await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("slow_down".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_expired() {
|
||||
let poll = mock_poll(r#"{"error":"expired_token"}"#).await;
|
||||
assert_eq!(poll.status, "expired");
|
||||
assert_eq!(poll.error, Some("expired_token".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_other_error() {
|
||||
let poll = mock_poll(r#"{"error":"access_denied"}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("access_denied".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_http_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(503)
|
||||
.with_body("Service Unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap_err();
|
||||
mock.assert_async().await;
|
||||
assert!(err.contains("Device flow poll HTTP error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_unknown_error() {
|
||||
let poll = mock_poll(r#"{}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("unknown".to_string()));
|
||||
}
|
||||
}
|
||||
324
src-tauri/src/github/clone.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Clones a GitHub repo to a local path using HTTPS + token auth.
|
||||
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
prepare_clone_destination(dest, local_path)?;
|
||||
|
||||
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
|
||||
let auth_url = inject_token_into_url(url, token)?;
|
||||
|
||||
if let Err(err) = run_clone(&auth_url, local_path) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Configure the remote to use token auth for future pushes
|
||||
if let Err(err) = configure_remote_auth(local_path, url, token) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Ensure sensible .gitignore defaults (especially .DS_Store on macOS)
|
||||
crate::git::ensure_gitignore(local_path)?;
|
||||
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Clones a public repo to a local path without modifying the remote URL.
|
||||
pub fn clone_public_repo(url: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
prepare_clone_destination(dest, local_path)?;
|
||||
|
||||
if let Err(err) = run_clone(url, local_path) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Injects an OAuth token into an HTTPS GitHub URL.
|
||||
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
|
||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
||||
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
|
||||
} else if let Some(rest) = url.strip_prefix("https://") {
|
||||
// Handle URLs that already have a host
|
||||
Ok(format!("https://oauth2:{}@{}", token, rest))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Unsupported URL format: {}. Use an HTTPS URL.",
|
||||
url
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_clone_destination(dest: &Path, local_path: &str) -> Result<(), String> {
|
||||
if dest.exists() {
|
||||
if !dest.is_dir() {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not a directory",
|
||||
local_path
|
||||
));
|
||||
}
|
||||
let has_entries = dest
|
||||
.read_dir()
|
||||
.map_err(|e| format!("Failed to inspect destination '{}': {}", local_path, e))?
|
||||
.next()
|
||||
.is_some();
|
||||
if has_entries {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
local_path
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create parent directory for '{}': {}",
|
||||
local_path, e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_clone(url: &str, local_path: &str) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", url, local_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("git clone failed: {}", stderr.trim()))
|
||||
}
|
||||
|
||||
fn cleanup_failed_clone(dest: &Path) {
|
||||
if dest.exists() && dest.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up the git remote to use token-based HTTPS auth.
|
||||
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
|
||||
let auth_url = inject_token_into_url(original_url, token)?;
|
||||
let vault = Path::new(local_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "set-url", "origin", &auth_url])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to configure remote: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to set remote URL: {}", stderr));
|
||||
}
|
||||
|
||||
// Also configure git user if not set
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.email", "laputa@app.local"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
fn clone_err_contains(url: &str, expected: &str) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("dest");
|
||||
let result = clone_repo(url, "token", dest.to_str().unwrap());
|
||||
assert!(result.unwrap_err().contains(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_basic_github_url() {
|
||||
let result = inject_token_into_url("https://github.com/user/repo.git", "gho_abc123");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"https://oauth2:gho_abc123@github.com/user/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_generic_https_url() {
|
||||
let result = inject_token_into_url("https://gitlab.com/user/repo.git", "glpat-abc");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"https://oauth2:glpat-abc@gitlab.com/user/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_ssh_url_rejected() {
|
||||
let err = inject_token_into_url("git@github.com:user/repo.git", "token").unwrap_err();
|
||||
assert!(err.contains("Unsupported URL format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_http_url_rejected() {
|
||||
assert!(inject_token_into_url("http://github.com/user/repo.git", "token").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_github_without_dot_git() {
|
||||
let result = inject_token_into_url("https://github.com/user/repo", "tok");
|
||||
assert_eq!(result.unwrap(), "https://oauth2:tok@github.com/user/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_nonempty_dest() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("existing.txt"), "data").unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://github.com/test/repo.git",
|
||||
"token",
|
||||
dir.path().to_str().unwrap(),
|
||||
);
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_ssh_url_rejected() {
|
||||
clone_err_contains("git@github.com:user/repo.git", "Unsupported URL format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_empty_dest_allowed() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("empty-dir");
|
||||
std::fs::create_dir(&dest).unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://github.com/nonexistent/repo.git",
|
||||
"token",
|
||||
dest.to_str().unwrap(),
|
||||
);
|
||||
// Should fail at git clone, not at directory check
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_configure_remote_auth_on_git_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/user/repo.git",
|
||||
])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
configure_remote_auth(
|
||||
path.to_str().unwrap(),
|
||||
"https://github.com/user/repo.git",
|
||||
"gho_test123",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = StdCommand::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
|
||||
}
|
||||
|
||||
fn init_local_repo(path: &Path) {
|
||||
std::fs::create_dir_all(path).unwrap();
|
||||
std::fs::write(path.join("welcome.md"), "# Welcome\n").unwrap();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.email", "laputa@app.local"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["commit", "-m", "Initial vault"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_public_repo_clones_local_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("source");
|
||||
let dest = dir.path().join("dest");
|
||||
init_local_repo(&source);
|
||||
|
||||
let result = clone_public_repo(source.to_str().unwrap(), dest.to_str().unwrap());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
format!("Cloned to {}", dest.to_string_lossy())
|
||||
);
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
|
||||
let status = StdCommand::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&dest)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&status.stdout).trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_public_repo_cleans_failed_clone_destination() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("dest");
|
||||
let missing = dir.path().join("missing-repo");
|
||||
|
||||
let result = clone_public_repo(missing.to_str().unwrap(), dest.to_str().unwrap());
|
||||
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
}
|
||||
139
src-tauri/src/github/mod.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
mod api;
|
||||
mod auth;
|
||||
mod clone;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use api::{github_create_repo, github_get_user, github_list_repos};
|
||||
pub use auth::{github_device_flow_poll, github_device_flow_start};
|
||||
pub use clone::{clone_public_repo, clone_repo};
|
||||
|
||||
/// GitHub App client ID for OAuth device flow.
|
||||
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
|
||||
/// Enable "Device authorization flow" under Optional features. Webhook can be disabled.
|
||||
const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GithubRepo {
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub description: Option<String>,
|
||||
pub private: bool,
|
||||
pub clone_url: String,
|
||||
pub html_url: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowStart {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowPollResult {
|
||||
pub status: String,
|
||||
pub access_token: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GitHubUser {
|
||||
pub login: String,
|
||||
pub name: Option<String>,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_serialization_roundtrip() {
|
||||
let repo = GithubRepo {
|
||||
name: "test-repo".to_string(),
|
||||
full_name: "user/test-repo".to_string(),
|
||||
description: Some("A test repo".to_string()),
|
||||
private: true,
|
||||
clone_url: "https://github.com/user/test-repo.git".to_string(),
|
||||
html_url: "https://github.com/user/test-repo".to_string(),
|
||||
updated_at: Some("2026-02-20T10:00:00Z".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&repo).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GithubRepo>(&json).unwrap(), repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_deserialization_null_fields() {
|
||||
let json = r#"{"name":"r","full_name":"u/r","description":null,"private":false,"clone_url":"https://x","html_url":"https://y","updated_at":null}"#;
|
||||
let repo: GithubRepo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(repo.name, "r");
|
||||
assert!(!repo.private);
|
||||
assert!(repo.description.is_none());
|
||||
assert!(repo.updated_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_start_serialization_roundtrip() {
|
||||
let start = DeviceFlowStart {
|
||||
device_code: "dc_123".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
};
|
||||
let json = serde_json::to_string(&start).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowStart>(&json).unwrap(),
|
||||
start
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_poll_result_roundtrip() {
|
||||
let complete = DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_abc123".to_string()),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&complete).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
complete
|
||||
);
|
||||
|
||||
let pending = DeviceFlowPollResult {
|
||||
status: "pending".to_string(),
|
||||
access_token: None,
|
||||
error: Some("authorization_pending".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&pending).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
pending
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_serialization_roundtrip() {
|
||||
let user = GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&user).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GitHubUser>(&json).unwrap(), user);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_deserialization_null_name() {
|
||||
let user: GitHubUser =
|
||||
serde_json::from_str(r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#)
|
||||
.unwrap();
|
||||
assert_eq!(user.login, "bot");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod claude_cli;
|
||||
mod commands;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod mcp;
|
||||
#[cfg(desktop)]
|
||||
pub mod menu;
|
||||
@@ -47,7 +48,7 @@ fn run_startup_tasks() {
|
||||
// Seed AGENTS.md and config.md at vault root if missing
|
||||
vault::seed_config_files(vp_str);
|
||||
|
||||
// Register Tolaria MCP server in Claude Code and Cursor configs
|
||||
// Register Laputa MCP server in Claude Code and Cursor configs
|
||||
match mcp::register_mcp(vp_str) {
|
||||
Ok(status) => log::info!("MCP registration: {status}"),
|
||||
Err(e) => log::warn!("MCP registration failed: {e}"),
|
||||
@@ -85,7 +86,6 @@ fn setup_common_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
setup_macos_webview_shortcut_prevention(app)?;
|
||||
app.handle()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
app.handle().plugin(tauri_plugin_process::init())?;
|
||||
@@ -94,35 +94,6 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];
|
||||
|
||||
#[cfg(all(desktop, target_os = "macos"))]
|
||||
fn setup_macos_webview_shortcut_prevention(
|
||||
app: &mut tauri::App,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use tauri_plugin_prevent_default::ModifierKey::{MetaKey, ShiftKey};
|
||||
use tauri_plugin_prevent_default::{Flags, KeyboardShortcut};
|
||||
|
||||
let mut builder = tauri_plugin_prevent_default::Builder::new().with_flags(Flags::empty());
|
||||
|
||||
// WKWebView can swallow some browser-reserved chords before our shared
|
||||
// renderer shortcut handler sees them. Keep this list narrow and verify
|
||||
// every addition with native QA.
|
||||
for key in MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS {
|
||||
builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey, ShiftKey]));
|
||||
}
|
||||
|
||||
app.handle().plugin(builder.build())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(desktop, target_os = "macos")))]
|
||||
fn setup_macos_webview_shortcut_prevention(
|
||||
_app: &mut tauri::App,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
setup_common_plugins(app)?;
|
||||
|
||||
@@ -142,72 +113,6 @@ fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_invoke_handler(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
builder.invoke_handler(tauri::generate_handler![
|
||||
commands::list_vault,
|
||||
commands::list_vault_folders,
|
||||
commands::get_note_content,
|
||||
commands::save_note_content,
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::rename_note_filename,
|
||||
commands::auto_rename_untitled,
|
||||
commands::detect_renames,
|
||||
commands::update_wikilinks_for_renames,
|
||||
commands::get_file_history,
|
||||
commands::get_modified_files,
|
||||
commands::get_file_diff,
|
||||
commands::get_file_diff_at_commit,
|
||||
commands::get_vault_pulse,
|
||||
commands::git_commit,
|
||||
commands::get_build_number,
|
||||
commands::get_last_commit_info,
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::git_remote_status,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
commands::git_commit_conflict_resolution,
|
||||
commands::git_discard_file,
|
||||
commands::is_git_repo,
|
||||
commands::init_git_repo,
|
||||
commands::check_claude_cli,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::sync_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::delete_note,
|
||||
commands::batch_delete_notes,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::create_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::get_settings,
|
||||
commands::update_menu_state,
|
||||
commands::trigger_menu_command,
|
||||
commands::save_settings,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
commands::clone_repo,
|
||||
commands::search_vault,
|
||||
commands::create_empty_vault,
|
||||
commands::create_getting_started_vault,
|
||||
commands::check_vault_exists,
|
||||
commands::get_default_vault_path,
|
||||
commands::register_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
|
||||
use tauri::Manager;
|
||||
@@ -229,8 +134,74 @@ pub fn run() {
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.manage(WsBridgeChild(Mutex::new(None)));
|
||||
|
||||
with_invoke_handler(builder)
|
||||
builder
|
||||
.setup(setup_app)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::list_vault,
|
||||
commands::list_vault_folders,
|
||||
commands::get_note_content,
|
||||
commands::save_note_content,
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::auto_rename_untitled,
|
||||
commands::detect_renames,
|
||||
commands::update_wikilinks_for_renames,
|
||||
commands::get_file_history,
|
||||
commands::get_modified_files,
|
||||
commands::get_file_diff,
|
||||
commands::get_file_diff_at_commit,
|
||||
commands::get_vault_pulse,
|
||||
commands::git_commit,
|
||||
commands::get_build_number,
|
||||
commands::get_last_commit_info,
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::git_remote_status,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
commands::git_commit_conflict_resolution,
|
||||
commands::git_discard_file,
|
||||
commands::is_git_repo,
|
||||
commands::init_git_repo,
|
||||
commands::check_claude_cli,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::sync_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::delete_note,
|
||||
commands::batch_delete_notes,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::create_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::get_settings,
|
||||
commands::update_menu_state,
|
||||
commands::save_settings,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
commands::github_list_repos,
|
||||
commands::github_create_repo,
|
||||
commands::clone_repo,
|
||||
commands::github_device_flow_start,
|
||||
commands::github_device_flow_poll,
|
||||
commands::github_get_user,
|
||||
commands::search_vault,
|
||||
commands::create_empty_vault,
|
||||
commands::create_getting_started_vault,
|
||||
commands::check_vault_exists,
|
||||
commands::get_default_vault_path,
|
||||
commands::register_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
@@ -238,13 +209,3 @@ pub fn run() {
|
||||
handle_run_event(app_handle, &event);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
|
||||
|
||||
#[test]
|
||||
fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() {
|
||||
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tolaria_lib::run();
|
||||
laputa_lib::run();
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
|
||||
const MCP_SERVER_NAME: &str = "tolaria";
|
||||
const LEGACY_MCP_SERVER_NAME: &str = "laputa";
|
||||
|
||||
/// Status of the MCP server installation.
|
||||
#[derive(Debug, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -23,45 +20,11 @@ pub(crate) fn find_node() -> Result<PathBuf, String> {
|
||||
.arg("node")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run `which node`: {e}"))?;
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err("node not found in PATH".into());
|
||||
}
|
||||
|
||||
if let Some(path) = fallback_node_path() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
Err("node not found in PATH or common install locations".into())
|
||||
}
|
||||
|
||||
fn fallback_node_path() -> Option<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("/opt/homebrew/bin/node"),
|
||||
PathBuf::from("/usr/local/bin/node"),
|
||||
];
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.push(home.join(".volta").join("bin").join("node"));
|
||||
|
||||
let nvm_dir = home.join(".nvm").join("versions").join("node");
|
||||
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
|
||||
let mut versions = entries
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.collect::<Vec<_>>();
|
||||
versions.sort();
|
||||
versions.reverse();
|
||||
candidates.extend(
|
||||
versions
|
||||
.into_iter()
|
||||
.map(|version| version.join("bin").join("node")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.into_iter().find(|path| path.is_file())
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
|
||||
/// Resolve the path to `mcp-server/`.
|
||||
@@ -139,7 +102,7 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
|
||||
status.to_string()
|
||||
}
|
||||
|
||||
/// Register Tolaria as an MCP server in Claude Code and Cursor config files.
|
||||
/// Register Laputa as an MCP server in Claude Code and Cursor config files.
|
||||
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
|
||||
@@ -157,7 +120,7 @@ pub fn register_mcp(vault_path: &str) -> Result<String, String> {
|
||||
Ok(register_mcp_to_configs(&entry, &configs))
|
||||
}
|
||||
|
||||
/// Insert or update the Tolaria entry in an MCP config file.
|
||||
/// Insert or update the "laputa" entry in an MCP config file.
|
||||
fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bool, String> {
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
@@ -179,14 +142,12 @@ fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bo
|
||||
.entry("mcpServers")
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
|
||||
let servers = servers
|
||||
.as_object_mut()
|
||||
.ok_or("mcpServers is not a JSON object")?;
|
||||
let was_update = servers.get("laputa").is_some();
|
||||
|
||||
let was_update =
|
||||
servers.get(MCP_SERVER_NAME).is_some() || servers.get(LEGACY_MCP_SERVER_NAME).is_some();
|
||||
servers.remove(LEGACY_MCP_SERVER_NAME);
|
||||
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
|
||||
servers
|
||||
.as_object_mut()
|
||||
.ok_or("mcpServers is not a JSON object")?
|
||||
.insert("laputa".to_string(), entry.clone());
|
||||
|
||||
let json = serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("Failed to serialize config: {e}"))?;
|
||||
@@ -198,7 +159,7 @@ fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bo
|
||||
|
||||
/// Check whether the MCP server is properly installed and registered.
|
||||
///
|
||||
/// Returns `Installed` when the Tolaria entry exists in `~/.claude/mcp.json`
|
||||
/// Returns `Installed` when the laputa entry exists in `~/.claude/mcp.json`
|
||||
/// and the referenced index.js file is present. Returns `NoClaudeCli` when
|
||||
/// the Claude CLI binary cannot be found. Otherwise returns `NotInstalled`.
|
||||
pub fn check_mcp_status() -> McpStatus {
|
||||
@@ -226,15 +187,10 @@ pub fn check_mcp_status() -> McpStatus {
|
||||
Err(_) => return McpStatus::NotInstalled,
|
||||
};
|
||||
|
||||
let Some(servers) = config.get("mcpServers").and_then(|value| value.as_object()) else {
|
||||
let entry = &config["mcpServers"]["laputa"];
|
||||
if entry.is_null() {
|
||||
return McpStatus::NotInstalled;
|
||||
};
|
||||
let Some(entry) = servers
|
||||
.get(MCP_SERVER_NAME)
|
||||
.or_else(|| servers.get(LEGACY_MCP_SERVER_NAME))
|
||||
else {
|
||||
return McpStatus::NotInstalled;
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the referenced index.js actually exists on disk
|
||||
if let Some(index_js) = entry["args"]
|
||||
@@ -275,12 +231,9 @@ mod tests {
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/test/index.js"
|
||||
);
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
|
||||
config["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
|
||||
"/test/vault"
|
||||
);
|
||||
}
|
||||
@@ -300,40 +253,11 @@ mod tests {
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
|
||||
config["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
|
||||
"/vault/v2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_migrates_legacy_server_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
|
||||
let existing = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"laputa": {
|
||||
"command": "node",
|
||||
"args": ["/old/index.js"],
|
||||
"env": { "VAULT_PATH": "/old" }
|
||||
}
|
||||
}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(was_update);
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert!(config["mcpServers"][LEGACY_MCP_SERVER_NAME].is_null());
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/test/index.js"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_other_servers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -352,7 +276,7 @@ mod tests {
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert!(config["mcpServers"]["other-server"].is_object());
|
||||
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
|
||||
assert!(config["mcpServers"]["laputa"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -434,10 +358,7 @@ mod tests {
|
||||
|
||||
let raw = std::fs::read_to_string(&claude_cfg).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/test/index.js"
|
||||
);
|
||||
assert_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
|
||||
}
|
||||
#[test]
|
||||
fn upsert_returns_error_for_invalid_json() {
|
||||
|
||||
@@ -35,7 +35,6 @@ const GO_ARCHIVED: &str = "go-archived";
|
||||
const GO_CHANGES: &str = "go-changes";
|
||||
const GO_INBOX: &str = "go-inbox";
|
||||
|
||||
const NOTE_TOGGLE_ORGANIZED: &str = "note-toggle-organized";
|
||||
const NOTE_ARCHIVE: &str = "note-archive";
|
||||
const NOTE_DELETE: &str = "note-delete";
|
||||
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
|
||||
@@ -78,8 +77,6 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
GO_ALL_NOTES,
|
||||
GO_ARCHIVED,
|
||||
GO_CHANGES,
|
||||
GO_INBOX,
|
||||
NOTE_TOGGLE_ORGANIZED,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_DELETE,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
@@ -99,7 +96,6 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
/// IDs of menu items that should be disabled when no note tab is active.
|
||||
const NOTE_DEPENDENT_IDS: &[&str] = &[
|
||||
FILE_SAVE,
|
||||
NOTE_TOGGLE_ORGANIZED,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_DELETE,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
@@ -128,7 +124,7 @@ fn build_app_menu(app: &App) -> MenuResult {
|
||||
.id(APP_CHECK_FOR_UPDATES)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Tolaria")
|
||||
Ok(SubmenuBuilder::new(app, "Laputa")
|
||||
.about(None)
|
||||
.separator()
|
||||
.item(&check_updates_item)
|
||||
@@ -212,8 +208,6 @@ fn build_view_menu(app: &App) -> MenuResult {
|
||||
.id(VIEW_ALL)
|
||||
.accelerator("CmdOrCtrl+3")
|
||||
.build(app)?;
|
||||
// Keep Cmd+Shift+I on the renderer path. The menu item stays available,
|
||||
// but the native accelerator has proven unreliable for this command.
|
||||
let toggle_properties = MenuItemBuilder::new("Toggle Properties Panel")
|
||||
.id(VIEW_TOGGLE_PROPERTIES)
|
||||
.build(app)?;
|
||||
@@ -279,12 +273,9 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
}
|
||||
|
||||
fn build_note_menu(app: &App) -> MenuResult {
|
||||
let toggle_organized = MenuItemBuilder::new("Toggle Organized")
|
||||
.id(NOTE_TOGGLE_ORGANIZED)
|
||||
.accelerator("CmdOrCtrl+E")
|
||||
.build(app)?;
|
||||
let archive_note = MenuItemBuilder::new("Archive Note")
|
||||
.id(NOTE_ARCHIVE)
|
||||
.accelerator("CmdOrCtrl+E")
|
||||
.build(app)?;
|
||||
let delete_note = MenuItemBuilder::new("Delete Note")
|
||||
.id(NOTE_DELETE)
|
||||
@@ -304,14 +295,13 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
|
||||
.id(VIEW_TOGGLE_AI_CHAT)
|
||||
.accelerator("Cmd+Shift+L")
|
||||
.accelerator("CmdOrCtrl+Shift+L")
|
||||
.build(app)?;
|
||||
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
|
||||
.id(VIEW_TOGGLE_BACKLINKS)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Note")
|
||||
.item(&toggle_organized)
|
||||
.item(&archive_note)
|
||||
.item(&delete_note)
|
||||
.item(&restore_deleted_note)
|
||||
@@ -407,21 +397,14 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
app.on_menu_event(|app_handle, event| {
|
||||
let id = event.id().0.as_str();
|
||||
let _ = emit_custom_menu_event(app_handle, id);
|
||||
if CUSTOM_IDS.contains(&id) {
|
||||
let _ = app_handle.emit("menu-event", id);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn emit_custom_menu_event(app_handle: &AppHandle, id: &str) -> Result<(), String> {
|
||||
if !CUSTOM_IDS.contains(&id) {
|
||||
return Err(format!("Unknown custom menu event: {id}"));
|
||||
}
|
||||
app_handle
|
||||
.emit("menu-event", id)
|
||||
.map_err(|err| format!("Failed to emit menu-event {id}: {err}"))
|
||||
}
|
||||
|
||||
fn set_items_enabled(app_handle: &AppHandle, ids: &[&str], enabled: bool) {
|
||||
let Some(menu) = app_handle.menu() else {
|
||||
return;
|
||||
|
||||
@@ -2,11 +2,10 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const APP_CONFIG_DIR: &str = "com.tolaria.app";
|
||||
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
pub github_token: Option<String>,
|
||||
pub github_username: Option<String>,
|
||||
pub auto_pull_interval_minutes: Option<u32>,
|
||||
pub telemetry_consent: Option<bool>,
|
||||
pub crash_reporting_enabled: Option<bool>,
|
||||
@@ -15,32 +14,10 @@ pub struct Settings {
|
||||
pub release_channel: Option<String>,
|
||||
}
|
||||
|
||||
fn app_config_dir() -> Result<PathBuf, String> {
|
||||
dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
|
||||
Ok(app_config_dir()?.join(APP_CONFIG_DIR).join(file_name))
|
||||
}
|
||||
|
||||
fn resolve_existing_or_preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
|
||||
let preferred = preferred_app_config_path(file_name)?;
|
||||
if preferred.exists() {
|
||||
return Ok(preferred);
|
||||
}
|
||||
|
||||
let legacy = app_config_dir()?
|
||||
.join(LEGACY_APP_CONFIG_DIR)
|
||||
.join(file_name);
|
||||
if legacy.exists() {
|
||||
return Ok(legacy);
|
||||
}
|
||||
|
||||
Ok(preferred)
|
||||
}
|
||||
|
||||
fn settings_path() -> Result<PathBuf, String> {
|
||||
resolve_existing_or_preferred_app_config_path("settings.json")
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("settings.json"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn get_settings_at(path: &PathBuf) -> Result<Settings, String> {
|
||||
@@ -58,8 +35,16 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
|
||||
// Trim whitespace and convert empty strings to None.
|
||||
// Trim whitespace and convert empty strings to None
|
||||
let cleaned = Settings {
|
||||
github_token: settings
|
||||
.github_token
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
github_username: settings
|
||||
.github_username
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
|
||||
telemetry_consent: settings.telemetry_consent,
|
||||
crash_reporting_enabled: settings.crash_reporting_enabled,
|
||||
@@ -84,11 +69,13 @@ pub fn get_settings() -> Result<Settings, String> {
|
||||
}
|
||||
|
||||
pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
save_settings_at(&preferred_app_config_path("settings.json")?, settings)
|
||||
save_settings_at(&settings_path()?, settings)
|
||||
}
|
||||
|
||||
fn last_vault_file() -> Result<PathBuf, String> {
|
||||
resolve_existing_or_preferred_app_config_path("last-vault.txt")
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("last-vault.txt"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn get_last_vault_at(path: &PathBuf) -> Option<String> {
|
||||
@@ -112,40 +99,13 @@ pub fn get_last_vault() -> Option<String> {
|
||||
}
|
||||
|
||||
pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
|
||||
set_last_vault_at(&preferred_app_config_path("last-vault.txt")?, vault_path)
|
||||
set_last_vault_at(&last_vault_file()?, vault_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings_snapshot(
|
||||
settings: &Settings,
|
||||
) -> (
|
||||
Option<u32>,
|
||||
Option<bool>,
|
||||
Option<bool>,
|
||||
Option<bool>,
|
||||
Option<&str>,
|
||||
Option<&str>,
|
||||
) {
|
||||
(
|
||||
settings.auto_pull_interval_minutes,
|
||||
settings.telemetry_consent,
|
||||
settings.crash_reporting_enabled,
|
||||
settings.analytics_enabled,
|
||||
settings.anonymous_id.as_deref(),
|
||||
settings.release_channel.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_empty_settings(settings: &Settings) {
|
||||
assert_eq!(
|
||||
settings_snapshot(settings),
|
||||
(None, None, None, None, None, None)
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: save settings to a temp file and reload them.
|
||||
fn save_and_reload(settings: Settings) -> Settings {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
@@ -154,38 +114,37 @@ mod tests {
|
||||
get_settings_at(&path).unwrap()
|
||||
}
|
||||
|
||||
fn create_last_vault_path(path_parts: &[&str]) -> (tempfile::TempDir, PathBuf) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = path_parts
|
||||
.iter()
|
||||
.fold(dir.path().to_path_buf(), |acc, part| acc.join(part));
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn write_and_assert_last_vault(path: &PathBuf, value: &str) {
|
||||
set_last_vault_at(path, value).unwrap();
|
||||
assert_eq!(get_last_vault_at(path).as_deref(), Some(value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_settings_all_none() {
|
||||
assert_empty_settings(&Settings::default());
|
||||
let s = Settings::default();
|
||||
assert!(s.github_token.is_none());
|
||||
assert!(s.github_username.is_none());
|
||||
assert!(s.auto_pull_interval_minutes.is_none());
|
||||
assert!(s.telemetry_consent.is_none());
|
||||
assert!(s.crash_reporting_enabled.is_none());
|
||||
assert!(s.analytics_enabled.is_none());
|
||||
assert!(s.anonymous_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_json_roundtrip() {
|
||||
let settings = Settings {
|
||||
auto_pull_interval_minutes: Some(10),
|
||||
github_token: Some("gho_xyz789".to_string()),
|
||||
github_username: Some("lucaong".to_string()),
|
||||
telemetry_consent: Some(true),
|
||||
crash_reporting_enabled: Some(true),
|
||||
analytics_enabled: Some(false),
|
||||
anonymous_id: Some("abc-123-uuid".to_string()),
|
||||
release_channel: Some("beta".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
let parsed: Settings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(settings_snapshot(&parsed), settings_snapshot(&settings));
|
||||
assert_eq!(parsed.github_token, settings.github_token);
|
||||
assert_eq!(parsed.github_username, settings.github_username);
|
||||
assert_eq!(parsed.telemetry_consent, Some(true));
|
||||
assert_eq!(parsed.crash_reporting_enabled, Some(true));
|
||||
assert_eq!(parsed.analytics_enabled, Some(false));
|
||||
assert_eq!(parsed.anonymous_id.as_deref(), Some("abc-123-uuid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -193,38 +152,40 @@ mod tests {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nonexistent.json");
|
||||
let result = get_settings_at(&path).unwrap();
|
||||
assert!(result.auto_pull_interval_minutes.is_none());
|
||||
assert!(result.github_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_preserves_values() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
github_token: Some("gho_token123".to_string()),
|
||||
github_username: Some("lucaong".to_string()),
|
||||
auto_pull_interval_minutes: Some(10),
|
||||
release_channel: Some("alpha".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(loaded.github_token.as_deref(), Some("gho_token123"));
|
||||
assert_eq!(loaded.github_username.as_deref(), Some("lucaong"));
|
||||
assert_eq!(loaded.auto_pull_interval_minutes, Some(10));
|
||||
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_trims_whitespace() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
anonymous_id: Some(" test-uuid ".to_string()),
|
||||
release_channel: Some(" beta ".to_string()),
|
||||
github_token: Some(" gho_abc ".to_string()),
|
||||
github_username: Some(" lucaong ".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid"));
|
||||
assert_eq!(loaded.release_channel.as_deref(), Some("beta"));
|
||||
assert_eq!(loaded.github_token.as_deref(), Some("gho_abc"));
|
||||
assert_eq!(loaded.github_username.as_deref(), Some("lucaong"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_filters_empty_and_whitespace_only() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
release_channel: Some("".to_string()),
|
||||
github_username: Some("".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(loaded.release_channel.is_none());
|
||||
assert!(loaded.github_username.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -235,15 +196,15 @@ mod tests {
|
||||
save_settings_at(
|
||||
&path,
|
||||
Settings {
|
||||
anonymous_id: Some("test-uuid".to_string()),
|
||||
github_token: Some("gho_test".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(
|
||||
get_settings_at(&path).unwrap().anonymous_id.as_deref(),
|
||||
Some("test-uuid")
|
||||
get_settings_at(&path).unwrap().github_token.as_deref(),
|
||||
Some("gho_test")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -266,51 +227,31 @@ mod tests {
|
||||
anonymous_id: Some("test-uuid-v4".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
settings_snapshot(&loaded),
|
||||
(
|
||||
None,
|
||||
Some(true),
|
||||
Some(true),
|
||||
Some(false),
|
||||
Some("test-uuid-v4"),
|
||||
None
|
||||
)
|
||||
);
|
||||
assert_eq!(loaded.telemetry_consent, Some(true));
|
||||
assert_eq!(loaded.crash_reporting_enabled, Some(true));
|
||||
assert_eq!(loaded.analytics_enabled, Some(false));
|
||||
assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid-v4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_old_settings_json_missing_telemetry_fields() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("settings.json");
|
||||
// Simulate an old settings.json that still contains removed GitHub auth fields.
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"github_token":"gho_test","github_username":"lucaong"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Simulate old settings.json without telemetry fields
|
||||
fs::write(&path, r#"{"github_token":"gho_test"}"#).unwrap();
|
||||
let loaded = get_settings_at(&path).unwrap();
|
||||
assert_empty_settings(&loaded);
|
||||
assert_eq!(loaded.github_token.as_deref(), Some("gho_test"));
|
||||
assert!(loaded.telemetry_consent.is_none());
|
||||
assert!(loaded.crash_reporting_enabled.is_none());
|
||||
assert!(loaded.analytics_enabled.is_none());
|
||||
assert!(loaded.anonymous_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_path_returns_ok() {
|
||||
let result = settings_path();
|
||||
assert!(result.is_ok());
|
||||
let path = result.unwrap();
|
||||
let path = path.to_str().unwrap();
|
||||
assert!(path.contains("com.tolaria.app") || path.contains("com.laputa.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preferred_settings_path_uses_tolaria_namespace() {
|
||||
let result = preferred_app_config_path("settings.json");
|
||||
assert!(result.is_ok());
|
||||
assert!(result
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("com.tolaria.app"));
|
||||
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -322,14 +263,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_set_and_get_last_vault_roundtrip() {
|
||||
let (_dir, path) = create_last_vault_path(&["last-vault.txt"]);
|
||||
write_and_assert_last_vault(&path, "/Users/test/MyVault");
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/MyVault").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/MyVault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_trims_whitespace() {
|
||||
let (_dir, path) = create_last_vault_path(&["last-vault.txt"]);
|
||||
write_and_assert_last_vault(&path, "/Users/test/Vault");
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, " /Users/test/Vault ").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/Vault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -342,15 +293,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_creates_parent_directories() {
|
||||
let (_dir, path) = create_last_vault_path(&["nested", "dir", "last-vault.txt"]);
|
||||
write_and_assert_last_vault(&path, "/Users/test/Vault");
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nested").join("dir").join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/Vault").unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/Vault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_overwrites_previous() {
|
||||
let (_dir, path) = create_last_vault_path(&["last-vault.txt"]);
|
||||
write_and_assert_last_vault(&path, "/Users/test/OldVault");
|
||||
write_and_assert_last_vault(&path, "/Users/test/NewVault");
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/OldVault").unwrap();
|
||||
set_last_vault_at(&path, "/Users/test/NewVault").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/NewVault")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
/// v12: fix gray_matter YAML sanitization (unquoted colons / hash comments in list items)
|
||||
/// v13: preserve plain square brackets in parsed markdown H1 titles
|
||||
const CACHE_VERSION: u32 = 13;
|
||||
const CACHE_VERSION: u32 = 12;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
|
||||
@@ -159,7 +159,7 @@ mod tests {
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(content.contains("Tolaria Vault"));
|
||||
assert!(content.contains("Laputa Vault"));
|
||||
// Must NOT create config/ directory
|
||||
assert!(!vault.join("config").exists());
|
||||
}
|
||||
@@ -205,7 +205,7 @@ mod tests {
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(content.contains("Tolaria Vault"));
|
||||
assert!(content.contains("Laputa Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -272,7 +272,7 @@ mod tests {
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("Tolaria Vault"));
|
||||
assert!(root.contains("Laputa Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -305,7 +305,7 @@ mod tests {
|
||||
assert!(!vault.join("config").exists());
|
||||
|
||||
let agents = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(agents.contains("Tolaria Vault"));
|
||||
assert!(agents.contains("Laputa Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,9 +19,9 @@ pub fn vault_exists(path: &str) -> bool {
|
||||
/// Default AGENTS.md content — vault instructions for AI agents.
|
||||
/// Describes Laputa vault mechanics only; no vault-specific structure.
|
||||
/// The vault scanner will pick this up as a regular entry.
|
||||
pub(super) const AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
pub(super) const AGENTS_MD: &str = r##"# AGENTS.md — Laputa Vault
|
||||
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
This is a [Laputa](https://github.com/refactoringhq/laputa-app) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
## Note structure
|
||||
|
||||
@@ -115,13 +115,12 @@ fn create_getting_started_vault_from_repo(
|
||||
return Err("Target path is required".to_string());
|
||||
}
|
||||
|
||||
crate::git::clone_repo(repo_url, target_path)?;
|
||||
crate::github::clone_public_repo(repo_url, target_path)?;
|
||||
canonical_vault_path(target_path)
|
||||
}
|
||||
|
||||
fn getting_started_repo_url() -> String {
|
||||
std::env::var("TOLARIA_GETTING_STARTED_REPO_URL")
|
||||
.or_else(|_| std::env::var("LAPUTA_GETTING_STARTED_REPO_URL"))
|
||||
std::env::var("LAPUTA_GETTING_STARTED_REPO_URL")
|
||||
.unwrap_or_else(|_| GETTING_STARTED_REPO_URL.to_string())
|
||||
}
|
||||
|
||||
@@ -144,7 +143,7 @@ mod tests {
|
||||
fs::create_dir_all(path.join("views")).unwrap();
|
||||
fs::write(
|
||||
path.join("welcome.md"),
|
||||
"# Welcome to Tolaria\n\nThis is the starter vault.\n",
|
||||
"# Welcome to Laputa\n\nThis is the starter vault.\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
@@ -159,12 +158,12 @@ mod tests {
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.email", "tolaria@app.local"])
|
||||
.args(["config", "user.email", "laputa@app.local"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", "Tolaria App"])
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
@@ -20,8 +20,8 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{
|
||||
auto_rename_untitled, detect_renames, rename_note, rename_note_filename,
|
||||
update_wikilinks_for_renames, DetectedRename, RenameResult,
|
||||
auto_rename_untitled, detect_renames, rename_note, update_wikilinks_for_renames,
|
||||
DetectedRename, RenameResult,
|
||||
};
|
||||
pub use title_sync::{sync_title_on_open, SyncAction};
|
||||
pub use trash::{batch_delete_notes, delete_note};
|
||||
|
||||
@@ -249,9 +249,7 @@ fn extract_wikilink_display(inner: &str) -> &str {
|
||||
inner.find('|').map_or(inner, |idx| &inner[idx + 1..])
|
||||
}
|
||||
|
||||
/// Process bracketed text.
|
||||
/// Real markdown links `[text](url)` are unwrapped to `text`.
|
||||
/// Plain bracketed text `[text]` is preserved verbatim.
|
||||
/// Process a markdown link `[text](url)`, extracting only the link text.
|
||||
fn process_markdown_link(
|
||||
chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
|
||||
result: &mut String,
|
||||
@@ -260,13 +258,8 @@ fn process_markdown_link(
|
||||
if chars.peek() == Some(&'(') {
|
||||
chars.next();
|
||||
skip_until(chars, ')');
|
||||
result.push_str(&inner);
|
||||
return;
|
||||
}
|
||||
|
||||
result.push('[');
|
||||
result.push_str(&inner);
|
||||
result.push(']');
|
||||
}
|
||||
|
||||
/// Collect chars inside a wikilink until `]]`, consuming both closing brackets.
|
||||
@@ -370,15 +363,6 @@ mod tests {
|
||||
assert_eq!(extract_h1_title(content), Some("Spaced Title".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_preserves_plain_square_brackets() {
|
||||
let content = "# [26Q2] Tolaria MVP\n\nBody.";
|
||||
assert_eq!(
|
||||
extract_h1_title(content),
|
||||
Some("[26Q2] Tolaria MVP".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_none_when_no_h1() {
|
||||
assert_eq!(extract_h1_title("Just body text."), None);
|
||||
@@ -742,7 +726,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_bracket_without_url() {
|
||||
assert_eq!(strip_markdown_chars("[just brackets]"), "[just brackets]");
|
||||
assert_eq!(strip_markdown_chars("[just brackets]"), "just brackets");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
@@ -29,17 +28,13 @@ pub(super) fn title_to_slug(title: &str) -> String {
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Build a regex that matches wiki links referencing any of the provided targets.
|
||||
fn build_wikilink_pattern(targets: &[&str]) -> Option<Regex> {
|
||||
let escaped_targets: Vec<String> = targets
|
||||
.iter()
|
||||
.filter(|target| !target.is_empty())
|
||||
.map(|target| regex::escape(target))
|
||||
.collect();
|
||||
if escaped_targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let pattern_str = format!(r"\[\[(?:{})(\|[^\]]*?)?\]\]", escaped_targets.join("|"));
|
||||
/// Build a regex that matches wiki links referencing old title or path stem.
|
||||
fn build_wikilink_pattern(old_title: &str, old_path_stem: &str) -> Option<Regex> {
|
||||
let pattern_str = format!(
|
||||
r"\[\[(?:{}|{})(\|[^\]]*?)?\]\]",
|
||||
regex::escape(old_title),
|
||||
regex::escape(old_path_stem),
|
||||
);
|
||||
Regex::new(&pattern_str).ok()
|
||||
}
|
||||
|
||||
@@ -49,13 +44,13 @@ fn is_replaceable_md_file(path: &Path, exclude: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Replace wikilink references in a single file's content. Returns updated content if changed.
|
||||
fn replace_wikilinks_in_content(content: &str, re: &Regex, new_target: &str) -> Option<String> {
|
||||
fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> Option<String> {
|
||||
if !re.is_match(content) {
|
||||
return None;
|
||||
}
|
||||
let replaced = re.replace_all(content, |caps: ®ex::Captures| match caps.get(1) {
|
||||
Some(pipe) => format!("[[{}{}]]", new_target, pipe.as_str()),
|
||||
None => format!("[[{}]]", new_target),
|
||||
Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()),
|
||||
None => format!("[[{}]]", new_title),
|
||||
});
|
||||
if replaced != content {
|
||||
Some(replaced.into_owned())
|
||||
@@ -64,6 +59,15 @@ fn replace_wikilinks_in_content(content: &str, re: &Regex, new_target: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for a vault-wide wikilink replacement.
|
||||
struct WikilinkReplacement<'a> {
|
||||
vault_path: &'a Path,
|
||||
old_title: &'a str,
|
||||
new_title: &'a str,
|
||||
old_path_stem: &'a str,
|
||||
exclude_path: &'a Path,
|
||||
}
|
||||
|
||||
/// Collect all .md file paths in vault eligible for wikilink replacement.
|
||||
fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec<std::path::PathBuf> {
|
||||
WalkDir::new(vault_path)
|
||||
@@ -75,76 +79,47 @@ fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec<std::path::PathBuf
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unique_wikilink_targets(targets: Vec<&str>) -> Vec<&str> {
|
||||
let mut seen = HashSet::new();
|
||||
targets
|
||||
.into_iter()
|
||||
.filter(|target| !target.is_empty())
|
||||
.filter(|target| seen.insert(*target))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_legacy_wikilink_targets<'a>(old_title: &'a str, old_path_stem: &'a str) -> Vec<&'a str> {
|
||||
let old_filename_stem = old_path_stem.rsplit('/').next().unwrap_or(old_path_stem);
|
||||
unique_wikilink_targets(vec![old_title, old_path_stem, old_filename_stem])
|
||||
}
|
||||
|
||||
/// Replace wiki link references across all vault markdown files.
|
||||
fn update_wikilinks_in_vault(
|
||||
vault_path: &Path,
|
||||
old_targets: &[&str],
|
||||
new_target: &str,
|
||||
exclude_path: &Path,
|
||||
) -> usize {
|
||||
let re = match build_wikilink_pattern(old_targets) {
|
||||
fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize {
|
||||
let re = match build_wikilink_pattern(params.old_title, params.old_path_stem) {
|
||||
Some(r) => r,
|
||||
None => return 0,
|
||||
};
|
||||
replace_wikilinks_in_files(collect_md_files(vault_path, exclude_path), &re, new_target)
|
||||
}
|
||||
|
||||
fn replace_wikilinks_in_files(
|
||||
files: Vec<std::path::PathBuf>,
|
||||
re: &Regex,
|
||||
replacement: &str,
|
||||
) -> usize {
|
||||
let files = collect_md_files(params.vault_path, params.exclude_path);
|
||||
files
|
||||
.iter()
|
||||
.filter(|path| rewrite_wikilinks_in_file(path, re, replacement))
|
||||
.filter(|path| {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
match replace_wikilinks_in_content(&content, &re, params.new_title) {
|
||||
Some(new_content) => fs::write(path, &new_content).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> bool {
|
||||
let Ok(content) = fs::read_to_string(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(new_content) = replace_wikilinks_in_content(&content, re, replacement) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
fs::write(path, &new_content).is_ok()
|
||||
}
|
||||
|
||||
/// Extract the value of the `title:` frontmatter field from raw content.
|
||||
fn extract_fm_title_value(content: &str) -> Option<String> {
|
||||
if !content.starts_with("---\n") {
|
||||
return None;
|
||||
}
|
||||
let fm = content[4..].split("\n---").next()?;
|
||||
fm.lines()
|
||||
.map(str::trim_start)
|
||||
.find_map(extract_title_value_from_frontmatter_line)
|
||||
}
|
||||
|
||||
fn extract_title_value_from_frontmatter_line(line: &str) -> Option<String> {
|
||||
["title:", "\"title\":"]
|
||||
.iter()
|
||||
.find_map(|prefix| line.strip_prefix(prefix))
|
||||
.map(str::trim)
|
||||
.map(|value| value.trim_matches('"').trim_matches('\''))
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_string())
|
||||
for line in fm.lines() {
|
||||
let t = line.trim_start();
|
||||
for prefix in &["title:", "\"title\":"] {
|
||||
if let Some(rest) = t.strip_prefix(prefix) {
|
||||
let val = rest.trim().trim_matches('"').trim_matches('\'');
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Update the `title:` frontmatter field in content.
|
||||
@@ -193,22 +168,6 @@ fn unique_dest_path(dest_dir: &Path, filename: &str, exclude: &Path) -> std::pat
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_filename_stem(new_filename_stem: &str) -> Result<String, String> {
|
||||
let trimmed = new_filename_stem.trim();
|
||||
let stem = trimmed.strip_suffix(".md").unwrap_or(trimmed).trim();
|
||||
if stem.is_empty() {
|
||||
return Err("New filename cannot be empty".to_string());
|
||||
}
|
||||
if is_invalid_filename_stem(stem) {
|
||||
return Err("Invalid filename".to_string());
|
||||
}
|
||||
Ok(stem.to_string())
|
||||
}
|
||||
|
||||
fn is_invalid_filename_stem(stem: &str) -> bool {
|
||||
stem == "." || stem == ".." || stem.contains('/') || stem.contains('\\')
|
||||
}
|
||||
|
||||
/// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault.
|
||||
///
|
||||
/// When `old_title_hint` is provided it is used instead of extracting the title from
|
||||
@@ -278,9 +237,13 @@ pub fn rename_note(
|
||||
// Update wikilinks across the vault
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix).to_string();
|
||||
let old_targets = collect_legacy_wikilink_targets(old_title, old_path_stem);
|
||||
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
|
||||
let updated_files = update_wikilinks_in_vault(&WikilinkReplacement {
|
||||
vault_path: vault,
|
||||
old_title,
|
||||
new_title,
|
||||
old_path_stem,
|
||||
exclude_path: &new_file,
|
||||
});
|
||||
|
||||
Ok(RenameResult {
|
||||
new_path: new_path_str,
|
||||
@@ -288,67 +251,6 @@ pub fn rename_note(
|
||||
})
|
||||
}
|
||||
|
||||
/// Rename only the file path stem while preserving title/frontmatter content.
|
||||
pub fn rename_note_filename(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
new_filename_stem: &str,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
|
||||
if !old_file.exists() {
|
||||
return Err(format!("File does not exist: {}", old_path));
|
||||
}
|
||||
|
||||
let normalized_stem = normalize_filename_stem(new_filename_stem)?;
|
||||
let old_filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let fm_title = extract_fm_title_value(&content);
|
||||
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
|
||||
let new_filename = format!("{}.md", normalized_stem);
|
||||
|
||||
if old_filename == new_filename {
|
||||
return Ok(RenameResult {
|
||||
new_path: old_path.to_string(),
|
||||
updated_files: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let new_file = parent_dir.join(&new_filename);
|
||||
if new_file.exists() && new_file != old_file {
|
||||
return Err("A note with that name already exists".to_string());
|
||||
}
|
||||
|
||||
fs::rename(old_file, &new_file).map_err(|e| {
|
||||
format!(
|
||||
"Failed to rename {} to {}: {}",
|
||||
old_path,
|
||||
new_file.to_string_lossy(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let new_path = new_file.to_string_lossy().to_string();
|
||||
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
|
||||
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
|
||||
|
||||
Ok(RenameResult {
|
||||
new_path,
|
||||
updated_files,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md").
|
||||
fn is_untitled_filename(filename: &str) -> bool {
|
||||
let stem = filename.strip_suffix(".md").unwrap_or(filename);
|
||||
@@ -450,12 +352,22 @@ pub fn update_wikilinks_for_renames(
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&rename.new_path);
|
||||
let old_filename_stem = old_stem.split('/').next_back().unwrap_or(old_stem);
|
||||
let new_filename_stem = new_stem.split('/').next_back().unwrap_or(new_stem);
|
||||
|
||||
// Build title from filename stem (kebab-case → Title Case)
|
||||
let old_title = super::parsing::slug_to_title(old_filename_stem);
|
||||
let new_title = super::parsing::slug_to_title(new_filename_stem);
|
||||
|
||||
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
|
||||
let new_file = vault.join(&rename.new_path);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem);
|
||||
let updated = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
|
||||
|
||||
let updated = update_wikilinks_in_vault(&WikilinkReplacement {
|
||||
vault_path: vault,
|
||||
old_title: &old_title,
|
||||
new_title: &new_title,
|
||||
old_path_stem: old_filename_stem,
|
||||
exclude_path: &new_file,
|
||||
});
|
||||
total_updated += updated;
|
||||
}
|
||||
|
||||
@@ -545,11 +457,11 @@ mod tests {
|
||||
assert_eq!(result.updated_files, 2);
|
||||
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[note/sprint-retrospective]]"));
|
||||
assert!(other_content.contains("[[Sprint Retrospective]]"));
|
||||
assert!(!other_content.contains("[[Weekly Review]]"));
|
||||
|
||||
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
|
||||
assert!(project_content.contains("[[note/sprint-retrospective]]"));
|
||||
assert!(project_content.contains("[[Sprint Retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -609,33 +521,7 @@ mod tests {
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains("[[note/sprint-retro|my review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_updates_filename_only_wikilinks_to_canonical_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/ref.md",
|
||||
"# Ref\n\nSee [[weekly-review]] for info.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retro",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains("[[note/sprint-retro]]"));
|
||||
assert!(!ref_content.contains("[[weekly-review]]"));
|
||||
assert!(ref_content.contains("[[Sprint Retro|my review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -812,58 +698,6 @@ mod tests {
|
||||
assert!(vault.join("note/my-note.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_filename_preserves_title_and_updates_path_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/project-kickoff.md",
|
||||
"---\ntitle: Project Kickoff\ntype: Note\n---\n\n# Project Kickoff\n\nBody.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/ref.md",
|
||||
"# Ref\n\nSee [[note/project-kickoff]] and [[Project Kickoff]].\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/project-kickoff.md");
|
||||
let result = rename_note_filename(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"manual-name",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.new_path.ends_with("manual-name.md"));
|
||||
assert!(!old_path.exists());
|
||||
|
||||
let renamed = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert!(renamed.contains("title: Project Kickoff"));
|
||||
assert!(renamed.contains("# Project Kickoff"));
|
||||
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains("[[note/manual-name]]"));
|
||||
assert!(!ref_content.contains("[[Project Kickoff]]"));
|
||||
assert!(!ref_content.contains("[[note/project-kickoff]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_filename_rejects_existing_destination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/current.md", "# Current\n");
|
||||
create_test_file(vault, "note/manual-name.md", "# Existing\n");
|
||||
|
||||
let result = rename_note_filename(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("note/current.md").to_str().unwrap(),
|
||||
"manual-name",
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap_err(), "A note with that name already exists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_with_old_title_hint_updates_wikilinks() {
|
||||
// Simulates H1 sync: content already saved with new H1, but wikilinks still use old title.
|
||||
@@ -902,11 +736,11 @@ mod tests {
|
||||
assert!(!vault.join("note/weekly-review.md").exists());
|
||||
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[note/sprint-retrospective]]"));
|
||||
assert!(other_content.contains("[[Sprint Retrospective]]"));
|
||||
assert!(!other_content.contains("[[Weekly Review]]"));
|
||||
|
||||
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
|
||||
assert!(project_content.contains("[[note/sprint-retrospective]]"));
|
||||
assert!(project_content.contains("[[Sprint Retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -936,7 +770,7 @@ mod tests {
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[note/sprint-retrospective]]"));
|
||||
assert!(other_content.contains("[[Sprint Retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,9 +2,6 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const APP_CONFIG_DIR: &str = "com.tolaria.app";
|
||||
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VaultEntry {
|
||||
pub label: String,
|
||||
@@ -19,32 +16,10 @@ pub struct VaultList {
|
||||
pub hidden_defaults: Vec<String>,
|
||||
}
|
||||
|
||||
fn app_config_dir() -> Result<PathBuf, String> {
|
||||
dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
|
||||
Ok(app_config_dir()?.join(APP_CONFIG_DIR).join(file_name))
|
||||
}
|
||||
|
||||
fn resolve_existing_or_preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
|
||||
let preferred = preferred_app_config_path(file_name)?;
|
||||
if preferred.exists() {
|
||||
return Ok(preferred);
|
||||
}
|
||||
|
||||
let legacy = app_config_dir()?
|
||||
.join(LEGACY_APP_CONFIG_DIR)
|
||||
.join(file_name);
|
||||
if legacy.exists() {
|
||||
return Ok(legacy);
|
||||
}
|
||||
|
||||
Ok(preferred)
|
||||
}
|
||||
|
||||
fn vault_list_path() -> Result<PathBuf, String> {
|
||||
resolve_existing_or_preferred_app_config_path("vaults.json")
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("vaults.json"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn load_at(path: &PathBuf) -> Result<VaultList, String> {
|
||||
@@ -71,7 +46,7 @@ pub fn load_vault_list() -> Result<VaultList, String> {
|
||||
}
|
||||
|
||||
pub fn save_vault_list(list: &VaultList) -> Result<(), String> {
|
||||
save_at(&preferred_app_config_path("vaults.json")?, list)
|
||||
save_at(&vault_list_path()?, list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -156,20 +131,7 @@ mod tests {
|
||||
fn vault_list_path_returns_ok() {
|
||||
let result = vault_list_path();
|
||||
assert!(result.is_ok());
|
||||
let path = result.unwrap();
|
||||
let path = path.to_str().unwrap();
|
||||
assert!(path.contains("com.tolaria.app") || path.contains("com.laputa.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_vault_list_path_uses_tolaria_namespace() {
|
||||
let result = preferred_app_config_path("vaults.json");
|
||||
assert!(result.is_ok());
|
||||
assert!(result
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("com.tolaria.app"));
|
||||
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Tolaria",
|
||||
"productName": "laputa",
|
||||
"version": "0.1.0",
|
||||
"identifier": "club.refactoring.tolaria",
|
||||
"identifier": "club.refactoring.laputa",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5202",
|
||||
@@ -13,7 +13,7 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Tolaria",
|
||||
"title": "Laputa",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 1200,
|
||||
@@ -54,9 +54,9 @@
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://refactoringhq.github.io/tolaria/latest.json"
|
||||
"https://refactoringhq.github.io/laputa-app/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE4NkQ5MDI3REVCRkFGNUMKUldSY3I3L2VKNUJ0cU5JRlRZZlp3NGhnU3ZwbkVKeGVvREpmb2sxRVJndHFpVFZPNlArbEE5R1IK"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
get_file_history: [],
|
||||
get_settings: { auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
|
||||
get_settings: { github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
|
||||
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
|
||||
save_settings: null,
|
||||
check_vault_exists: true,
|
||||
@@ -128,10 +128,7 @@ describe('App', () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Reset view mode and onboarding state between tests
|
||||
localStorage.removeItem('tolaria-view-mode')
|
||||
localStorage.removeItem('tolaria-view-mode')
|
||||
localStorage.removeItem('laputa-view-mode')
|
||||
localStorage.removeItem('tolaria_welcome_dismissed')
|
||||
localStorage.removeItem('laputa_welcome_dismissed')
|
||||
})
|
||||
|
||||
@@ -188,27 +185,6 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
|
||||
const disabledWorkflowConfig = JSON.stringify({
|
||||
zoom: null,
|
||||
view_mode: null,
|
||||
editor_mode: null,
|
||||
tag_colors: null,
|
||||
status_colors: null,
|
||||
property_display_modes: null,
|
||||
inbox: { noteListProperties: null, explicitOrganization: false },
|
||||
})
|
||||
localStorage.setItem('laputa:vault-config:/Users/mock/Documents/Getting Started', disabledWorkflowConfig)
|
||||
localStorage.setItem('laputa:vault-config:/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2', disabledWorkflowConfig)
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Inbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders status bar', async () => {
|
||||
render(<App />)
|
||||
// StatusBar should be present
|
||||
|
||||
104
src/App.tsx
@@ -14,10 +14,9 @@ import { CommitDialog } from './components/CommitDialog'
|
||||
import { PulseView } from './components/PulseView'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { CloneVaultModal } from './components/CloneVaultModal'
|
||||
import { GitHubVaultModal } from './components/GitHubVaultModal'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen'
|
||||
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
|
||||
import { FeedbackDialog } from './components/FeedbackDialog'
|
||||
import { useTelemetry } from './hooks/useTelemetry'
|
||||
import { useMcpStatus } from './hooks/useMcpStatus'
|
||||
import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus'
|
||||
@@ -64,12 +63,6 @@ import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
|
||||
import {
|
||||
INBOX_SELECTION,
|
||||
isExplicitOrganizationEnabled,
|
||||
sanitizeSelectionForOrganization,
|
||||
} from './utils/organizationWorkflow'
|
||||
import './App.css'
|
||||
|
||||
// Type declarations for mock content storage and test overrides
|
||||
@@ -81,7 +74,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION
|
||||
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'inbox' }
|
||||
|
||||
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
||||
function App() {
|
||||
@@ -98,19 +91,6 @@ function App() {
|
||||
const visibleNotesRef = useRef<VaultEntry[]>([])
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const dialogs = useDialogs()
|
||||
const { showAIChat, toggleAIChat } = dialogs
|
||||
const [showFeedback, setShowFeedback] = useState(false)
|
||||
const openFeedback = useCallback(() => setShowFeedback(true), [])
|
||||
const closeFeedback = useCallback(() => setShowFeedback(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenAiChat = () => {
|
||||
if (!showAIChat) toggleAIChat()
|
||||
}
|
||||
|
||||
window.addEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
|
||||
return () => window.removeEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
|
||||
}, [showAIChat, toggleAIChat])
|
||||
|
||||
// onSwitch closure captures `notes` declared below — safe because it's only
|
||||
// called on user interaction, never during render (refs inside the hook
|
||||
@@ -144,22 +124,6 @@ function App() {
|
||||
|
||||
const vault = useVaultLoader(resolvedPath)
|
||||
const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath)
|
||||
const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization)
|
||||
const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization)
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveSelection !== selection) {
|
||||
setSelection(effectiveSelection)
|
||||
setNoteListFilter('open')
|
||||
}
|
||||
}, [effectiveSelection, selection])
|
||||
|
||||
const handleSaveExplicitOrganization = useCallback((enabled: boolean) => {
|
||||
updateConfig('inbox', {
|
||||
noteListProperties: vaultConfig.inbox?.noteListProperties ?? null,
|
||||
explicitOrganization: enabled,
|
||||
})
|
||||
}, [updateConfig, vaultConfig.inbox?.noteListProperties])
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
useTelemetry(settings, settingsLoaded)
|
||||
|
||||
@@ -223,7 +187,7 @@ function App() {
|
||||
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
|
||||
})
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterPersisted: vault.loadModifiedFiles })
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
|
||||
|
||||
// Note window: auto-open the note from URL params once vault entries load
|
||||
const noteWindowOpenedRef = useRef(false)
|
||||
@@ -291,7 +255,7 @@ function App() {
|
||||
})
|
||||
|
||||
const appSave = useAppSave({
|
||||
updateEntry: vault.updateEntry, setTabs: notes.setTabs, handleSwitchTab: notes.handleSwitchTab, setToastMessage,
|
||||
updateEntry: vault.updateEntry, setTabs: notes.setTabs, setToastMessage,
|
||||
loadModifiedFiles: vault.loadModifiedFiles, reloadViews: vault.reloadViews,
|
||||
clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths,
|
||||
tabs: notes.tabs, activeTabPath: notes.activeTabPath,
|
||||
@@ -299,13 +263,6 @@ function App() {
|
||||
replaceEntry: vault.replaceEntry, resolvedPath,
|
||||
})
|
||||
|
||||
const handleFilenameRename = useCallback((path: string, newFilenameStem: string) => {
|
||||
appSave.savePendingForPath(path)
|
||||
.then(() => notes.handleRenameFilename(path, newFilenameStem, resolvedPath, vault.replaceEntry))
|
||||
.then(vault.loadModifiedFiles)
|
||||
.catch((err) => console.error('Filename rename failed:', err))
|
||||
}, [appSave, notes, resolvedPath, vault])
|
||||
|
||||
const aiActivity = useAiActivity({
|
||||
onOpenNote: vaultBridge.openNoteByPath,
|
||||
onOpenTab: vaultBridge.openNoteByPath,
|
||||
@@ -424,6 +381,7 @@ function App() {
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
onBeforeAction: appSave.flushBeforeAction,
|
||||
})
|
||||
|
||||
@@ -544,7 +502,7 @@ function App() {
|
||||
visibleNotesRef,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
selection: effectiveSelection,
|
||||
selection,
|
||||
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
||||
onSearch: dialogs.openSearch,
|
||||
onCreateNote: notes.handleCreateNoteImmediate,
|
||||
@@ -552,7 +510,6 @@ function App() {
|
||||
onCreateNoteOfType: notes.handleCreateNoteImmediate,
|
||||
onSave: appSave.handleSave,
|
||||
onOpenSettings: dialogs.openSettings,
|
||||
onOpenFeedback: openFeedback,
|
||||
onDeleteNote: deleteActions.handleDeleteNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog,
|
||||
@@ -565,7 +522,6 @@ function App() {
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: handleSetSelection,
|
||||
showInbox: explicitOrganizationEnabled,
|
||||
onReplaceActiveTab: notes.handleReplaceActiveTab,
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
onGoBack: handleGoBack, onGoForward: handleGoForward,
|
||||
@@ -594,9 +550,9 @@ function App() {
|
||||
onSetNoteListFilter: setNoteListFilter,
|
||||
onOpenInNewWindow: handleOpenInNewWindow,
|
||||
onToggleFavorite: entryActions.handleToggleFavorite,
|
||||
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
|
||||
onToggleOrganized: entryActions.handleToggleOrganized,
|
||||
onCustomizeInboxColumns: handleCustomizeInboxColumns,
|
||||
canCustomizeInboxColumns: explicitOrganizationEnabled && effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox',
|
||||
canCustomizeInboxColumns: selection.kind === 'filter' && selection.filter === 'inbox',
|
||||
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
|
||||
canRestoreDeletedNote: !!activeDeletedFile,
|
||||
})
|
||||
@@ -606,18 +562,18 @@ function App() {
|
||||
const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod])
|
||||
|
||||
const aiNoteList = useMemo<NoteListItem[]>(() => {
|
||||
const isInbox = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox'
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, effectiveSelection, undefined, vault.views)
|
||||
const isInbox = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, selection, undefined, vault.views)
|
||||
return filtered.map(e => ({
|
||||
path: e.path, title: e.title, type: e.isA ?? 'Note',
|
||||
}))
|
||||
}, [vault.entries, vault.views, effectiveSelection, inboxPeriod])
|
||||
}, [vault.entries, vault.views, selection, inboxPeriod])
|
||||
|
||||
const aiNoteListFilter = useMemo(() => {
|
||||
if (effectiveSelection.kind === 'sectionGroup') return { type: effectiveSelection.type, query: '' }
|
||||
if (effectiveSelection.kind === 'entity') return { type: null, query: effectiveSelection.entry.title }
|
||||
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
|
||||
if (selection.kind === 'entity') return { type: null, query: selection.entry.title }
|
||||
return { type: null, query: '' }
|
||||
}, [effectiveSelection])
|
||||
}, [selection])
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing')) {
|
||||
@@ -667,7 +623,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} inboxCount={inboxCount} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -675,10 +631,10 @@ function App() {
|
||||
{noteListVisible && (
|
||||
<>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -712,13 +668,13 @@ function App() {
|
||||
noteList={aiNoteList}
|
||||
noteListFilter={aiNoteListFilter}
|
||||
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
|
||||
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
|
||||
onToggleOrganized={activeDeletedFile ? undefined : entryActions.handleToggleOrganized}
|
||||
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
|
||||
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
|
||||
onContentChange={appSave.handleContentChange}
|
||||
onSave={appSave.handleSave}
|
||||
onRenameFilename={activeDeletedFile ? undefined : handleFilenameRename}
|
||||
onTitleSync={activeDeletedFile ? undefined : appSave.handleTitleSync}
|
||||
rawToggleRef={rawToggleRef}
|
||||
diffToggleRef={diffToggleRef}
|
||||
canGoBack={canGoBack}
|
||||
@@ -737,16 +693,10 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette
|
||||
open={dialogs.showCommandPalette}
|
||||
commands={commands}
|
||||
entries={vault.entries}
|
||||
claudeCodeReady={claudeCodeStatus === 'installed'}
|
||||
onClose={dialogs.closeCommandPalette}
|
||||
/>
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
|
||||
@@ -762,9 +712,15 @@ function App() {
|
||||
onCommit={conflictResolver.commitResolution}
|
||||
onClose={conflictFlow.handleCloseConflictResolver}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />
|
||||
<GitHubVaultModal
|
||||
open={dialogs.showGitHubVault}
|
||||
githubToken={settings.github_token}
|
||||
onClose={dialogs.closeGitHubVault}
|
||||
onVaultCloned={vaultSwitcher.handleVaultCloned}
|
||||
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
|
||||
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
|
||||
/>
|
||||
{deleteActions.confirmDelete && (
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
|
||||
@@ -31,7 +31,7 @@ const TOOL_ICON_MAP: Record<string, IconRenderer> = {
|
||||
Read: (s) => <File size={s} />,
|
||||
Glob: (s) => <FolderOpen size={s} />,
|
||||
Grep: (s) => <MagnifyingGlass size={s} />,
|
||||
// Tolaria MCP tools
|
||||
// Laputa MCP tools
|
||||
search_notes: (s) => <MagnifyingGlass size={s} />,
|
||||
get_vault_context: (s) => <ChartBar size={s} />,
|
||||
get_note: (s) => <File size={s} />,
|
||||
@@ -100,7 +100,7 @@ function DetailBlock({ label, content, isError }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether this tool is a Tolaria UI-only tool (lighter styling). */
|
||||
/** Whether this tool is a Laputa UI-only tool (lighter styling). */
|
||||
function isUiOnlyTool(tool: string): boolean {
|
||||
return tool === 'open_note'
|
||||
}
|
||||
|
||||
@@ -2,20 +2,16 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt } from '../utils/aiPromptBridge'
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
|
||||
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
|
||||
const mockSendMessage = vi.fn()
|
||||
const mockClearConversation = vi.fn()
|
||||
|
||||
vi.mock('../hooks/useAiAgent', () => ({
|
||||
useAiAgent: () => ({
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: mockSendMessage,
|
||||
clearConversation: mockClearConversation,
|
||||
sendMessage: vi.fn(),
|
||||
clearConversation: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -52,8 +48,6 @@ describe('AiPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockMessages = []
|
||||
mockStatus = 'idle'
|
||||
mockSendMessage.mockReset()
|
||||
mockClearConversation.mockReset()
|
||||
})
|
||||
|
||||
it('renders panel with AI Chat header', () => {
|
||||
@@ -120,7 +114,7 @@ describe('AiPanel', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toBeTruthy()
|
||||
expect(input).toHaveAttribute('contenteditable', 'true')
|
||||
expect((input as HTMLInputElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('has send button disabled when input is empty', () => {
|
||||
@@ -134,14 +128,14 @@ describe('AiPanel', () => {
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask about this note...')
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask about this note...')
|
||||
})
|
||||
|
||||
it('shows generic placeholder when no active entry', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask the AI agent...')
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask the AI agent...')
|
||||
})
|
||||
|
||||
it('auto-focuses input on mount', async () => {
|
||||
@@ -209,20 +203,4 @@ describe('AiPanel', () => {
|
||||
fireEvent.click(wikilinks[1])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
|
||||
})
|
||||
|
||||
it('auto-sends a queued prompt from the command palette bridge', async () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" entries={[makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' })]} />)
|
||||
|
||||
await act(async () => {
|
||||
queueAiPrompt('summarize [[alpha]]', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
})
|
||||
|
||||
expect(mockClearConversation).toHaveBeenCalledOnce()
|
||||
expect(mockSendMessage).toHaveBeenCalledWith('summarize [[alpha]]', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
expect(screen.getByTestId('agent-send')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import { type AiAgentMessage } from '../hooks/useAiAgent'
|
||||
import { type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
|
||||
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
|
||||
import { useAiPanelController } from './useAiPanelController'
|
||||
import { useAiPanelPromptQueue } from './useAiPanelPromptQueue'
|
||||
import { useAiPanelFocus } from './useAiPanelFocus'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
|
||||
@@ -115,89 +111,75 @@ function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, ha
|
||||
)
|
||||
}
|
||||
|
||||
function AiPanelComposer({
|
||||
entries,
|
||||
hasContext,
|
||||
input,
|
||||
inputRef,
|
||||
isActive,
|
||||
onChange,
|
||||
onSend,
|
||||
}: {
|
||||
entries: VaultEntry[]
|
||||
hasContext: boolean
|
||||
input: string
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
isActive: boolean
|
||||
onChange: (value: string) => void
|
||||
onSend: (text: string, references: NoteReference[]) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries}
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={isActive}
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
|
||||
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
|
||||
disabled={isActive || !input.trim()}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
const [input, setInput] = useState('')
|
||||
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
|
||||
const {
|
||||
agent,
|
||||
input,
|
||||
setInput,
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
} = useAiPanelController({
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
return collectLinkedEntries(activeEntry, entries)
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent: activeNoteContent ?? undefined,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
entries,
|
||||
references: pendingRefs.length > 0 ? pendingRefs : undefined,
|
||||
})
|
||||
}, [activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
})
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
|
||||
useAiPanelPromptQueue({ agent, input, isActive, setInput })
|
||||
useAiPanelFocus({ inputRef, panelRef, isActive, onClose })
|
||||
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
|
||||
const hasContext = !!activeEntry
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
panelRef.current?.focus()
|
||||
} else {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && panelRef.current?.contains(document.activeElement)) {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
setPendingRefs(references)
|
||||
agent.sendMessage(text, references)
|
||||
setInput('')
|
||||
}, [isActive, agent])
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -226,15 +208,39 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
|
||||
onNavigateWikilink={handleNavigateWikilink}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
<AiPanelComposer
|
||||
entries={entries ?? []}
|
||||
hasContext={hasContext}
|
||||
input={input}
|
||||
inputRef={inputRef}
|
||||
isActive={isActive}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries ?? []}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
disabled={isActive}
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
|
||||
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={() => handleSend(input, pendingRefs)}
|
||||
disabled={isActive || !input.trim()}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ describe('ArchivedNoteBanner', () => {
|
||||
expect(screen.getByText('Archived')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders unarchive button without the stale archive shortcut hint', () => {
|
||||
it('renders unarchive button with keyboard hint', () => {
|
||||
render(<ArchivedNoteBanner onUnarchive={vi.fn()} />)
|
||||
const btn = screen.getByTestId('unarchive-btn')
|
||||
expect(btn).toBeTruthy()
|
||||
expect(btn.textContent).toContain('Unarchive')
|
||||
expect(btn.title).toBe('Unarchive')
|
||||
expect(btn.title).toBe('Unarchive (Cmd+E)')
|
||||
})
|
||||
|
||||
it('calls onUnarchive when button is clicked', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
|
||||
color: 'var(--muted-foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Unarchive"
|
||||
title="Unarchive (Cmd+E)"
|
||||
>
|
||||
<ArrowUUpLeft size={12} />
|
||||
Unarchive
|
||||
|
||||
@@ -55,13 +55,6 @@ describe('BreadcrumbBar — drag region', () => {
|
||||
const bar = container.firstElementChild as HTMLElement
|
||||
expect(bar.dataset.tauriDragRegion).toBeDefined()
|
||||
})
|
||||
|
||||
it('marks the center spacer as a drag region', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
const spacer = container.querySelector('.breadcrumb-bar__drag-spacer')
|
||||
expect(spacer).toHaveAttribute('data-tauri-drag-region')
|
||||
expect(spacer).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — delete', () => {
|
||||
@@ -81,43 +74,31 @@ describe('BreadcrumbBar — delete', () => {
|
||||
describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
it('shows archive button for non-archived note', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
expect(screen.getByTitle('Archive')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Unarchive')).not.toBeInTheDocument()
|
||||
expect(screen.getByTitle('Archive (Cmd+E)')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Unarchive (Cmd+E)')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unarchive button for archived note', () => {
|
||||
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
expect(screen.getByTitle('Unarchive')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Archive')).not.toBeInTheDocument()
|
||||
expect(screen.getByTitle('Unarchive (Cmd+E)')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Archive (Cmd+E)')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onArchive when archive button is clicked', () => {
|
||||
const onArchive = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={onArchive} />)
|
||||
fireEvent.click(screen.getByTitle('Archive'))
|
||||
fireEvent.click(screen.getByTitle('Archive (Cmd+E)'))
|
||||
expect(onArchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onUnarchive when unarchive button is clicked', () => {
|
||||
const onUnarchive = vi.fn()
|
||||
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onUnarchive={onUnarchive} />)
|
||||
fireEvent.click(screen.getByTitle('Unarchive'))
|
||||
fireEvent.click(screen.getByTitle('Unarchive (Cmd+E)'))
|
||||
expect(onUnarchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — organized shortcut hint', () => {
|
||||
it('shows Cmd+E on the organized toggle tooltip', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)
|
||||
expect(screen.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the organized toggle when the workflow is disabled', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
expect(screen.queryByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => {
|
||||
it('always renders title elements in the DOM', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
@@ -144,85 +125,13 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
|
||||
expect(screen.getByText('Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('separator visibility is controlled by data-title-hidden while using the shared border chrome', () => {
|
||||
it('shadow is controlled by data-title-hidden attribute via CSS', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
const bar = container.querySelector('.breadcrumb-bar')!
|
||||
expect(bar).toHaveClass('border-b', 'border-transparent')
|
||||
expect(bar).not.toHaveAttribute('data-title-hidden')
|
||||
bar.setAttribute('data-title-hidden', '')
|
||||
expect(bar).toHaveAttribute('data-title-hidden')
|
||||
})
|
||||
|
||||
it('keeps the breadcrumb title visible in raw mode', () => {
|
||||
const { container } = render(
|
||||
<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode onToggleRaw={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — filename controls', () => {
|
||||
it('shows the sync button when the filename diverges from the title slug', () => {
|
||||
const entry = { ...baseEntry, title: 'Fresh Title', filename: 'untitled-note-123.md' }
|
||||
render(<BreadcrumbBar entry={entry} {...defaultProps} onRenameFilename={vi.fn()} />)
|
||||
expect(screen.getByTestId('breadcrumb-sync-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the sync button when the filename already matches the title slug', () => {
|
||||
const entry = { ...baseEntry, title: 'Test Note', filename: 'test-note.md' }
|
||||
render(<BreadcrumbBar entry={entry} {...defaultProps} onRenameFilename={vi.fn()} />)
|
||||
expect(screen.queryByTestId('breadcrumb-sync-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking the sync button renames the file to the title slug', () => {
|
||||
const onRenameFilename = vi.fn()
|
||||
const entry = { ...baseEntry, title: 'Fresh Title', filename: 'untitled-note-123.md' }
|
||||
render(<BreadcrumbBar entry={entry} {...defaultProps} onRenameFilename={onRenameFilename} />)
|
||||
fireEvent.click(screen.getByTestId('breadcrumb-sync-button'))
|
||||
expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'fresh-title')
|
||||
})
|
||||
|
||||
it('lets keyboard users press Enter on the filename to start editing', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={vi.fn()} />)
|
||||
fireEvent.keyDown(screen.getByTestId('breadcrumb-filename-trigger'), { key: 'Enter' })
|
||||
expect(screen.getByTestId('breadcrumb-filename-input')).toHaveValue('test')
|
||||
})
|
||||
|
||||
it('double-clicking the filename enters edit mode and Enter confirms the rename', () => {
|
||||
const onRenameFilename = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={onRenameFilename} />)
|
||||
|
||||
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
|
||||
const input = screen.getByTestId('breadcrumb-filename-input')
|
||||
fireEvent.change(input, { target: { value: 'renamed-file' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onRenameFilename).toHaveBeenCalledWith(baseEntry.path, 'renamed-file')
|
||||
})
|
||||
|
||||
it('pressing Escape while editing cancels the inline rename', () => {
|
||||
const onRenameFilename = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={onRenameFilename} />)
|
||||
|
||||
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
|
||||
const input = screen.getByTestId('breadcrumb-filename-input')
|
||||
fireEvent.change(input, { target: { value: 'renamed-file' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
|
||||
expect(onRenameFilename).not.toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('breadcrumb-filename-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('blur confirms the inline rename when the value changed', () => {
|
||||
const onRenameFilename = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRenameFilename={onRenameFilename} />)
|
||||
|
||||
fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger'))
|
||||
const input = screen.getByTestId('breadcrumb-filename-input')
|
||||
fireEvent.change(input, { target: { value: 'renamed-on-blur' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onRenameFilename).toHaveBeenCalledWith(baseEntry.path, 'renamed-on-blur')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — action buttons always right-aligned', () => {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { memo } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
GitBranch,
|
||||
@@ -17,10 +14,8 @@ import {
|
||||
ArrowUUpLeft,
|
||||
Star,
|
||||
CheckCircle,
|
||||
ArrowsClockwise,
|
||||
} from '@phosphor-icons/react'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { slugify } from '../hooks/useNoteCreation'
|
||||
|
||||
interface BreadcrumbBarProps {
|
||||
entry: VaultEntry
|
||||
@@ -42,501 +37,183 @@ interface BreadcrumbBarProps {
|
||||
onDelete?: () => void
|
||||
onArchive?: () => void
|
||||
onUnarchive?: () => void
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
|
||||
barRef?: React.Ref<HTMLDivElement>
|
||||
}
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
const BREADCRUMB_ICON_CLASS = 'size-[16px]'
|
||||
|
||||
function focusFilenameInput(
|
||||
isEditing: boolean,
|
||||
inputRef: React.RefObject<HTMLInputElement | null>,
|
||||
) {
|
||||
if (!isEditing) return
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}
|
||||
|
||||
function beginFilenameEditing(
|
||||
onRenameFilename: BreadcrumbBarProps['onRenameFilename'],
|
||||
filenameStem: string,
|
||||
setDraftStem: (value: string) => void,
|
||||
setIsEditing: (value: boolean) => void,
|
||||
) {
|
||||
if (!onRenameFilename) return
|
||||
setDraftStem(filenameStem)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
function resolveFilenameRenameTarget(draftStem: string, filenameStem: string): string | null {
|
||||
const nextStem = normalizeFilenameStemInput(draftStem)
|
||||
if (!nextStem || nextStem === filenameStem) return null
|
||||
return nextStem
|
||||
}
|
||||
|
||||
function handleFilenameInputKeyDown(
|
||||
event: KeyboardEvent<HTMLInputElement>,
|
||||
submitRename: () => void,
|
||||
cancelEditing: () => void,
|
||||
) {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
submitRename()
|
||||
return
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
cancelEditing()
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function IconActionButton({
|
||||
title,
|
||||
onClick,
|
||||
className,
|
||||
style,
|
||||
disabled,
|
||||
tabIndex,
|
||||
children,
|
||||
testId,
|
||||
}: {
|
||||
title: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
disabled?: boolean
|
||||
tabIndex?: number
|
||||
children: ReactNode
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn('text-muted-foreground [&_svg:not([class*=size-])]:size-4', className)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
title={title}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
|
||||
return (
|
||||
<IconActionButton
|
||||
title={rawMode ? 'Back to editor' : 'Raw editor'}
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors',
|
||||
rawMode ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={onToggleRaw}
|
||||
className={cn(rawMode ? 'text-foreground' : 'hover:text-foreground')}
|
||||
title={rawMode ? 'Back to editor' : 'Raw editor'}
|
||||
>
|
||||
<Code size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
<Code size={16} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
|
||||
return (
|
||||
<IconActionButton
|
||||
title={favorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
onClick={onToggleFavorite}
|
||||
className={cn(favorite ? 'text-yellow-500' : 'hover:text-foreground')}
|
||||
>
|
||||
<Star size={16} weight={favorite ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function OrganizedAction({
|
||||
organized,
|
||||
onToggleOrganized,
|
||||
}: {
|
||||
organized: boolean
|
||||
onToggleOrganized?: () => void
|
||||
}) {
|
||||
if (!onToggleOrganized) return null
|
||||
return (
|
||||
<IconActionButton
|
||||
title={organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
|
||||
onClick={onToggleOrganized}
|
||||
className={cn(organized ? 'text-green-600' : 'hover:text-foreground')}
|
||||
>
|
||||
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchAction() {
|
||||
return (
|
||||
<IconActionButton title="Search in file" className="hover:text-foreground">
|
||||
<MagnifyingGlass size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffAction({
|
||||
showDiffToggle,
|
||||
diffMode,
|
||||
diffLoading,
|
||||
onToggleDiff,
|
||||
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'onToggleDiff'>) {
|
||||
if (!showDiffToggle) {
|
||||
return (
|
||||
<IconActionButton title="No changes" style={DISABLED_ICON_STYLE} tabIndex={-1}>
|
||||
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IconActionButton
|
||||
title={diffLoading ? 'Loading diff...' : diffMode ? 'Back to editor' : 'Show diff'}
|
||||
onClick={onToggleDiff}
|
||||
disabled={diffLoading}
|
||||
className={cn(diffMode ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaceholderAction({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<IconActionButton title={title} style={DISABLED_ICON_STYLE} tabIndex={-1}>
|
||||
{children}
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
|
||||
return (
|
||||
<IconActionButton
|
||||
title={showAIChat ? 'Close AI Chat' : 'Open AI Chat'}
|
||||
onClick={onToggleAIChat}
|
||||
className={cn(showAIChat ? 'text-primary' : 'hover:text-foreground')}
|
||||
>
|
||||
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function ArchiveAction({
|
||||
archived,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'onArchive' | 'onUnarchive'>) {
|
||||
if (archived) {
|
||||
return (
|
||||
<IconActionButton title="Unarchive" onClick={onUnarchive} className="hover:text-foreground">
|
||||
<ArrowUUpLeft size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IconActionButton title="Archive" onClick={onArchive} className="hover:text-foreground">
|
||||
<Archive size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
|
||||
return (
|
||||
<IconActionButton title="Delete (Cmd+Delete)" onClick={onDelete} className="hover:text-destructive">
|
||||
<Trash size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function InspectorAction({
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'onToggleInspector'>) {
|
||||
if (!inspectorCollapsed) return null
|
||||
return (
|
||||
<IconActionButton title="Properties (⌘⇧I)" onClick={onToggleInspector} className="hover:text-foreground">
|
||||
<SlidersHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeFilenameStemInput(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.replace(/\.md$/i, '').trim()
|
||||
}
|
||||
|
||||
function deriveSyncStem(entry: VaultEntry): string | null {
|
||||
const expectedStem = slugify(entry.title.trim())
|
||||
const filenameStem = entry.filename.replace(/\.md$/, '')
|
||||
if (!expectedStem || expectedStem === filenameStem) return null
|
||||
return expectedStem
|
||||
}
|
||||
|
||||
function FilenameInput({
|
||||
inputRef,
|
||||
draftStem,
|
||||
onDraftStemChange,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
draftStem: string
|
||||
onDraftStemChange: (nextValue: string) => void
|
||||
onBlur: () => void
|
||||
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={draftStem}
|
||||
onChange={(event) => onDraftStemChange(event.target.value)}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
className="h-7 w-[180px] text-sm"
|
||||
data-testid="breadcrumb-filename-input"
|
||||
aria-label="Rename filename"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FilenameTrigger({
|
||||
entry,
|
||||
filenameStem,
|
||||
onStartEditing,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
filenameStem: string
|
||||
onStartEditing: () => void
|
||||
}) {
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
onStartEditing()
|
||||
}, [onStartEditing])
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-auto min-w-0 gap-1 px-0 py-0 text-sm font-medium text-foreground hover:bg-transparent hover:text-foreground"
|
||||
onDoubleClick={onStartEditing}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="breadcrumb-filename-trigger"
|
||||
aria-label={`Filename ${filenameStem}. Press Enter to rename`}
|
||||
>
|
||||
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
|
||||
<span className="truncate">{filenameStem}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SyncFilenameButton({
|
||||
entryPath,
|
||||
syncStem,
|
||||
onRenameFilename,
|
||||
}: {
|
||||
entryPath: string
|
||||
syncStem: string | null
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
}) {
|
||||
if (!syncStem || !onRenameFilename) return null
|
||||
return (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onRenameFilename(entryPath, syncStem)}
|
||||
data-testid="breadcrumb-sync-button"
|
||||
aria-label="Rename file to match title"
|
||||
>
|
||||
<ArrowsClockwise size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Rename file to match title
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function FilenameDisplay({
|
||||
entry,
|
||||
filenameStem,
|
||||
syncStem,
|
||||
onRenameFilename,
|
||||
onStartEditing,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
filenameStem: string
|
||||
syncStem: string | null
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
onStartEditing: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<FilenameTrigger entry={entry} filenameStem={filenameStem} onStartEditing={onStartEditing} />
|
||||
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
|
||||
const filenameStem = useMemo(() => entry.filename.replace(/\.md$/, ''), [entry.filename])
|
||||
const syncStem = useMemo(() => deriveSyncStem(entry), [entry])
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [draftStem, setDraftStem] = useState(filenameStem)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
focusFilenameInput(isEditing, inputRef)
|
||||
}, [isEditing])
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
beginFilenameEditing(onRenameFilename, filenameStem, setDraftStem, setIsEditing)
|
||||
}, [onRenameFilename, filenameStem])
|
||||
|
||||
const cancelEditing = useCallback(() => {
|
||||
setDraftStem(filenameStem)
|
||||
setIsEditing(false)
|
||||
}, [filenameStem])
|
||||
|
||||
const submitRename = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
const nextStem = resolveFilenameRenameTarget(draftStem, filenameStem)
|
||||
if (!nextStem) return
|
||||
onRenameFilename?.(entry.path, nextStem)
|
||||
}, [draftStem, filenameStem, onRenameFilename, entry.path])
|
||||
|
||||
const handleInputKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
||||
handleFilenameInputKeyDown(event, submitRename, cancelEditing)
|
||||
}, [submitRename, cancelEditing])
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<FilenameInput
|
||||
inputRef={inputRef}
|
||||
draftStem={draftStem}
|
||||
onDraftStemChange={setDraftStem}
|
||||
onBlur={submitRename}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FilenameDisplay
|
||||
entry={entry}
|
||||
filenameStem={filenameStem}
|
||||
syncStem={syncStem}
|
||||
onRenameFilename={onRenameFilename}
|
||||
onStartEditing={startEditing}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbActions({
|
||||
entry,
|
||||
showDiffToggle,
|
||||
diffMode,
|
||||
diffLoading,
|
||||
onToggleDiff,
|
||||
rawMode,
|
||||
onToggleRaw,
|
||||
forceRawMode,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount' | 'barRef' | 'onRenameFilename'>) {
|
||||
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
|
||||
rawMode, onToggleRaw, forceRawMode,
|
||||
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onToggleFavorite, onToggleOrganized, onDelete, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount'>) {
|
||||
return (
|
||||
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
|
||||
<FavoriteAction favorite={entry.favorite} onToggleFavorite={onToggleFavorite} />
|
||||
<OrganizedAction organized={entry.organized} onToggleOrganized={onToggleOrganized} />
|
||||
<SearchAction />
|
||||
<DiffAction
|
||||
showDiffToggle={showDiffToggle}
|
||||
diffMode={diffMode}
|
||||
diffLoading={diffLoading}
|
||||
onToggleDiff={onToggleDiff}
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
entry.favorite ? "text-yellow-500" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onToggleFavorite}
|
||||
title={entry.favorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Star size={16} weight={entry.favorite ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
entry.organized ? "text-green-600" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onToggleOrganized}
|
||||
title={entry.organized ? 'Mark as unorganized (back to Inbox)' : 'Mark as organized (remove from Inbox)'}
|
||||
>
|
||||
<CheckCircle size={16} weight={entry.organized ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
title="Search in file"
|
||||
>
|
||||
<MagnifyingGlass size={16} />
|
||||
</button>
|
||||
{showDiffToggle ? (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
diffMode ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onToggleDiff}
|
||||
disabled={diffLoading}
|
||||
title={diffLoading ? 'Loading diff...' : diffMode ? 'Back to editor' : 'Show diff'}
|
||||
>
|
||||
<GitBranch size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
title="No changes"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<GitBranch size={16} />
|
||||
</button>
|
||||
)}
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
|
||||
<PlaceholderAction title="Coming soon">
|
||||
<CursorText size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</PlaceholderAction>
|
||||
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
|
||||
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
|
||||
<DeleteAction onDelete={onDelete} />
|
||||
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
|
||||
<PlaceholderAction title="Coming soon">
|
||||
<DotsThree size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</PlaceholderAction>
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
title="Coming soon"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<CursorText size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
showAIChat ? "" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
style={showAIChat ? { color: 'var(--primary)' } : undefined}
|
||||
onClick={onToggleAIChat}
|
||||
title={showAIChat ? 'Close AI Chat' : 'Open AI Chat'}
|
||||
>
|
||||
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
{entry.archived ? (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onUnarchive}
|
||||
title="Unarchive (Cmd+E)"
|
||||
>
|
||||
<ArrowUUpLeft size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onArchive}
|
||||
title="Archive (Cmd+E)"
|
||||
>
|
||||
<Archive size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
title="Delete (Cmd+Delete)"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
{inspectorCollapsed && (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onToggleInspector}
|
||||
title="Properties (⌘⇧I)"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
title="Coming soon"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<DotsThree size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbTitle({
|
||||
entry,
|
||||
onRenameFilename,
|
||||
}: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
|
||||
function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
|
||||
const typeLabel = entry.isA ?? 'Note'
|
||||
const filenameStem = entry.filename.replace(/\.md$/, '')
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
|
||||
<span className="shrink-0">{typeLabel}</span>
|
||||
<span className="shrink-0 text-border">›</span>
|
||||
<div className="flex min-w-0 items-center gap-1 truncate">
|
||||
<FilenameCrumb entry={entry} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
<span className="flex min-w-0 items-center gap-1 truncate font-medium text-foreground">
|
||||
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
|
||||
<span className="truncate">{filenameStem}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry,
|
||||
barRef,
|
||||
onRenameFilename,
|
||||
...actionProps
|
||||
entry, barRef, ...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
// In raw/diff mode the title section is not rendered — always show title in breadcrumb.
|
||||
// Using a prop-driven attribute avoids the timing issues of DOM mutation in useEffect.
|
||||
const titleAlwaysVisible = actionProps.rawMode || actionProps.diffMode
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
data-tauri-drag-region
|
||||
data-title-hidden=""
|
||||
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
|
||||
{...(titleAlwaysVisible ? { 'data-title-hidden': '' } : {})}
|
||||
className="breadcrumb-bar flex shrink-0 items-center"
|
||||
style={{
|
||||
height: 52,
|
||||
background: 'var(--background)',
|
||||
padding: '6px 16px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div className="breadcrumb-bar__title min-w-0">
|
||||
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
|
||||
<div className="breadcrumb-bar__title flex-1 min-w-0">
|
||||
<BreadcrumbTitle entry={entry} />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-tauri-drag-region
|
||||
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
|
||||
/>
|
||||
<BreadcrumbActions entry={entry} {...actionProps} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import './Editor.css'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const baseEntry: VaultEntry = {
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
template: null,
|
||||
sort: null,
|
||||
sidebarLabel: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
properties: {},
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
hasH1: false,
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
wordCount: 100,
|
||||
showDiffToggle: false,
|
||||
diffMode: false,
|
||||
diffLoading: false,
|
||||
onToggleDiff: vi.fn(),
|
||||
}
|
||||
|
||||
describe('BreadcrumbBar filename visibility', () => {
|
||||
it('keeps the filename visible in the breadcrumb by default', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
expect(screen.getByText('test')).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps the filename visible even when the bar is marked as title-hidden', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
container.querySelector('.breadcrumb-bar')?.setAttribute('data-title-hidden', '')
|
||||
expect(screen.getByText('test')).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps breadcrumb action buttons on the zero-padding footprint', () => {
|
||||
const editorCss = readFileSync(`${process.cwd()}/src/components/Editor.css`, 'utf8')
|
||||
|
||||
expect(editorCss).toContain(".breadcrumb-bar__actions [data-slot='button']")
|
||||
expect(editorCss).toContain('width: auto;')
|
||||
expect(editorCss).toContain('height: auto;')
|
||||
expect(editorCss).toContain('padding: 0;')
|
||||
expect(editorCss).toContain('border-radius: 0;')
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { CloneVaultModal } from './CloneVaultModal'
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn(),
|
||||
}))
|
||||
|
||||
import { mockInvoke } from '../mock-tauri'
|
||||
|
||||
const mockInvokeFn = vi.mocked(mockInvoke)
|
||||
|
||||
describe('CloneVaultModal', () => {
|
||||
const onClose = vi.fn()
|
||||
const onVaultCloned = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockResolvedValue('Cloned successfully')
|
||||
})
|
||||
|
||||
it('renders nothing when not open', () => {
|
||||
const { container } = render(
|
||||
<CloneVaultModal open={false} onClose={onClose} onVaultCloned={onVaultCloned} />
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-testid="clone-vault-modal"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the clone form when open', () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
expect(screen.getByText('Clone Git Repo')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('clone-repo-url')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('clone-vault-path')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('suggests a vault path from the repository URL', () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('clone-repo-url'), {
|
||||
target: { value: 'https://gitlab.com/user/my-vault.git' },
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('clone-vault-path')).toHaveValue('~/Vaults/my-vault')
|
||||
})
|
||||
|
||||
it('calls clone_repo and reports the cloned vault on submit', async () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('clone-repo-url'), {
|
||||
target: { value: 'git@github.com:user/my-vault.git' },
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('clone-vault-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('clone_repo', {
|
||||
url: 'git@github.com:user/my-vault.git',
|
||||
localPath: '~/Vaults/my-vault',
|
||||
})
|
||||
})
|
||||
|
||||
expect(onVaultCloned).toHaveBeenCalledWith('~/Vaults/my-vault', 'my-vault')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the backend error when cloning fails', async () => {
|
||||
mockInvokeFn.mockRejectedValueOnce(new Error('Permission denied'))
|
||||
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('clone-repo-url'), {
|
||||
target: { value: 'https://example.com/user/private-vault.git' },
|
||||
})
|
||||
fireEvent.change(screen.getByTestId('clone-vault-path'), {
|
||||
target: { value: '~/Vaults/private-vault' },
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('clone-vault-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('clone-vault-error')).toHaveTextContent('Clone failed: Error: Permission denied')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,204 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
|
||||
type CloneStatus = 'idle' | 'cloning' | 'error'
|
||||
|
||||
interface CloneVaultModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onVaultCloned: (path: string, label: string) => void
|
||||
}
|
||||
|
||||
interface CloneVaultFormState {
|
||||
repoUrl: string
|
||||
localPath: string
|
||||
cloneStatus: CloneStatus
|
||||
cloneError: string | null
|
||||
isCloneDisabled: boolean
|
||||
handleClose: () => void
|
||||
handleRepoUrlChange: (value: string) => void
|
||||
handleLocalPathChange: (value: string) => void
|
||||
handleClone: () => Promise<void>
|
||||
}
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
function repoNameFromUrl(url: string): string {
|
||||
const trimmed = url.trim().replace(/\/+$/g, '')
|
||||
if (!trimmed) return ''
|
||||
const segment = trimmed.split(/[/:]/).pop() ?? ''
|
||||
return segment.replace(/\.git$/i, '')
|
||||
}
|
||||
|
||||
function suggestedPathFromUrl(url: string): string {
|
||||
const repoName = repoNameFromUrl(url)
|
||||
return repoName ? `~/Vaults/${repoName}` : ''
|
||||
}
|
||||
|
||||
function labelFromPath(path: string): string {
|
||||
const trimmed = path.trim().replace(/\/+$/g, '')
|
||||
return trimmed.split('/').pop() || 'Vault'
|
||||
}
|
||||
|
||||
function shouldSyncSuggestedPath(localPath: string, pathDirty: boolean, previousSuggestedPath: string): boolean {
|
||||
return !pathDirty || !localPath.trim() || localPath === previousSuggestedPath
|
||||
}
|
||||
|
||||
function useCloneVaultForm(onClose: () => void, onVaultCloned: (path: string, label: string) => void): CloneVaultFormState {
|
||||
const [repoUrl, setRepoUrl] = useState('')
|
||||
const [localPath, setLocalPath] = useState('')
|
||||
const [pathDirty, setPathDirty] = useState(false)
|
||||
const [cloneStatus, setCloneStatus] = useState<CloneStatus>('idle')
|
||||
const [cloneError, setCloneError] = useState<string | null>(null)
|
||||
const previousSuggestedPathRef = useRef('')
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setRepoUrl('')
|
||||
setLocalPath('')
|
||||
setPathDirty(false)
|
||||
setCloneStatus('idle')
|
||||
setCloneError(null)
|
||||
previousSuggestedPathRef.current = ''
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [onClose, resetState])
|
||||
|
||||
const handleRepoUrlChange = useCallback((value: string) => {
|
||||
setRepoUrl(value)
|
||||
setCloneError(null)
|
||||
|
||||
const nextSuggestedPath = suggestedPathFromUrl(value)
|
||||
const previousSuggestedPath = previousSuggestedPathRef.current
|
||||
|
||||
if (shouldSyncSuggestedPath(localPath, pathDirty, previousSuggestedPath)) {
|
||||
setLocalPath(nextSuggestedPath)
|
||||
}
|
||||
|
||||
previousSuggestedPathRef.current = nextSuggestedPath
|
||||
}, [localPath, pathDirty])
|
||||
|
||||
const handleLocalPathChange = useCallback((value: string) => {
|
||||
setPathDirty(true)
|
||||
setLocalPath(value)
|
||||
setCloneError(null)
|
||||
}, [])
|
||||
|
||||
const handleClone = useCallback(async () => {
|
||||
const trimmedUrl = repoUrl.trim()
|
||||
const trimmedPath = localPath.trim()
|
||||
if (!trimmedUrl || !trimmedPath) return
|
||||
|
||||
setCloneStatus('cloning')
|
||||
setCloneError(null)
|
||||
|
||||
try {
|
||||
await tauriCall<string>('clone_repo', { url: trimmedUrl, localPath: trimmedPath })
|
||||
onVaultCloned(trimmedPath, labelFromPath(trimmedPath))
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
setCloneStatus('error')
|
||||
setCloneError(`Clone failed: ${String(error)}`)
|
||||
}
|
||||
}, [handleClose, localPath, onVaultCloned, repoUrl])
|
||||
|
||||
return {
|
||||
repoUrl,
|
||||
localPath,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
isCloneDisabled: !repoUrl.trim() || !localPath.trim() || cloneStatus === 'cloning',
|
||||
handleClose,
|
||||
handleRepoUrlChange,
|
||||
handleLocalPathChange,
|
||||
handleClone,
|
||||
}
|
||||
}
|
||||
|
||||
export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModalProps) {
|
||||
const {
|
||||
repoUrl,
|
||||
localPath,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
isCloneDisabled,
|
||||
handleClose,
|
||||
handleRepoUrlChange,
|
||||
handleLocalPathChange,
|
||||
handleClone,
|
||||
} = useCloneVaultForm(onClose, onVaultCloned)
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) handleClose()
|
||||
}, [handleClose])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[520px]" data-testid="clone-vault-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clone Git Repo</DialogTitle>
|
||||
<DialogDescription>
|
||||
Clone any remote repository into a local vault folder. Tolaria uses your existing system git
|
||||
configuration for authentication.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground" htmlFor="clone-repo-url">Repository URL</label>
|
||||
<Input
|
||||
id="clone-repo-url"
|
||||
placeholder="git@host:owner/repo.git or https://host/owner/repo.git"
|
||||
value={repoUrl}
|
||||
onChange={(event) => handleRepoUrlChange(event.target.value)}
|
||||
data-testid="clone-repo-url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground" htmlFor="clone-vault-path">Clone to</label>
|
||||
<Input
|
||||
id="clone-vault-path"
|
||||
placeholder="~/Vaults/my-vault"
|
||||
value={localPath}
|
||||
onChange={(event) => handleLocalPathChange(event.target.value)}
|
||||
data-testid="clone-vault-path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
SSH keys, the git credential manager, `gh auth`, and other system git auth methods all work.
|
||||
</p>
|
||||
|
||||
{cloneError && (
|
||||
<p className="text-xs text-destructive" data-testid="clone-vault-error">{cloneError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
|
||||
<Button
|
||||
onClick={handleClone}
|
||||
disabled={isCloneDisabled}
|
||||
data-testid="clone-vault-submit"
|
||||
>
|
||||
{cloneStatus === 'cloning' ? 'Cloning...' : 'Clone & Open'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||