Compare commits
1282 Commits
v0.2026040
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57a5693922 | ||
|
|
2ca8f1b2a6 | ||
|
|
eb65bb8f05 | ||
|
|
8fb229ede3 | ||
|
|
ce84b34890 | ||
|
|
e98a186389 | ||
|
|
258b54b074 | ||
|
|
f694b9b5e4 | ||
|
|
32ee2b781b | ||
|
|
c78793cfe3 | ||
|
|
cd9e5fc403 | ||
|
|
798a6b2125 | ||
|
|
dbf54657f0 | ||
|
|
0c365eb7dd | ||
|
|
20b789271d | ||
|
|
36d3c8731b | ||
|
|
e412fa8fd7 | ||
|
|
76c37cf783 | ||
|
|
4b60b9539d | ||
|
|
69e520b5aa | ||
|
|
272f2c0b3c | ||
|
|
d7bdab5b2e | ||
|
|
5a5ea8d6f0 | ||
|
|
86306dc9de | ||
|
|
974ca6148b | ||
|
|
b1bc056afb | ||
|
|
c24f60c594 | ||
|
|
71a3be577d | ||
|
|
717fa9d1a6 | ||
|
|
78e76e0d54 | ||
|
|
b01c6b4a4b | ||
|
|
db4359981f | ||
|
|
98d19e4c41 | ||
|
|
5ebffc447b | ||
|
|
a44dd41f95 | ||
|
|
eb67b98d96 | ||
|
|
968c4d05a9 | ||
|
|
c8db92c92d | ||
|
|
1e36cd2855 | ||
|
|
d86335c86d | ||
|
|
330fc29113 | ||
|
|
0aa3d156fc | ||
|
|
7bc74432ac | ||
|
|
0e1d98a217 | ||
|
|
f37d11101e | ||
|
|
3c51a18776 | ||
|
|
acd9addefd | ||
|
|
7dace14831 | ||
|
|
09f5ab9080 | ||
|
|
7304c3c55f | ||
|
|
378990c084 | ||
|
|
f745bc44aa | ||
|
|
b13d22338b | ||
|
|
c6d1034f37 | ||
|
|
165f53fb3b | ||
|
|
31c8ac4774 | ||
|
|
dd78e254ff | ||
|
|
cb2e28f3d9 | ||
|
|
d13e181658 | ||
|
|
4634949f71 | ||
|
|
90fbe82bca | ||
|
|
64e923ac5f | ||
|
|
3ac2326d05 | ||
|
|
f4b59c973d | ||
|
|
21ad9ad505 | ||
|
|
8b4ab4a9cc | ||
|
|
680a56104d | ||
|
|
cffac2881a | ||
|
|
c3a9400f35 | ||
|
|
3feb167b12 | ||
|
|
459145ba0d | ||
|
|
24f6a014dc | ||
|
|
6640691514 | ||
|
|
429aba7ae7 | ||
|
|
ad46eb0295 | ||
|
|
5c5d40ff24 | ||
|
|
b06d0847c3 | ||
|
|
7da50be1a4 | ||
|
|
8f614e411c | ||
|
|
c0a5e8e714 | ||
|
|
528c711e68 | ||
|
|
90785440a0 | ||
|
|
e7fe938b03 | ||
|
|
616952bdaa | ||
|
|
e3eb774757 | ||
|
|
be57656d75 | ||
|
|
f66c8cf8ca | ||
|
|
8141b8fc8c | ||
|
|
0cc21e64ad | ||
|
|
ef4a51202c | ||
|
|
037fd39c95 | ||
|
|
b0b6a4f4cd | ||
|
|
d33117de40 | ||
|
|
751d1e3331 | ||
|
|
63eedbdb91 | ||
|
|
b674d20a05 | ||
|
|
f00eb652c1 | ||
|
|
927aa5c73c | ||
|
|
f0ac684524 | ||
|
|
7e48b81f30 | ||
|
|
771409d30b | ||
|
|
2eb8e02061 | ||
|
|
31e41e869a | ||
|
|
9cc3bcf758 | ||
|
|
6c67b39ae5 | ||
|
|
8218bcf558 | ||
|
|
59205c4012 | ||
|
|
c68c0a6c10 | ||
|
|
b384cbf6ee | ||
|
|
578f369f4c | ||
|
|
cbad38354b | ||
|
|
6a02317294 | ||
|
|
a12b3503e4 | ||
|
|
c403522116 | ||
|
|
4a4ce4a402 | ||
|
|
387ca56af6 | ||
|
|
e0ea0ddad9 | ||
|
|
c9da52a7ba | ||
|
|
c5f6e15670 | ||
|
|
89f655cc04 | ||
|
|
ceda8abe5d | ||
|
|
95edc7f1a2 | ||
|
|
8fe5340416 | ||
|
|
8b98e77680 | ||
|
|
ca507f6796 | ||
|
|
f756e6a831 | ||
|
|
0d08a80a0f | ||
|
|
3411f4e817 | ||
|
|
e2890ecbb5 | ||
|
|
8bd26b5d26 | ||
|
|
6762c5cec8 | ||
|
|
d284befd5f | ||
|
|
9c87eca226 | ||
|
|
d50f3479dc | ||
|
|
0b58fd6061 | ||
|
|
0c5db9ebea | ||
|
|
d0c3a6b889 | ||
|
|
53072eb4f3 | ||
|
|
463c9bd928 | ||
|
|
b9ad9d4452 | ||
|
|
768b4c764d | ||
|
|
4573ea80c3 | ||
|
|
4bbd1f4b29 | ||
|
|
2a5386a9a8 | ||
|
|
ef17e7b3b1 | ||
|
|
61a83f437d | ||
|
|
d235f9b279 | ||
|
|
d06dea9e45 | ||
|
|
9ee66913be | ||
|
|
b10cf8d14e | ||
|
|
27b53d8441 | ||
|
|
f902b23970 | ||
|
|
b4f2fe0a7a | ||
|
|
64238e5e55 | ||
|
|
c8b26b1caa | ||
|
|
7c1269c047 | ||
|
|
95742c39e9 | ||
|
|
fea8038263 | ||
|
|
e98576d2b0 | ||
|
|
de8a246521 | ||
|
|
c282244cf8 | ||
|
|
bf530c8a26 | ||
|
|
db31c11a87 | ||
|
|
5db899aae8 | ||
|
|
8e9a1bafcf | ||
|
|
1145f4a572 | ||
|
|
49d7353f2d | ||
|
|
bfe06ae5c4 | ||
|
|
f44c8318a8 | ||
|
|
7d16c75bc5 | ||
|
|
a3da859c06 | ||
|
|
8b60c9834c | ||
|
|
2f0be6ea5f | ||
|
|
66a7540f26 | ||
|
|
29bc072e56 | ||
|
|
3d6f16fa74 | ||
|
|
e6274e8325 | ||
|
|
97dc6e91d0 | ||
|
|
2978149679 | ||
|
|
8ae5b7c5d3 | ||
|
|
e1aa57f11c | ||
|
|
c07bdfd1a1 | ||
|
|
ee3415cece | ||
|
|
9a82e9a637 | ||
|
|
1d301ea7a8 | ||
|
|
ec16e75c1e | ||
|
|
162aea6d77 | ||
|
|
e70b5aabe0 | ||
|
|
8d90b8489b | ||
|
|
0be1060d81 | ||
|
|
b6517e2794 | ||
|
|
0e71403cd7 | ||
|
|
7cd813eb31 | ||
|
|
df8ed500ac | ||
|
|
f163d63033 | ||
|
|
c5b151a32e | ||
|
|
f1476e13cc | ||
|
|
08e280368e | ||
|
|
49df920ae2 | ||
|
|
b84dc12291 | ||
|
|
ee5fd00d5e | ||
|
|
8433aad652 | ||
|
|
d610a43977 | ||
|
|
0270e29c09 | ||
|
|
734f13a47f | ||
|
|
d3dd0e6ace | ||
|
|
fbf8909b9d | ||
|
|
e0cd5378a3 | ||
|
|
de033c79b1 | ||
|
|
b44a6c7900 | ||
|
|
81142cf15f | ||
|
|
252618b6bd | ||
|
|
8c4652a79b | ||
|
|
9a1ca42c66 | ||
|
|
0ed72c5b98 | ||
|
|
8cbbe1e7a0 | ||
|
|
269cb5816c | ||
|
|
05a4194159 | ||
|
|
cd3747c33a | ||
|
|
8cb32edf57 | ||
|
|
fc1d74b84c | ||
|
|
da51174777 | ||
|
|
e6e8e8aa00 | ||
|
|
f185f22ca0 | ||
|
|
b695a63e90 | ||
|
|
aadc704d48 | ||
|
|
3e18a34098 | ||
|
|
5fbb02631b | ||
|
|
6608730eb5 | ||
|
|
b40606794b | ||
|
|
13c3fe20fc | ||
|
|
1fc8652aa8 | ||
|
|
3e1ece2588 | ||
|
|
790ef77a75 | ||
|
|
0e68f0bed9 | ||
|
|
592cef40ac | ||
|
|
b7c4071dd6 | ||
|
|
fda54c955e | ||
|
|
55352c0afd | ||
|
|
0fb115bbe1 | ||
|
|
cd39070569 | ||
|
|
ccf1b8517e | ||
|
|
d037272741 | ||
|
|
c7f857f24b | ||
|
|
620515ecea | ||
|
|
a34274db25 | ||
|
|
927e0e11e7 | ||
|
|
d65bea0552 | ||
|
|
30f8820a29 | ||
|
|
292f751461 | ||
|
|
0889d747fe | ||
|
|
c85d12ab55 | ||
|
|
fd24777a51 | ||
|
|
57b89fc871 | ||
|
|
49aff89de4 | ||
|
|
c64a3e5d52 | ||
|
|
a353509038 | ||
|
|
a525eca980 | ||
|
|
06bde75d72 | ||
|
|
ba056a628b | ||
|
|
3cefc75ec3 | ||
|
|
13e02813a3 | ||
|
|
37f32de292 | ||
|
|
01fdadf425 | ||
|
|
1488f6eb08 | ||
|
|
8a17421000 | ||
|
|
81f999597c | ||
|
|
4eca9ca8fd | ||
|
|
8365186b7e | ||
|
|
10c4d79c83 | ||
|
|
fa37f6ad9e | ||
|
|
06485d517c | ||
|
|
f8d94c3097 | ||
|
|
e7c7c49dc0 | ||
|
|
e19476b069 | ||
|
|
997fa477bf | ||
|
|
b9dd9eb9dc | ||
|
|
1e71869395 | ||
|
|
407d78c066 | ||
|
|
4022129481 | ||
|
|
5a791d8a26 | ||
|
|
e1efcf63e0 | ||
|
|
f89904620a | ||
|
|
44f59432c0 | ||
|
|
93b83007eb | ||
|
|
3ede96a437 | ||
|
|
4dd30c53b9 | ||
|
|
5db92fa94c | ||
|
|
e693ac2b21 | ||
|
|
8dd7501a2d | ||
|
|
19326a7681 | ||
|
|
19795b6874 | ||
|
|
5fe18c017c | ||
|
|
5e060c26b0 | ||
|
|
f3c97bb7d9 | ||
|
|
1b4341b24f | ||
|
|
c30a4ae0f3 | ||
|
|
3418b18504 | ||
|
|
46089ee545 | ||
|
|
fb8160cf36 | ||
|
|
552e085c2c | ||
|
|
ee41060f00 | ||
|
|
c938e10c70 | ||
|
|
c59b8cfea4 | ||
|
|
f6b99975ab | ||
|
|
27ec739c8e | ||
|
|
9743f46afd | ||
|
|
8d08545815 | ||
|
|
b10e090922 | ||
|
|
9e849e49ee | ||
|
|
d3e2d45261 | ||
|
|
b229d59437 | ||
|
|
d137c3a062 | ||
|
|
72b1562480 | ||
|
|
084820cb79 | ||
|
|
56ceee20e9 | ||
|
|
f595ee95c3 | ||
|
|
f9f48bd166 | ||
|
|
bb5fa2880f | ||
|
|
e7a5ee1cad | ||
|
|
86569800c9 | ||
|
|
2a88681c28 | ||
|
|
9d4736c856 | ||
|
|
873f2909bb | ||
|
|
99d1987203 | ||
|
|
18fd740988 | ||
|
|
682470f062 | ||
|
|
f2a26cc73f | ||
|
|
d756273bc0 | ||
|
|
196ed61e9c | ||
|
|
add2876f11 | ||
|
|
6aab170374 | ||
|
|
9e883ca5b8 | ||
|
|
60796dc747 | ||
|
|
2dad6c9c0a | ||
|
|
1751240b3a | ||
|
|
a81a36216e | ||
|
|
1c5e216a30 | ||
|
|
2cc0458070 | ||
|
|
b1d2c7168f | ||
|
|
7019cd400b | ||
|
|
4180599598 | ||
|
|
08bcbc065e | ||
|
|
730b56ecbd | ||
|
|
9df41c3287 | ||
|
|
fc4427750d | ||
|
|
a42a15c30c | ||
|
|
334ad09089 | ||
|
|
80fbf15b7f | ||
|
|
c9715d7570 | ||
|
|
e06178565a | ||
|
|
2f6b53776f | ||
|
|
82d3c60f55 | ||
|
|
6a74f53ca5 | ||
|
|
c3bd0630c9 | ||
|
|
1971019106 | ||
|
|
c6b09c511a | ||
|
|
edc3c35acd | ||
|
|
0ab7665db0 | ||
|
|
9b442b7c57 | ||
|
|
dc05effce2 | ||
|
|
da52a99286 | ||
|
|
c3da107102 | ||
|
|
99fe614e9d | ||
|
|
1e004e4abe | ||
|
|
04ff69a657 | ||
|
|
b8c2e8a83e | ||
|
|
c909870e3e | ||
|
|
165ce53ad3 | ||
|
|
960ccde17f | ||
|
|
d7855b9381 | ||
|
|
294df7047c | ||
|
|
59e3d4b2e8 | ||
|
|
bfbc9d49d1 | ||
|
|
71f6f4cc5d | ||
|
|
98debe15f1 | ||
|
|
5ec508bc38 | ||
|
|
2799d8f0a6 | ||
|
|
929a96a433 | ||
|
|
ae5cc42fbc | ||
|
|
d82dc2759e | ||
|
|
e23c703071 | ||
|
|
ed01dc3b7a | ||
|
|
2fd5ce2e94 | ||
|
|
d7166ea993 | ||
|
|
3028a3616c | ||
|
|
76c10ea876 | ||
|
|
08672dbf59 | ||
|
|
b315ada7e4 | ||
|
|
c4d954a3b9 | ||
|
|
8bfdb5b330 | ||
|
|
b5c1369fbf | ||
|
|
3cd097d14c | ||
|
|
cd7a570240 | ||
|
|
aaa46912f3 | ||
|
|
8046e54e27 | ||
|
|
473239be3a | ||
|
|
ae2260c47f | ||
|
|
d263e43025 | ||
|
|
caaa582ddd | ||
|
|
ad9d0e5e67 | ||
|
|
c8bb3fe5e3 | ||
|
|
0d32735976 | ||
|
|
c6cb1ea4f7 | ||
|
|
b3a98ce696 | ||
|
|
1bea9d4e84 | ||
|
|
5b1ecd7a94 | ||
|
|
971564ee6f | ||
|
|
3e9c3bd8e1 | ||
|
|
3f4ce6a2ae | ||
|
|
0ee8d38a83 | ||
|
|
a22fd73c3b | ||
|
|
c0790679cd | ||
|
|
63054f7eb9 | ||
|
|
e47625f8bb | ||
|
|
8a86995c23 | ||
|
|
67e620c872 | ||
|
|
1984b8ed77 | ||
|
|
8bf868875b | ||
|
|
6187eee74d | ||
|
|
e74d4c8392 | ||
|
|
e84d21d36d | ||
|
|
5504941209 | ||
|
|
c372193a0c | ||
|
|
fdf8af288f | ||
|
|
c11a983d74 | ||
|
|
85efe31296 | ||
|
|
2a419ffcf0 | ||
|
|
00adf2380e | ||
|
|
5a376f93a0 | ||
|
|
a1ca7e302f | ||
|
|
5a578f60d1 | ||
|
|
93b654101c | ||
|
|
a43a98e93f | ||
|
|
b8e3b88e62 | ||
|
|
586e488072 | ||
|
|
51fc9298aa | ||
|
|
2d0d310c19 | ||
|
|
dd2a02074e | ||
|
|
6aca4e86d4 | ||
|
|
caa81ae96b | ||
|
|
afc8fd5822 | ||
|
|
6cc07faac4 | ||
|
|
26f09093cf | ||
|
|
b53243938d | ||
|
|
515e771f0d | ||
|
|
8bd6df2053 | ||
|
|
8155b940a4 | ||
|
|
27f8c9530d | ||
|
|
d05b435a5c | ||
|
|
4362405dd3 | ||
|
|
968d9073d2 | ||
|
|
8f6da24ef5 | ||
|
|
7db3c2f00a | ||
|
|
abe465cc74 | ||
|
|
fa6f0fcab3 | ||
|
|
5940181a09 | ||
|
|
2214a5c45c | ||
|
|
2f43b4bfeb | ||
|
|
a1600d5dee | ||
|
|
0bda7f4379 | ||
|
|
15b11d118b | ||
|
|
f94d8c63c8 | ||
|
|
fc77ce6e11 | ||
|
|
75ad88fb54 | ||
|
|
a4d9807894 | ||
|
|
bc58424f3c | ||
|
|
a9229d4469 | ||
|
|
027a85fb06 | ||
|
|
ce7b5f93d3 | ||
|
|
9c0e39180c | ||
|
|
2b786892ee | ||
|
|
b846de15ad | ||
|
|
d9fd65c926 | ||
|
|
9aa16b8eb4 | ||
|
|
ddbbebbb67 | ||
|
|
0897cc5d95 | ||
|
|
dec9fe6412 | ||
|
|
4cfd31fe53 | ||
|
|
c02584fd86 | ||
|
|
0ac8ad8dff | ||
|
|
77b6961cb9 | ||
|
|
9112a24fcb | ||
|
|
663e9563b2 | ||
|
|
e74d0e46c8 | ||
|
|
7976d9f2c8 | ||
|
|
3890960b8a | ||
|
|
ee9af1eb43 | ||
|
|
edc4911eaf | ||
|
|
4d61774a70 | ||
|
|
164b76d35c | ||
|
|
f9e89e4616 | ||
|
|
4a69fc1c9c | ||
|
|
bc4e637cdd | ||
|
|
bb9fef9f77 | ||
|
|
db0589c99d | ||
|
|
07a6ef0970 | ||
|
|
af46c0499a | ||
|
|
6371e80f06 | ||
|
|
fb825828f7 | ||
|
|
427672cf4e | ||
|
|
56b0a86f03 | ||
|
|
71ccae1c70 | ||
|
|
54e769fc21 | ||
|
|
f5d0bd0c7b | ||
|
|
49ab91541f | ||
|
|
22d002c1c6 | ||
|
|
9f81a00347 | ||
|
|
b5eb781d14 | ||
|
|
c921403825 | ||
|
|
6b6bd4693e | ||
|
|
6520e366c8 | ||
|
|
cd0152c0a4 | ||
|
|
144a621b3e | ||
|
|
a12ad57699 | ||
|
|
3fb7cf6a56 | ||
|
|
23d04172c4 | ||
|
|
1932b35c92 | ||
|
|
ede51e26a3 | ||
|
|
7f1f81c031 | ||
|
|
d9e25be5a6 | ||
|
|
a8989e1445 | ||
|
|
db94b8b3a0 | ||
|
|
ed33058862 | ||
|
|
6460eaeaf0 | ||
|
|
c7892fca22 | ||
|
|
a33d1a0db5 | ||
|
|
9ceb005068 | ||
|
|
a27904a757 | ||
|
|
582b181f41 | ||
|
|
da3ec342d4 | ||
|
|
50826df420 | ||
|
|
ac035e483e | ||
|
|
76c988db21 | ||
|
|
ddab8862da | ||
|
|
90a3ff8194 | ||
|
|
19830c1734 | ||
|
|
a333a631d9 | ||
|
|
b916204a9a | ||
|
|
968328b016 | ||
|
|
526965eb86 | ||
|
|
0d23d9f001 | ||
|
|
094e806b13 | ||
|
|
69f50e5950 | ||
|
|
c431c1adf7 | ||
|
|
b05f9ad6df | ||
|
|
37a2bd501f | ||
|
|
42f27eeb9b | ||
|
|
2b33536a68 | ||
|
|
1b0f7d71d4 | ||
|
|
8e0bf679f1 | ||
|
|
68bbb19007 | ||
|
|
3436a48a62 | ||
|
|
e81faf2e9b | ||
|
|
848d3c163e | ||
|
|
6acc802824 | ||
|
|
f5db98417d | ||
|
|
19e386ccda | ||
|
|
1a05f5d9ed | ||
|
|
0e265dc848 | ||
|
|
8ab83157ee | ||
|
|
ab1a1fb206 | ||
|
|
1ed9d0f5da | ||
|
|
f53829f3fb | ||
|
|
5fa928a055 | ||
|
|
4dd10dc7e7 | ||
|
|
e2b2abce37 | ||
|
|
ce85d351d9 | ||
|
|
642955196c | ||
|
|
9fef2551b9 | ||
|
|
af0c21722c | ||
|
|
7a919ffd78 | ||
|
|
2c0ad915a8 | ||
|
|
ae92e05740 | ||
|
|
a9411c1080 | ||
|
|
e2c6f540fc | ||
|
|
ce6a61c369 | ||
|
|
7249a9eb32 | ||
|
|
33c4daaa8d | ||
|
|
fe37bb4b27 | ||
|
|
4a973e9ba8 | ||
|
|
b02227a5a6 | ||
|
|
aaa5bc2e81 | ||
|
|
5107cd13f4 | ||
|
|
891e298f3a | ||
|
|
12a5a7af47 | ||
|
|
d1775ff1c2 | ||
|
|
7f2aaf596a | ||
|
|
4405ecc9c4 | ||
|
|
1e1eb4facc | ||
|
|
d28c46123c | ||
|
|
04f580310c | ||
|
|
c5b583131c | ||
|
|
a90ca8bf3a | ||
|
|
2bca5a21e1 | ||
|
|
ee94ecb731 | ||
|
|
1486bae4bd | ||
|
|
92f0f96ad2 | ||
|
|
e0bf514f5d | ||
|
|
761d6dc3b5 | ||
|
|
5f08e8eba2 | ||
|
|
7005f54476 | ||
|
|
265f7b3eb2 | ||
|
|
8a8fa802bb | ||
|
|
4c7e64dc0c | ||
|
|
e891ff6191 | ||
|
|
3084702e5c | ||
|
|
89ae7e54bb | ||
|
|
517a2af730 | ||
|
|
484324ae3f | ||
|
|
a2e65b36c3 | ||
|
|
d8b61dd62f | ||
|
|
db66e7c9da | ||
|
|
a760594d66 | ||
|
|
73060f3d00 | ||
|
|
46f289d229 | ||
|
|
182f88cd10 | ||
|
|
d6d35b3057 | ||
|
|
a73f18142b | ||
|
|
656679e21a | ||
|
|
ba5ba58c6b | ||
|
|
bc2d54d062 | ||
|
|
1354e504c3 | ||
|
|
0082c02eb4 | ||
|
|
e3d616cd26 | ||
|
|
afe549478e | ||
|
|
e414d3b86c | ||
|
|
d3657d86bd | ||
|
|
664426d598 | ||
|
|
0755c4a8c0 | ||
|
|
fdb3fdf6fe | ||
|
|
010d2c3949 | ||
|
|
dedb5b2721 | ||
|
|
587eb0231e | ||
|
|
9cc9ff80d8 | ||
|
|
3e05a9425a | ||
|
|
55e479be13 | ||
|
|
02611f6d41 | ||
|
|
b0252118a8 | ||
|
|
4649662f3e | ||
|
|
dd1595cf68 | ||
|
|
ab1cb6260c | ||
|
|
873f85afc9 | ||
|
|
2d2c162c3c | ||
|
|
b53a1588b9 | ||
|
|
3d5423a4c2 | ||
|
|
4ce9167e09 | ||
|
|
dd7f0f978a | ||
|
|
6801546549 | ||
|
|
4c753d059d | ||
|
|
3dc1ae51c8 | ||
|
|
749647bf64 | ||
|
|
cfedcfe531 | ||
|
|
8ce0706b82 | ||
|
|
2fe62badb6 | ||
|
|
f8f8d541df | ||
|
|
ca65abd73c | ||
|
|
d9f8d42733 | ||
|
|
ea26e0a2ee | ||
|
|
4787673841 | ||
|
|
8eac9f9e3f | ||
|
|
71810a7a90 | ||
|
|
87090cebc3 | ||
|
|
bb42ebbdc1 | ||
|
|
f2a9f02d2a | ||
|
|
12fe4978ab | ||
|
|
78954d863d | ||
|
|
3c711cbe04 | ||
|
|
9af329f0ca | ||
|
|
48b6434dd4 | ||
|
|
270856f556 | ||
|
|
09a8b55394 | ||
|
|
944bd8f834 | ||
|
|
d41fe306fe | ||
|
|
5290e1847d | ||
|
|
d37c75296c | ||
|
|
9c7f05fb7d | ||
|
|
c47a67168c | ||
|
|
43629ca62c | ||
|
|
35afd09364 | ||
|
|
fe2cef092a | ||
|
|
e29360ef0a | ||
|
|
2d414961a9 | ||
|
|
f136fd57d6 | ||
|
|
c1e11f45df | ||
|
|
50e4a63ef2 | ||
|
|
dc22a2d7d9 | ||
|
|
f2ec2ab55c | ||
|
|
0d4ca63ec8 | ||
|
|
5324f729a9 | ||
|
|
99112617bf | ||
|
|
924fb59bc2 | ||
|
|
d3a2951bc7 | ||
|
|
41cc824180 | ||
|
|
a0eb1a292d | ||
|
|
415bbb5413 | ||
|
|
fe9ebc9063 | ||
|
|
a7928ea9d7 | ||
|
|
872ccf1efe | ||
|
|
f54929c08b | ||
|
|
36065b0ca5 | ||
|
|
1dccc0239c | ||
|
|
47fe307d45 | ||
|
|
4c28fa8e25 | ||
|
|
6e102b9f37 | ||
|
|
902a545c23 | ||
|
|
a4dac661ac | ||
|
|
bd6487c1d2 | ||
|
|
875d8177a4 | ||
|
|
21bfaa8c30 | ||
|
|
7610f1b89a | ||
|
|
95f6c05053 | ||
|
|
3f6b5b9a06 | ||
|
|
ddd65926d8 | ||
|
|
7a7ba6086a | ||
|
|
7f6f5c2a27 | ||
|
|
ef4208697e | ||
|
|
e5aaacdc27 | ||
|
|
78c45970bf | ||
|
|
486b0e9ed1 | ||
|
|
4df386d310 | ||
|
|
48c3bd11aa | ||
|
|
f7ac01ad1f | ||
|
|
a7d94b354e | ||
|
|
1817cb3794 | ||
|
|
66f58446b4 | ||
|
|
5fdece6d2d | ||
|
|
99faee6773 | ||
|
|
d81e2111fc | ||
|
|
40b5d31003 | ||
|
|
bfc79c870b | ||
|
|
1c2552a1bb | ||
|
|
d7c210222c | ||
|
|
8712ce8e13 | ||
|
|
f4af2b4b19 | ||
|
|
282c3bebab | ||
|
|
ebdc39b308 | ||
|
|
b0b0464f99 | ||
|
|
aaecd9a626 | ||
|
|
e7f43d5d10 | ||
|
|
c876997fba | ||
|
|
049b4a9def | ||
|
|
7c9067527b | ||
|
|
eda8bc373e | ||
|
|
5d4c372042 | ||
|
|
c2b54e7390 | ||
|
|
817055a9e1 | ||
|
|
a2f58e1b2d | ||
|
|
3306d5ff3d | ||
|
|
a3148de1d3 | ||
|
|
66c086f8c9 | ||
|
|
51ccece936 | ||
|
|
f8e5cbfa36 | ||
|
|
a3e3d9a72b | ||
|
|
559c585a08 | ||
|
|
3431b794d1 | ||
|
|
6b13120fa6 | ||
|
|
87e28b0816 | ||
|
|
239ff7fd97 | ||
|
|
a364cbc2bd | ||
|
|
338f073421 | ||
|
|
03d4d51ee9 | ||
|
|
a966b2cc65 | ||
|
|
0eedf0c83a | ||
|
|
3f9c7b7395 | ||
|
|
9f35f049d7 | ||
|
|
da01d2ec28 | ||
|
|
85234b1b98 | ||
|
|
f864bc4990 | ||
|
|
5189ed6cad | ||
|
|
d5849593ea | ||
|
|
e817d088eb | ||
|
|
eeada90b37 | ||
|
|
795badf78b | ||
|
|
1056a152c1 | ||
|
|
f3d197a3b0 | ||
|
|
1c7ee05639 | ||
|
|
db1ae9b283 | ||
|
|
1694676472 | ||
|
|
9d8cf4da29 | ||
|
|
4dbcc7c298 | ||
|
|
1f1e115d70 | ||
|
|
c127130c29 | ||
|
|
5a2bcecaac | ||
|
|
3ab3ca22ff | ||
|
|
8029e07945 | ||
|
|
3893d01a1f | ||
|
|
93d598d04c | ||
|
|
a2f1ab9bdc | ||
|
|
f99b9d0b9b | ||
|
|
389e9d4372 | ||
|
|
9599ddaea8 | ||
|
|
5dbdf5077f | ||
|
|
cfe5d99d7f | ||
|
|
cc19d83407 | ||
|
|
49092eaa8a | ||
|
|
3f2a1516bc | ||
|
|
c887a0c5f6 | ||
|
|
7cbe9b9010 | ||
|
|
f4c832ad9c | ||
|
|
beac0788eb | ||
|
|
2548a80e34 | ||
|
|
d81eedf142 | ||
|
|
6475160a14 | ||
|
|
deabb13c01 | ||
|
|
21e0cb6c6e | ||
|
|
4041655d95 | ||
|
|
79e8c9fef5 | ||
|
|
0eeb45e16e | ||
|
|
4499fe0ca3 | ||
|
|
995c81e327 | ||
|
|
f91c6f15d6 | ||
|
|
44256ba350 | ||
|
|
835578e43c | ||
|
|
48b61d65c5 | ||
|
|
c3722ba8ea | ||
|
|
351a91c571 | ||
|
|
35aea7a50d | ||
|
|
4a88cb3e94 | ||
|
|
afa51bf147 | ||
|
|
d227cb4ac7 | ||
|
|
fbbfe0ea6d | ||
|
|
851e482ab7 | ||
|
|
fe8c9f574b | ||
|
|
7bd19f0120 | ||
|
|
3d6e237c5e | ||
|
|
1681193750 | ||
|
|
aa0f657c48 | ||
|
|
e769de3029 | ||
|
|
1b6d7e5b09 | ||
|
|
b473d704e4 | ||
|
|
3e83d314f0 | ||
|
|
68d28bc556 | ||
|
|
30f3cdf450 | ||
|
|
39161c8360 | ||
|
|
a293512442 | ||
|
|
1d4036e9ad | ||
|
|
e2e5befef1 | ||
|
|
b649cba92a | ||
|
|
5f1a3aecb9 | ||
|
|
7543f37ddd | ||
|
|
e0b6e84e10 | ||
|
|
05c6863119 | ||
|
|
5a7f5f7c32 | ||
|
|
21d99fa5be | ||
|
|
cf8142bd25 | ||
|
|
f289ede15e | ||
|
|
f467037e97 | ||
|
|
f152e0343d | ||
|
|
7169634dc3 | ||
|
|
d1d56778dc | ||
|
|
4a0ce390ac | ||
|
|
f4d3640f49 | ||
|
|
ef675dab7c | ||
|
|
2bd41bcf5f | ||
|
|
fea24f1458 | ||
|
|
cd1cda7a0a | ||
|
|
74038af110 | ||
|
|
4474492868 | ||
|
|
60dbfad1bf | ||
|
|
b043a7efe8 | ||
|
|
c78d4b3c43 | ||
|
|
ecd19c5be4 | ||
|
|
80b150fffe | ||
|
|
677585f3f0 | ||
|
|
bfd871704d | ||
|
|
13ee62ce67 | ||
|
|
178efb6512 | ||
|
|
81990f0292 | ||
|
|
312abc4ae0 | ||
|
|
0b86070f13 | ||
|
|
0846183d53 | ||
|
|
8aee1315d9 | ||
|
|
e58f521262 | ||
|
|
986965a28f | ||
|
|
7d563b28fd | ||
|
|
88954151f3 | ||
|
|
9ec0e8a3a1 | ||
|
|
8e2ca1e3a1 | ||
|
|
ee6972113a | ||
|
|
568f0aef4f | ||
|
|
2bcb36c7b4 | ||
|
|
1a18de2340 | ||
|
|
36a421d42a | ||
|
|
5937a4457e | ||
|
|
5fb7b12a11 | ||
|
|
e0c5753be6 | ||
|
|
0b936304d5 | ||
|
|
5782bd9dcb | ||
|
|
5ba45fc557 | ||
|
|
01d827fb5c | ||
|
|
5efb0e4b87 | ||
|
|
366991d5de | ||
|
|
eac2962840 | ||
|
|
b6c826b54a | ||
|
|
cb214ea2b0 | ||
|
|
264d4af5fc | ||
|
|
3d0b4935de | ||
|
|
fdb6a73b21 | ||
|
|
cae821ef4c | ||
|
|
06e42b182e | ||
|
|
690dd6d841 | ||
|
|
e405ea74d9 | ||
|
|
b610f1c673 | ||
|
|
2137de8bac | ||
|
|
f3d0681306 | ||
|
|
9d0c549041 | ||
|
|
aeb4f640c5 | ||
|
|
e1e4b23a55 | ||
|
|
daae171d6e | ||
|
|
324e389fd0 | ||
|
|
c1a3208485 | ||
|
|
ab4a2437ee | ||
|
|
793c3dceb6 | ||
|
|
2bd9cd91dd | ||
|
|
d7f1fb31be | ||
|
|
9f3c36eade | ||
|
|
eb574ca275 | ||
|
|
cbfcb9d03a | ||
|
|
75f68a80b4 | ||
|
|
e0b8f66bc6 | ||
|
|
3e87cd7148 | ||
|
|
12f1e1e6e3 | ||
|
|
c2e064000d | ||
|
|
fac1fefcf7 | ||
|
|
9338356e20 | ||
|
|
7a606f0ba6 | ||
|
|
184055ccc8 | ||
|
|
4e93e1ea95 | ||
|
|
18cef0b002 | ||
|
|
074c22286c | ||
|
|
9385cbd9f0 | ||
|
|
4cf8697812 | ||
|
|
ef3e39785a | ||
|
|
b03d6df8a9 | ||
|
|
2aef46709d | ||
|
|
99fcd2775f | ||
|
|
54e5b2a5b2 | ||
|
|
33b851c4ab | ||
|
|
cc00e7f137 | ||
|
|
fb4896f49f | ||
|
|
8388029cfb | ||
|
|
84c9f26364 | ||
|
|
222a9ff2dd | ||
|
|
c3e9804132 | ||
|
|
acd4112c64 | ||
|
|
89fbe15c9d | ||
|
|
ce83151e00 | ||
|
|
3182fbecee | ||
|
|
a1ece6f395 | ||
|
|
0948bdb4fa | ||
|
|
cbbea2c174 | ||
|
|
d889c4f876 | ||
|
|
e541c1f21f | ||
|
|
c1819c40bb | ||
|
|
fe79e6131e | ||
|
|
0cfce651a8 | ||
|
|
2f56fad3d7 | ||
|
|
52e1e3f73b | ||
|
|
44f49e2d94 | ||
|
|
2ff5467475 | ||
|
|
5e6d2fd108 | ||
|
|
ac81eafc07 | ||
|
|
05aa6f6365 | ||
|
|
55018b8681 | ||
|
|
c3f71f73dd | ||
|
|
21c0c12dcb | ||
|
|
29fddea2f0 | ||
|
|
a3b7305e51 | ||
|
|
c03bc895a6 | ||
|
|
30ca211bb0 | ||
|
|
f3dcaadca8 | ||
|
|
6e359d5b9b | ||
|
|
24791738d1 | ||
|
|
e9f42f7953 | ||
|
|
43e8fc764a | ||
|
|
15089a6e48 | ||
|
|
9de3cecaa2 | ||
|
|
a990b24861 | ||
|
|
d202268ed3 | ||
|
|
e5f3746e8e | ||
|
|
142ff54e5a | ||
|
|
707546c1df | ||
|
|
91cff383f3 | ||
|
|
95cbf1a73c | ||
|
|
47acd1c30d | ||
|
|
4e6bbf4b88 | ||
|
|
a5c2d26508 | ||
|
|
5941f16d06 | ||
|
|
190ae6420d | ||
|
|
80dd525898 | ||
|
|
f3629e24d5 | ||
|
|
6602763805 | ||
|
|
eee0316b26 | ||
|
|
c4c806c9a8 | ||
|
|
175bcb3eb8 | ||
|
|
6baa7795df | ||
|
|
87f847b710 | ||
|
|
268b87cec2 | ||
|
|
cdb9de59bb | ||
|
|
82b60f1349 | ||
|
|
f019389212 | ||
|
|
02a04ec100 | ||
|
|
9145023b77 | ||
|
|
8e5cbb2a34 | ||
|
|
c177dd2e99 | ||
|
|
ba2c1e5b4b | ||
|
|
fc1badc150 | ||
|
|
5dddf532f7 | ||
|
|
97d23cbf52 | ||
|
|
f351ed9a1d | ||
|
|
943f6e3bc7 | ||
|
|
c0240c5d84 | ||
|
|
f842f2b580 | ||
|
|
72295dadae | ||
|
|
2907bc592e | ||
|
|
8fbaff792b | ||
|
|
981784270a | ||
|
|
bd65a4b8bc | ||
|
|
410f8b953c | ||
|
|
cb8efa7b0f | ||
|
|
25a297007b | ||
|
|
e96158495c | ||
|
|
369ecdc009 | ||
|
|
c85e343dd9 | ||
|
|
7ca5ee47b5 | ||
|
|
ac4db43fd5 | ||
|
|
9e9096857d | ||
|
|
4224a918ed | ||
|
|
0d6f83d36c | ||
|
|
a6d8cc2eb8 | ||
|
|
7bcf8bc43b | ||
|
|
8b8cfee9e0 | ||
|
|
27f0adc104 | ||
|
|
98f0e68e38 | ||
|
|
5f0de00427 | ||
|
|
5758b848e1 | ||
|
|
3a85d47393 | ||
|
|
e473b41692 | ||
|
|
292cc68f63 | ||
|
|
462b920da1 | ||
|
|
48cf359eaa | ||
|
|
05ac689c2f | ||
|
|
6e23fc2c62 | ||
|
|
c46a0d3477 | ||
|
|
53846795ea | ||
|
|
2c8befab93 | ||
|
|
fa4905989a | ||
|
|
2903196da6 | ||
|
|
2d2fe0ff49 | ||
|
|
6b0ee4bf12 | ||
|
|
4fd1d97ac1 | ||
|
|
4ff34725de | ||
|
|
2f7689599b | ||
|
|
ca9ede51d5 | ||
|
|
f84ebbe662 | ||
|
|
e48ac6a828 | ||
|
|
558f50e610 | ||
|
|
131fe7dcd6 | ||
|
|
a8692d021e | ||
|
|
c4be44ffca | ||
|
|
160db39763 | ||
|
|
7b4a9c1a2a | ||
|
|
af0492b82e | ||
|
|
0e144b5b40 | ||
|
|
9109d6dac2 | ||
|
|
27b58a4a5c | ||
|
|
812c35631c | ||
|
|
49178ea404 | ||
|
|
9a7f2cb064 | ||
|
|
3f79d1631b | ||
|
|
c33495aa82 | ||
|
|
e9e50a7356 | ||
|
|
a95625e2bb | ||
|
|
e40c4612d6 | ||
|
|
1cabd55d08 | ||
|
|
6438c15113 | ||
|
|
154aab5098 | ||
|
|
f51f061e91 | ||
|
|
765f26c3e5 | ||
|
|
5d24fdaef3 | ||
|
|
f8edad2ce3 | ||
|
|
ceff322a2f | ||
|
|
23afa89e2a | ||
|
|
7ed9e30299 | ||
|
|
5b43e92819 | ||
|
|
4b4b64d845 | ||
|
|
2d9593cbfb | ||
|
|
dd6eb3405f | ||
|
|
4e02644cf8 | ||
|
|
76504d300a | ||
|
|
113bf6c121 | ||
|
|
048391adcc | ||
|
|
4f03751da5 | ||
|
|
0e9de3c1e2 | ||
|
|
2519df7425 | ||
|
|
95f11ad2a2 | ||
|
|
7b0286708f | ||
|
|
875d2fbea9 | ||
|
|
4c6bc91971 | ||
|
|
68b5b5fed3 | ||
|
|
7b02dc1f9d | ||
|
|
236ac1c007 | ||
|
|
81195d6681 | ||
|
|
0d6b285709 | ||
|
|
55aff60571 | ||
|
|
e55e4b6fd0 | ||
|
|
809f8cbff0 | ||
|
|
dd9b306100 | ||
|
|
cb623c69c5 | ||
|
|
f9008fcfff | ||
|
|
51b891a490 | ||
|
|
d2b2ae5d9e | ||
|
|
4c6a0d4606 | ||
|
|
00b0c1d2b5 | ||
|
|
f507998358 | ||
|
|
6162cf2968 | ||
|
|
11a9f37e05 | ||
|
|
61b727bb78 | ||
|
|
41b15cb76e | ||
|
|
3adf785f86 | ||
|
|
67bf4ecbb2 | ||
|
|
d2c8d25add | ||
|
|
399e30c59e | ||
|
|
c3b894ee1f | ||
|
|
ec06d5ad7e | ||
|
|
91c3f4bf16 | ||
|
|
b8273e7d95 | ||
|
|
2660a480f7 | ||
|
|
adb8c0ffe8 | ||
|
|
91d014904b | ||
|
|
ab01dfb4db | ||
|
|
526cb5596b | ||
|
|
e770a5a521 | ||
|
|
16ae4bdc22 | ||
|
|
4089e0ec4d | ||
|
|
8164576d6c | ||
|
|
db3ace23ff | ||
|
|
fbb1811c52 | ||
|
|
059990fce4 | ||
|
|
328ea88328 | ||
|
|
4b5812acdd | ||
|
|
fafa876a4d | ||
|
|
367eff34aa | ||
|
|
635112b499 | ||
|
|
192b5ee080 | ||
|
|
68bc72a830 | ||
|
|
22d9a35099 | ||
|
|
361f2b05e3 | ||
|
|
448c558bb8 | ||
|
|
f2268a4aa5 | ||
|
|
80a2e4de06 | ||
|
|
ad10550872 | ||
|
|
d5484ff1e2 | ||
|
|
c6f0bdb86b | ||
|
|
7a58291700 | ||
|
|
8a5df707f5 | ||
|
|
e3e6114064 | ||
|
|
ee85bfbf84 | ||
|
|
eb67253df7 | ||
|
|
3ce3e4e150 | ||
|
|
96bc494e36 | ||
|
|
2c553e81af | ||
|
|
1dedd04bdd | ||
|
|
0aa5d7ecc4 | ||
|
|
81b98575a6 | ||
|
|
98d0b39b65 | ||
|
|
2ed9dafbad | ||
|
|
aba23b3433 | ||
|
|
1149c059fe | ||
|
|
4a26066db2 | ||
|
|
1b22a12dcb | ||
|
|
f78f5d5d3f | ||
|
|
ecdf759524 | ||
|
|
868e430cee | ||
|
|
2a831e69c6 | ||
|
|
7a66fd4149 | ||
|
|
885f328347 | ||
|
|
e4060e3a5f | ||
|
|
2582250286 | ||
|
|
6500e721c1 | ||
|
|
017120990f | ||
|
|
0eb0cfdf47 | ||
|
|
31232db22f | ||
|
|
572113fe86 | ||
|
|
536e7b628f | ||
|
|
da8f65233e | ||
|
|
5f1c02284e | ||
|
|
ca22bca355 | ||
|
|
80096f26b6 | ||
|
|
941c4c78e6 | ||
|
|
75b9d9c80e | ||
|
|
e19ff5deef | ||
|
|
d26cef9cf3 | ||
|
|
cbc37c94db | ||
|
|
40061a5baa | ||
|
|
fa0049f7f8 | ||
|
|
f6a4ab14de | ||
|
|
9770763674 | ||
|
|
b8c3a7302c | ||
|
|
35f34a5551 | ||
|
|
a63a4a22bf | ||
|
|
ac362ef9c6 | ||
|
|
e968d2b3f2 | ||
|
|
83431351c3 | ||
|
|
9fb6c86a61 | ||
|
|
a0783921cd | ||
|
|
17e11669cb | ||
|
|
4eb04b59db | ||
|
|
2f4b23cac3 | ||
|
|
99c41ce942 | ||
|
|
9226d0c66e | ||
|
|
f5254264ad | ||
|
|
7c0111b53d | ||
|
|
171436243a | ||
|
|
ec73ce360e | ||
|
|
cef7f6f0cd | ||
|
|
288003a207 | ||
|
|
1d96106a13 | ||
|
|
bb64dc7654 | ||
|
|
57f00f8e7b | ||
|
|
59081611e3 | ||
|
|
7e65e0a0fb | ||
|
|
3d35eaff20 | ||
|
|
38694823f6 | ||
|
|
2baff0a6f1 | ||
|
|
31f794180e | ||
|
|
4ad3777ad7 | ||
|
|
d5b89de0b2 | ||
|
|
da6da48af6 | ||
|
|
6e8c7a63d8 | ||
|
|
bf2c80c996 | ||
|
|
4a8e1afd86 | ||
|
|
75a3e1e251 | ||
|
|
1a57793337 | ||
|
|
6c2e1b7607 | ||
|
|
50e505e1b5 | ||
|
|
abe1b93a1c | ||
|
|
2a7de36f9e | ||
|
|
4438e9c4ef | ||
|
|
d2b97a2c38 | ||
|
|
08a4c61f94 | ||
|
|
fe07401177 | ||
|
|
d442d2a49c | ||
|
|
d5fbff07ad | ||
|
|
180ade7734 | ||
|
|
1e19f226d4 | ||
|
|
d1d10af275 | ||
|
|
b37b9e5476 | ||
|
|
6a794f93a8 | ||
|
|
fc7b874797 | ||
|
|
1029d4582f | ||
|
|
8ec33d230e | ||
|
|
67d5015a3c | ||
|
|
509ef9e714 | ||
|
|
17443fda00 | ||
|
|
f9aa24d5e3 | ||
|
|
9aa00c9c8b | ||
|
|
29eb458d57 | ||
|
|
61a596f777 | ||
|
|
62860b3118 | ||
|
|
9df9130b32 | ||
|
|
ca7e1da7bf | ||
|
|
4106bea670 | ||
|
|
62c52c7266 | ||
|
|
4b8d974eb9 | ||
|
|
d14f6f59bd | ||
|
|
b88329e8df | ||
|
|
9d2360bdba | ||
|
|
0e628c114a | ||
|
|
fc5a97ee91 | ||
|
|
c9cc47df90 | ||
|
|
eb8584f9da | ||
|
|
c346fac855 | ||
|
|
30862330d9 | ||
|
|
2f8f594dff | ||
|
|
79f5da0cd7 | ||
|
|
23a69d75ac | ||
|
|
8d0fe8b91d | ||
|
|
b91aded1ba | ||
|
|
25e03ee2d6 | ||
|
|
deed4b1c4c |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.68
|
||||
AVERAGE_THRESHOLD=9.32
|
||||
HOTSPOT_THRESHOLD=9.7
|
||||
AVERAGE_THRESHOLD=9.36
|
||||
|
||||
@@ -14,7 +14,7 @@ These frontmatter field names have special meaning in Laputa's UI:
|
||||
|
||||
| Field | Meaning | UI behavior |
|
||||
|---|---|---|
|
||||
| `title:` | Human-readable title (synced with filename) | Breadcrumb, sidebar. Filename = `slugify(title).md` |
|
||||
| `title:` | Legacy display-title fallback for older notes | Used only when a note has no H1; new notes do not write it automatically |
|
||||
| `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 |
|
||||
@@ -237,16 +237,17 @@ 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.
|
||||
- **On rename / explicit title edits** (`rename_note`): Laputa updates both filename and `title` frontmatter atomically, plus wikilinks across the vault.
|
||||
- **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.
|
||||
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
|
||||
|
||||
### Title Field (UI)
|
||||
### Title Surface (UI)
|
||||
|
||||
The dedicated `TitleField` is a fallback editing surface, not the canonical one:
|
||||
The BlockNote body is the only title editing surface:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### Sidebar Selection
|
||||
|
||||
@@ -503,7 +504,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
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides, explicit organization workflow toggle
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
### Getting Started / Onboarding
|
||||
|
||||
@@ -420,6 +420,7 @@ 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
|
||||
|
||||
@@ -670,6 +671,7 @@ 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
|
||||
|
||||
@@ -705,6 +707,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `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`.
|
||||
|
||||
@@ -724,6 +727,18 @@ 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
|
||||
|
||||
@@ -65,7 +65,6 @@ laputa-app/
|
||||
│ │ ├── WelcomeScreen.tsx # Onboarding screen
|
||||
│ │ ├── 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
|
||||
@@ -97,6 +96,8 @@ laputa-app/
|
||||
│ │ ├── 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
|
||||
@@ -245,8 +246,8 @@ laputa-app/
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `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. |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI including GitHub OAuth connection and the vault-level explicit organization toggle. |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
@@ -280,10 +281,17 @@ 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. The native macOS menu bar also triggers commands via `useMenuEvents`.
|
||||
`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.
|
||||
|
||||
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
|
||||
@@ -337,7 +345,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 `useAppKeyboard.ts`
|
||||
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
|
||||
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
|
||||
|
||||
29
docs/adr/0050-deterministic-shortcut-command-routing.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
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.”
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
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.”
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
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.
|
||||
35
docs/adr/0054-deterministic-shortcut-qa-matrix.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
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.
|
||||
42
docs/adr/0055-h1-is-the-only-editor-title-surface.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
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.
|
||||
@@ -99,9 +99,15 @@ 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 | 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) |
|
||||
| [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 |
|
||||
|
||||
@@ -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/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.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/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:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
234
patches/prosemirror-tables@1.8.5.patch
Normal file
@@ -0,0 +1,234 @@
|
||||
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,6 +8,9 @@ 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:
|
||||
|
||||
@@ -4491,7 +4494,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
|
||||
prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b)
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
rehype-format: 5.0.1
|
||||
@@ -6245,7 +6248,7 @@ snapshots:
|
||||
prosemirror-schema-basic: 1.2.4
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b)
|
||||
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
|
||||
@@ -8099,7 +8102,7 @@ snapshots:
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.6
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
prosemirror-tables@1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b):
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
@@ -6,3 +6,4 @@ 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
|
||||
|
||||
51
src-tauri/Cargo.lock
generated
@@ -1012,6 +1012,12 @@ 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"
|
||||
@@ -2150,6 +2156,15 @@ 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"
|
||||
@@ -2278,6 +2293,7 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-prevent-default",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
@@ -4504,6 +4520,27 @@ 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"
|
||||
@@ -4887,6 +4924,20 @@ 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"
|
||||
|
||||
@@ -35,6 +35,7 @@ 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"] }
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.6 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.5 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 115 KiB |
@@ -3,6 +3,7 @@ use crate::menu;
|
||||
use crate::settings::Settings;
|
||||
use crate::vault_list;
|
||||
use crate::vault_list::VaultList;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::parse_build_label;
|
||||
|
||||
@@ -41,23 +42,29 @@ pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
|
||||
|
||||
// ── Menu commands ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn update_menu_state(
|
||||
app_handle: tauri::AppHandle,
|
||||
#[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,
|
||||
) -> Result<(), String> {
|
||||
menu::set_note_items_enabled(&app_handle, has_active_note);
|
||||
if let Some(v) = has_modified_files {
|
||||
menu::set_note_items_enabled(&app_handle, state.has_active_note);
|
||||
if let Some(v) = state.has_modified_files {
|
||||
menu::set_git_commit_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = has_conflicts {
|
||||
if let Some(v) = state.has_conflicts {
|
||||
menu::set_git_conflict_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = has_restorable_deleted_note {
|
||||
if let Some(v) = state.has_restorable_deleted_note {
|
||||
menu::set_restore_deleted_item_enabled(&app_handle, v);
|
||||
}
|
||||
Ok(())
|
||||
@@ -67,14 +74,23 @@ pub fn update_menu_state(
|
||||
#[tauri::command]
|
||||
pub fn update_menu_state(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_has_active_note: bool,
|
||||
_has_modified_files: Option<bool>,
|
||||
_has_conflicts: Option<bool>,
|
||||
_has_restorable_deleted_note: Option<bool>,
|
||||
_state: MenuStateUpdate,
|
||||
) -> 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,6 +45,17 @@ 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,
|
||||
|
||||
@@ -86,6 +86,7 @@ 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,6 +95,35 @@ 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)?;
|
||||
|
||||
@@ -113,6 +143,77 @@ 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::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
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
|
||||
use tauri::Manager;
|
||||
@@ -134,74 +235,8 @@ pub fn run() {
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.manage(WsBridgeChild(Mutex::new(None)));
|
||||
|
||||
builder
|
||||
with_invoke_handler(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| {
|
||||
@@ -209,3 +244,13 @@ 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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ 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";
|
||||
@@ -77,6 +78,8 @@ 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,
|
||||
@@ -96,6 +99,7 @@ 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,
|
||||
@@ -208,6 +212,8 @@ 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)?;
|
||||
@@ -273,9 +279,12 @@ 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)
|
||||
@@ -295,13 +304,14 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
|
||||
.id(VIEW_TOGGLE_AI_CHAT)
|
||||
.accelerator("CmdOrCtrl+Shift+L")
|
||||
.accelerator("Cmd+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)
|
||||
@@ -397,14 +407,21 @@ 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();
|
||||
if CUSTOM_IDS.contains(&id) {
|
||||
let _ = app_handle.emit("menu-event", id);
|
||||
}
|
||||
let _ = emit_custom_menu_event(app_handle, 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;
|
||||
|
||||
@@ -12,7 +12,8 @@ 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)
|
||||
const CACHE_VERSION: u32 = 12;
|
||||
/// v13: preserve plain square brackets in parsed markdown H1 titles
|
||||
const CACHE_VERSION: u32 = 13;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
|
||||
@@ -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, update_wikilinks_for_renames,
|
||||
DetectedRename, RenameResult,
|
||||
auto_rename_untitled, detect_renames, rename_note, rename_note_filename,
|
||||
update_wikilinks_for_renames, DetectedRename, RenameResult,
|
||||
};
|
||||
pub use title_sync::{sync_title_on_open, SyncAction};
|
||||
pub use trash::{batch_delete_notes, delete_note};
|
||||
|
||||
@@ -249,7 +249,9 @@ fn extract_wikilink_display(inner: &str) -> &str {
|
||||
inner.find('|').map_or(inner, |idx| &inner[idx + 1..])
|
||||
}
|
||||
|
||||
/// Process a markdown link `[text](url)`, extracting only the link text.
|
||||
/// Process bracketed text.
|
||||
/// Real markdown links `[text](url)` are unwrapped to `text`.
|
||||
/// Plain bracketed text `[text]` is preserved verbatim.
|
||||
fn process_markdown_link(
|
||||
chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
|
||||
result: &mut String,
|
||||
@@ -258,8 +260,13 @@ 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.
|
||||
@@ -363,6 +370,15 @@ 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);
|
||||
@@ -726,7 +742,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,5 +1,6 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
@@ -28,13 +29,17 @@ pub(super) fn title_to_slug(title: &str) -> String {
|
||||
.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),
|
||||
);
|
||||
/// 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("|"));
|
||||
Regex::new(&pattern_str).ok()
|
||||
}
|
||||
|
||||
@@ -44,13 +49,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_title: &str) -> Option<String> {
|
||||
fn replace_wikilinks_in_content(content: &str, re: &Regex, new_target: &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_title, pipe.as_str()),
|
||||
None => format!("[[{}]]", new_title),
|
||||
Some(pipe) => format!("[[{}{}]]", new_target, pipe.as_str()),
|
||||
None => format!("[[{}]]", new_target),
|
||||
});
|
||||
if replaced != content {
|
||||
Some(replaced.into_owned())
|
||||
@@ -59,15 +64,6 @@ fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> O
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -79,47 +75,76 @@ 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(params: &WikilinkReplacement) -> usize {
|
||||
let re = match build_wikilink_pattern(params.old_title, params.old_path_stem) {
|
||||
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) {
|
||||
Some(r) => r,
|
||||
None => return 0,
|
||||
};
|
||||
replace_wikilinks_in_files(collect_md_files(vault_path, exclude_path), &re, new_target)
|
||||
}
|
||||
|
||||
let files = collect_md_files(params.vault_path, params.exclude_path);
|
||||
fn replace_wikilinks_in_files(
|
||||
files: Vec<std::path::PathBuf>,
|
||||
re: &Regex,
|
||||
replacement: &str,
|
||||
) -> usize {
|
||||
files
|
||||
.iter()
|
||||
.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,
|
||||
}
|
||||
})
|
||||
.filter(|path| rewrite_wikilinks_in_file(path, re, replacement))
|
||||
.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()?;
|
||||
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
|
||||
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())
|
||||
}
|
||||
|
||||
/// Update the `title:` frontmatter field in content.
|
||||
@@ -168,6 +193,22 @@ 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
|
||||
@@ -237,13 +278,9 @@ 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 updated_files = update_wikilinks_in_vault(&WikilinkReplacement {
|
||||
vault_path: vault,
|
||||
old_title,
|
||||
new_title,
|
||||
old_path_stem,
|
||||
exclude_path: &new_file,
|
||||
});
|
||||
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);
|
||||
|
||||
Ok(RenameResult {
|
||||
new_path: new_path_str,
|
||||
@@ -251,6 +288,67 @@ 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);
|
||||
@@ -352,22 +450,12 @@ 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 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,
|
||||
});
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem);
|
||||
let updated = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
|
||||
total_updated += updated;
|
||||
}
|
||||
|
||||
@@ -457,11 +545,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("[[Sprint Retrospective]]"));
|
||||
assert!(other_content.contains("[[note/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("[[Sprint Retrospective]]"));
|
||||
assert!(project_content.contains("[[note/sprint-retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -521,7 +609,33 @@ 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("[[Sprint Retro|my review]]"));
|
||||
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]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -698,6 +812,58 @@ 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.
|
||||
@@ -736,11 +902,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("[[Sprint Retrospective]]"));
|
||||
assert!(other_content.contains("[[note/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("[[Sprint Retrospective]]"));
|
||||
assert!(project_content.contains("[[note/sprint-retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -770,7 +936,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("[[Sprint Retrospective]]"));
|
||||
assert!(other_content.contains("[[note/sprint-retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -185,6 +185,27 @@ 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
|
||||
|
||||
74
src/App.tsx
@@ -17,6 +17,7 @@ import { SettingsPanel } from './components/SettingsPanel'
|
||||
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'
|
||||
@@ -63,6 +64,11 @@ import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import {
|
||||
INBOX_SELECTION,
|
||||
isExplicitOrganizationEnabled,
|
||||
sanitizeSelectionForOrganization,
|
||||
} from './utils/organizationWorkflow'
|
||||
import './App.css'
|
||||
|
||||
// Type declarations for mock content storage and test overrides
|
||||
@@ -74,7 +80,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'inbox' }
|
||||
const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION
|
||||
|
||||
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
||||
function App() {
|
||||
@@ -91,6 +97,9 @@ function App() {
|
||||
const visibleNotesRef = useRef<VaultEntry[]>([])
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const dialogs = useDialogs()
|
||||
const [showFeedback, setShowFeedback] = useState(false)
|
||||
const openFeedback = useCallback(() => setShowFeedback(true), [])
|
||||
const closeFeedback = useCallback(() => setShowFeedback(false), [])
|
||||
|
||||
// onSwitch closure captures `notes` declared below — safe because it's only
|
||||
// called on user interaction, never during render (refs inside the hook
|
||||
@@ -124,6 +133,22 @@ 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)
|
||||
|
||||
@@ -187,7 +212,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 })
|
||||
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 })
|
||||
|
||||
// Note window: auto-open the note from URL params once vault entries load
|
||||
const noteWindowOpenedRef = useRef(false)
|
||||
@@ -255,7 +280,7 @@ function App() {
|
||||
})
|
||||
|
||||
const appSave = useAppSave({
|
||||
updateEntry: vault.updateEntry, setTabs: notes.setTabs, setToastMessage,
|
||||
updateEntry: vault.updateEntry, setTabs: notes.setTabs, handleSwitchTab: notes.handleSwitchTab, setToastMessage,
|
||||
loadModifiedFiles: vault.loadModifiedFiles, reloadViews: vault.reloadViews,
|
||||
clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths,
|
||||
tabs: notes.tabs, activeTabPath: notes.activeTabPath,
|
||||
@@ -263,6 +288,13 @@ 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,
|
||||
@@ -381,7 +413,6 @@ function App() {
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
onBeforeAction: appSave.flushBeforeAction,
|
||||
})
|
||||
|
||||
@@ -502,7 +533,7 @@ function App() {
|
||||
visibleNotesRef,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
selection,
|
||||
selection: effectiveSelection,
|
||||
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
||||
onSearch: dialogs.openSearch,
|
||||
onCreateNote: notes.handleCreateNoteImmediate,
|
||||
@@ -510,6 +541,7 @@ 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,
|
||||
@@ -522,6 +554,7 @@ 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,
|
||||
@@ -550,9 +583,9 @@ function App() {
|
||||
onSetNoteListFilter: setNoteListFilter,
|
||||
onOpenInNewWindow: handleOpenInNewWindow,
|
||||
onToggleFavorite: entryActions.handleToggleFavorite,
|
||||
onToggleOrganized: entryActions.handleToggleOrganized,
|
||||
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
|
||||
onCustomizeInboxColumns: handleCustomizeInboxColumns,
|
||||
canCustomizeInboxColumns: selection.kind === 'filter' && selection.filter === 'inbox',
|
||||
canCustomizeInboxColumns: explicitOrganizationEnabled && effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox',
|
||||
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
|
||||
canRestoreDeletedNote: !!activeDeletedFile,
|
||||
})
|
||||
@@ -562,18 +595,18 @@ function App() {
|
||||
const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod])
|
||||
|
||||
const aiNoteList = useMemo<NoteListItem[]>(() => {
|
||||
const isInbox = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, selection, undefined, vault.views)
|
||||
const isInbox = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox'
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, effectiveSelection, undefined, vault.views)
|
||||
return filtered.map(e => ({
|
||||
path: e.path, title: e.title, type: e.isA ?? 'Note',
|
||||
}))
|
||||
}, [vault.entries, vault.views, selection, inboxPeriod])
|
||||
}, [vault.entries, vault.views, effectiveSelection, inboxPeriod])
|
||||
|
||||
const aiNoteListFilter = useMemo(() => {
|
||||
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
|
||||
if (selection.kind === 'entity') return { type: null, query: selection.entry.title }
|
||||
if (effectiveSelection.kind === 'sectionGroup') return { type: effectiveSelection.type, query: '' }
|
||||
if (effectiveSelection.kind === 'entity') return { type: null, query: effectiveSelection.entry.title }
|
||||
return { type: null, query: '' }
|
||||
}, [selection])
|
||||
}, [effectiveSelection])
|
||||
|
||||
// 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')) {
|
||||
@@ -623,7 +656,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<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} />
|
||||
<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} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -631,10 +664,10 @@ function App() {
|
||||
{noteListVisible && (
|
||||
<>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<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} />
|
||||
<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} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -668,13 +701,13 @@ function App() {
|
||||
noteList={aiNoteList}
|
||||
noteListFilter={aiNoteListFilter}
|
||||
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
|
||||
onToggleOrganized={activeDeletedFile ? undefined : entryActions.handleToggleOrganized}
|
||||
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
|
||||
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
|
||||
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
|
||||
onContentChange={appSave.handleContentChange}
|
||||
onSave={appSave.handleSave}
|
||||
onTitleSync={activeDeletedFile ? undefined : appSave.handleTitleSync}
|
||||
onRenameFilename={activeDeletedFile ? undefined : handleFilenameRename}
|
||||
rawToggleRef={rawToggleRef}
|
||||
diffToggleRef={diffToggleRef}
|
||||
canGoBack={canGoBack}
|
||||
@@ -693,7 +726,7 @@ 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} 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} />
|
||||
<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} 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} onClose={dialogs.closeCommandPalette} />
|
||||
@@ -712,7 +745,8 @@ function App() {
|
||||
onCommit={conflictResolver.commitResolution}
|
||||
onClose={conflictFlow.handleCloseConflictResolver}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<GitHubVaultModal
|
||||
open={dialogs.showGitHubVault}
|
||||
githubToken={settings.github_token}
|
||||
|
||||
@@ -9,12 +9,12 @@ describe('ArchivedNoteBanner', () => {
|
||||
expect(screen.getByText('Archived')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders unarchive button with keyboard hint', () => {
|
||||
it('renders unarchive button without the stale archive shortcut 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 (Cmd+E)')
|
||||
expect(btn.title).toBe('Unarchive')
|
||||
})
|
||||
|
||||
it('calls onUnarchive when button is clicked', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
|
||||
color: 'var(--muted-foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Unarchive (Cmd+E)"
|
||||
title="Unarchive"
|
||||
>
|
||||
<ArrowUUpLeft size={12} />
|
||||
Unarchive
|
||||
|
||||
@@ -74,31 +74,43 @@ 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 (Cmd+E)')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Unarchive (Cmd+E)')).not.toBeInTheDocument()
|
||||
expect(screen.getByTitle('Archive')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Unarchive')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unarchive button for archived note', () => {
|
||||
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
expect(screen.getByTitle('Unarchive (Cmd+E)')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Archive (Cmd+E)')).not.toBeInTheDocument()
|
||||
expect(screen.getByTitle('Unarchive')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Archive')).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 (Cmd+E)'))
|
||||
fireEvent.click(screen.getByTitle('Archive'))
|
||||
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 (Cmd+E)'))
|
||||
fireEvent.click(screen.getByTitle('Unarchive'))
|
||||
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} />)
|
||||
@@ -125,13 +137,85 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
|
||||
expect(screen.getByText('Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shadow is controlled by data-title-hidden attribute via CSS', () => {
|
||||
it('separator visibility is controlled by data-title-hidden while using the shared border chrome', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
const bar = container.querySelector('.breadcrumb-bar')!
|
||||
expect(bar).not.toHaveAttribute('data-title-hidden')
|
||||
bar.setAttribute('data-title-hidden', '')
|
||||
expect(bar).toHaveClass('border-b', 'border-transparent')
|
||||
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,6 +1,9 @@
|
||||
import { memo } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } 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,
|
||||
@@ -14,8 +17,10 @@ import {
|
||||
ArrowUUpLeft,
|
||||
Star,
|
||||
CheckCircle,
|
||||
ArrowsClockwise,
|
||||
} from '@phosphor-icons/react'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { slugify } from '../hooks/useNoteCreation'
|
||||
|
||||
interface BreadcrumbBarProps {
|
||||
entry: VaultEntry
|
||||
@@ -37,182 +42,495 @@ 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 RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
|
||||
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
|
||||
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}
|
||||
title={rawMode ? 'Back to editor' : 'Raw editor'}
|
||||
<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}
|
||||
>
|
||||
<Code size={16} />
|
||||
</button>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
|
||||
rawMode, onToggleRaw, forceRawMode,
|
||||
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onToggleFavorite, onToggleOrganized, onDelete, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount'>) {
|
||||
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
|
||||
return (
|
||||
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
|
||||
<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} />}
|
||||
<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>
|
||||
<IconActionButton
|
||||
title={rawMode ? 'Back to editor' : 'Raw editor'}
|
||||
onClick={onToggleRaw}
|
||||
className={cn(rawMode ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
<Code size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
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 BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
|
||||
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'>) {
|
||||
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}
|
||||
/>
|
||||
{!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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbTitle({
|
||||
entry,
|
||||
onRenameFilename,
|
||||
}: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
|
||||
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>
|
||||
<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 className="flex min-w-0 items-center gap-1 truncate">
|
||||
<FilenameCrumb entry={entry} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry, barRef, ...actionProps
|
||||
entry,
|
||||
barRef,
|
||||
onRenameFilename,
|
||||
...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
|
||||
{...(titleAlwaysVisible ? { 'data-title-hidden': '' } : {})}
|
||||
className="breadcrumb-bar flex shrink-0 items-center"
|
||||
data-title-hidden=""
|
||||
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
|
||||
style={{
|
||||
height: 52,
|
||||
background: 'var(--background)',
|
||||
padding: '6px 16px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div className="breadcrumb-bar__title flex-1 min-w-0">
|
||||
<BreadcrumbTitle entry={entry} />
|
||||
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
<BreadcrumbActions entry={entry} {...actionProps} />
|
||||
</div>
|
||||
|
||||
70
src/components/BreadcrumbBar.visibility.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
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;')
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@ const makeCommand = (overrides: Partial<CommandAction> = {}): CommandAction => (
|
||||
|
||||
const commands: CommandAction[] = [
|
||||
makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find'] }),
|
||||
makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N' }),
|
||||
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note', shortcut: '⌘N' }),
|
||||
makeCommand({ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'sync'] }),
|
||||
makeCommand({ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,' }),
|
||||
makeCommand({ id: 'disabled-cmd', label: 'Disabled Command', group: 'Note', enabled: false }),
|
||||
@@ -47,7 +47,7 @@ describe('CommandPalette', () => {
|
||||
it('shows all enabled commands grouped by category', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
expect(screen.getByText('Search Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Create New Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('New Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
|
||||
expect(screen.getByText('Open Settings')).toBeInTheDocument()
|
||||
// Disabled command should not appear
|
||||
@@ -115,7 +115,7 @@ describe('CommandPalette', () => {
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
// Second enabled command (Create New Note) should execute
|
||||
// Second enabled command (New Note) should execute
|
||||
expect(commands[1].execute).toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
@@ -168,7 +168,7 @@ describe('CommandPalette', () => {
|
||||
|
||||
describe('relevance ranking', () => {
|
||||
const relevanceCommands: CommandAction[] = [
|
||||
makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note' }),
|
||||
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note' }),
|
||||
makeCommand({ id: 'toggle-raw', label: 'Toggle Raw Editor', group: 'View' }),
|
||||
makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation' }),
|
||||
]
|
||||
@@ -182,24 +182,20 @@ describe('CommandPalette', () => {
|
||||
).map(el => el.textContent)
|
||||
}
|
||||
|
||||
it('ranks "Toggle Raw Editor" before "Create New Note" for query "raw"', () => {
|
||||
it('shows only the relevant raw command for query "raw"', () => {
|
||||
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'raw' } })
|
||||
|
||||
const labels = getVisibleLabels()
|
||||
const rawIdx = labels.indexOf('Toggle Raw Editor')
|
||||
const createIdx = labels.indexOf('Create New Note')
|
||||
expect(rawIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(createIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(rawIdx).toBeLessThan(createIdx)
|
||||
expect(labels).toEqual(['Toggle Raw Editor'])
|
||||
})
|
||||
|
||||
it('ranks "Create New Note" first for query "new note"', () => {
|
||||
it('ranks "New Note" first for query "new note"', () => {
|
||||
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'new note' } })
|
||||
|
||||
const labels = getVisibleLabels()
|
||||
expect(labels[0]).toBe('Create New Note')
|
||||
expect(labels[0]).toBe('New Note')
|
||||
})
|
||||
|
||||
it('preserves default section order with empty query', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
@@ -47,6 +47,9 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
|
||||
{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed
|
||||
</Badge>
|
||||
</div>
|
||||
<DialogDescription className="sr-only">
|
||||
Review changed files and enter a commit message before committing and pushing.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -52,6 +52,9 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Note</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Enter a title and choose a type for the new note.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -700,6 +700,22 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByTestId('url-link')).toHaveTextContent('https://example.com')
|
||||
})
|
||||
|
||||
it('gives long URL values a truncating value-cell layout inside the properties panel', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ url: 'https://example.com/very/long/path/that/should/truncate' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
|
||||
const link = screen.getByTestId('url-link')
|
||||
const value = screen.getByText('https://example.com/very/long/path/that/should/truncate')
|
||||
expect(link).toHaveClass('flex-1', 'overflow-hidden')
|
||||
expect(value).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('renders bare domain values as URL links', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
|
||||
@@ -35,6 +35,13 @@ describe('EditableValue', () => {
|
||||
expect(onStartEdit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses a full-width truncating layout in view mode', () => {
|
||||
render(<EditableValue value="A very long property value" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
|
||||
const value = screen.getByText('A very long property value')
|
||||
expect(value.parentElement).toHaveClass('w-full', 'overflow-hidden')
|
||||
expect(value).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('shows input in editing mode', () => {
|
||||
render(<EditableValue value="Active" onSave={onSave} onCancel={onCancel} isEditing={true} onStartEdit={onStartEdit} />)
|
||||
const input = screen.getByDisplayValue('Active')
|
||||
@@ -168,6 +175,11 @@ describe('TagPillList', () => {
|
||||
fireEvent.blur(input)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('truncates long pill labels independently', () => {
|
||||
render(<TagPillList items={['A very long property list value']} onSave={onSave} label="Tags" />)
|
||||
expect(screen.getByText('A very long property list value')).toHaveClass('truncate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUrlValue', () => {
|
||||
@@ -238,6 +250,14 @@ describe('UrlValue', () => {
|
||||
expect(openExternalUrl).toHaveBeenCalledWith('https://example.com')
|
||||
})
|
||||
|
||||
it('uses a flexible truncating layout for long URL values', () => {
|
||||
render(<UrlValue value="https://example.com/very/long/path/that/needs/truncation" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
|
||||
const link = screen.getByTestId('url-link')
|
||||
const value = screen.getByText('https://example.com/very/long/path/that/needs/truncation')
|
||||
expect(link).toHaveClass('flex-1', 'overflow-hidden')
|
||||
expect(value).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('does not open malformed URL', () => {
|
||||
render(<UrlValue value="://broken" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
|
||||
fireEvent.click(screen.getByTestId('url-link'))
|
||||
|
||||
@@ -64,14 +64,14 @@ export function UrlValue({
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="group/url flex min-w-0 max-w-full items-center gap-1">
|
||||
<span className="group/url flex w-full min-w-0 items-center gap-1">
|
||||
<span
|
||||
className="inline-flex h-6 min-w-0 cursor-pointer items-center truncate rounded-md px-2 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
|
||||
className="inline-flex h-6 min-w-0 flex-1 cursor-pointer items-center justify-end overflow-hidden rounded-md px-2 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
|
||||
onClick={handleOpen}
|
||||
title={value}
|
||||
data-testid="url-link"
|
||||
>
|
||||
{value || '\u2014'}
|
||||
<span className="min-w-0 truncate">{value || '\u2014'}</span>
|
||||
</span>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-0 text-[12px] leading-none text-muted-foreground opacity-0 transition-all hover:text-foreground group-hover/url:opacity-100"
|
||||
@@ -125,11 +125,11 @@ export function EditableValue({
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-center truncate rounded-md px-2 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
className="inline-flex h-6 w-full min-w-0 cursor-pointer items-center justify-end overflow-hidden rounded-md px-2 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title={value || 'Click to edit'}
|
||||
>
|
||||
{value || '\u2014'}
|
||||
<span className="min-w-0 truncate">{value || '\u2014'}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -212,7 +212,7 @@ export function TagPillList({
|
||||
) : (
|
||||
<span
|
||||
key={idx}
|
||||
className="group/pill relative inline-flex h-6 cursor-pointer items-center rounded-md transition-colors"
|
||||
className="group/pill relative inline-flex h-6 max-w-full min-w-0 cursor-pointer items-center overflow-hidden rounded-md transition-colors"
|
||||
style={{
|
||||
...getTagStyle(item),
|
||||
backgroundColor: getTagStyle(item).bg,
|
||||
@@ -223,7 +223,7 @@ export function TagPillList({
|
||||
onClick={() => handleStartEdit(idx)}
|
||||
title="Click to edit"
|
||||
>
|
||||
{item}
|
||||
<span className="min-w-0 truncate pr-4">{item}</span>
|
||||
<button
|
||||
className="absolute right-0.5 top-1/2 flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-full border-none p-0 text-[10px] leading-none opacity-0 transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
|
||||
style={{ color: getTagStyle(item).color, backgroundColor: getTagStyle(item).bg }}
|
||||
|
||||
@@ -22,22 +22,30 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Breadcrumb bar: title + shadow toggled via data attribute (no React re-render) */
|
||||
/* Breadcrumb bar: border can still react to the data attribute, but the
|
||||
breadcrumb filename/title stays visible at all times. */
|
||||
.breadcrumb-bar {
|
||||
transition: box-shadow 0.2s ease;
|
||||
box-shadow: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-bar[data-title-hidden] {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
|
||||
.breadcrumb-bar__title {
|
||||
display: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.breadcrumb-bar[data-title-hidden] .breadcrumb-bar__title {
|
||||
display: flex;
|
||||
.breadcrumb-bar__actions [data-slot='button'] {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-bar__actions [data-slot='button']:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Scroll area wrapping title + editor — single scroll context for alignment */
|
||||
@@ -196,57 +204,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- Title Section: wraps icon + title + separator --- */
|
||||
.title-section {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title-section__heading {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title-section__inline-add-icon {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.title-section:hover .title-section__inline-add-icon,
|
||||
.title-section__inline-add-icon:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.title-section__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding-top: 8px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* No emoji: title aligns flush left (no indent for icon area) */
|
||||
.title-section__row--no-icon {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* When emoji is present, restore top padding to the row itself */
|
||||
.title-section__row:has(.note-icon-button--active) {
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.title-section__separator {
|
||||
border-bottom: 1px solid var(--border-primary, rgba(0, 0, 0, 0.08));
|
||||
margin-top: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* --- Note Icon Area --- */
|
||||
.note-icon-area {
|
||||
display: flex;
|
||||
@@ -295,7 +252,6 @@
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.title-section:hover .note-icon-button--add,
|
||||
.note-icon-button--add:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -344,56 +300,6 @@
|
||||
color: var(--destructive, #ef4444);
|
||||
}
|
||||
|
||||
/* --- Title Field --- */
|
||||
.title-field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-field__input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: var(--headings-h1-font-size, 32px);
|
||||
font-weight: var(--headings-h1-font-weight, 700);
|
||||
line-height: var(--headings-h1-line-height, 1.2);
|
||||
letter-spacing: var(--headings-h1-letter-spacing, -0.015em);
|
||||
color: var(--foreground);
|
||||
padding: 0;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
field-sizing: content;
|
||||
}
|
||||
|
||||
.title-field__input::placeholder {
|
||||
color: var(--text-faint, #ccc);
|
||||
}
|
||||
|
||||
.title-field__input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.title-field__filename {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint, #999);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* When the legacy title UI is shown, hide the first H1 heading in BlockNote
|
||||
to avoid duplicate title display. Otherwise the editor's H1 remains visible
|
||||
and serves as the title surface. */
|
||||
.title-section[data-title-ui-visible] ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child [data-content-type="heading"][data-level="1"] {
|
||||
display: none;
|
||||
}
|
||||
.title-section[data-title-ui-visible] ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Disable BlockNote/Mantine editor animations
|
||||
=============================================
|
||||
|
||||
@@ -413,7 +413,7 @@ describe('wikilink autocomplete', () => {
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
items[0].onItemClick()
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'vault/project/test|Alpha Project' } },
|
||||
{ type: 'wikilink', props: { target: 'vault/project/test' } },
|
||||
' ',
|
||||
])
|
||||
})
|
||||
@@ -566,7 +566,7 @@ describe('person @mention autocomplete', () => {
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
items[0].onItemClick()
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini|Matteo Cellini' } },
|
||||
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini' } },
|
||||
' ',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -54,8 +54,8 @@ interface EditorProps {
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
/** Called when the user edits the title in TitleField. */
|
||||
onTitleSync?: (path: string, newTitle: string) => void
|
||||
/** Called when the user explicitly renames the filename from the breadcrumb. */
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
canGoBack?: boolean
|
||||
canGoForward?: boolean
|
||||
onGoBack?: () => void
|
||||
@@ -203,7 +203,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onTitleSync,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
} = props
|
||||
@@ -254,7 +254,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onTitleChange={onTitleSync}
|
||||
onRenameFilename={onRenameFilename}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
|
||||
@@ -75,6 +75,7 @@ export function EditorRightPanel({
|
||||
content={inspectorContent}
|
||||
entries={entries}
|
||||
gitHistory={gitHistory}
|
||||
vaultPath={vaultPath}
|
||||
onNavigate={onNavigateWikilink}
|
||||
onViewCommitDiff={onViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
|
||||
53
src/components/FeedbackDialog.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FeedbackDialog } from './FeedbackDialog'
|
||||
import { LAPUTA_GITHUB_ISSUES_URL } from '../constants/feedback'
|
||||
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
const { openExternalUrl } = await import('../utils/url') as typeof import('../utils/url') & {
|
||||
openExternalUrl: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
describe('FeedbackDialog', () => {
|
||||
it('renders the instructional copy when open', () => {
|
||||
render(<FeedbackDialog open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Share feedback')).toBeInTheDocument()
|
||||
expect(screen.getByText(/best way to share product feedback/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/check whether a similar one already exists/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('focuses the primary CTA when opened', async () => {
|
||||
render(<FeedbackDialog open={true} onClose={vi.fn()} />)
|
||||
const cta = screen.getByRole('button', { name: 'Go to Issues' })
|
||||
await waitFor(() => expect(cta).toHaveFocus())
|
||||
})
|
||||
|
||||
it('opens GitHub Issues without closing the modal', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<FeedbackDialog open={true} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Go to Issues' }))
|
||||
|
||||
await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith(LAPUTA_GITHUB_ISSUES_URL))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes when pressing Escape', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<FeedbackDialog open={true} onClose={onClose} />)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes when clicking Close', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<FeedbackDialog open={true} onClose={onClose} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
59
src/components/FeedbackDialog.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Megaphone } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { LAPUTA_GITHUB_ISSUES_URL } from '../constants/feedback'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
interface FeedbackDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function FeedbackDialog({ open, onClose }: FeedbackDialogProps) {
|
||||
const handleOpenIssues = () => {
|
||||
void openExternalUrl(LAPUTA_GITHUB_ISSUES_URL)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[460px]" data-testid="feedback-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Megaphone size={18} weight="duotone" />
|
||||
Share feedback
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
The best way to share product feedback is through a GitHub Issue.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 text-sm leading-6 text-muted-foreground">
|
||||
<p>
|
||||
Before opening a new issue, please check whether a similar one already exists.
|
||||
If it does, add an upvote or comment there instead of opening a duplicate.
|
||||
</p>
|
||||
<p>
|
||||
When you do open a new issue, include the steps to reproduce, what you expected,
|
||||
and what actually happened so it is easier to triage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button type="button" autoFocus onClick={handleOpenIssues}>
|
||||
Go to Issues
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import type { FilterGroup } from '../types'
|
||||
|
||||
@@ -135,6 +135,23 @@ describe('FilterBuilder value inputs', () => {
|
||||
expect(screen.getByTestId('date-picker-trigger')).toHaveAttribute('title', 'Mar 28, 2026')
|
||||
})
|
||||
|
||||
it('keeps filter controls top-aligned when the date preview adds a second line', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-07T12:00:00Z'))
|
||||
|
||||
renderBuilder({
|
||||
all: [{ field: 'created', op: 'after', value: '10 days ago' }],
|
||||
})
|
||||
|
||||
fireEvent.focus(screen.getByTestId('date-value-input'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-value-preview')).toHaveTextContent('Resolves to March 28, 2026')
|
||||
expect(screen.getByTestId('filter-row')).toHaveClass('items-start')
|
||||
})
|
||||
|
||||
it('filters the field combobox as the user types', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
|
||||
@@ -141,7 +141,7 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
|
||||
const regexEnabled = regexSupported && condition.regex === true
|
||||
const invalidRegex = regexSupported && hasInvalidRegex(String(condition.value ?? ''), regexEnabled)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-start gap-1.5" data-testid="filter-row">
|
||||
<FilterFieldCombobox
|
||||
value={condition.field}
|
||||
fields={fields}
|
||||
|
||||
@@ -54,6 +54,62 @@ function stepHighlightedIndex(current: number, optionCount: number, direction: '
|
||||
return (current - 1 + optionCount) % optionCount
|
||||
}
|
||||
|
||||
function moveHighlightedOption({
|
||||
event,
|
||||
open,
|
||||
options,
|
||||
direction,
|
||||
openCombobox,
|
||||
setHighlightedIndex,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
options: string[]
|
||||
direction: 'next' | 'previous'
|
||||
openCombobox: () => void
|
||||
setHighlightedIndex: (updater: number | ((current: number) => number)) => void
|
||||
}) {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, direction))
|
||||
}
|
||||
|
||||
function selectHighlightedOption({
|
||||
event,
|
||||
open,
|
||||
options,
|
||||
highlightedIndex,
|
||||
selectOption,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
options: string[]
|
||||
highlightedIndex: number
|
||||
selectOption: (value: string) => void
|
||||
}) {
|
||||
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectOption(options[highlightedIndex])
|
||||
}
|
||||
|
||||
function closeOpenCombobox({
|
||||
event,
|
||||
open,
|
||||
closeCombobox,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
closeCombobox: () => void
|
||||
}) {
|
||||
if (!open) return
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
}
|
||||
|
||||
function handleFilterFieldKeyDown({
|
||||
event,
|
||||
open,
|
||||
@@ -75,32 +131,16 @@ function handleFilterFieldKeyDown({
|
||||
}) {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'next'))
|
||||
moveHighlightedOption({ event, open, options, direction: 'next', openCombobox, setHighlightedIndex })
|
||||
return
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'previous'))
|
||||
moveHighlightedOption({ event, open, options, direction: 'previous', openCombobox, setHighlightedIndex })
|
||||
return
|
||||
case 'Enter':
|
||||
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectOption(options[highlightedIndex])
|
||||
selectHighlightedOption({ event, open, options, highlightedIndex, selectOption })
|
||||
return
|
||||
case 'Escape':
|
||||
if (!open) return
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
closeOpenCombobox({ event, open, closeCombobox })
|
||||
return
|
||||
default:
|
||||
return
|
||||
@@ -178,21 +218,27 @@ function FilterFieldPopoverPanel({
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="max-h-60 overflow-y-auto p-1"
|
||||
className="overflow-hidden p-1"
|
||||
style={{ width: contentWidth }}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
data-testid="filter-field-combobox-popover"
|
||||
>
|
||||
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
|
||||
<FilterFieldOptionsList
|
||||
listboxId={listboxId}
|
||||
fieldGroups={fieldGroups}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlight={onHighlight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
<div
|
||||
className="max-h-60 overflow-y-auto overscroll-contain"
|
||||
data-testid="filter-field-combobox-scroll-area"
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
|
||||
<FilterFieldOptionsList
|
||||
listboxId={listboxId}
|
||||
fieldGroups={fieldGroups}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlight={onHighlight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ interface InspectorProps {
|
||||
content: string | null
|
||||
entries: VaultEntry[]
|
||||
gitHistory: GitCommit[]
|
||||
vaultPath?: string
|
||||
onNavigate: (target: string) => void
|
||||
onViewCommitDiff?: (commitHash: string) => void
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
@@ -41,6 +42,7 @@ export function Inspector({
|
||||
content,
|
||||
entries,
|
||||
gitHistory,
|
||||
vaultPath,
|
||||
onNavigate,
|
||||
onViewCommitDiff,
|
||||
onUpdateFrontmatter,
|
||||
@@ -96,6 +98,7 @@ export function Inspector({
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
vaultPath={vaultPath}
|
||||
onNavigate={onNavigate}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Related to' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
|
||||
fireEvent.click(screen.getByTestId('submit-add-relationship'))
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[AI]]')
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]')
|
||||
})
|
||||
|
||||
it('cancels add relationship form', () => {
|
||||
@@ -427,14 +427,14 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'AI' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[AI]]'])
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
|
||||
})
|
||||
|
||||
it('does not add duplicate refs', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[AI]]'] }}
|
||||
frontmatter={{ 'Belongs to': ['[[topic/ai]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
@@ -533,7 +533,7 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note')
|
||||
await vi.waitFor(() => {
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[Brand New Note]]'])
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[brand-new-note]]'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -647,7 +647,7 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('New Person')
|
||||
await vi.waitFor(() => {
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[New Person]]')
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[new-person]]')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ const { openExternalUrl } = await import('../utils/url') as typeof import('../ut
|
||||
openExternalUrl: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const NOW_SECONDS = 1_744_286_400
|
||||
|
||||
describe('NoteItem', () => {
|
||||
beforeEach(() => {
|
||||
openExternalUrl.mockClear()
|
||||
@@ -87,6 +89,60 @@ describe('NoteItem', () => {
|
||||
expect(screen.queryByTestId('change-status-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds more breathing room between note sections', () => {
|
||||
const entry = makeEntry({
|
||||
title: 'Spaced note',
|
||||
snippet: 'Body preview',
|
||||
createdAt: NOW_SECONDS - 86400 * 3,
|
||||
modifiedAt: NOW_SECONDS - 86400,
|
||||
properties: { Status: 'Active' },
|
||||
})
|
||||
|
||||
render(
|
||||
<NoteItem
|
||||
entry={entry}
|
||||
isSelected={false}
|
||||
typeEntryMap={{}}
|
||||
displayPropsOverride={['Status']}
|
||||
onClickNote={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('note-content-stack').className).toContain('space-y-2')
|
||||
})
|
||||
|
||||
it('shows created date on the right side of the date row when available', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(NOW_SECONDS * 1000))
|
||||
const entry = makeEntry({
|
||||
title: 'Dated note',
|
||||
createdAt: NOW_SECONDS - 86400 * 5,
|
||||
modifiedAt: NOW_SECONDS - 86400 * 2,
|
||||
})
|
||||
|
||||
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} />)
|
||||
|
||||
const dateRow = screen.getByTestId('note-date-row')
|
||||
expect(dateRow.className).toContain('grid')
|
||||
expect(dateRow).toHaveTextContent('2d ago')
|
||||
expect(dateRow).toHaveTextContent('Created 5d ago')
|
||||
})
|
||||
|
||||
it('leaves the right side empty when no creation date exists', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(NOW_SECONDS * 1000))
|
||||
const entry = makeEntry({
|
||||
title: 'Modified note',
|
||||
createdAt: null,
|
||||
modifiedAt: NOW_SECONDS - 3600,
|
||||
})
|
||||
|
||||
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('note-date-row')).toHaveTextContent('1h ago')
|
||||
expect(screen.queryByText(/Created /)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('colors relationship chips by target type and opens the related note on Cmd+click only', () => {
|
||||
const linkedProject = makeEntry({
|
||||
path: '/vault/project/build-app.md',
|
||||
|
||||
@@ -163,35 +163,72 @@ function StandardNoteContent({
|
||||
<>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
|
||||
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
|
||||
<div className="pr-5">
|
||||
<div className={cn('truncate text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
|
||||
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
|
||||
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
|
||||
{entry.title}
|
||||
{!isBinary && <StateBadge archived={entry.archived} />}
|
||||
</div>
|
||||
</div>
|
||||
{entry.snippet && !isBinary && (
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</div>
|
||||
)}
|
||||
{!isBinary && displayProps.length > 0 && (
|
||||
<PropertyChips
|
||||
<div className="space-y-2" data-testid="note-content-stack">
|
||||
<NoteTitleRow
|
||||
entry={entry}
|
||||
displayProps={displayProps}
|
||||
allEntries={allEntries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
onOpenNote={onClickNote}
|
||||
isBinary={isBinary}
|
||||
isSelected={isSelected}
|
||||
noteStatus={noteStatus}
|
||||
/>
|
||||
)}
|
||||
{!isBinary && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||
)}
|
||||
{!isBinary && entry.snippet && (
|
||||
<div
|
||||
className="text-[12px] leading-[1.5] text-muted-foreground"
|
||||
data-testid="note-snippet"
|
||||
style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}
|
||||
>
|
||||
{entry.snippet}
|
||||
</div>
|
||||
)}
|
||||
{!isBinary && displayProps.length > 0 && (
|
||||
<PropertyChips
|
||||
entry={entry}
|
||||
displayProps={displayProps}
|
||||
allEntries={allEntries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
onOpenNote={onClickNote}
|
||||
/>
|
||||
)}
|
||||
{!isBinary && <NoteDateRow entry={entry} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteTitleRow({
|
||||
entry,
|
||||
isBinary,
|
||||
isSelected,
|
||||
noteStatus,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
isBinary: boolean
|
||||
isSelected: boolean
|
||||
noteStatus: NoteStatus
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('truncate pr-5 text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
|
||||
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
|
||||
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
|
||||
{entry.title}
|
||||
{!isBinary && <StateBadge archived={entry.archived} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteDateRow({ entry }: { entry: VaultEntry }) {
|
||||
const modifiedLabel = relativeDate(getDisplayDate(entry))
|
||||
const createdLabel = entry.createdAt ? `Created ${relativeDate(entry.createdAt)}` : null
|
||||
|
||||
if (!modifiedLabel && !createdLabel) return null
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-[10px] text-muted-foreground" data-testid="note-date-row">
|
||||
<span>{modifiedLabel}</span>
|
||||
{createdLabel && <span className="justify-self-end text-right">{createdLabel}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor: string, typeLightColor: string): React.CSSProperties {
|
||||
const base: React.CSSProperties = { padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px' }
|
||||
if (isMultiSelected) base.backgroundColor = 'color-mix(in srgb, var(--accent-blue) 10%, transparent)'
|
||||
|
||||
@@ -9,6 +9,23 @@ import {
|
||||
renderNoteList,
|
||||
} from '../test-utils/noteListTestUtils'
|
||||
|
||||
function makeBookTypeEntries(
|
||||
displayProps: string[] = [],
|
||||
entryOverrides: Parameters<typeof makeEntry>[0] = {},
|
||||
) {
|
||||
return [
|
||||
makeTypeDefinition('Book', displayProps),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
createdAt: 1700000000,
|
||||
...entryOverrides,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
describe('NoteList rendering', () => {
|
||||
it('shows an empty state when there are no entries', () => {
|
||||
renderNoteList({ entries: [] })
|
||||
@@ -137,20 +154,8 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
it('shows the inbox customize-columns action and falls back to type-defined chips', () => {
|
||||
const entries = [
|
||||
makeTypeDefinition('Book', ['Priority']),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
properties: { Priority: 'High', Owner: 'Luca' },
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
|
||||
selection: { kind: 'filter', filter: 'inbox' },
|
||||
inboxNoteListProperties: null,
|
||||
onUpdateInboxNoteListProperties: () => undefined,
|
||||
@@ -163,20 +168,9 @@ describe('NoteList rendering', () => {
|
||||
|
||||
it('opens the inbox column picker from the global event and saves new columns', () => {
|
||||
const onUpdateInboxNoteListProperties = vi.fn()
|
||||
const entries = [
|
||||
makeTypeDefinition('Book', ['Priority']),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
properties: { Priority: 'High', Owner: 'Luca' },
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
|
||||
selection: { kind: 'filter', filter: 'inbox' },
|
||||
inboxNoteListProperties: null,
|
||||
onUpdateInboxNoteListProperties,
|
||||
@@ -194,20 +188,8 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
it('shows status in the type column picker when at least one note has it set', () => {
|
||||
const entries = [
|
||||
makeTypeDefinition('Book'),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
status: 'Active',
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
entries: makeBookTypeEntries([], { status: 'Active' }),
|
||||
selection: { kind: 'sectionGroup', type: 'Book' },
|
||||
onUpdateTypeSort: () => undefined,
|
||||
})
|
||||
@@ -221,21 +203,8 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
it('keeps blank statuses out of the type column picker', () => {
|
||||
const entries = [
|
||||
makeTypeDefinition('Book'),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
status: '',
|
||||
properties: { Owner: 'Luca' },
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
entries: makeBookTypeEntries([], { status: '', properties: { Owner: 'Luca' } }),
|
||||
selection: { kind: 'sectionGroup', type: 'Book' },
|
||||
onUpdateTypeSort: () => undefined,
|
||||
})
|
||||
@@ -250,41 +219,41 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
it('renders status as a note-list chip when a type displays it', () => {
|
||||
const entries = [
|
||||
makeTypeDefinition('Book', ['status']),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
status: 'Active',
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
entries: makeBookTypeEntries(['status'], { status: 'Active' }),
|
||||
selection: { kind: 'sectionGroup', type: 'Book' },
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('property-chips')).toHaveTextContent('Active')
|
||||
const chip = screen.getByTestId('property-chip-status-0')
|
||||
expect(chip).toHaveTextContent('• Active')
|
||||
expect(chip).toHaveStyle({ backgroundColor: 'var(--accent-green-light)', color: 'var(--accent-green)' })
|
||||
})
|
||||
|
||||
it('auto-detects status-like property values in note-list chips', () => {
|
||||
renderNoteList({
|
||||
entries: makeBookTypeEntries(['Phase'], { properties: { Phase: 'Draft' } }),
|
||||
selection: { kind: 'sectionGroup', type: 'Book' },
|
||||
})
|
||||
|
||||
const chip = screen.getByTestId('property-chip-phase-0')
|
||||
expect(chip).toHaveTextContent('• Draft')
|
||||
expect(chip).toHaveStyle({ backgroundColor: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' })
|
||||
})
|
||||
|
||||
it('keeps unknown status values on neutral note-list chip styling', () => {
|
||||
renderNoteList({
|
||||
entries: makeBookTypeEntries(['status'], { status: 'Needs Review' }),
|
||||
selection: { kind: 'sectionGroup', type: 'Book' },
|
||||
})
|
||||
|
||||
const chip = screen.getByTestId('property-chip-status-0')
|
||||
expect(chip).toHaveTextContent('• Needs Review')
|
||||
expect(chip.getAttribute('style')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses inbox overrides when configured', () => {
|
||||
const entries = [
|
||||
makeTypeDefinition('Book', ['Priority']),
|
||||
makeEntry({
|
||||
path: '/vault/book.md',
|
||||
filename: 'book.md',
|
||||
title: 'Book Note',
|
||||
isA: 'Book',
|
||||
properties: { Priority: 'High', Owner: 'Luca' },
|
||||
createdAt: 1700000000,
|
||||
}),
|
||||
]
|
||||
|
||||
renderNoteList({
|
||||
entries,
|
||||
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
|
||||
selection: { kind: 'filter', filter: 'inbox' },
|
||||
inboxNoteListProperties: ['Owner'],
|
||||
onUpdateInboxNoteListProperties: () => undefined,
|
||||
|
||||
@@ -48,6 +48,7 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="quick-open-palette"
|
||||
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
|
||||
onClick={onClose}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, act } from '@testing-library/react'
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
||||
|
||||
function entry(title: string, path = `/vault/note/${title}.md`) {
|
||||
return {
|
||||
@@ -23,61 +22,6 @@ const defaultProps = {
|
||||
onSave: vi.fn(),
|
||||
}
|
||||
|
||||
describe('extractWikilinkQuery', () => {
|
||||
it('returns null when no [[ trigger', () => {
|
||||
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns empty string immediately after [[', () => {
|
||||
const text = 'see [['
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('')
|
||||
})
|
||||
|
||||
it('returns query after [[', () => {
|
||||
const text = 'see [[Proj'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
|
||||
})
|
||||
|
||||
it('returns null when ]] closes the link', () => {
|
||||
const text = '[[Proj]]'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when newline is in query', () => {
|
||||
const text = '[[Proj\ncontinued'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles cursor before end of text', () => {
|
||||
const text = '[[Proj after'
|
||||
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectYamlError', () => {
|
||||
it('returns null for content without frontmatter', () => {
|
||||
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid frontmatter', () => {
|
||||
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns error for unclosed frontmatter', () => {
|
||||
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
|
||||
expect(error).toContain('Unclosed frontmatter')
|
||||
})
|
||||
|
||||
it('returns error for tab indentation in frontmatter', () => {
|
||||
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
|
||||
expect(error).toContain('tab indentation')
|
||||
})
|
||||
|
||||
it('returns null for content not starting with ---', () => {
|
||||
expect(detectYamlError('Not frontmatter')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RawEditorView', () => {
|
||||
it('renders CodeMirror container', () => {
|
||||
render(<RawEditorView {...defaultProps} />)
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
|
||||
import { MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { NoteSearchList } from './NoteSearchList'
|
||||
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
||||
import {
|
||||
buildRawEditorAutocompleteState,
|
||||
buildRawEditorBaseItems,
|
||||
detectYamlError,
|
||||
extractWikilinkQuery,
|
||||
getRawEditorDropdownPosition,
|
||||
replaceActiveWikilinkQuery,
|
||||
type RawEditorAutocompleteState,
|
||||
} from '../utils/rawEditorUtils'
|
||||
import { useCodeMirror } from '../hooks/useCodeMirror'
|
||||
import type { WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
interface AutocompleteState {
|
||||
caretTop: number
|
||||
caretLeft: number
|
||||
selectedIndex: number
|
||||
items: WikilinkSuggestionItem[]
|
||||
}
|
||||
|
||||
export interface RawEditorViewProps {
|
||||
content: string
|
||||
path: string
|
||||
@@ -32,13 +31,6 @@ export interface RawEditorViewProps {
|
||||
const DEBOUNCE_MS = 500
|
||||
const DROPDOWN_MAX_HEIGHT = 200
|
||||
|
||||
function getCursorCoords(view: EditorView): { top: number; left: number } | null {
|
||||
const pos = view.state.selection.main.head
|
||||
const coords = view.coordsAtPos(pos)
|
||||
if (!coords) return null
|
||||
return { top: coords.bottom, left: coords.left }
|
||||
}
|
||||
|
||||
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -52,23 +44,14 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
|
||||
useEffect(() => { onSaveRef.current = onSave }, [onSave])
|
||||
|
||||
const [autocomplete, setAutocomplete] = useState<AutocompleteState | null>(null)
|
||||
const [autocomplete, setAutocomplete] = useState<RawEditorAutocompleteState | null>(null)
|
||||
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))),
|
||||
[entries],
|
||||
)
|
||||
const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries])
|
||||
|
||||
const insertWikilinkRef = useRef<(entryTitle: string) => void>(() => {})
|
||||
const insertWikilinkRef = useRef<(target: string) => void>(() => {})
|
||||
|
||||
const latestContentRefStable = useRef(latestContentRef)
|
||||
useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef])
|
||||
@@ -91,12 +74,15 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
setAutocomplete(null)
|
||||
return
|
||||
}
|
||||
const coords = getCursorCoords(view)
|
||||
if (!coords) { setAutocomplete(null); return }
|
||||
const candidates = preFilterWikilinks(baseItems, query)
|
||||
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title), vaultPath ?? '')
|
||||
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
|
||||
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
|
||||
const nextAutocomplete = buildRawEditorAutocompleteState({
|
||||
view,
|
||||
baseItems,
|
||||
query,
|
||||
typeEntryMap,
|
||||
onInsertTarget: (target: string) => insertWikilinkRef.current(target),
|
||||
vaultPath: vaultPath ?? '',
|
||||
})
|
||||
setAutocomplete(nextAutocomplete)
|
||||
}, [baseItems, typeEntryMap, vaultPath])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -120,30 +106,25 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
onEscape: handleEscape,
|
||||
})
|
||||
|
||||
const insertWikilink = useCallback((entryTitle: string) => {
|
||||
const insertWikilink = useCallback((target: string) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
const cursor = view.state.selection.main.head
|
||||
const doc = view.state.doc.toString()
|
||||
const before = doc.slice(0, cursor)
|
||||
const triggerIdx = before.lastIndexOf('[[')
|
||||
if (triggerIdx === -1) return
|
||||
|
||||
const after = doc.slice(cursor)
|
||||
const newText = `${doc.slice(0, triggerIdx)}[[${entryTitle}]]${after}`
|
||||
const newCursor = triggerIdx + entryTitle.length + 4
|
||||
const replacement = replaceActiveWikilinkQuery(doc, cursor, target)
|
||||
if (!replacement) return
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: doc.length, insert: newText },
|
||||
selection: { anchor: newCursor },
|
||||
changes: { from: 0, to: doc.length, insert: replacement.text },
|
||||
selection: { anchor: replacement.cursor },
|
||||
})
|
||||
trackEvent('wikilink_inserted')
|
||||
setAutocomplete(null)
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
latestDocRef.current = newText
|
||||
onContentChangeRef.current(pathRef.current, newText)
|
||||
latestDocRef.current = replacement.text
|
||||
onContentChangeRef.current(pathRef.current, replacement.text)
|
||||
|
||||
view.focus()
|
||||
}, [viewRef])
|
||||
@@ -165,9 +146,9 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const item = autocomplete.items[autocomplete.selectedIndex]
|
||||
if (item) insertWikilink(item.entryTitle ?? item.title)
|
||||
if (item) item.onItemClick()
|
||||
}
|
||||
}, [autocomplete, insertWikilink])
|
||||
}, [autocomplete])
|
||||
|
||||
// Flush pending debounce on unmount
|
||||
useEffect(() => {
|
||||
@@ -179,15 +160,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dropdownBelow = autocomplete
|
||||
? autocomplete.caretTop + 20 + DROPDOWN_MAX_HEIGHT <= window.innerHeight
|
||||
: true
|
||||
const dropdownTop = autocomplete
|
||||
? (dropdownBelow ? autocomplete.caretTop + 4 : autocomplete.caretTop - DROPDOWN_MAX_HEIGHT - 24)
|
||||
: 0
|
||||
const dropdownLeft = autocomplete
|
||||
? Math.min(autocomplete.caretLeft, window.innerWidth - 260)
|
||||
: 0
|
||||
const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window)
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={handleAutocompleteKey} role="presentation">
|
||||
@@ -212,8 +185,8 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
<div
|
||||
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
|
||||
style={{
|
||||
top: dropdownTop,
|
||||
left: dropdownLeft,
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
maxHeight: DROPDOWN_MAX_HEIGHT,
|
||||
background: 'var(--popover)',
|
||||
borderColor: 'var(--border)',
|
||||
@@ -224,7 +197,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
items={autocomplete.items}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
|
||||
onItemClick={(item) => insertWikilink(item.entryTitle ?? item.title)}
|
||||
onItemClick={(item) => item.onItemClick()}
|
||||
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +75,32 @@ describe('SettingsPanel', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults the organization workflow switch to on', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByRole('switch', { name: 'Organize notes explicitly' })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('saves the organization workflow preference when toggled off', () => {
|
||||
const onSaveExplicitOrganization = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
open={true}
|
||||
settings={emptySettings}
|
||||
onSave={onSave}
|
||||
explicitOrganizationEnabled={true}
|
||||
onSaveExplicitOrganization={onSaveExplicitOrganization}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Organize notes explicitly' }))
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSaveExplicitOrganization).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
|
||||
@@ -3,11 +3,14 @@ import { X, GithubLogo, SignOut } from '@phosphor-icons/react'
|
||||
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
|
||||
import type { Settings } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { Switch } from './ui/switch'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
open: boolean
|
||||
settings: Settings
|
||||
onSave: (settings: Settings) => void
|
||||
explicitOrganizationEnabled?: boolean
|
||||
onSaveExplicitOrganization?: (enabled: boolean) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -59,18 +62,48 @@ function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDi
|
||||
|
||||
// --- Settings Panel ---
|
||||
|
||||
export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) {
|
||||
if (!open) return null
|
||||
return <SettingsPanelInner settings={settings} onSave={onSave} onClose={onClose} />
|
||||
function isSaveShortcut(event: React.KeyboardEvent): boolean {
|
||||
return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
|
||||
}
|
||||
|
||||
function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelProps, 'open'>) {
|
||||
export function SettingsPanel({
|
||||
open,
|
||||
settings,
|
||||
onSave,
|
||||
explicitOrganizationEnabled = true,
|
||||
onSaveExplicitOrganization,
|
||||
onClose,
|
||||
}: SettingsPanelProps) {
|
||||
if (!open) return null
|
||||
return (
|
||||
<SettingsPanelInner
|
||||
settings={settings}
|
||||
onSave={onSave}
|
||||
explicitOrganizationEnabled={explicitOrganizationEnabled}
|
||||
onSaveExplicitOrganization={onSaveExplicitOrganization}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SettingsPanelInnerProps = Omit<SettingsPanelProps, 'open' | 'explicitOrganizationEnabled'> & {
|
||||
explicitOrganizationEnabled: boolean
|
||||
}
|
||||
|
||||
function SettingsPanelInner({
|
||||
settings,
|
||||
onSave,
|
||||
explicitOrganizationEnabled,
|
||||
onSaveExplicitOrganization,
|
||||
onClose,
|
||||
}: SettingsPanelInnerProps) {
|
||||
const [githubToken, setGithubToken] = useState(settings.github_token)
|
||||
const [githubUsername, setGithubUsername] = useState(settings.github_username)
|
||||
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
|
||||
const [releaseChannel, setReleaseChannel] = useState(settings.release_channel ?? 'stable')
|
||||
const [crashReporting, setCrashReporting] = useState(settings.crash_reporting_enabled ?? false)
|
||||
const [analytics, setAnalytics] = useState(settings.analytics_enabled ?? false)
|
||||
const [explicitOrganization, setExplicitOrganization] = useState(explicitOrganizationEnabled)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-focus first input when settings panel opens
|
||||
@@ -99,6 +132,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
if (!prevAnalytics && newAnalytics) trackEvent('telemetry_opted_in')
|
||||
if (prevAnalytics && !newAnalytics) trackEvent('telemetry_opted_out')
|
||||
onSave(buildSettings())
|
||||
onSaveExplicitOrganization?.(explicitOrganization)
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -119,7 +153,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
if (isSaveShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
@@ -144,6 +178,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
|
||||
pullInterval={pullInterval} setPullInterval={setPullInterval}
|
||||
releaseChannel={releaseChannel} setReleaseChannel={setReleaseChannel}
|
||||
explicitOrganization={explicitOrganization}
|
||||
setExplicitOrganization={setExplicitOrganization}
|
||||
crashReporting={crashReporting} setCrashReporting={setCrashReporting}
|
||||
analytics={analytics} setAnalytics={setAnalytics}
|
||||
/>
|
||||
@@ -177,6 +213,7 @@ interface SettingsBodyProps {
|
||||
onGitHubDisconnect: () => void
|
||||
pullInterval: number; setPullInterval: (v: number) => void
|
||||
releaseChannel: string; setReleaseChannel: (v: string) => void
|
||||
explicitOrganization: boolean; setExplicitOrganization: (v: boolean) => void
|
||||
crashReporting: boolean; setCrashReporting: (v: boolean) => void
|
||||
analytics: boolean; setAnalytics: (v: boolean) => void
|
||||
}
|
||||
@@ -251,6 +288,13 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
<OrganizationWorkflowSection
|
||||
checked={props.explicitOrganization}
|
||||
onChange={props.setExplicitOrganization}
|
||||
/>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Privacy & Telemetry</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
@@ -264,6 +308,39 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function OrganizationWorkflowSection({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Workflow</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
Choose whether Laputa shows the Inbox workflow and the organized toggle.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className="flex items-start justify-between gap-3"
|
||||
style={{ cursor: 'pointer' }}
|
||||
data-testid="settings-explicit-organization"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--foreground)' }}>Organize notes explicitly</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>
|
||||
When enabled, an Inbox section shows unorganized notes, and a toggle lets you mark notes as organized.
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={checked} onCheckedChange={onChange} aria-label="Organize notes explicitly" />
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TelemetryToggle({ label, description, checked, onChange, testId }: { label: string; description: string; checked: boolean; onChange: (v: boolean) => void; testId: string }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }} data-testid={testId}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
@@ -867,6 +867,13 @@ describe('Sidebar', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' })
|
||||
})
|
||||
|
||||
it('hides Inbox when explicit organization is disabled', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} showInbox={false} inboxCount={3} />)
|
||||
expect(screen.queryByText('Inbox')).not.toBeInTheDocument()
|
||||
const topNav = screen.getByTestId('sidebar-top-nav')
|
||||
expect(topNav.children[0].textContent).toContain('All Notes')
|
||||
})
|
||||
|
||||
it('does not show inline entries — no child items in type sections', () => {
|
||||
const entriesWithEmoji: VaultEntry[] = [
|
||||
{
|
||||
@@ -893,12 +900,60 @@ describe('Sidebar', () => {
|
||||
favorite: true, favoriteIndex: 0,
|
||||
}
|
||||
|
||||
function getFavoriteAndTypeRows(favoriteTitle = 'My Favorite Note') {
|
||||
const favoriteLabel = screen.getByText(favoriteTitle)
|
||||
const favoriteRow = favoriteLabel.closest('.cursor-pointer')
|
||||
const typeLabel = screen.getByText('Projects')
|
||||
const typeRow = typeLabel.closest('.cursor-pointer')
|
||||
expect(favoriteRow).not.toBeNull()
|
||||
expect(typeRow).not.toBeNull()
|
||||
|
||||
return {
|
||||
favoriteLabel,
|
||||
favoriteRow: favoriteRow as HTMLElement,
|
||||
typeLabel,
|
||||
typeRow: typeRow as HTMLElement,
|
||||
}
|
||||
}
|
||||
|
||||
function expectFavoriteIconToMatchTypeSizing(favoriteRow: HTMLElement) {
|
||||
const favoriteIcon = favoriteRow.querySelector('svg')
|
||||
expect(favoriteIcon).not.toBeNull()
|
||||
expect(favoriteIcon!.getAttribute('width')).toBe('16')
|
||||
expect(favoriteIcon!.getAttribute('height')).toBe('16')
|
||||
expect(favoriteIcon!.getAttribute('style')).toContain('var(--accent-red)')
|
||||
return favoriteIcon as SVGElement
|
||||
}
|
||||
|
||||
function expectFavoriteRowToMatchTypeRow(favoriteTitle = 'My Favorite Note') {
|
||||
const { favoriteLabel, favoriteRow, typeLabel, typeRow } = getFavoriteAndTypeRows(favoriteTitle)
|
||||
|
||||
expect(favoriteRow.className).toBe(typeRow.className)
|
||||
expect(favoriteRow.style.padding).toBe(typeRow.style.padding)
|
||||
expect(favoriteRow.style.gap).toBe(typeRow.style.gap)
|
||||
expect(favoriteLabel.className).toContain(typeLabel.className)
|
||||
expect(favoriteLabel.className).toContain('truncate')
|
||||
return {
|
||||
favoriteRow,
|
||||
typeRow,
|
||||
favoriteIcon: expectFavoriteIconToMatchTypeSizing(favoriteRow),
|
||||
}
|
||||
}
|
||||
|
||||
it('shows FAVORITES section when there are favorites', () => {
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('FAVORITES')).toBeInTheDocument()
|
||||
expect(screen.getByText('My Favorite Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves plain square brackets in favorite titles', () => {
|
||||
const bracketedFavorite = { ...favEntry, title: '[26Q2] Tolaria MVP' }
|
||||
|
||||
render(<Sidebar entries={[...mockEntries, bracketedFavorite]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
expect(screen.getByText('[26Q2] Tolaria MVP')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides FAVORITES section when no favorites', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument()
|
||||
@@ -910,6 +965,60 @@ describe('Sidebar', () => {
|
||||
fireEvent.click(screen.getByText('My Favorite Note'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'favorites' })
|
||||
})
|
||||
|
||||
it('matches the Types row styling and type color for favorites', () => {
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expectFavoriteRowToMatchTypeRow()
|
||||
})
|
||||
|
||||
it('prefers a favorite note emoji icon over the type icon fallback', () => {
|
||||
const emojiFavorite = { ...favEntry, title: 'Emoji Favorite', icon: '🚀' }
|
||||
|
||||
render(<Sidebar entries={[...mockEntries, emojiFavorite]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const emojiRow = screen.getByText('Emoji Favorite').closest('.cursor-pointer')
|
||||
expect(within(emojiRow as HTMLElement).getByText('🚀')).toBeInTheDocument()
|
||||
expect(emojiRow?.querySelector('svg')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses a favorite note phosphor icon and keeps the type color', () => {
|
||||
const iconFavorite = { ...favEntry, title: 'Icon Favorite', icon: 'rocket' }
|
||||
|
||||
render(<Sidebar entries={[...mockEntries, iconFavorite]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const { favoriteIcon, typeRow } = expectFavoriteRowToMatchTypeRow('Icon Favorite')
|
||||
const typeIcon = typeRow.querySelector('svg')
|
||||
expect(typeIcon).not.toBeNull()
|
||||
expect(favoriteIcon.innerHTML).not.toBe(typeIcon!.innerHTML)
|
||||
})
|
||||
|
||||
it('falls back to a neutral icon color when the favorite type has no defined color', () => {
|
||||
const customType: VaultEntry = {
|
||||
path: '/vault/types/recipe.md', filename: 'recipe.md', title: 'Recipe',
|
||||
isA: 'Type', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 120, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: 'flask', color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
}
|
||||
const recipeFavorite: VaultEntry = {
|
||||
path: '/vault/recipe/sourdough.md', filename: 'sourdough.md', title: 'Sourdough',
|
||||
isA: 'Recipe', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 120, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
favorite: true, favoriteIndex: 0,
|
||||
}
|
||||
|
||||
render(<Sidebar entries={[...mockEntries, customType, recipeFavorite]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const recipeRow = screen.getByText('Sourdough').closest('.cursor-pointer')
|
||||
const recipeIcon = recipeRow?.querySelector('svg')
|
||||
|
||||
expect(recipeIcon?.getAttribute('style')).toContain('var(--muted-foreground)')
|
||||
expect(within(recipeRow as HTMLElement).getByText('Sourdough')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('group separators', () => {
|
||||
@@ -1049,5 +1158,35 @@ describe('Sidebar', () => {
|
||||
const viewContainer = screen.getByText('Empty View').closest('div')
|
||||
expect(viewContainer?.querySelector('span:last-child')?.textContent).not.toBe('0')
|
||||
})
|
||||
|
||||
it('adds hover and focus classes that hide the view count chip while showing the action buttons', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
entries={mockEntries}
|
||||
selection={defaultSelection}
|
||||
onSelect={() => {}}
|
||||
views={mockViews}
|
||||
onEditView={() => {}}
|
||||
onDeleteView={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
const label = screen.getByText('Active Projects')
|
||||
const viewItem = label.closest('.group.relative') as HTMLElement
|
||||
const navItem = label.closest('[class*="cursor-pointer"]') as HTMLElement
|
||||
const countChip = navItem.querySelector('span:last-child') as HTMLElement
|
||||
expect(countChip).toBeTruthy()
|
||||
expect(countChip.className).toContain('transition-opacity')
|
||||
expect(countChip.className).toContain('group-hover:opacity-0')
|
||||
expect(countChip.className).toContain('group-focus-within:opacity-0')
|
||||
|
||||
const actionButton = within(viewItem).getByTitle('Edit view')
|
||||
const actionContainer = actionButton.parentElement as HTMLElement
|
||||
expect(actionContainer.className).toContain('pointer-events-none')
|
||||
expect(actionContainer.className).toContain('group-hover:pointer-events-auto')
|
||||
expect(actionContainer.className).toContain('group-focus-within:pointer-events-auto')
|
||||
expect(actionContainer.className).toContain('group-hover:opacity-100')
|
||||
expect(actionContainer.className).toContain('group-focus-within:opacity-100')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,6 +44,7 @@ interface SidebarProps {
|
||||
onDeleteView?: (filename: string) => void
|
||||
folders?: FolderNode[]
|
||||
onCreateFolder?: (name: string) => void
|
||||
showInbox?: boolean
|
||||
inboxCount?: number
|
||||
onCollapse?: () => void
|
||||
}
|
||||
@@ -65,6 +66,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
onDeleteView,
|
||||
folders = [],
|
||||
onCreateFolder,
|
||||
showInbox = true,
|
||||
inboxCount = 0,
|
||||
onCollapse,
|
||||
onCreateNewType,
|
||||
@@ -160,6 +162,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
<SidebarTopNav
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
showInbox={showInbox}
|
||||
inboxCount={inboxCount}
|
||||
activeCount={activeCount}
|
||||
archivedCount={archivedCount}
|
||||
|
||||
@@ -51,6 +51,19 @@ describe('StatusBar', () => {
|
||||
expect(screen.queryByText('main')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Feedback button when callback is provided', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenFeedback={vi.fn()} />)
|
||||
expect(screen.getByTestId('status-feedback')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feedback')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onOpenFeedback when Feedback is clicked', () => {
|
||||
const onOpenFeedback = vi.fn()
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenFeedback={onOpenFeedback} />)
|
||||
fireEvent.click(screen.getByTestId('status-feedback'))
|
||||
expect(onOpenFeedback).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows clickable commit hash that opens URL via openExternalUrl', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
|
||||
@@ -34,6 +34,7 @@ interface StatusBarProps {
|
||||
onOpenConflictResolver?: () => void
|
||||
zoomLevel?: number
|
||||
onZoomReset?: () => void
|
||||
onOpenFeedback?: () => void
|
||||
buildNumber?: string
|
||||
onCheckForUpdates?: () => void
|
||||
onRemoveVault?: (path: string) => void
|
||||
@@ -67,6 +68,7 @@ export function StatusBar({
|
||||
onOpenConflictResolver,
|
||||
zoomLevel = 100,
|
||||
onZoomReset,
|
||||
onOpenFeedback,
|
||||
buildNumber,
|
||||
onCheckForUpdates,
|
||||
onRemoveVault,
|
||||
@@ -131,6 +133,7 @@ export function StatusBar({
|
||||
noteCount={noteCount}
|
||||
zoomLevel={zoomLevel}
|
||||
onZoomReset={onZoomReset}
|
||||
onOpenFeedback={onOpenFeedback}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</footer>
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { TitleField } from './TitleField'
|
||||
|
||||
describe('TitleField', () => {
|
||||
it('renders the title in the input', () => {
|
||||
render(<TitleField title="My Note" filename="my-note.md" onTitleChange={() => {}} />)
|
||||
expect(screen.getByTestId('title-field-input')).toHaveValue('My Note')
|
||||
})
|
||||
|
||||
it('calls onTitleChange on blur with new value', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TitleField title="Old Title" filename="old-title.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
fireEvent.change(input, { target: { value: 'New Title' } })
|
||||
fireEvent.blur(input)
|
||||
expect(onChange).toHaveBeenCalledWith('New Title')
|
||||
})
|
||||
|
||||
it('does not call onTitleChange if title unchanged', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TitleField title="Same Title" filename="same-title.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.blur(input)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reverts to original title if input is emptied', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TitleField title="Keep This" filename="keep-this.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
fireEvent.blur(input)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(input).toHaveValue('Keep This')
|
||||
})
|
||||
|
||||
it('shows filename indicator when slug differs and title is focused', () => {
|
||||
render(<TitleField title="My Note" filename="wrong-name.md" onTitleChange={() => {}} />)
|
||||
// Not shown when unfocused
|
||||
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
|
||||
// Shown when focused
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-filename')).toHaveTextContent('my-note.md')
|
||||
})
|
||||
|
||||
it('does not show filename when slug matches and not editing', () => {
|
||||
render(<TitleField title="My Note" filename="my-note.md" onTitleChange={() => {}} />)
|
||||
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables input when editable is false', () => {
|
||||
render(<TitleField title="Read Only" filename="read-only.md" editable={false} onTitleChange={() => {}} />)
|
||||
expect(screen.getByTestId('title-field-input')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('commits title on Enter key', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TitleField title="Before" filename="before.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
fireEvent.change(input, { target: { value: 'After' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
// In jsdom, blur() after keyDown needs explicit blur event
|
||||
fireEvent.blur(input)
|
||||
expect(onChange).toHaveBeenCalledWith('After')
|
||||
})
|
||||
|
||||
it('reverts on Escape key', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TitleField title="Original" filename="original.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
fireEvent.change(input, { target: { value: 'Changed' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
// Escape reverts value and blurs
|
||||
expect(input).toHaveValue('Original')
|
||||
})
|
||||
|
||||
it('shows new title optimistically after commit (before prop updates)', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<TitleField title="Old Title" filename="old-title.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.change(input, { target: { value: 'New Title' } })
|
||||
fireEvent.blur(input)
|
||||
// After commit, should show new title even though prop is still "Old Title"
|
||||
expect(input).toHaveValue('New Title')
|
||||
// After prop updates to match, should still show new title
|
||||
rerender(<TitleField title="New Title" filename="new-title.md" onTitleChange={onChange} />)
|
||||
expect(input).toHaveValue('New Title')
|
||||
})
|
||||
|
||||
it('resets optimistic title when prop changes from external source', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<TitleField title="Title A" filename="title-a.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
// Simulate external title change (e.g., tab switch)
|
||||
rerender(<TitleField title="Title B" filename="title-b.md" onTitleChange={onChange} />)
|
||||
expect(input).toHaveValue('Title B')
|
||||
})
|
||||
|
||||
it('responds to laputa:focus-editor event with selectTitle', () => {
|
||||
render(<TitleField title="Focus Me" filename="focus-me.md" onTitleChange={() => {}} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
it('resets stale localValue when title prop changes after focus', () => {
|
||||
// Regression: creating a new note fires focus-editor before React re-renders,
|
||||
// so handleFocus captures the OLD note's title into localValue.
|
||||
// When React re-renders with the new title, localValue should be cleared.
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<TitleField title="Old Note" filename="old-note.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
// Simulate: focus fires while title prop is still "Old Note"
|
||||
fireEvent.focus(input)
|
||||
expect(input).toHaveValue('Old Note')
|
||||
// React re-renders with new note's title (tab switched)
|
||||
rerender(<TitleField title="Untitled note" filename="untitled-note.md" onTitleChange={onChange} />)
|
||||
expect(input).toHaveValue('Untitled note')
|
||||
})
|
||||
|
||||
it('shows vault-relative path (without .md) only when title is focused', () => {
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
|
||||
// Path hidden by default
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
// Focus title → path appears
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
|
||||
// No bare filename shown when path is visible
|
||||
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides path on blur', () => {
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByTestId('title-field-path')).toBeInTheDocument()
|
||||
fireEvent.blur(input)
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides path for notes at vault root even when focused', () => {
|
||||
render(<TitleField title="Root Note" filename="root-note.md" notePath="/Users/luca/Laputa/root-note.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides path when vaultPath is not provided', () => {
|
||||
render(<TitleField title="Note" filename="note.md" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resolves vault-relative path when paths differ by symlink prefix', () => {
|
||||
// vaultPath uses symlink /Users/luca/... but notePath is canonical /Volumes/Jupiter/...
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Workspace/laputa-app/demo-vault-v2" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
|
||||
})
|
||||
|
||||
it('handles vaultPath with trailing slash', () => {
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa/" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
|
||||
})
|
||||
})
|
||||
@@ -1,170 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { slugify } from '../hooks/useNoteCreation'
|
||||
|
||||
interface TitleFieldProps {
|
||||
title: string
|
||||
filename: string
|
||||
editable?: boolean
|
||||
/** Absolute path of the note file. */
|
||||
notePath?: string
|
||||
/** Absolute path of the vault root. */
|
||||
vaultPath?: string
|
||||
/** Called when the user finishes editing the title (blur or Enter). */
|
||||
onTitleChange: (newTitle: string) => void
|
||||
}
|
||||
|
||||
/** Manages local edit + optimistic title state for TitleField. */
|
||||
function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
|
||||
const [localValue, setLocalValue] = useState<string | null>(null)
|
||||
// [optimisticTitle, forPropTitle]: shown after commit until title prop catches up
|
||||
const [optimistic, setOptimistic] = useState<[string, string] | null>(null)
|
||||
const isFocusedRef = useRef(false)
|
||||
const [prevTitle, setPrevTitle] = useState(title)
|
||||
|
||||
// Reset local edit when the title prop changes (e.g. note switch).
|
||||
// This prevents a stale handleFocus closure from locking in the old note's title
|
||||
// when focus-editor fires before React re-renders with the new tab.
|
||||
if (prevTitle !== title) {
|
||||
setPrevTitle(title)
|
||||
if (localValue !== null) setLocalValue(null)
|
||||
}
|
||||
|
||||
// Clear optimistic once the prop changes (rename completed or tab switched)
|
||||
const optimisticValue = optimistic && optimistic[1] === title ? optimistic[0] : null
|
||||
const value = localValue ?? optimisticValue ?? title
|
||||
const isEditing = localValue !== null || optimisticValue !== null
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
isFocusedRef.current = true
|
||||
setLocalValue(title)
|
||||
}, [title])
|
||||
|
||||
const commitTitle = useCallback(() => {
|
||||
isFocusedRef.current = false
|
||||
const trimmed = (localValue ?? '').trim()
|
||||
if (trimmed && trimmed !== title) {
|
||||
setLocalValue(null)
|
||||
setOptimistic([trimmed, title])
|
||||
onTitleChange(trimmed)
|
||||
} else {
|
||||
setLocalValue(null)
|
||||
}
|
||||
}, [localValue, title, onTitleChange])
|
||||
|
||||
const revert = useCallback(() => setLocalValue(null), [])
|
||||
const setEdit = useCallback((v: string) => setLocalValue(v), [])
|
||||
|
||||
return { value, isEditing, handleFocus, commitTitle, revert, setEdit }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated title input field above the editor.
|
||||
* Displays the title as an editable field and shows the resulting filename below.
|
||||
*/
|
||||
export function TitleField({ title, filename, editable = true, notePath, vaultPath, onTitleChange }: TitleFieldProps) {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const { value, isEditing, handleFocus, commitTitle, revert, setEdit } =
|
||||
useOptimisticTitle(title, onTitleChange)
|
||||
|
||||
// Auto-resize textarea to fit content (fallback for browsers without field-sizing: content)
|
||||
const autoResize = useCallback(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
autoResize()
|
||||
// Schedule a second measurement after the browser has painted, to catch
|
||||
// cases where scrollHeight is stale on the first read (e.g. tab switch).
|
||||
const id = requestAnimationFrame(autoResize)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [value, autoResize])
|
||||
|
||||
// Re-measure when container width changes (text may re-wrap)
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(autoResize)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [autoResize])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail
|
||||
if (detail?.selectTitle && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}
|
||||
window.addEventListener('laputa:focus-editor', handler)
|
||||
return () => window.removeEventListener('laputa:focus-editor', handler)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
revert()
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}, [revert])
|
||||
|
||||
const expectedSlug = slugify(value.trim() || title)
|
||||
const currentStem = filename.replace(/\.md$/, '')
|
||||
|
||||
// Compute vault-relative path (only for notes in subdirectories)
|
||||
const relativePath = (() => {
|
||||
if (!notePath || !vaultPath) return null
|
||||
const vp = vaultPath.replace(/\/+$/, '')
|
||||
const np = notePath.replace(/\.md$/, '')
|
||||
if (np.startsWith(vp + '/')) return np.slice(vp.length + 1)
|
||||
// Fallback: match by vault directory name for symlink-resolved paths
|
||||
const vaultName = vp.split('/').pop()
|
||||
if (!vaultName) return null
|
||||
const segments = np.split('/')
|
||||
const idx = segments.lastIndexOf(vaultName)
|
||||
if (idx >= 0) return segments.slice(idx + 1).join('/')
|
||||
return null
|
||||
})()
|
||||
const isSubdirectory = relativePath != null && relativePath.includes('/')
|
||||
|
||||
// Show path only when title is focused and note is in a subdirectory
|
||||
const showRelativePath = isFocused && isSubdirectory
|
||||
// Show filename hint when slug differs, but only when focused (not always)
|
||||
const showFilename = isFocused && !showRelativePath && (isEditing || currentStem !== expectedSlug)
|
||||
|
||||
return (
|
||||
<div className="title-field" data-testid="title-field">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="title-field__input"
|
||||
value={value}
|
||||
rows={1}
|
||||
onChange={e => { setEdit(e.target.value); autoResize() }}
|
||||
onFocus={() => { setIsFocused(true); handleFocus() }}
|
||||
onBlur={() => { setIsFocused(false); commitTitle() }}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!editable}
|
||||
placeholder="Untitled"
|
||||
spellCheck={false}
|
||||
data-testid="title-field-input"
|
||||
/>
|
||||
{showFilename && (
|
||||
<span className="title-field__filename" data-testid="title-field-filename">
|
||||
{expectedSlug}.md
|
||||
</span>
|
||||
)}
|
||||
{showRelativePath && (
|
||||
<span className="title-field__path" data-testid="title-field-path" style={{ display: 'block', fontSize: 11, color: 'var(--muted-foreground)', marginTop: 2 }}>
|
||||
{relativePath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,18 +7,6 @@ vi.mock('../BreadcrumbBar', () => ({
|
||||
BreadcrumbBar: () => <div data-testid="breadcrumb-bar" />,
|
||||
}))
|
||||
|
||||
vi.mock('../TitleField', () => ({
|
||||
TitleField: () => <div data-testid="title-field-input" />,
|
||||
}))
|
||||
|
||||
vi.mock('../NoteIcon', () => ({
|
||||
NoteIcon: ({ icon }: { icon: string | null }) => (
|
||||
icon
|
||||
? <button type="button" data-testid="note-icon-display" />
|
||||
: <button type="button" data-testid="note-icon-add" />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../ArchivedNoteBanner', () => ({
|
||||
ArchivedNoteBanner: () => <div data-testid="archived-banner" />,
|
||||
}))
|
||||
@@ -69,12 +57,7 @@ function createModel(overrides: Record<string, unknown> = {}) {
|
||||
onKeepTheirs: vi.fn(),
|
||||
breadcrumbBarRef: createRef<HTMLDivElement>(),
|
||||
wordCount: 12,
|
||||
titleSectionRef: createRef<HTMLDivElement>(),
|
||||
showTitleSection: true,
|
||||
hasDisplayIcon: false,
|
||||
entryIcon: null,
|
||||
vaultPath: '/vault',
|
||||
onTitleChange: vi.fn(),
|
||||
cssVars: {},
|
||||
onNavigateWikilink: vi.fn(),
|
||||
onEditorChange: vi.fn(),
|
||||
@@ -95,27 +78,12 @@ function createModel(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe('EditorContentLayout', () => {
|
||||
it('does not render a standalone add-icon row when the note has no icon', () => {
|
||||
it('never renders the legacy title section', () => {
|
||||
const { container } = render(<EditorContentLayout {...createModel()} />)
|
||||
|
||||
expect(container.querySelector('.title-section__add-icon')).toBeNull()
|
||||
expect(container.querySelector('.title-section__inline-add-icon')).not.toBeNull()
|
||||
expect(screen.getByTestId('note-icon-add')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the existing icon and title inside the same title row', () => {
|
||||
const { container } = render(
|
||||
<EditorContentLayout
|
||||
{...createModel({
|
||||
hasDisplayIcon: true,
|
||||
entryIcon: 'rocket',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
const titleRow = container.querySelector('.title-section__row')
|
||||
expect(titleRow?.querySelector('[data-testid="note-icon-display"]')).not.toBeNull()
|
||||
expect(titleRow?.querySelector('[data-testid="title-field-input"]')).not.toBeNull()
|
||||
expect(container.querySelector('.title-section')).toBeNull()
|
||||
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('single-editor-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the loading skeleton instead of stale editor chrome while switching tabs', () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type React from 'react'
|
||||
import { DiffView } from '../DiffView'
|
||||
import { BreadcrumbBar } from '../BreadcrumbBar'
|
||||
import { TitleField } from '../TitleField'
|
||||
import { NoteIcon } from '../NoteIcon'
|
||||
import { ArchivedNoteBanner } from '../ArchivedNoteBanner'
|
||||
import { ConflictNoteBanner } from '../ConflictNoteBanner'
|
||||
import { RawEditorView } from '../RawEditorView'
|
||||
@@ -29,6 +27,7 @@ type BreadcrumbActions = Pick<
|
||||
| 'onDeleteNote'
|
||||
| 'onArchiveNote'
|
||||
| 'onUnarchiveNote'
|
||||
| 'onRenameFilename'
|
||||
>
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -127,54 +126,11 @@ function ActiveTabBreadcrumb({
|
||||
onDelete={bindPath(actions.onDeleteNote, path)}
|
||||
onArchive={bindPath(actions.onArchiveNote, path)}
|
||||
onUnarchive={bindPath(actions.onUnarchiveNote, path)}
|
||||
onRenameFilename={actions.onRenameFilename}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TitleSection({
|
||||
activeTab,
|
||||
entryIcon,
|
||||
hasDisplayIcon,
|
||||
path,
|
||||
showTitleSection,
|
||||
titleSectionRef,
|
||||
vaultPath,
|
||||
onTitleChange,
|
||||
}: Pick<
|
||||
EditorContentModel,
|
||||
'activeTab' | 'entryIcon' | 'hasDisplayIcon' | 'path' | 'showTitleSection' | 'titleSectionRef' | 'vaultPath' | 'onTitleChange'
|
||||
>) {
|
||||
if (!activeTab) return null
|
||||
|
||||
return (
|
||||
<div ref={titleSectionRef} className="title-section" data-title-ui-visible={showTitleSection || undefined}>
|
||||
{showTitleSection && (
|
||||
<>
|
||||
<div className="title-section__heading">
|
||||
{!hasDisplayIcon && (
|
||||
<div className="title-section__inline-add-icon">
|
||||
<NoteIcon icon={null} editable />
|
||||
</div>
|
||||
)}
|
||||
<div className={`title-section__row${hasDisplayIcon ? '' : ' title-section__row--no-icon'}`}>
|
||||
{hasDisplayIcon && <NoteIcon icon={entryIcon} editable />}
|
||||
<TitleField
|
||||
title={activeTab.entry.title}
|
||||
filename={activeTab.entry.filename}
|
||||
editable
|
||||
notePath={path}
|
||||
vaultPath={vaultPath}
|
||||
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="title-section__separator" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditorChrome({
|
||||
isArchived,
|
||||
onUnarchiveNote,
|
||||
@@ -208,52 +164,28 @@ function EditorChrome({
|
||||
function EditorCanvas({
|
||||
showEditor,
|
||||
cssVars,
|
||||
activeTab,
|
||||
entryIcon,
|
||||
hasDisplayIcon,
|
||||
path,
|
||||
showTitleSection,
|
||||
titleSectionRef,
|
||||
vaultPath,
|
||||
onTitleChange,
|
||||
editor,
|
||||
entries,
|
||||
onNavigateWikilink,
|
||||
onEditorChange,
|
||||
isDeletedPreview,
|
||||
vaultPath,
|
||||
}: Pick<
|
||||
EditorContentModel,
|
||||
| 'showEditor'
|
||||
| 'cssVars'
|
||||
| 'activeTab'
|
||||
| 'entryIcon'
|
||||
| 'hasDisplayIcon'
|
||||
| 'path'
|
||||
| 'showTitleSection'
|
||||
| 'titleSectionRef'
|
||||
| 'vaultPath'
|
||||
| 'onTitleChange'
|
||||
| 'editor'
|
||||
| 'entries'
|
||||
| 'onNavigateWikilink'
|
||||
| 'onEditorChange'
|
||||
| 'isDeletedPreview'
|
||||
| 'vaultPath'
|
||||
>) {
|
||||
if (!showEditor) return null
|
||||
|
||||
return (
|
||||
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
|
||||
<div className="editor-content-wrapper">
|
||||
<TitleSection
|
||||
activeTab={activeTab}
|
||||
entryIcon={entryIcon}
|
||||
hasDisplayIcon={hasDisplayIcon}
|
||||
path={path}
|
||||
showTitleSection={showTitleSection}
|
||||
titleSectionRef={titleSectionRef}
|
||||
vaultPath={vaultPath}
|
||||
onTitleChange={onTitleChange}
|
||||
/>
|
||||
<SingleEditorView
|
||||
editor={editor}
|
||||
entries={entries}
|
||||
@@ -288,12 +220,7 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
onKeepTheirs,
|
||||
breadcrumbBarRef,
|
||||
wordCount,
|
||||
titleSectionRef,
|
||||
showTitleSection,
|
||||
hasDisplayIcon,
|
||||
entryIcon,
|
||||
vaultPath,
|
||||
onTitleChange,
|
||||
cssVars,
|
||||
onNavigateWikilink,
|
||||
onEditorChange,
|
||||
@@ -333,6 +260,7 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
onDeleteNote: model.onDeleteNote,
|
||||
onArchiveNote: model.onArchiveNote,
|
||||
onUnarchiveNote: model.onUnarchiveNote,
|
||||
onRenameFilename: model.onRenameFilename,
|
||||
}}
|
||||
/>
|
||||
<EditorChrome
|
||||
@@ -358,14 +286,7 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
<EditorCanvas
|
||||
showEditor={showEditor}
|
||||
cssVars={cssVars}
|
||||
activeTab={activeTab}
|
||||
entryIcon={entryIcon}
|
||||
hasDisplayIcon={hasDisplayIcon}
|
||||
path={path}
|
||||
showTitleSection={showTitleSection}
|
||||
titleSectionRef={titleSectionRef}
|
||||
vaultPath={vaultPath}
|
||||
onTitleChange={onTitleChange}
|
||||
editor={editor}
|
||||
entries={entries}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
|
||||
@@ -46,47 +46,47 @@ function deriveState(tab: EditorContentTab | null, overrides?: Partial<VaultEntr
|
||||
}
|
||||
|
||||
describe('deriveEditorContentState', () => {
|
||||
it('hides the legacy title section when loaded content contains a top-level H1', () => {
|
||||
it('marks loaded content with a top-level H1 as titled', () => {
|
||||
const state = deriveState({
|
||||
entry: baseEntry,
|
||||
content: '---\ntitle: Legacy Project\n---\n# Legacy Project\n\nBody',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(true)
|
||||
expect(state.showTitleSection).toBe(false)
|
||||
expect(state.showEditor).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the title section for notes without an H1', () => {
|
||||
it('keeps editor content visible for notes without an H1', () => {
|
||||
const state = deriveState({
|
||||
entry: baseEntry,
|
||||
content: '---\ntitle: Legacy Project\n---\nBody without a heading',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(false)
|
||||
expect(state.showEditor).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the legacy title section when a frontmatter title drives the display title', () => {
|
||||
it('keeps editor content visible when a legacy frontmatter title exists', () => {
|
||||
const state = deriveState({
|
||||
entry: baseEntry,
|
||||
content: '---\ntitle: Spring 2026\nstatus: Active\n---\n## Goals',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(false)
|
||||
expect(state.showEditor).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the title section when the document title still comes from the filename', () => {
|
||||
it('does not fall back to a separate title section when the filename drives the display title', () => {
|
||||
const state = deriveState({
|
||||
entry: baseEntry,
|
||||
content: '---\nstatus: Active\n---\nBody without a heading',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(true)
|
||||
expect(state.showEditor).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the title section for untitled drafts before they get an H1', () => {
|
||||
it('keeps untitled drafts in the editor even before they get an H1', () => {
|
||||
const draftEntry = {
|
||||
...baseEntry,
|
||||
path: '/vault/untitled-note-1700000000.md',
|
||||
@@ -105,6 +105,6 @@ describe('deriveEditorContentState', () => {
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(false)
|
||||
expect(state.showEditor).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { NoteStatus, VaultEntry } from '../../types'
|
||||
import { contentDefinesDisplayTitle, extractH1TitleFromContent } from '../../utils/noteTitle'
|
||||
import { extractH1TitleFromContent } from '../../utils/noteTitle'
|
||||
import { countWords } from '../../utils/wikilinks'
|
||||
|
||||
export interface EditorContentTab {
|
||||
@@ -14,17 +14,11 @@ interface EditorContentStateInput {
|
||||
activeStatus: NoteStatus
|
||||
}
|
||||
|
||||
interface TitleSectionState {
|
||||
hasDisplayTitle: boolean
|
||||
hasH1: boolean
|
||||
}
|
||||
|
||||
interface VisibilityState {
|
||||
effectiveRawMode: boolean
|
||||
isDeletedPreview: boolean
|
||||
isNonMarkdownText: boolean
|
||||
showEditor: boolean
|
||||
showTitleSection: boolean
|
||||
}
|
||||
|
||||
export interface EditorContentState {
|
||||
@@ -35,7 +29,6 @@ export interface EditorContentState {
|
||||
isNonMarkdownText: boolean
|
||||
effectiveRawMode: boolean
|
||||
showEditor: boolean
|
||||
showTitleSection: boolean
|
||||
path: string
|
||||
wordCount: number
|
||||
}
|
||||
@@ -49,38 +42,18 @@ function contentHasTopLevelH1(activeTab: EditorContentTab | null): boolean {
|
||||
return activeTab ? extractH1TitleFromContent(activeTab.content) !== null : false
|
||||
}
|
||||
|
||||
function contentDefinesTitle(activeTab: EditorContentTab | null): boolean {
|
||||
return activeTab ? contentDefinesDisplayTitle(activeTab.content) : false
|
||||
}
|
||||
|
||||
function resolveHasH1(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): boolean {
|
||||
return contentHasTopLevelH1(activeTab) || freshEntry?.hasH1 === true || activeTab?.entry.hasH1 === true
|
||||
}
|
||||
|
||||
function resolveHasDisplayTitle(activeTab: EditorContentTab | null, hasH1: boolean): boolean {
|
||||
return hasH1 || contentDefinesTitle(activeTab)
|
||||
}
|
||||
|
||||
function deriveTitleSectionState(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): TitleSectionState {
|
||||
const hasH1 = resolveHasH1(activeTab, freshEntry)
|
||||
return {
|
||||
hasDisplayTitle: resolveHasDisplayTitle(activeTab, hasH1),
|
||||
hasH1,
|
||||
}
|
||||
}
|
||||
|
||||
function deriveVisibilityState(input: {
|
||||
activeStatus: NoteStatus
|
||||
activeTab: EditorContentTab | null
|
||||
freshEntry: VaultEntry | undefined
|
||||
hasDisplayTitle: boolean
|
||||
rawMode: boolean
|
||||
}): VisibilityState {
|
||||
const {
|
||||
activeStatus,
|
||||
activeTab,
|
||||
freshEntry,
|
||||
hasDisplayTitle,
|
||||
rawMode,
|
||||
} = input
|
||||
const isDeletedPreview = !!activeTab && !freshEntry
|
||||
@@ -92,36 +65,23 @@ function deriveVisibilityState(input: {
|
||||
isNonMarkdownText,
|
||||
effectiveRawMode,
|
||||
showEditor: !effectiveRawMode,
|
||||
showTitleSection: !isDeletedPreview && !hasDisplayTitle && !isUnsavedUntitledDraft(activeTab, activeStatus),
|
||||
}
|
||||
}
|
||||
|
||||
function isUnsavedUntitledDraft(activeTab: EditorContentTab | null, activeStatus: NoteStatus): boolean {
|
||||
if (!activeTab) return false
|
||||
if (!activeTab.entry.filename.startsWith('untitled-')) return false
|
||||
return activeStatus === 'new' || activeStatus === 'unsaved' || activeStatus === 'pendingSave'
|
||||
}
|
||||
|
||||
export function deriveEditorContentState({
|
||||
activeTab,
|
||||
entries,
|
||||
rawMode,
|
||||
activeStatus,
|
||||
}: EditorContentStateInput): EditorContentState {
|
||||
export function deriveEditorContentState(input: EditorContentStateInput): EditorContentState {
|
||||
const { activeTab, entries, rawMode } = input
|
||||
const freshEntry = findFreshEntry(activeTab, entries)
|
||||
const titleState = deriveTitleSectionState(activeTab, freshEntry)
|
||||
const hasH1 = resolveHasH1(activeTab, freshEntry)
|
||||
const visibilityState = deriveVisibilityState({
|
||||
activeStatus,
|
||||
activeTab,
|
||||
freshEntry,
|
||||
hasDisplayTitle: titleState.hasDisplayTitle,
|
||||
rawMode,
|
||||
})
|
||||
|
||||
return {
|
||||
freshEntry,
|
||||
isArchived: freshEntry?.archived ?? activeTab?.entry.archived ?? false,
|
||||
hasH1: titleState.hasH1,
|
||||
hasH1,
|
||||
...visibilityState,
|
||||
path: activeTab?.entry.path ?? '',
|
||||
wordCount: activeTab ? countWords(activeTab.content) : 0,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type React from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import type { NoteStatus, VaultEntry } from '../../types'
|
||||
import { useEditorTheme } from '../../hooks/useTheme'
|
||||
import { resolveNoteIcon } from '../../utils/noteIcon'
|
||||
import { deriveEditorContentState } from './editorContentState'
|
||||
|
||||
export interface Tab {
|
||||
@@ -39,7 +38,7 @@ export interface EditorContentProps {
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
rawLatestContentRef?: React.MutableRefObject<string | null>
|
||||
onTitleChange?: (path: string, newTitle: string) => void
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
isConflicted?: boolean
|
||||
onKeepMine?: (path: string) => void
|
||||
onKeepTheirs?: (path: string) => void
|
||||
@@ -50,7 +49,6 @@ export function useEditorContentModel(props: EditorContentProps) {
|
||||
activeTab,
|
||||
entries,
|
||||
rawMode,
|
||||
activeStatus,
|
||||
diffMode,
|
||||
} = props
|
||||
|
||||
@@ -62,42 +60,17 @@ export function useEditorContentModel(props: EditorContentProps) {
|
||||
effectiveRawMode,
|
||||
showEditor: showContentEditor,
|
||||
path,
|
||||
showTitleSection,
|
||||
wordCount,
|
||||
} = deriveEditorContentState({
|
||||
activeTab,
|
||||
entries,
|
||||
rawMode,
|
||||
activeStatus,
|
||||
activeStatus: props.activeStatus,
|
||||
})
|
||||
const showEditor = !diffMode && showContentEditor
|
||||
const entryIcon = activeTab?.entry.icon ?? null
|
||||
const hasDisplayIcon = resolveNoteIcon(entryIcon).kind !== 'none'
|
||||
|
||||
const titleSectionRef = useRef<HTMLDivElement | null>(null)
|
||||
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEditor) return
|
||||
|
||||
const bar = breadcrumbBarRef.current
|
||||
const titleSection = titleSectionRef.current
|
||||
if (!bar || !titleSection) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) bar.removeAttribute('data-title-hidden')
|
||||
else bar.setAttribute('data-title-hidden', '')
|
||||
},
|
||||
{ threshold: 0 },
|
||||
)
|
||||
observer.observe(titleSection)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
bar.removeAttribute('data-title-hidden')
|
||||
}
|
||||
}, [path, showEditor])
|
||||
|
||||
return {
|
||||
...props,
|
||||
cssVars,
|
||||
@@ -106,11 +79,7 @@ export function useEditorContentModel(props: EditorContentProps) {
|
||||
effectiveRawMode,
|
||||
forceRawMode: isNonMarkdownText || isDeletedPreview,
|
||||
showEditor,
|
||||
entryIcon,
|
||||
hasDisplayIcon,
|
||||
path,
|
||||
showTitleSection,
|
||||
titleSectionRef,
|
||||
breadcrumbBarRef,
|
||||
wordCount,
|
||||
}
|
||||
|
||||