1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
3870
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
3887
3888
3889
3890
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
3957
3958
3959
3960
3961
3962
3963
3964
3965
3966
3967
3968
3969
3970
3971
3972
3973
3974
3975
3976
3977
3978
3979
3980
3981
3982
3983
3984
3985
3986
3987
3988
3989
3990
3991
3992
3993
3994
3995
3996
3997
3998
3999
4000
4001
4002
4003
4004
4005
4006
4007
4008
4009
4010
4011
4012
4013
4014
4015
4016
4017
4018
4019
4020
4021
4022
4023
4024
4025
4026
4027
4028
4029
4030
4031
4032
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044
4045
4046
4047
4048
4049
4050
4051
4052
4053
4054
4055
4056
4057
4058
4059
4060
4061
4062
4063
4064
4065
4066
4067
4068
4069
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
4102
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
4133
4134
4135
4136
4137
4138
4139
4140
4141
4142
4143
4144
4145
4146
4147
4148
4149
4150
4151
4152
4153
4154
4155
4156
4157
4158
4159
4160
4161
4162
4163
4164
4165
4166
4167
4168
4169
4170
4171
4172
4173
4174
4175
4176
4177
4178
4179
4180
4181
4182
4183
4184
4185
4186
4187
4188
4189
4190
4191
4192
4193
4194
4195
4196
4197
4198
4199
4200
4201
4202
4203
4204
4205
4206
4207
4208
4209
4210
4211
4212
4213
4214
4215
4216
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
4248
4249
4250
4251
4252
4253
4254
4255
4256
4257
4258
4259
4260
4261
4262
4263
4264
4265
4266
4267
4268
4269
4270
4271
4272
4273
4274
4275
4276
4277
4278
4279
4280
4281
4282
4283
4284
4285
4286
4287
4288
4289
4290
4291
4292
4293
4294
4295
4296
4297
4298
4299
4300
4301
4302
4303
4304
4305
4306
4307
4308
4309
4310
4311
4312
4313
4314
4315
4316
4317
4318
4319
4320
4321
4322
4323
4324
4325
4326
4327
4328
4329
4330
4331
4332
4333
4334
4335
4336
4337
4338
4339
4340
4341
4342
4343
4344
4345
4346
4347
4348
4349
4350
4351
4352
4353
4354
4355
4356
4357
4358
4359
4360
4361
4362
4363
4364
4365
4366
4367
4368
4369
4370
4371
4372
4373
4374
4375
4376
4377
4378
4379
4380
4381
4382
4383
4384
4385
4386
4387
4388
4389
4390
4391
4392
4393
4394
4395
4396
4397
4398
4399
4400
4401
4402
4403
4404
4405
4406
4407
4408
4409
4410
4411
4412
4413
4414
4415
4416
4417
4418
4419
4420
4421
4422
4423
4424
4425
4426
4427
4428
4429
4430
4431
4432
4433
4434
4435
4436
4437
4438
4439
4440
4441
4442
4443
4444
4445
4446
4447
4448
4449
4450
4451
4452
4453
4454
4455
4456
4457
4458
4459
4460
4461
4462
4463
4464
4465
4466
4467
4468
4469
4470
4471
4472
4473
4474
4475
4476
4477
4478
4479
4480
4481
4482
4483
4484
4485
4486
4487
4488
4489
4490
4491
4492
4493
4494
4495
4496
4497
4498
4499
4500
4501
4502
4503
4504
4505
4506
4507
4508
4509
4510
4511
4512
4513
4514
4515
4516
4517
4518
4519
4520
4521
4522
4523
4524
4525
4526
4527
4528
4529
4530
4531
4532
4533
4534
4535
4536
4537
4538
4539
4540
4541
4542
4543
4544
4545
4546
4547
4548
4549
4550
4551
4552
4553
4554
4555
4556
4557
4558
4559
4560
4561
4562
4563
4564
4565
4566
4567
4568
4569
4570
4571
4572
4573
4574
4575
4576
4577
4578
4579
4580
4581
4582
4583
4584
4585
4586
4587
4588
4589
4590
4591
4592
4593
4594
4595
4596
4597
4598
4599
4600
4601
4602
4603
4604
4605
4606
4607
4608
4609
4610
4611
4612
4613
4614
4615
4616
4617
4618
4619
4620
4621
4622
4623
4624
4625
4626
4627
4628
4629
4630
4631
4632
4633
4634
4635
4636
4637
4638
4639
4640
4641
4642
4643
4644
4645
4646
4647
4648
4649
4650
4651
4652
4653
4654
4655
4656
4657
4658
4659
4660
4661
4662
4663
4664
4665
4666
4667
4668
4669
4670
4671
4672
4673
4674
4675
4676
4677
4678
4679
4680
4681
4682
4683
4684
4685
4686
4687
4688
4689
4690
4691
4692
4693
4694
4695
4696
4697
4698
4699
4700
4701
4702
4703
4704
4705
4706
4707
4708
4709
4710
4711
4712
4713
4714
4715
4716
4717
4718
4719
4720
4721
4722
4723
4724
4725
4726
4727
4728
4729
4730
4731
4732
4733
4734
4735
4736
4737
4738
4739
4740
4741
4742
4743
4744
4745
4746
4747
4748
4749
4750
4751
4752
4753
4754
4755
4756
4757
4758
4759
4760
4761
4762
4763
4764
4765
4766
4767
4768
4769
4770
4771
4772
4773
4774
4775
4776
4777
4778
4779
4780
4781
4782
4783
4784
4785
4786
4787
4788
4789
4790
4791
4792
4793
4794
4795
4796
4797
4798
4799
4800
4801
4802
4803
4804
4805
4806
4807
4808
4809
4810
4811
4812
4813
4814
4815
4816
4817
4818
4819
4820
4821
4822
4823
4824
4825
4826
4827
4828
4829
4830
4831
4832
4833
4834
4835
4836
4837
4838
4839
4840
4841
4842
4843
4844
4845
4846
4847
4848
4849
4850
4851
4852
4853
4854
4855
4856
4857
4858
4859
4860
4861
4862
4863
4864
4865
4866
4867
4868
4869
4870
4871
4872
4873
4874
4875
4876
4877
4878
4879
4880
4881
4882
4883
4884
4885
4886
4887
4888
4889
4890
4891
4892
4893
4894
4895
4896
4897
4898
4899
4900
4901
4902
4903
4904
4905
4906
4907
4908
4909
4910
4911
4912
4913
4914
4915
4916
4917
4918
4919
4920
4921
4922
4923
4924
4925
4926
4927
4928
4929
4930
4931
4932
4933
4934
4935
4936
4937
4938
4939
4940
4941
4942
4943
4944
4945
4946
4947
4948
4949
4950
4951
4952
4953
4954
4955
4956
4957
4958
4959
4960
4961
4962
4963
4964
4965
4966
4967
4968
4969
4970
4971
4972
4973
4974
4975
4976
4977
4978
4979
4980
4981
4982
4983
4984
4985
4986
4987
4988
4989
4990
4991
4992
4993
4994
4995
4996
4997
4998
4999
5000
5001
5002
5003
5004
5005
5006
5007
5008
5009
5010
5011
5012
5013
5014
5015
5016
5017
5018
5019
5020
5021
5022
5023
5024
5025
5026
5027
5028
5029
5030
5031
5032
5033
5034
5035
5036
5037
5038
5039
5040
5041
5042
5043
5044
5045
5046
5047
5048
5049
5050
5051
5052
5053
5054
5055
5056
5057
5058
5059
5060
5061
5062
|
/*
* qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
* Copyright (C) 2026 Danilo M. <danix@danix.xyz>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
#include "mainwindow.h"
#include <QAction>
#include <QApplication>
#include <QCloseEvent>
#include <QKeyEvent>
#include <QComboBox>
#include <QDialog>
#include <QDialogButtonBox>
#include <QDir>
#include <QFileInfo>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QIcon>
#include <QLabel>
#include <QLineEdit>
#include <QMenu>
#include <QMenuBar>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QProgressBar>
#include <QPushButton>
#include <QSettings>
#include <QSplitter>
#include <QStandardPaths>
#include <QStatusBar>
#include <QScrollBar>
#include <QTimer>
#include <QToolBar>
#include <QToolButton>
#include <QVBoxLayout>
#include "mailsync.h"
#include "messageview.h"
#include "mimeparser.h"
#include "notmuchworker.h"
#include "querycompleter.h"
#include "carddelegate.h"
#include "cardlayout.h"
#include "searchterm.h"
#include "tagchip.h"
#include "tagdialog.h"
#include "savequerydialog.h"
#include "tagrulesdialog.h"
#include "threadlistmodel.h"
#include "threadlistview.h"
#include "version.h"
QStringList MainWindow::registeredActionNames() const
{
// Derived from the actions themselves, so it cannot drift from what
// registerActions() really installed.
QStringList names = m_actions.keys();
names.sort();
return names;
}
QString MainWindow::cidPrefixForIndex(int index)
{
// "m<index>" is digits only after the 'm', so it cannot contain '!'.
return QStringLiteral("m%1").arg(index);
}
QString MainWindow::uiStatePath()
{
// GenericStateLocation, not StateLocation: the latter appends both the
// organization and the application name, and both are "qtmaildir" here,
// so it yields ~/.local/state/qtmaildir/qtmaildir. Built the same way
// Config::defaultPath() builds its own.
const QString base =
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation);
return base + QStringLiteral("/qtmaildir/uistate.conf");
}
namespace {
/// Overridden only by setLocksPathForTesting(); "/proc/locks" in every real run.
QString g_locksPath = QStringLiteral("/proc/locks");
} // namespace
void MainWindow::setLocksPathForTesting(const QString &path)
{
g_locksPath = path;
}
QString MainWindow::locksPath()
{
return g_locksPath;
}
/// The thread row containing an index: the index itself when it is already a
/// thread row, its parent when it is a message row.
///
/// Replaces the arithmetic on row numbers that a table permitted. In a tree a
/// row number only identifies a row within one parent, so "current.row() + 1"
/// means the next SIBLING, which under an expanded thread is the next reply.
QModelIndex MainWindow::threadRowOf(const QModelIndex &index) const
{
if (!index.isValid())
return {};
return index.parent().isValid() ? index.parent() : index;
}
/// Selects a whole row, the way QTableView::selectRow did.
///
/// QTreeView has no selectRow, and SelectRows on the selection model is not a
/// substitute: it governs what a click extends to, not what a programmatic
/// select() covers.
void MainWindow::selectRowAt(const QModelIndex &index)
{
if (!index.isValid())
return;
m_threadView->selectionModel()->select(
index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
m_threadView->setCurrentIndex(index);
}
/// Selects the top-level thread row at `row`.
void MainWindow::selectThreadRow(int row)
{
selectRowAt(m_model->index(row, 0, QModelIndex()));
}
void MainWindow::restoreUiState()
{
QSettings state(uiStatePath(), QSettings::IniFormat);
// Every restore is conditional: an absent or rejected blob must leave the
// buildUi() defaults alone rather than produce a zero-size window.
const QByteArray geometry = state.value(QStringLiteral("window/geometry"))
.toByteArray();
if (!geometry.isEmpty()) {
restoreGeometry(geometry);
}
const QByteArray windowState = state.value(QStringLiteral("window/state"))
.toByteArray();
if (!windowState.isEmpty()) {
restoreState(windowState);
}
const QByteArray splitter = state.value(QStringLiteral("window/splitter"))
.toByteArray();
if (!splitter.isEmpty()) {
m_splitter->restoreState(splitter);
}
// No thread-list header state is read. The pane is one column drawn whole
// by CardDelegate, so there are no widths to restore; a blob saved by an
// older version is simply ignored (item 53's Upgrading note).
// Range-guarded on read: a stale or hand-edited file can hold anything,
// and setCurrentIndex() on a value with no row silently selects nothing.
const int sort =
state.value(QStringLiteral("threadlist/sortOrder"), 0).toInt();
m_sortOrder->setCurrentIndex(sort == 1 ? 1 : 0);
// The config value is the starting point for a profile that has never
// zoomed; once the user does, the state file is what they last had.
// clampZoom() rejects the garbage a hand-edited file can hold.
m_messageView->setZoomFactor(
state.value(QStringLiteral("message/zoom"), m_config.messageZoom())
.toDouble());
}
void MainWindow::saveUiState() const
{
QDir().mkpath(QFileInfo(uiStatePath()).absolutePath());
QSettings state(uiStatePath(), QSettings::IniFormat);
state.setValue(QStringLiteral("window/geometry"), saveGeometry());
state.setValue(QStringLiteral("window/state"), saveState());
state.setValue(QStringLiteral("window/splitter"), m_splitter->saveState());
state.setValue(QStringLiteral("threadlist/sortOrder"),
m_sortOrder->currentIndex());
state.setValue(QStringLiteral("message/zoom"), m_messageView->zoomFactor());
}
void MainWindow::closeEvent(QCloseEvent *event)
{
// A sync started for exit is still running: hold the window open. Its
// finished signal closes us, and asking again here would stack prompts.
if (m_syncingForExit) {
event->ignore();
return;
}
if (!m_closeApproved && pendingEditCount() > 0
&& m_config.syncOnExit() != Config::SyncOnExit::Never) {
// Not a destructive-action confirmation, which CLAUDE.md forbids for
// tag mutations. Those get undo instead. This asks about LOSING work at
// the one point where undo cannot help, which is the opposite case.
const bool canSync = m_sync && m_sync->isAvailable();
if (!canSync) {
// Degrade to a warning rather than offering a sync that cannot run.
const auto answer = QMessageBox::warning(
this, tr("Unsynced changes"),
tr("%n change(s) have not been synced, and no sync command "
"is configured. Quit anyway?", "", pendingEditCount()),
QMessageBox::Discard | QMessageBox::Cancel,
QMessageBox::Cancel);
if (answer == QMessageBox::Cancel) {
event->ignore();
return;
}
} else if (m_config.syncOnExit() == Config::SyncOnExit::Ask) {
// Three buttons, not two: a user who hit Quit by mistake needs a
// way back that is not "sync".
QMessageBox box(this);
box.setIcon(QMessageBox::Question);
box.setWindowTitle(tr("Unsynced changes"));
box.setText(tr("%n change(s) have not been synced.", "",
pendingEditCount()));
box.setInformativeText(tr("Sync before quitting?"));
QPushButton *sync =
box.addButton(tr("Sync and quit"), QMessageBox::AcceptRole);
QPushButton *quit =
box.addButton(tr("Quit anyway"), QMessageBox::DestructiveRole);
box.addButton(QMessageBox::Cancel);
box.setDefaultButton(sync);
// The default is set correctly and Qt agrees (isDefault() and
// hasFocus() are both true on it), but qt6ct-style draws no
// visible default-button decoration, so Enter's target is
// invisible on this desktop. Naming it in the text costs nothing
// and does not fight the theme.
// ponytail: text, not a styled button. Restyling the button means
// overriding the user's theme, which is worse than a sentence.
sync->setText(tr("Sync and quit (default)"));
box.exec();
if (box.clickedButton() == sync) {
if (m_sync->start(pendingSyncChannels())) {
m_syncingForExit = true;
m_syncLog->clear();
setSyncBusy(true);
m_statusLabel->setText(tr("Syncing before quitting..."));
event->ignore();
return;
}
// Could not start after all: say so and stay, rather than
// quitting as though the sync had happened.
QMessageBox::warning(this, tr("Sync failed"),
tr("The sync could not be started, so "
"your changes are still unsynced."));
event->ignore();
return;
}
if (box.clickedButton() != quit) {
event->ignore(); // Cancel, or the dialog was dismissed.
return;
}
} else if (m_config.syncOnExit() == Config::SyncOnExit::Always) {
if (m_sync->start(pendingSyncChannels())) {
m_syncingForExit = true;
m_syncLog->clear();
setSyncBusy(true);
m_statusLabel->setText(tr("Syncing before quitting..."));
event->ignore();
return;
}
QMessageBox::warning(this, tr("Sync failed"),
tr("The sync could not be started, so your "
"changes are still unsynced."));
event->ignore();
return;
}
}
saveUiState();
QMainWindow::closeEvent(event);
}
bool MainWindow::eventFilter(QObject *watched, QEvent *event)
{
// Return is bound to open_thread as a WindowShortcut. A shortcut is
// dispatched before the focused widget sees the key, and Qt's protection
// for editable widgets covers plain LETTERS only, so from inside the query
// bar Return triggered the action, focus jumped to the thread list, and the
// query was never run.
//
// Accepting the ShortcutOverride tells Qt the focused widget wants this key
// as ordinary input, which stops the shortcut from being dispatched at all;
// QLineEdit then emits returnPressed as usual. Narrow on purpose: one
// widget, one key, so open_thread keeps working everywhere else.
if (watched == m_queryEdit && event->type() == QEvent::ShortcutOverride) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Return
|| keyEvent->key() == Qt::Key_Enter) {
keyEvent->accept();
return true;
}
// Delete needs NO entry here, and that is worth stating because the
// reasoning that says it does is nearly right. It is bound bare to
// `delete`, and Qt's protection for editable widgets covers plain
// LETTERS only, so by the same argument that made Return a problem it
// should trigger the action while the user edits a query.
//
// It does not, because QLineEdit accepts the ShortcutOverride for
// Delete itself: Delete is one of its own editing keys, which Return
// is not. Measured both ways, with this branch present and absent:
// the action fires 0 times either way and the text is edited either
// way. Adding a guard here would be dead code carrying a test that
// cannot fail.
}
return QMainWindow::eventFilter(watched, event);
}
MainWindow::MainWindow(const Config &config, QWidget *parent)
: QMainWindow(parent), m_config(config)
{
qRegisterMetaType<ThreadSummary>();
qRegisterMetaType<MessageRef>();
qRegisterMetaType<TagChange>();
qRegisterMetaType<DatabaseStats>();
qRegisterMetaType<MessageNode>();
qRegisterMetaType<QVector<ThreadSummary>>();
qRegisterMetaType<QVector<MessageRef>>();
qRegisterMetaType<QVector<MessageNode>>();
m_keyMap.loadDefaults();
{
QSettings settings(Config::defaultPath(), QSettings::IniFormat);
m_keyMap.loadOverrides(settings);
m_tagColors.load(settings);
}
// An account's chip colour comes from its own stanza, since an account tag
// is a different taxonomy from a functional one.
for (const Account &account : m_config.accounts()) {
m_tagColors.setAccountColour(account.key, account.color);
m_tagColors.setAccountLabel(account.key, account.label);
}
buildUi();
registerActions();
// After registerActions(), not inside buildUi(): the query bar exists by
// then but the action does not, so wiring this where the field is built
// silently connected nothing and left Save query enabled on an empty
// query. Hung on textChanged rather than textEdited, because the field is
// also set programmatically, by the saved-query buttons and by
// recoverStaleThread(), and the action must track those too.
if (QAction *save = m_actions.value(QStringLiteral("save_query"))) {
// setDefaultAction, not a second connect: the button then takes the
// action's text, icon, tooltip and ENABLED state, so it cannot end up
// offering to save an empty query while the menu entry refuses.
m_saveQueryButton->setDefaultAction(save);
// Icon AND text, unlike the toolbar, which follows the desktop's
// button style. This button sits in a row of text buttons, the saved
// queries, and an icon on its own next to them reads as a different
// kind of control than it is. It is also the one action whose meaning
// an icon alone does not carry: "save" is a shape everyone knows and
// the question is always "save WHAT".
m_saveQueryButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
// Its own text, not the action's: "&Save query..." is menu phrasing,
// and a button rendering the ampersand's accelerator and the ellipsis
// that promises a dialog reads as a menu entry that escaped. The
// action keeps both for the menu it lives in.
m_saveQueryButton->setText(tr("Save"));
auto updateQueryState = [this, save]() {
const bool hasQuery = !m_queryEdit->text().trimmed().isEmpty();
save->setEnabled(hasQuery);
// The message pane greys "Exclude from search" without it: there
// would be nothing to exclude FROM. Both widgets exist by now,
// buildUi() having run before registerActions().
m_messageView->setHasQuery(hasQuery);
};
connect(m_queryEdit, &QLineEdit::textChanged, this, updateQueryState);
updateQueryState();
}
buildMenus();
// After buildMenus(): QMainWindow::restoreState() matches toolbars by
// object name, so they must already exist or their position is dropped.
restoreUiState();
wireWorker();
// Sets the status label only. The modal that used to live here is raised
// by the caller after show(), because a modal in a constructor cannot be
// dismissed under the offscreen platform and hung the whole suite.
applyWarnings();
// No window-wide event filter: QAction shortcuts are dispatched before the
// focused widget sees the key, so they beat QAbstractItemView's
// type-to-search without one. Qt also suppresses a plain-letter shortcut
// while an editable widget has focus, so typing in the query bar stays
// typing; modifier shortcuts such as Ctrl+Q still work there, which the old
// filter blocked.
//
// That letter rule does NOT cover Return, which is bound to open_thread:
// it reached the action from inside the query bar and stole the key. The
// narrow filter buildUi() installs on the query bar claims it back. See
// eventFilter().
// Not savedQueries().first(): [queries] is read through childKeys(), which
// sorts alphabetically, so "first" means whatever happens to sort first
// rather than anything the user chose. Config resolves the name.
// BEFORE the startup query runs, so the query below is composed in this
// scope. That is the whole of `startup_account`: a built-in filter composes
// with the dropdown, so setting the dropdown is all that is needed and the
// key never reaches a query builder.
//
// Config has already checked the key names a real account and cleared it if
// not, so findData either matches or this is "All accounts" anyway.
const QString startupAccount = m_config.startupAccount();
if (!startupAccount.isEmpty()) {
const int index = m_accountBox->findData(startupAccount);
if (index >= 0)
m_accountBox->setCurrentIndex(index);
}
// resolvedQuery(), not startup.query: a generated entry stores no query at
// all, since its text is composed from the accounts at run time. Reading
// the field directly meant a startup_query naming a built-in filter opened
// an empty bar and ran nothing.
const SavedQuery startup = m_config.startupSavedQuery();
const QString startupQuery =
m_config.resolvedQuery(startup, startupAccount);
if (!startupQuery.isEmpty()) {
m_queryEdit->setText(startupQuery);
// Which of the two applies the scope depends on what the startup entry
// IS, and getting this wrong is silent in both directions.
//
// A generated filter came back from resolvedQuery() already scoped to
// the startup account, so applying the dropdown again gives
// path:"work/**" and (path:"work/**" and (tag:inbox))
// which returns exactly the right rows while being the double scope
// this item exists to avoid.
//
// A saved query did NOT: resolvedQuery() ignores the account key for
// one, because a saved query states its own scope. Claiming it was
// already scoped leaves it unscoped for good, with the dropdown sitting
// on Work and the list showing every account.
runQuery(FlatResult::No,
startup.isGenerated() ? AccountScope::AlreadyScoped
: AccountScope::Apply);
}
}
MainWindow::~MainWindow()
{
m_workerThread.quit();
m_workerThread.wait();
}
void MainWindow::buildUi()
{
auto *central = new QWidget(this);
auto *layout = new QVBoxLayout(central);
// The status label is created first: the sync wiring below can report into
// it before the rest of the UI exists.
m_statusLabel = new QLabel(this);
m_statusLabel->setObjectName(QStringLiteral("statusMessage"));
statusBar()->addWidget(m_statusLabel);
// Transient messages describe an EVENT and go stale: "Sync complete" reads
// as the present tense until something else overwrites it. State messages,
// the selection count above all, describe what is true right now and must
// not expire while it stays true, so only showTransientStatus() arms this.
//
// ponytail: one timer beside the label, not QStatusBar::showMessage().
// That would mean moving off addWidget() and reworking the permanent
// widgets beside it, for the same behaviour.
m_statusTimer = new QTimer(this);
m_statusTimer->setObjectName(QStringLiteral("statusTimer"));
m_statusTimer->setSingleShot(true);
m_statusTimer->setInterval(kStatusMessageMs);
connect(m_statusTimer, &QTimer::timeout, this, [this]() {
// Only take back a message this timer armed. Anything written since is
// newer and more relevant than the default.
if (m_statusLabel->text() == m_transientMessage)
m_statusLabel->setText(m_defaultStatus);
m_transientMessage.clear();
});
// Beside the sync status rather than as a widget competing with it: the two
// say related things and reading them apart would be worse than reading
// them together.
m_pendingLabel = new QLabel(this);
m_pendingLabel->setObjectName(QStringLiteral("pendingEdits"));
m_pendingLabel->hide();
statusBar()->addPermanentWidget(m_pendingLabel);
// Indeterminate: setRange(0, 0). A sync has no measurable progress, since
// mbsync reports no percentage and the script's output is unstructured, so
// a bar filling left to right would be inventing a fraction. This one
// animates to say "working, duration unknown".
m_syncProgress = new QProgressBar(this);
m_syncProgress->setObjectName(QStringLiteral("syncProgress"));
m_syncProgress->setRange(0, 0);
m_syncProgress->setTextVisible(false);
m_syncProgress->setMaximumWidth(120);
m_syncProgress->hide();
statusBar()->addPermanentWidget(m_syncProgress);
// Query row.
auto *queryRow = new QHBoxLayout;
m_accountBox = new QComboBox(central);
m_accountBox->setObjectName(QStringLiteral("accountBox"));
m_accountBox->addItem(tr("All accounts"), QString());
for (const Account &account : m_config.accounts()) {
m_accountBox->addItem(account.key, account.key);
// The RAW account colour here, not CardDelegate's blended line colour:
// a swatch is a filled patch like a chip, not a thin line, so it wants
// the colour the account was actually given. Qt renders a
// DecorationRole colour as a swatch itself, with no delegate.
//
// This is what makes the accent bar on a card mean anything: a colour
// down a card's edge says nothing until something maps it to a name.
m_accountBox->setItemData(
m_accountBox->count() - 1,
m_tagColors.colourFor(TagColors::tagForAccountKey(account.key)),
Qt::DecorationRole);
}
// Sort order. Two entries, straight to notmuch: this ADDS a feature rather
// than replacing one, since the old column header was decorative and
// nothing implemented click-to-sort.
m_sortOrder = new QComboBox(central);
m_sortOrder->setObjectName(QStringLiteral("sortOrder"));
// Order matters: the index is what uistate.conf stores.
m_sortOrder->addItem(tr("Newest first"));
m_sortOrder->addItem(tr("Oldest first"));
m_sortOrder->setToolTip(tr("The order threads are listed in"));
connect(m_sortOrder, &QComboBox::currentIndexChanged,
this, &MainWindow::runCurrentQuery);
m_queryEdit = new QLineEdit(central);
m_queryEdit->setObjectName(QStringLiteral("queryEdit"));
m_queryEdit->setPlaceholderText(tr("notmuch query, e.g. tag:inbox"));
// Qt draws the clear button inside the field and shows it only when there
// is text, themed by the desktop. A hand-rolled button beside the bar would
// read as "Search" and duplicate Return, which is how item 45 started.
m_queryEdit->setClearButtonEnabled(true);
connect(m_queryEdit, &QLineEdit::returnPressed,
this, &MainWindow::runCurrentQuery);
// Return is bound to open_thread as a WindowShortcut, and a shortcut is
// dispatched before the focused widget sees the key. Qt withholds a plain
// LETTER shortcut from an editable widget, which is why every other binding
// here is safe, but Return is not a letter and gets no such protection: it
// reached the action, focus jumped to the thread list, and the query never
// ran. Accepting the ShortcutOverride is what claims the key back, and it
// is scoped to the one widget and the one key, so open_thread still works
// everywhere else in the window.
m_queryEdit->installEventFilter(this);
m_queryCompleter = new QueryCompleter(m_queryEdit, m_config, this);
m_markReadTimer = new QTimer(this);
// Named so a test can observe whether it is armed without the window
// having to expose the timer or the decision that armed it.
m_markReadTimer->setObjectName(QStringLiteral("markReadTimer"));
m_markReadTimer->setSingleShot(true);
connect(m_markReadTimer, &QTimer::timeout,
this, &MainWindow::markCurrentThreadRead);
m_autoSyncTimer = new QTimer(this);
// Named for the same reason: a test can assert that an edit armed the
// debounce without waiting out the delay or starting a real mbsync.
m_autoSyncTimer->setObjectName(QStringLiteral("autoSyncTimer"));
m_autoSyncTimer->setSingleShot(true);
connect(m_autoSyncTimer, &QTimer::timeout, this, &MainWindow::runAutoSync);
// The pane and its close button travel together: a QPlainTextEdit has
// nowhere to put one, and a pane that appears on a failed sync and can
// never be dismissed is worse than one that does not appear at all.
m_syncLogPane = new QWidget(central);
m_syncLogPane->setObjectName(QStringLiteral("syncLogPane"));
auto *syncLogLayout = new QVBoxLayout(m_syncLogPane);
syncLogLayout->setContentsMargins(0, 0, 0, 0);
syncLogLayout->setSpacing(2);
auto *syncLogHeader = new QHBoxLayout;
syncLogHeader->addWidget(new QLabel(tr("Sync output"), m_syncLogPane));
syncLogHeader->addStretch();
auto *closeSyncLog = new QPushButton(tr("Close"), m_syncLogPane);
closeSyncLog->setObjectName(QStringLiteral("closeSyncLog"));
closeSyncLog->setToolTip(tr("Hide the sync output until the next failure"));
connect(closeSyncLog, &QPushButton::clicked,
m_syncLogPane, &QWidget::hide);
syncLogHeader->addWidget(closeSyncLog);
syncLogLayout->addLayout(syncLogHeader);
m_syncLog = new QPlainTextEdit(m_syncLogPane);
m_syncLog->setReadOnly(true);
// 200 rather than 120: mbsync's output is wide and repetitive, and the
// shorter pane showed too little of it to read.
m_syncLog->setMaximumHeight(200);
syncLogLayout->addWidget(m_syncLog);
m_syncLogPane->hide();
// Sync is reached from the toolbar, the File menu and the shortcut, all of
// them one QAction. A second QPushButton sat beside the query bar until
// 0.9.x, where it read as a Search button given what it stood next to, and
// carried behaviour the action did not: item 45.
m_sync = new MailSync(m_config.syncCommand(), this);
connect(m_sync, &MailSync::finished, this, &MainWindow::onSyncFinished);
connect(m_sync, &MailSync::outputReceived, this, [this](const QString &chunk) {
m_syncLog->appendPlainText(chunk.trimmed());
feedSyncPhase(chunk);
});
// Syncs this window did not start. The user's cron runs the same script
// every ten minutes, so mail arrives and tags change while the window sits
// idle, and until now nothing here noticed.
m_syncMonitor = new SyncMonitor(SyncMonitor::defaultLockPath(),
locksPath(), this);
connect(m_syncMonitor, &SyncMonitor::stateChanged,
this, &MainWindow::onExternalSyncStateChanged);
m_syncMonitor->start();
// The query row proper: account, sort order, the field. The saved queries
// used to share it and now have a row of their own below, which is what
// stops an unbounded list squeezing the field (item 23; the ponytail note
// that stood here predicted exactly this).
queryRow->addWidget(m_accountBox);
queryRow->addWidget(m_sortOrder);
queryRow->addWidget(m_queryEdit, 1);
// Beside the field, where a user looks for it. The menu entry and Ctrl+S
// were not enough on their own: saving is a thing you decide on while
// looking at the results, so it needs to be visible at the query bar
// rather than remembered. Created here and given its action in the
// constructor, since registerActions() has not run yet.
m_saveQueryButton = new QToolButton(central);
m_saveQueryButton->setObjectName(QStringLiteral("saveQueryButton"));
queryRow->addWidget(m_saveQueryButton);
layout->addLayout(queryRow);
buildSavedQueryRow(central, layout);
// Thread list and message pane.
m_model = new ThreadListModel(this);
m_model->setTagColors(&m_tagColors);
m_model->setDateFormat(m_config.dateFormat());
// ThreadListView, not a plain QTableView: it paints the row-wide tag
// strip under each row's cells, which no delegate can do because a
// delegate is confined to one column's rectangle.
m_threadView = new ThreadListView(central);
m_threadView->setModel(m_model);
m_threadView->setItemDelegate(new CardDelegate(this));
m_threadView->setHeaderHidden(true);
m_threadView->setSelectionBehavior(QAbstractItemView::SelectRows);
m_threadView->setSelectionMode(QAbstractItemView::ExtendedSelection);
// No style-drawn branch decoration. CardDelegate draws the expander itself,
// because drawBranches runs BEFORE the row's cells and the delegate's own
// background paints straight over anything put there: a 60-pixel triangle
// once survived as 8. Leaving both enabled would draw the theme's dot
// underneath the delegate's glyph.
m_threadView->setRootIsDecorated(false);
// Zero, because CardLayout draws the indent itself. Qt's own indentation
// would shift the card's rect, and every rect on the card is measured from
// that rect's left edge, so the two would compound.
m_threadView->setIndentation(0);
// One height for every row. A QTreeView has no vertical header to carry a
// default section size, so the height comes from uniformRowHeights plus
// CardDelegate::sizeHint.
m_threadView->setUniformRowHeights(true);
// Banding, so the eye can follow a card across the pane. The colour comes
// from the palette's AlternateBase, so it follows the desktop theme.
m_threadView->setAlternatingRowColors(true);
// A card is exactly viewport width, so there is nothing to scroll to
// sideways. Turning the bar off is what closes item 51: a click used to
// scroll the list horizontally, because the subject column was wider than
// the viewport and auto-scroll brought the clicked index fully into view.
m_threadView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
// Scrolling a whole card at a time rather than a fraction of one, so a
// card is never left half above the top edge.
m_threadView->verticalScrollBar()->setSingleStep(
CardLayout::heightFor(m_threadView->font()));
// Replies are loaded when a thread is expanded, not with the query.
// Walking the reply tree of every thread in a 10k-thread result would cost
// far more than the query itself and almost none of it would be looked at.
connect(m_threadView, &QTreeView::expanded,
this, &MainWindow::onThreadExpanded);
connect(m_threadView->selectionModel(),
&QItemSelectionModel::currentRowChanged,
this, &MainWindow::onThreadSelected);
// Separate from currentRowChanged: a selection can grow without current
// moving at all. Ctrl+click adds a row and leaves current where it was, and
// selectAll() emits no currentRowChanged whatsoever (verified against
// Qt 6.11). Both are multi-select gestures that have to blank the pane and
// cancel a pending mark-read, so neither can rely on the current-index
// signal to notice them.
connect(m_threadView->selectionModel(),
&QItemSelectionModel::selectionChanged,
this, &MainWindow::onSelectionChanged);
connect(m_threadView, &QAbstractItemView::doubleClicked,
this, &MainWindow::onRowDoubleClicked);
m_messageView = new MessageView(central);
m_messageView->setTagColors(&m_tagColors);
connect(m_messageView, &MessageView::statusMessage,
this, [this](const QString &text) { m_statusLabel->setText(text); });
connect(m_messageView, &MessageView::queryRequested,
this, &MainWindow::onPlaceholderQueryRequested);
connect(m_messageView, &MessageView::staleThreadRecoveryRequested,
this, &MainWindow::recoverStaleThread);
connect(m_messageView, &MessageView::searchRequested,
this, &MainWindow::runSearchFromPane);
m_splitter = new QSplitter(Qt::Horizontal, central);
m_splitter->addWidget(m_threadView);
m_splitter->addWidget(m_messageView);
m_splitter->setStretchFactor(1, 2);
// A splitter position is saved in PIXELS, so one saved in a wide window
// does not fit a narrower one: QSplitter restores the first pane's size
// verbatim and gives the second whatever is left. A real 1285/1252 split
// restored into a 1136px window left the message pane 29px wide, a sliver
// of rendered mail beside a full-width thread list. A floor on the pane
// covers that and the equivalent drag, and needs no restore-time repair.
// Only the message pane: a minimum on the thread view as well would leave
// a narrow window unable to satisfy either, the same fault from the other
// side.
m_splitter->setCollapsible(1, false);
m_messageView->setMinimumWidth(kMinMessagePaneWidth);
layout->addWidget(m_splitter, 1);
layout->addWidget(m_syncLogPane);
setCentralWidget(central);
resize(1200, 800);
setWindowTitle(QStringLiteral("qtmaildir %1").arg(QTMAILDIR_VERSION));
}
QAction *MainWindow::addAction(const QString &name, const QString &text,
const QString &description,
const std::function<void()> &handler)
{
auto *action = new QAction(text, this);
action->setObjectName(name);
action->setStatusTip(description);
m_actionDescriptions.insert(name, description);
// The binding comes from KeyMap, so a [keys] override reaches the menus
// and the shortcut reference as well as the keyboard.
// Plural: an action can carry more than one binding, and setShortcut()
// keeps only the last one given. next_thread has both Ctrl+J and Alt+Down.
const QList<QKeySequence> sequences = m_keyMap.sequencesFor(name);
if (!sequences.isEmpty())
action->setShortcuts(sequences);
// Shortcuts must work while focus is in the thread list or the message
// view, not only on the window itself.
action->setShortcutContext(Qt::WindowShortcut);
connect(action, &QAction::triggered, this, handler);
// Added to the window so the shortcut is live even before the action is
// put in a menu; the ones that never reach a menu depend on this.
QMainWindow::addAction(action);
m_actions.insert(name, action);
return action;
}
void MainWindow::registerActions()
{
addAction(QStringLiteral("focus_query"), tr("&Find"),
tr("Focus and select the query bar"), [this]() {
m_queryEdit->setFocus();
m_queryEdit->selectAll();
});
addAction(QStringLiteral("next_thread"), tr("&Next thread"),
tr("Select the next thread"), [this]() {
// Walked by INDEX, never by row number. A tree numbers rows per
// parent, so current.row() + 1 names a SIBLING: from the last reply of
// an expanded thread it asks for a row that does not exist, and from a
// thread row it counts top-level threads only by accident (item 60).
//
// The skip loop is what keeps this meaning thread-to-thread while the
// view's own Up/Down still steps message-to-message.
QModelIndex index = m_threadView->indexBelow(
m_threadView->currentIndex());
while (index.isValid()
&& index.data(ThreadListModel::IsMessageRole).toBool()) {
index = m_threadView->indexBelow(index);
}
if (index.isValid())
selectRowAt(index);
});
addAction(QStringLiteral("prev_thread"), tr("&Previous thread"),
tr("Select the previous thread"), [this]() {
QModelIndex index = m_threadView->indexAbove(
m_threadView->currentIndex());
while (index.isValid()
&& index.data(ThreadListModel::IsMessageRole).toBool()) {
index = m_threadView->indexAbove(index);
}
if (index.isValid())
selectRowAt(index);
});
addAction(QStringLiteral("open_thread"), tr("&Open thread"),
tr("Focus the thread list"), [this]() {
m_threadView->setFocus();
});
addAction(QStringLiteral("archive"), tr("&Archive"),
tr("Remove inbox from every selected thread"), [this]() {
tagSelected({}, { QStringLiteral("inbox") }, tr("Archive"));
});
addAction(QStringLiteral("delete"), tr("&Delete"),
tr("Add or remove the deleted tag"), [this]() {
// A toggle, like toggle_unread: pressing Delete twice is the natural
// way to say "no, put it back", and adding a tag that is already there
// is a no-op the user cannot see.
//
// One direction for the WHOLE selection. Toggling each thread
// independently would leave one keystroke with the selection in two
// states, which is worse than either outcome, so undelete only when
// every selected thread is already deleted.
//
// Each row's own state, message or thread: a reply row is asked about
// the MESSAGE it stands for. Asking its thread made Delete one-way on
// a reply, since a message-scoped write never changes the thread's
// tags and the answer therefore stayed "not deleted" however many
// times it was pressed. Item 88 fixed which thread was read here; this
// is about reading a message at all.
const bool allDeleted = everySelectedRowHasTag(QStringLiteral("deleted"));
// Item 103. A MOVE now, not only a tag: Delete used to add `deleted`
// and leave the file exactly where it was, so deleted mail sat in the
// inbox indefinitely and only the chip said otherwise.
if (allDeleted)
restoreSelected();
else
trashSelected();
});
addAction(QStringLiteral("restore"), tr("&Restore from trash"),
tr("Move the selected messages out of the trash"), [this]() {
restoreSelectedFromTrash();
});
addAction(QStringLiteral("cleanup_stranded"),
tr("Find &stranded deleted mail"),
tr("Show mail tagged deleted that is not in a trash folder"),
[this]() {
showStrandedDeletedMail();
});
addAction(QStringLiteral("spam"), tr("Mark &spam"),
tr("Add spam and remove inbox"), [this]() {
tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") },
tr("Mark spam"));
});
// Item 57. The LABEL is "Important"; the action name and the tag are both
// still `flag`/`flagged`, deliberately. The name is what a user writes in
// the config's [keys] section, and `flagged` is a notmuch tag that neomutt,
// the user's saved queries and ThreadSummary::isFlagged() all read. Only
// the wording the user sees changes.
//
// &I rather than &S: the Message menu already has "Mark &spam", so
// "Starred" would have needed an accelerator from inside the word.
addAction(QStringLiteral("flag"), tr("&Important"),
tr("Add or remove the important tag"), [this]() {
// Item 98. A toggle, like Delete and Toggle unread beside it: adding a
// tag that is already there is a no-op the user cannot see, so a
// one-way add read as a dead key on anything already important.
//
// everySelectedRowHasTag() rather than a loop of its own. Two separate
// bugs went into that logic on 2026-08-16 (items 88 and 105), and a
// copy of the then-current Delete loop would have inherited both:
// resolving a reply's row number to the wrong thread, and asking a
// reply's THREAD where the write is message-scoped, which makes a
// toggle one-way.
const bool allFlagged = everySelectedRowHasTag(QStringLiteral("flagged"));
if (allFlagged)
tagSelected({}, { QStringLiteral("flagged") }, tr("Unmark important"));
else
tagSelected({ QStringLiteral("flagged") }, {}, tr("Mark important"));
});
addAction(QStringLiteral("toggle_unread"), tr("Toggle &unread"),
tr("Toggle the unread tag"), [this]() {
// The state of whatever the rows STAND FOR, which for a reply is the
// message and not its thread. See everySelectedRowHasTag(): reading
// the thread here made the key dead on a reply.
//
// Item 88 fixed WHICH thread this read. That was necessary and not
// sufficient: a reply needs a message read, not a better thread.
//
// Per selection rather than per current row, matching Delete. The old
// comment said the direction came from the current row while the
// change applied to the whole selection, which is the same split that
// makes a mixed selection land in two states.
const bool unread = everySelectedRowHasTag(QStringLiteral("unread"));
// An explicit toggle overrides the automatic one. Without this, marking
// a thread unread by hand would be undone a moment later by a timer
// armed when it was opened, and the key would look broken.
m_markReadTimer->stop();
m_markReadMessageId.clear();
if (unread)
tagSelected({}, { QStringLiteral("unread") }, tr("Mark read"));
else
tagSelected({ QStringLiteral("unread") }, {}, tr("Mark unread"));
});
addAction(QStringLiteral("mark_all_read"), tr("Mark all &read"),
tr("Remove the unread tag from every thread in this view"),
[this]() {
markAllRead();
});
addAction(QStringLiteral("edit_tags"), tr("Edit &tags..."),
tr("Add or remove any tag on the selected threads"), [this]() {
editTagsOnSelection();
});
// The whole-thread counterparts (item 108). Separate action NAMES, because
// a name is what a user writes in [keys]: reusing `delete` with new
// semantics would silently change what an existing config does, and
// renaming it would break one that mentions it. These are unbound by
// default; the submenu is how they are reached.
//
// Each one is its message-scoped twin with TagScope::Thread, so the two
// cannot drift in what they write, only in what they write it to.
addAction(QStringLiteral("archive_thread"), tr("&Archive thread"),
tr("Remove inbox from every message of the selected threads"),
[this]() {
tagSelected({}, { QStringLiteral("inbox") }, tr("Archive thread"),
TagScope::Thread);
});
addAction(QStringLiteral("delete_thread"), tr("&Delete thread"),
tr("Add or remove the deleted tag on whole threads"), [this]() {
// A MOVE now, like its message-scoped twin. It tagged and moved
// nothing until item 103's follow-up, so "Delete thread" left a whole
// conversation sitting in the inbox wearing a `deleted` chip: exactly
// the half-deleted state Delete stopped producing.
//
// The direction is read per MESSAGE, not from the thread's tag union.
// A thread whose root was deleted on its own carries `deleted` in the
// union while its replies do not, and asking the union there ran
// Delete a second time on messages already in the trash.
if (everySelectedRowHasTag(QStringLiteral("deleted"), TagScope::Thread)) {
restoreSelectedThreads();
} else {
trashSelectedThreads();
}
});
addAction(QStringLiteral("spam_thread"), tr("Mark thread as &spam"),
tr("Add spam and remove inbox on whole threads"), [this]() {
tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") },
tr("Mark thread spam"), TagScope::Thread);
});
addAction(QStringLiteral("toggle_unread_thread"), tr("Toggle &unread"),
tr("Toggle the unread tag on whole threads"), [this]() {
// Cancels the automatic mark-read for the same reason its
// message-scoped twin does: a thread marked unread by hand must not be
// undone a moment later by a timer armed when it was opened.
m_markReadTimer->stop();
m_markReadMessageId.clear();
if (everySelectedRowHasTag(QStringLiteral("unread"), TagScope::Thread)) {
tagSelected({}, { QStringLiteral("unread") },
tr("Mark thread read"), TagScope::Thread);
} else {
tagSelected({ QStringLiteral("unread") }, {},
tr("Mark thread unread"), TagScope::Thread);
}
});
addAction(QStringLiteral("flag_thread"), tr("&Important"),
tr("Mark every message of the selected threads as important"),
[this]() {
tagSelected({ QStringLiteral("flagged") }, {},
tr("Mark thread important"), TagScope::Thread);
});
addAction(QStringLiteral("tag_rules"), tr("Tagging &rules..."),
tr("Edit the rules that tag mail as it arrives"), [this]() {
showTagRulesDialog();
});
addAction(QStringLiteral("save_query"), tr("&Save query..."),
tr("Keep the current query as a saved query"), [this]() {
saveCurrentQuery();
});
addAction(QStringLiteral("toggle_html"), tr("Toggle &HTML"),
tr("Switch the thread between HTML and plain text"), [this]() {
m_messageView->toggleHtml();
});
addAction(QStringLiteral("load_remote"), tr("Load &remote content"),
tr("Load remote images for the current thread"), [this]() {
m_messageView->loadRemoteContent();
});
addAction(QStringLiteral("message_details"), tr("Message &details"),
tr("Show the full headers of every message in the thread"),
[this]() {
m_messageView->showDetailsDialog();
});
addAction(QStringLiteral("zoom_in"), tr("Zoom &in"),
tr("Enlarge the message text"), [this]() {
m_messageView->zoomIn();
});
addAction(QStringLiteral("zoom_out"), tr("Zoom &out"),
tr("Shrink the message text"), [this]() {
m_messageView->zoomOut();
});
auto *zoomReset =
addAction(QStringLiteral("zoom_reset"), tr("&Actual size"),
tr("Return the message text to its default size"), [this]() {
m_messageView->zoomReset();
});
// Ctrl+= alongside the configured binding: '=' reads as "back to normal",
// and on a layout where '+' is Shift+'=' it is the unshifted key next to
// zoom in. Appended rather than assigned, so a [keys] override of
// zoom_reset keeps working and simply gains this as a second way in.
// A user who bound Ctrl+= to something else in [keys] keeps their binding.
const QKeySequence altReset(QStringLiteral("Ctrl+="));
if (m_keyMap.actionFor(altReset).isEmpty()) {
QList<QKeySequence> shortcuts = zoomReset->shortcuts();
shortcuts.append(altReset);
zoomReset->setShortcuts(shortcuts);
}
addAction(QStringLiteral("undo"), tr("&Undo"),
tr("Undo the last tag change"), [this]() {
if (m_undoStack.canUndo())
m_undoStack.undo();
else
showTransientStatus(tr("Nothing to undo"));
});
addAction(QStringLiteral("sync"), tr("&Sync"),
tr("Run the configured sync command"), [this]() {
startSync();
});
addAction(QStringLiteral("complete_query"), tr("&Complete query"),
tr("Offer completions for the query bar"), [this]() {
// Focus first: the popup anchors on the line edit, and the binding is
// reachable from the thread list where the bar has no focus at all.
m_queryEdit->setFocus();
m_queryCompleter->triggerCompletion();
});
addAction(QStringLiteral("clear_pane"), tr("Clear &message pane"),
tr("Blank the message pane without changing the selection"),
[this]() {
// A view change, not a mail change: the selection, the query and the
// undo stack are all left alone.
//
// m_currentThreadId is cleared with the pane, not merely alongside it.
// A messageLoaded still in flight for that row would otherwise paint
// it straight back, which is the queued-reply race documented in
// CLAUDE.md.
m_currentThreadId.clear();
m_currentMessageId.clear();
m_currentMessageThreadId.clear();
m_messageView->clear();
showPlaceholderPane();
m_markReadTimer->stop();
m_markReadMessageId.clear();
});
addAction(QStringLiteral("clear_selection"), tr("Clear &selection"),
tr("Blank the message pane and deselect every thread"),
[this]() {
// Item 50, and the user's wording was "two actions instead of one":
// clear_pane above still blanks without touching the selection, this
// one does both. Esc defaults here, since deselecting is what Esc means
// nearly everywhere else.
//
// BOTH LINES BELOW ARE LOAD-BEARING, AND SO IS THEIR PLACE ABOVE THE
// BLANKING. clearSelection() leaves currentIndex() VALID, and
// onSelectionChanged() then takes its "one or fewer rows" branch, finds
// a current row whose id differs from m_currentThreadId, and calls
// onThreadSelected for it: the thread is re-adopted and a load sent
// for the row that was just being cleared.
//
// Clearing the selection FIRST means that runs while m_currentThreadId
// still names the displayed thread, so the ids match and nothing is
// reloaded; setCurrentIndex() then stops any later collapse-to-one-row
// reaching the same row again.
//
// All four arrangements were tried against
// clearSelectionBlanksThePaneAndDeselects, and only this one passes:
// dropping setCurrentIndex() fails, and moving either line after the
// blanking fails.
m_threadView->clearSelection();
m_threadView->setCurrentIndex(QModelIndex());
m_currentThreadId.clear();
m_currentMessageId.clear();
m_currentMessageThreadId.clear();
m_messageView->clear();
showPlaceholderPane();
m_markReadTimer->stop();
m_markReadMessageId.clear();
});
addAction(QStringLiteral("select_all"), tr("Select &all threads"),
tr("Select every thread in the current result list"), [this]() {
// A registered action rather than the view's built-in SelectAll key, so
// it reaches the Edit menu, the shortcut reference and [keys] the same
// way every other binding does. That is the whole point: multi-select
// already worked, it was simply invisible.
m_threadView->selectAll();
});
addAction(QStringLiteral("quit"), tr("&Quit"),
tr("Quit qtmaildir"), [this]() { close(); });
// A binding the user wrote for an action that does not exist would be
// silently dead. KeyMap warns about unknown names, but only a check here
// catches the reverse: a known action nothing implements.
Q_ASSERT(m_actions.size() == KeyMap::knownActions().size());
// QAction starts enabled, so the view-wide actions have to be put into
// their real state here rather than waiting for the first query: a window
// that has not run one yet has an empty model and no complete result set,
// and offering "Mark all read" against nothing is a live control that does
// nothing.
updateViewWideActions();
}
void MainWindow::buildMenus()
{
auto *fileMenu = menuBar()->addMenu(tr("&File"));
fileMenu->addAction(m_actions.value(QStringLiteral("sync")));
fileMenu->addSeparator();
fileMenu->addAction(m_actions.value(QStringLiteral("quit")));
auto *editMenu = menuBar()->addMenu(tr("&Edit"));
editMenu->addAction(m_actions.value(QStringLiteral("undo")));
editMenu->addSeparator();
editMenu->addAction(m_actions.value(QStringLiteral("focus_query")));
editMenu->addAction(m_actions.value(QStringLiteral("complete_query")));
editMenu->addAction(m_actions.value(QStringLiteral("save_query")));
editMenu->addSeparator();
editMenu->addAction(m_actions.value(QStringLiteral("select_all")));
auto *messageMenu = menuBar()->addMenu(tr("&Message"));
messageMenu->addAction(m_actions.value(QStringLiteral("archive")));
messageMenu->addAction(m_actions.value(QStringLiteral("delete")));
// Beside Delete, whose inverse it is. Greyed outside the trash view
// rather than hidden: an action that vanishes teaches nothing, while a
// disabled entry with its shortcut beside it says both that it exists and
// where it applies.
messageMenu->addAction(m_actions.value(QStringLiteral("restore")));
messageMenu->addAction(m_actions.value(QStringLiteral("spam")));
messageMenu->addSeparator();
messageMenu->addAction(m_actions.value(QStringLiteral("toggle_unread")));
messageMenu->addAction(m_actions.value(QStringLiteral("mark_all_read")));
messageMenu->addAction(m_actions.value(QStringLiteral("edit_tags")));
messageMenu->addAction(m_actions.value(QStringLiteral("flag")));
messageMenu->addSeparator();
messageMenu->addMenu(buildThreadActionsMenu(messageMenu));
// Separated from the entries above: those act on the selection, this edits
// a rule store shared with mailctl and changes nothing that is on screen.
messageMenu->addSeparator();
// A MENU entry and nothing else, at the user's request: "the cleanup
// should be a menu entry only, not to be confused with the filter Trash".
// It replaces the whole view like a filter does, so a sixth button beside
// the five filters would read as one of them.
messageMenu->addAction(m_actions.value(QStringLiteral("cleanup_stranded")));
messageMenu->addAction(m_actions.value(QStringLiteral("tag_rules")));
auto *viewMenu = menuBar()->addMenu(tr("&View"));
viewMenu->addAction(m_actions.value(QStringLiteral("prev_thread")));
viewMenu->addAction(m_actions.value(QStringLiteral("next_thread")));
viewMenu->addAction(m_actions.value(QStringLiteral("open_thread")));
viewMenu->addSeparator();
// The two clears. Both shipped keyboard-only, which is what
// everyActionIsReachableFromAMenu() exists to stop: an action reachable
// only by a chord is an action nobody discovers.
viewMenu->addAction(m_actions.value(QStringLiteral("clear_pane")));
viewMenu->addAction(m_actions.value(QStringLiteral("clear_selection")));
viewMenu->addSeparator();
viewMenu->addAction(m_actions.value(QStringLiteral("toggle_html")));
viewMenu->addAction(m_actions.value(QStringLiteral("load_remote")));
viewMenu->addAction(m_actions.value(QStringLiteral("message_details")));
viewMenu->addSeparator();
viewMenu->addAction(m_actions.value(QStringLiteral("zoom_in")));
viewMenu->addAction(m_actions.value(QStringLiteral("zoom_out")));
viewMenu->addAction(m_actions.value(QStringLiteral("zoom_reset")));
auto *helpMenu = menuBar()->addMenu(tr("&Help"));
auto *shortcuts = helpMenu->addAction(tr("&Keyboard shortcuts"));
connect(shortcuts, &QAction::triggered,
this, &MainWindow::showShortcutReference);
// A dialog the user asks for, per item 34: counting every message is not
// free on a large database, so this must not be anything that refreshes on
// its own.
auto *maildirInfo = helpMenu->addAction(tr("&Maildir overview"));
maildirInfo->setObjectName(QStringLiteral("maildirOverview"));
connect(maildirInfo, &QAction::triggered,
this, &MainWindow::showMaildirOverview);
auto *about = helpMenu->addAction(tr("&About"));
connect(about, &QAction::triggered, this, &MainWindow::showAbout);
// Standard names from the icon theme, so the buttons match the rest of the
// desktop rather than shipping bespoke art. A theme that lacks one leaves
// that action with text alone, which still works.
// Item 56: every registered action, not a subset. Eight of these carried an
// icon and sixteen did not, which reads worse than none having one: two
// adjacent entries in the same menu disagreed, and the toolbar's
// TextBesideIcon style laid out an empty slot for each of the sixteen.
//
// Names are freedesktop ones, and were probed against a real icon theme
// rather than taken from the spec on faith. A name the running theme lacks
// still degrades to text through the null check below.
const QHash<QString, QString> themeIcons = {
{ QStringLiteral("sync"), QStringLiteral("view-refresh") },
// NOT mail-mark-read, which mark_all_read below uses. The two shared it
// in 0.12.0, and with the toolbar icon-only the icon is the whole
// control: two buttons with different consequences looked identical.
{ QStringLiteral("archive"), QStringLiteral("mail-archive") },
{ QStringLiteral("delete"), QStringLiteral("edit-delete") },
// The inverse of delete, and the theme's own name for it: the icon
// every desktop uses for taking something back out of the wastebasket.
{ QStringLiteral("restore"), QStringLiteral("edit-undelete") },
// A SEARCH, not a delete. The action reports what it finds and moves
// nothing, so an icon from the delete family would promise the one
// thing it deliberately does not do.
{ QStringLiteral("cleanup_stranded"), QStringLiteral("system-search") },
{ QStringLiteral("undo"), QStringLiteral("edit-undo") },
{ QStringLiteral("spam"), QStringLiteral("mail-mark-junk") },
{ QStringLiteral("flag"), QStringLiteral("mail-mark-important") },
{ QStringLiteral("quit"), QStringLiteral("application-exit") },
{ QStringLiteral("focus_query"), QStringLiteral("edit-find") },
{ QStringLiteral("next_thread"), QStringLiteral("go-down") },
{ QStringLiteral("prev_thread"), QStringLiteral("go-up") },
{ QStringLiteral("open_thread"), QStringLiteral("document-open") },
{ QStringLiteral("toggle_unread"), QStringLiteral("mail-mark-unread") },
{ QStringLiteral("mark_all_read"), QStringLiteral("mail-mark-read") },
{ QStringLiteral("edit_tags"), QStringLiteral("tag") },
// NOT "tag", which edit_tags uses: with the toolbar icon-only the icon
// is the whole control, and editing the standing rules is not editing
// the selection's tags.
{ QStringLiteral("tag_rules"), QStringLiteral("configure") },
{ QStringLiteral("complete_query"), QStringLiteral("edit-find-replace") },
// NOT "document-save": that is the floppy/disk shape, which reads as
// "write a file somewhere" and asks the user to guess what is being
// written. Saving a query is bookmarking a search, and bookmark-new is
// the icon set every desktop already uses for "keep this for later".
{ QStringLiteral("save_query"), QStringLiteral("bookmark-new") },
{ QStringLiteral("select_all"), QStringLiteral("edit-select-all") },
{ QStringLiteral("clear_pane"), QStringLiteral("edit-clear") },
{ QStringLiteral("clear_selection"), QStringLiteral("edit-clear-all") },
{ QStringLiteral("toggle_html"), QStringLiteral("text-html") },
{ QStringLiteral("load_remote"), QStringLiteral("image-loading") },
{ QStringLiteral("message_details"), QStringLiteral("dialog-information") },
{ QStringLiteral("zoom_in"), QStringLiteral("zoom-in") },
{ QStringLiteral("zoom_out"), QStringLiteral("zoom-out") },
{ QStringLiteral("zoom_reset"), QStringLiteral("zoom-original") },
// The whole-thread tier (item 108) deliberately SHARES each icon with
// its message-scoped twin. The no-duplicates rule exists because the
// toolbar can be icon-only, where the icon is the entire control;
// these five never reach the toolbar. They live in a submenu whose
// entries always carry text, and "Delete thread" beside the delete
// icon is the honest pairing: the same operation, a wider scope, with
// the words saying which. Inventing five different shapes for the same
// five operations would be less clear, not more.
{ QStringLiteral("archive_thread"), QStringLiteral("mail-archive") },
{ QStringLiteral("delete_thread"), QStringLiteral("edit-delete") },
{ QStringLiteral("spam_thread"), QStringLiteral("mail-mark-junk") },
{ QStringLiteral("toggle_unread_thread"), QStringLiteral("mail-mark-unread") },
{ QStringLiteral("flag_thread"), QStringLiteral("mail-mark-important") },
};
for (auto it = themeIcons.cbegin(); it != themeIcons.cend(); ++it) {
QAction *action = m_actions.value(it.key());
if (!action)
continue;
const QIcon icon = QIcon::fromTheme(it.value());
if (!icon.isNull())
action->setIcon(icon);
}
// Right-click on the thread list. Built from the same registered QActions
// as the menu bar, never from parallel copies: a [keys] override then shows
// the right shortcut here too, and an action cannot end up doing one thing
// from the menu bar and another from the context menu.
//
// Every entry applies to the whole selection already, since they all funnel
// through tagSelected(), so this needs no multi-row special casing.
m_threadContextMenu = new QMenu(this);
m_threadContextMenu->setObjectName(QStringLiteral("threadContextMenu"));
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("archive")));
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("delete")));
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("restore")));
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("spam")));
m_threadContextMenu->addSeparator();
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("toggle_unread")));
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("flag")));
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("edit_tags")));
m_threadContextMenu->addSeparator();
m_threadContextMenu->addMenu(buildThreadActionsMenu(m_threadContextMenu));
m_threadContextMenu->addSeparator();
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("select_all")));
m_threadView->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_threadView, &QWidget::customContextMenuRequested,
this, &MainWindow::showThreadContextMenu);
// The frequent subset only. A toolbar holding every action is as
// unreadable as no toolbar.
auto *toolBar = addToolBar(tr("Main"));
toolBar->setObjectName(QStringLiteral("main_toolbar"));
// Item 56, second half: the user asked that buttons honour the desktop's
// "Icon only" setting. They cannot while this asserts a style of its own.
// Qt exposes the desktop's preference as SH_ToolButtonStyle, and a
// hardcoded setToolButtonStyle() overrides it whatever the user chose.
//
// Read rather than dropped entirely: with no call at all a QToolBar
// defaults to Qt::ToolButtonIconOnly rather than to the platform's hint,
// which would ignore the setting just as thoroughly in the other direction.
toolBar->setToolButtonStyle(static_cast<Qt::ToolButtonStyle>(
style()->styleHint(QStyle::SH_ToolButtonStyle, nullptr, toolBar)));
// Set explicitly rather than left to the style. With the button style above
// resolving to icon-only on this desktop, the icon IS the control, and this
// style's PM_ToolBarIconSize is 16px, which is a small target for it.
// Configurable because the right answer depends on the display, not on
// anything this code can see.
const int iconSize = m_config.toolbarIconSize();
toolBar->setIconSize(QSize(iconSize, iconSize));
QAction *syncAction = m_actions.value(QStringLiteral("sync"));
// Carried over from the QPushButton this replaced: with no command
// configured the control is disabled, and the tooltip is the only thing
// that says why.
if (syncAction && m_sync && !m_sync->isAvailable()) {
syncAction->setEnabled(false);
syncAction->setToolTip(
tr("No sync command configured ([sync] command in qtmaildir.conf)"));
}
toolBar->addAction(syncAction);
toolBar->addSeparator();
toolBar->addAction(m_actions.value(QStringLiteral("archive")));
toolBar->addAction(m_actions.value(QStringLiteral("delete")));
toolBar->addAction(m_actions.value(QStringLiteral("mark_all_read")));
toolBar->addSeparator();
toolBar->addAction(m_actions.value(QStringLiteral("undo")));
}
void MainWindow::showShortcutReference()
{
// Generated from the actions, so it cannot disagree with what the keys
// really do. A hand-written list would drift the first time a binding
// changed.
QStringList rows;
for (const QString &name : registeredActionNames()) {
const QAction *action = m_actions.value(name);
if (!action)
continue;
const QString sequence = action->shortcut().toString(QKeySequence::NativeText);
rows.append(QStringLiteral("<tr><td><tt>%1</tt> </td>"
"<td>%2 </td>"
"<td><tt>%3</tt></td></tr>")
.arg(sequence.isEmpty() ? tr("(unbound)") : sequence.toHtmlEscaped(),
m_actionDescriptions.value(name).toHtmlEscaped(),
name.toHtmlEscaped()));
}
// Two columns rather than one. Fourteen actions in a single table made a
// dialog taller than the screen, which cut off its own title bar.
const int half = (rows.size() + 1) / 2;
const QString header =
tr("<tr><th align='left'>Key</th><th align='left'>Does</th>"
"<th align='left'>Action name</th></tr>");
const QString left = header + rows.mid(0, half).join(QString());
const QString right = header + rows.mid(half).join(QString());
// A QDialog rather than QMessageBox: the message box wraps its text at a
// narrow default width, which turned every description into a column of
// single words and made the dialog taller than the screen.
QDialog dialog(this);
dialog.setWindowTitle(tr("Keyboard shortcuts"));
auto *label = new QLabel(&dialog);
label->setTextFormat(Qt::RichText);
label->setText(tr("<table cellspacing='0'><tr>"
"<td valign='top'><table cellpadding='3'>%1</table></td>"
"<td width='32'></td>"
"<td valign='top'><table cellpadding='3'>%2</table></td>"
"</tr></table>")
.arg(left, right));
// Mouse selection is view behaviour, not an action, so it cannot appear in
// the table above however the table is generated. Said here because it is
// otherwise undiscoverable: nothing in the UI hints that a thread list
// takes more than one row at a time.
auto *selectionNote = new QLabel(
tr("<b>Thread list:</b> <tt>Ctrl</tt>+click adds or removes a single "
"row, <tt>Shift</tt>+click extends the selection to a range. Tag, "
"archive and delete all apply to every selected thread."),
&dialog);
selectionNote->setTextFormat(Qt::RichText);
selectionNote->setWordWrap(true);
auto *note = new QLabel(
tr("Rebind any of these in the <tt>[keys]</tt> section of "
"<tt>qtmaildir.conf</tt>, using the action name."),
&dialog);
note->setTextFormat(Qt::RichText);
note->setWordWrap(true);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok, &dialog);
connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
auto *layout = new QVBoxLayout(&dialog);
layout->addWidget(label);
layout->addWidget(selectionNote);
layout->addWidget(note);
layout->addStretch();
layout->addWidget(buttons);
dialog.exec();
}
void MainWindow::showMaildirOverview()
{
auto *dialog = new QDialog(this);
dialog->setWindowTitle(tr("Maildir overview"));
dialog->setObjectName(QStringLiteral("maildirOverviewDialog"));
// Deleted on close, which is what makes m_overviewCounts a QPointer: the
// worker's reply can arrive after the user has dismissed it.
dialog->setAttribute(Qt::WA_DeleteOnClose);
auto *counts = new QLabel(dialog);
counts->setObjectName(QStringLiteral("maildirCounts"));
counts->setTextFormat(Qt::RichText);
// Shown as pending rather than as zero. The dialog opens before the answer
// arrives, and a zero would read as "no mail", which is a claim rather than
// an absence of one.
counts->setText(tr("<b>Counting...</b>"));
m_overviewCounts = counts;
// From config, never from notmuch, which does not model accounts at all.
// That is the whole reason per-account subdirectories are configured.
QString accountText;
const QList<Account> accounts = m_config.accounts();
accountText += tr("<b>%n account(s)</b>", "", int(accounts.size()));
if (!accounts.isEmpty()) {
accountText += QStringLiteral("<ul>");
for (const Account &account : accounts) {
// Account names are user-written config, and this label is rich
// text, so they are escaped like any other untrusted value.
const QString label = account.label.isEmpty() ? account.key
: account.label;
accountText += QStringLiteral("<li>%1</li>")
.arg(label.toHtmlEscaped());
}
accountText += QStringLiteral("</ul>");
}
auto *accountLabel = new QLabel(accountText, dialog);
accountLabel->setObjectName(QStringLiteral("maildirAccounts"));
accountLabel->setTextFormat(Qt::RichText);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, dialog);
connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::reject);
auto *layout = new QVBoxLayout(dialog);
layout->addWidget(counts);
layout->addWidget(accountLabel);
layout->addStretch();
layout->addWidget(buttons);
// Asked for when the dialog opens and never on a timer: counting every
// message in a large database is not free, which is the constraint that
// made this a dialog rather than a status-bar field.
QMetaObject::invokeMethod(m_worker, "requestDatabaseStats",
Qt::QueuedConnection,
Q_ARG(quint64, ++m_statsGeneration));
dialog->show();
}
void MainWindow::onDatabaseStatsReady(const DatabaseStats &stats,
quint64 generation)
{
// Closed and reopened while the count ran: this answer belongs to the old
// dialog. The QPointer covers "closed", this covers "closed and reopened".
if (generation != m_statsGeneration)
return;
if (!m_overviewCounts)
return;
// A field notmuch could not answer stays unknown. Printing 0 would say the
// database is empty, which is the opposite of "we could not tell".
const auto number = [](int value) {
return value < 0 ? tr("unknown") : QLocale().toString(value);
};
m_overviewCounts->setText(
tr("<b>%1</b> messages in <b>%2</b> threads<br>"
"<b>%3</b> tags")
.arg(number(stats.messages), number(stats.threads),
number(stats.tags)));
}
void MainWindow::showTagRulesDialog(const TagRule &seed)
{
// One dialog. A second would edit a stale copy and the last Save would
// silently win, which is the lost-edit case the atomic write cannot help
// with because both writers are this process.
if (m_tagRulesDialog) {
// Seeded into the dialog already up rather than dropped: the menu item
// must do something visible, and a second dialog would edit a stale
// copy whose Save would silently win.
if (!seed.query.isEmpty())
m_tagRulesDialog->seedRule(seed);
m_tagRulesDialog->raise();
m_tagRulesDialog->activateWindow();
return;
}
auto *dialog = new TagRulesDialog(seed, this);
dialog->setAttribute(Qt::WA_DeleteOnClose);
m_tagRulesDialog = dialog;
// The Folder row's dropdown, filled from the Maildir tree on disk rather
// than from config. Config names one subtree per account and nothing
// below it, so the dropdown offered five entries and no way to say Drafts
// or Sent, which is a folder a rule wants to target as often as a whole
// account. The answer comes back queued, after the dialog is already up;
// setFolders refills the rows that exist by then.
QMetaObject::invokeMethod(m_worker, "requestFolders", Qt::QueuedConnection);
connect(dialog, &TagRulesDialog::previewRequested,
this, &MainWindow::onRulePreviewRequested);
connect(dialog, &TagRulesDialog::countsRequested, this, [this, dialog]() {
QMetaObject::invokeMethod(
m_worker, "requestMessageCounts", Qt::QueuedConnection,
Q_ARG(QStringList, dialog->countQueries()),
Q_ARG(quint64, ++m_ruleCountGeneration));
});
dialog->show();
}
void MainWindow::onRuleCountsReady(const QVector<int> &counts,
quint64 generation)
{
// Stale reply, or the dialog closed while the count was in flight. Both
// are ordinary rather than rare: counting every rule against a cold index
// takes seconds, which is long enough for the user to close the dialog or
// press the button again.
if (generation != m_ruleCountGeneration || !m_tagRulesDialog)
return;
m_tagRulesDialog->setCounts(counts);
}
void MainWindow::showAbout()
{
QDialog dialog(this);
dialog.setWindowTitle(tr("About qtmaildir"));
auto *icon = new QLabel(&dialog);
icon->setPixmap(QIcon(QStringLiteral(":/icons/qtmaildir.svg"))
.pixmap(QSize(160, 160)));
icon->setAlignment(Qt::AlignCenter);
auto *text = new QLabel(&dialog);
text->setTextFormat(Qt::RichText);
text->setWordWrap(true);
text->setAlignment(Qt::AlignTop);
text->setText(tr("<h3>qtmaildir %1</h3>"
"<p>A Qt6 mail client for notmuch-indexed Maildirs.</p>"
"<p>Reads and organizes local mail. Fetching and sending "
"are handled by external scripts.</p>"
"<p>Copyright © 2026 Danilo M. "
"<danix@danix.xyz><br>"
"Licensed under the GNU General Public License "
"version 2.</p>"
"<p>Developed with AI assistance. All code is reviewed, "
"tested and curated by the maintainer.</p>")
.arg(QStringLiteral(QTMAILDIR_VERSION)));
auto *link = new QLabel(
QStringLiteral("<a href='https://danix.xyz/qtmaildir'>"
"https://danix.xyz/qtmaildir</a>"),
&dialog);
link->setTextFormat(Qt::RichText);
link->setAlignment(Qt::AlignCenter);
link->setOpenExternalLinks(true);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok, &dialog);
connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
auto *columns = new QHBoxLayout;
columns->addWidget(icon, 40);
columns->addWidget(text, 60);
auto *layout = new QVBoxLayout(&dialog);
layout->addLayout(columns);
layout->addWidget(link);
layout->addWidget(buttons);
dialog.exec();
}
void MainWindow::wireWorker()
{
m_worker = new NotmuchWorker(m_config.notmuchConfig());
m_worker->moveToThread(&m_workerThread);
connect(&m_workerThread, &QThread::finished, m_worker, &QObject::deleteLater);
connect(m_worker, &NotmuchWorker::threadsReady,
this, &MainWindow::onThreadsReady);
connect(m_worker, &NotmuchWorker::queryFinished,
this, &MainWindow::onQueryFinished);
connect(m_worker, &NotmuchWorker::threadTreeLoaded,
this, &MainWindow::onThreadTreeLoaded);
connect(m_worker, &NotmuchWorker::messageLoaded,
this, &MainWindow::onMessageLoaded);
connect(m_worker, &NotmuchWorker::errorOccurred,
this, &MainWindow::onWorkerError);
connect(m_worker, &NotmuchWorker::allTagsReady,
this, &MainWindow::onAllTagsReady);
connect(m_worker, &NotmuchWorker::countsReady,
this, &MainWindow::onCountsReady);
connect(m_worker, &NotmuchWorker::databaseStatsReady,
this, &MainWindow::onDatabaseStatsReady);
connect(m_worker, &NotmuchWorker::messageCountsReady,
this, &MainWindow::onRuleCountsReady);
// The rules dialog is the only consumer, and it may have been closed while
// the scan was in flight. No generation counter: the tree on disk does not
// change under a query, so a late answer is still the right one.
connect(m_worker, &NotmuchWorker::foldersReady, this,
[this](const QStringList &folders) {
if (m_tagRulesDialog)
m_tagRulesDialog->setFolders(folders);
});
// A confirmed write clears the pending revert: without this, a later
// unrelated error would roll back a change that actually succeeded.
connect(m_worker, &NotmuchWorker::tagsApplied,
this, &MainWindow::onTagsApplied);
// messagesMovedFrom rather than messagesMoved: the tags a move carries can
// only be resolved once the origins are known, and that signal is the one
// that reports them.
connect(m_worker, &NotmuchWorker::messagesMovedFrom,
this, &MainWindow::onMessagesMoved);
connect(m_worker, &NotmuchWorker::threadMessagesResolved,
this, &MainWindow::onThreadMessagesResolved);
m_workerThread.start();
// Queued behind the thread start, so the completer has real tags as soon
// as the database can be read. Nothing waits on the answer: requestAllTags
// stays silent when the database cannot be opened.
requestAllTags();
}
void MainWindow::requestAllTags()
{
// The generation is unused by the tag path, see onAllTagsReady().
QMetaObject::invokeMethod(m_worker, "requestAllTags", Qt::QueuedConnection,
Q_ARG(quint64, 0));
}
void MainWindow::onAllTagsReady(const QStringList &tags)
{
// The signal carries a generation, this slot deliberately does not take
// it. A tag list is not an ordered query result: a later one is always at
// least as good as an earlier one, and there is no partial state a stale
// arrival could corrupt. Discarding on generation would only be able to
// throw away a good list.
m_knownTags = tags;
m_queryCompleter->setTags(tags);
}
QList<MainWindow::PlaceholderLine> MainWindow::placeholderLines() const
{
// One list of (query, label-maker) pairs rather than two arrays indexed in
// parallel. The parallel version is what the fixed array was, and its
// hazard is that inserting an entry in one and not the other prints a real
// number against the wrong name, which reads as a plausible pane.
//
// The queries are wire format and deliberately untranslated: `tag:` is
// notmuch syntax, not user-facing prose. Only the labels are translated.
QList<PlaceholderLine> lines = {
{ QStringLiteral("tag:unread"),
[this](int n) { return tr("%n unread", "", n); } },
{ QStringLiteral("tag:flagged"),
[this](int n) { return tr("%n flagged", "", n); } },
{ QStringLiteral("tag:inbox"),
[this](int n) { return tr("%n in inbox", "", n); } },
};
// Sent and drafts are composed from the account folders, not from a tag.
// `tag:draft` counts 0 against a real database and no draft-ish tag exists
// in it, so a tag-based line would be a permanent zero.
//
// Omitted entirely when no account configures the folder, rather than
// shown as 0: item 63 established that a missing sent folder is a real
// configuration, and "0 sent" claims the user has sent nothing.
const QString sent = m_config.allSentQuery();
if (!sent.isEmpty()) {
lines.append({ sent, [this](int n) { return tr("%n sent", "", n); } });
}
const QString drafts = m_config.allDraftsQuery();
if (!drafts.isEmpty()) {
lines.append({ drafts,
[this](int n) { return tr("%n draft(s)", "", n); } });
}
return lines;
}
QStringList MainWindow::placeholderQueries() const
{
QStringList queries;
for (const PlaceholderLine &line : placeholderLines())
queries.append(line.query);
return queries;
}
QList<HtmlBuilder::PlaceholderHelper> MainWindow::placeholderHelpers() const
{
QList<HtmlBuilder::PlaceholderHelper> helpers;
const QList<PlaceholderLine> lines = placeholderLines();
// Empty until the first reply lands. Rendering zeroes meanwhile would be
// worse than rendering nothing: a zero is a claim.
//
// The size check is also what keeps the pairing honest across a config
// that changed shape between the request and the reply: counts that do not
// match the current line list are not this list's answers.
if (m_placeholderCounts.size() == lines.size()) {
for (int i = 0; i < lines.size(); ++i) {
// A query notmuch could not count yields -1; skip that line rather
// than print a negative number at the user.
if (m_placeholderCounts.at(i) < 0)
continue;
helpers.append({ lines.at(i).label(m_placeholderCounts.at(i)),
lines.at(i).query });
}
}
// The sync line, and only when something needs attention: a line that is
// always there becomes wallpaper and stops being read.
if (m_lastSyncFailed) {
helpers.append({ tr("last sync failed"), QString() });
} else if (const int pending = pendingEditCount(); pending > 0) {
helpers.append({ tr("%n change(s) waiting to sync", "", pending),
QString() });
}
return helpers;
}
void MainWindow::showPlaceholderPane()
{
m_messageView->showPlaceholder(placeholderHelpers());
QMetaObject::invokeMethod(m_worker, "requestCounts", Qt::QueuedConnection,
Q_ARG(QStringList, placeholderQueries()),
Q_ARG(quint64, ++m_countsGeneration));
}
void MainWindow::onCountsReady(const QVector<int> &counts, quint64 generation)
{
// A reply for a superseded request carries counts taken before whatever
// prompted the newer one, so accepting it would repaint the pane with
// older numbers than it already has.
if (generation != m_countsGeneration)
return;
m_placeholderCounts = counts;
// Only repaint what is actually on screen. Without this, a reply arriving
// after the user opened a thread would replace the message with the logo.
if (m_messageView->showingPlaceholder())
m_messageView->showPlaceholder(placeholderHelpers());
}
QString MainWindow::queryTextForTesting() const
{
return m_queryEdit->text();
}
QString MainWindow::selectedAccountForTesting() const
{
return m_accountBox->currentData().toString();
}
void MainWindow::selectAccountForTesting(const QString &key)
{
const int index = m_accountBox->findData(key);
if (index >= 0)
m_accountBox->setCurrentIndex(index);
}
void MainWindow::onRulePreviewRequested(const QString &query)
{
// Unscoped, deliberately. runQuery() wraps the bar's text in the selected
// account's scope, and a rule query usually names its own path already
// (path:"work/**" is what every account rule looks like), so previewing
// one with an account selected would scope it twice and match nothing.
// That reads as "this rule collects no mail", which is the opposite of
// what the preview is for.
m_accountBox->setCurrentIndex(0);
// Through the query bar, like onPlaceholderQueryRequested: the bar then
// shows what is on screen and the user can edit the rule's query there
// before deciding to change the rule itself.
m_queryEdit->setText(query);
runCurrentQuery();
// The dialog is a separate window and may be covering this one or sitting
// beside it. Raising makes the result visible either way, and the dialog
// stays open so the two can be compared.
raise();
activateWindow();
}
void MainWindow::onPlaceholderQueryRequested(const QString &query)
{
// Through the query bar rather than straight to the worker, so the bar
// shows what is being displayed and the user can edit it from there.
m_queryEdit->setText(query);
runCurrentQuery();
}
void MainWindow::runSearchFromPane(const QString &query,
SearchTerm::SearchMode mode)
{
if (query.isEmpty())
return;
QString next;
switch (mode) {
case SearchTerm::SearchMode::Replace:
next = query;
break;
case SearchTerm::SearchMode::Narrow:
next = SearchTerm::extend(m_queryEdit->text(), query);
break;
case SearchTerm::SearchMode::Exclude:
next = SearchTerm::exclude(m_queryEdit->text(), query);
break;
}
// exclude() returns empty when there is nothing to exclude from, which the
// greyed menu entry should already have prevented. Running it would clear
// the query bar and show the whole Maildir, so refuse instead.
if (next.isEmpty())
return;
// Through the query bar and the existing runner, so the account scope, the
// generation counter and the flat-mode reset all behave exactly as they do
// for a typed query. Nothing here builds a second query path.
m_queryEdit->setText(next);
runCurrentQuery();
}
void MainWindow::applyWarnings()
{
const QStringList warnings = m_config.warnings() + m_keyMap.warnings();
if (warnings.isEmpty())
return;
// Non-fatal: the app runs degraded rather than refusing to start.
m_statusLabel->setText(
tr("%n configuration warning(s)", "", warnings.size()));
}
QStringList MainWindow::configProblems() const
{
// Interrupt startup only for things that are actually wrong. Every KeyMap
// warning qualifies (each one means a binding the user wrote is being
// ignored), but a Config notice such as "no sync command configured" does
// not: nothing is broken, the feature is simply off, and a modal on every
// launch teaches the user to dismiss dialogs unread.
return m_config.problems() + m_keyMap.warnings();
}
void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
{
auto *row = new QWidget(parent);
row->setObjectName(QStringLiteral("savedQueryRow"));
auto *box = new QHBoxLayout(row);
box->setContentsMargins(0, 0, 0, 0);
// Cleared first: the row is rebuilt wholesale on every saved-query edit, so
// the buttons this hash points at are deleted and re-created. Keeping the
// old entries would leave dangling pointers that findChild() cannot save us
// from, since nothing looks them up by name.
m_filterButtons.clear();
// The built-in filters come first, in their own fixed order, and they are
// not saved queries: they are shipped, they are not in queries.json, and
// the user cannot edit or delete them (item 93). They are what the row is
// FOR; the pinned saved queries below them are the transitional half that
// item 94 removes.
for (const SavedQuery &filter : Config::builtinFilters()) {
// Sent with no account configuring a sent folder finds nothing by
// construction. Hidden rather than present and empty, which is what the
// hardcoded Sent button did and is worth keeping: a control that always
// returns nothing reads as broken rather than as absent.
if (m_config.resolvedQuery(filter, QString())
== Config::matchNothingQuery())
continue;
// A QToolButton, like the Save button at the other end of the row, so
// the two shipped controls carry icons the same way. The user's own
// queries stay plain QPushButtons: they have no icon to carry and
// nothing to say about which is which.
auto *button = new QToolButton(row);
button->setText(filter.name);
// A stable object name per filter, so a test finds the button without
// depending on the label, which is translated.
button->setObjectName(filter.generated + QStringLiteral("Button"));
// Theme icons, not the shipped SVGs in Marks: item 70's split is that
// the panes are ours and the chrome is the system's, and the query row
// is chrome. A name the running theme lacks degrades to text on its
// own, which is why nothing here checks whether it resolved.
//
// A STAR for Important, not mail-mark-important, which the `flag`
// action uses. Item 57 recorded the user asking for a star when the
// action was renamed, and on the query row the icon is read as a
// category rather than as "do this to the selection", so the two can
// differ. Chosen by the user on sight, 2026-08-15.
//
// mail-folder-sent, not mail-sent: the former is the folder shape every
// theme ships, the latter is the envelope-in-flight some do not.
static const QHash<QString, QString> filterIcons = {
{ QStringLiteral("unread"), QStringLiteral("mail-mark-unread") },
{ QStringLiteral("inbox"), QStringLiteral("mail-inbox") },
{ QStringLiteral("flagged"), QStringLiteral("starred") },
{ QStringLiteral("sent"), QStringLiteral("mail-folder-sent") },
{ QStringLiteral("trash"), QStringLiteral("user-trash") },
};
button->setIcon(
QIcon::fromTheme(filterIcons.value(filter.generated)));
// Icon AND text, for the reason the Save button records: this row is a
// row of text buttons, so an icon on its own reads as a different kind
// of control than it is.
button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
// Checkable so the style draws its own "this is the current view" look,
// which is why no colour is chosen here: a hand-picked highlight would
// have to be picked twice, once per theme, and would still be wrong
// under a third.
//
// Not auto-exclusive and never toggled by the click itself. The check
// state is derived from the query bar in updateFilterButtons(), so a
// button that runs a filter and then has its query edited away does not
// stay lit. Letting the click set it would make the highlight a record
// of what was pressed rather than of what is shown.
button->setCheckable(true);
button->setFocusPolicy(Qt::NoFocus);
connect(button, &QToolButton::clicked, this,
[this, filter]() { runFilter(filter); });
m_filterButtons.insert(filter.generated, button);
box->addWidget(button);
}
// The user's own saved queries. A pinned one is still a button, beside the
// filters, until item 94 makes the menu their only home.
QList<SavedQuery> unpinned;
for (const SavedQuery &saved : m_config.savedQueries()) {
// A generator whose accounts configure nothing produces a button that
// always finds nothing. Skipped entirely, which is what the hardcoded
// Sent button did and is worth keeping.
if (saved.isGenerated() && m_config.resolvedQuery(saved).isEmpty())
continue;
if (!saved.pinned) {
unpinned.append(saved);
continue;
}
auto *button = new QPushButton(saved.name, row);
// No object name here any more. "sentButton" now belongs to the BUILT-IN
// Sent filter, and a migrated Sent entry claiming it too would give two
// buttons one name, so findChild() would return whichever came first.
connect(button, &QPushButton::clicked, this,
[this, saved]() { runSavedQuery(saved); });
addSavedQueryActions(button, saved);
box->addWidget(button);
}
// Everything above is left-aligned; the stretch here pushes what follows
// to the right edge. The buttons are the row's content and read as a set,
// while the overflow menu is a control over that set, so it sits apart
// from them rather than trailing the last one.
const int contentCount = box->count();
box->addStretch(1);
// The overflow menu, and only when something is in it: an empty menu
// button is a control that always does nothing.
if (!unpinned.isEmpty()) {
auto *menuButton = new QPushButton(tr("More queries"), row);
menuButton->setObjectName(QStringLiteral("savedQueryMenuButton"));
auto *menu = new QMenu(menuButton);
for (const SavedQuery &saved : unpinned) {
QAction *action = menu->addAction(saved.name);
// A menu entry has no context menu of its own, so its own submenu
// carries the same actions; an unpinned query would otherwise be
// the one thing that cannot be edited or deleted.
auto *entryMenu = new QMenu(menu);
// Running the query is an item INSIDE that submenu, and must be:
// Qt does not emit triggered for an action that owns a menu, so a
// connection on `action` itself never fires and clicking the entry
// only opens the submenu. That shipped, and went unnoticed while
// the menu was the rarely-used half and the user's queries were
// pinned buttons. Item 93 moved every query into the menu, and item
// 94 makes it their only home.
auto *run = new QAction(tr("Run"), entryMenu);
run->setObjectName(QStringLiteral("runQuery"));
connect(run, &QAction::triggered, this,
[this, saved]() { runSavedQuery(saved); });
entryMenu->addAction(run);
auto *runSeparator = new QAction(entryMenu);
runSeparator->setSeparator(true);
entryMenu->addAction(runSeparator);
addSavedQueryActions(entryMenu, saved);
action->setMenu(entryMenu);
}
menuButton->setMenu(menu);
box->addWidget(menuButton);
}
layout->addWidget(row);
// Nothing on either side of the stretch leaves an empty strip of padding,
// so the row goes away rather than sitting there as a gap. Counted before
// the stretch was added, since the stretch is always there: an unpinned
// query with no pinned ones still needs the row for its menu.
if (contentCount == 0 && unpinned.isEmpty())
row->hide();
// Connected HERE rather than beside the query bar's other handlers, which
// run in registerActions() before this row exists. Both connections are
// owned by `row`, so a rebuild disconnects them with the widgets they
// update and cannot leave a second copy behind firing at deleted buttons.
//
// textChanged rather than editingFinished: the highlight has to clear while
// the user types, not once they leave the field.
connect(m_queryEdit, &QLineEdit::textChanged, row,
[this]() { updateFilterButtons(); });
// The account is the other half of a filter's resolved query, so switching
// account re-resolves it and the highlight has to be recomputed against the
// new scope rather than assumed to survive.
connect(m_accountBox, &QComboBox::currentIndexChanged, row,
[this]() { updateFilterButtons(); });
updateFilterButtons();
}
void MainWindow::addSavedQueryActions(QWidget *target, const SavedQuery &saved)
{
target->setContextMenuPolicy(Qt::ActionsContextMenu);
auto *edit = new QAction(tr("Edit..."), target);
edit->setObjectName(QStringLiteral("editQuery"));
connect(edit, &QAction::triggered, this,
[this, saved]() { editSavedQuery(saved); });
target->addAction(edit);
auto *pin = new QAction(saved.pinned ? tr("Move to menu")
: tr("Show as a button"),
target);
pin->setObjectName(QStringLiteral("pinQuery"));
connect(pin, &QAction::triggered, this, [this, saved]() {
SavedQuery toggled = saved;
toggled.pinned = !saved.pinned;
replaceSavedQuery(saved.name, toggled);
});
target->addAction(pin);
auto *separator = new QAction(target);
separator->setSeparator(true);
target->addAction(separator);
auto *remove = new QAction(tr("Delete"), target);
remove->setObjectName(QStringLiteral("deleteQuery"));
connect(remove, &QAction::triggered, this,
[this, saved]() { deleteSavedQuery(saved); });
target->addAction(remove);
// Stored queries only. A generated entry composes its query from the
// accounts at run time, so a rule made from one would freeze a snapshot
// that goes stale the day an account is added, in a file the post-new hook
// reads unattended.
if (saved.isGenerated())
return;
auto *ruleSeparator = new QAction(target);
ruleSeparator->setSeparator(true);
target->addAction(ruleSeparator);
auto *toRule = new QAction(tr("Create tagging rule..."), target);
toRule->setObjectName(QStringLiteral("queryToRule"));
connect(toRule, &QAction::triggered, this, [this, saved]() {
TagRule seed;
seed.id = TagRules::sanitiseId(saved.name);
seed.query = saved.query;
showTagRulesDialog(seed);
});
target->addAction(toRule);
}
void MainWindow::editSavedQuery(const SavedQuery &saved)
{
SaveQueryDialog dialog(m_config, saved, this);
if (dialog.exec() != QDialog::Accepted)
return;
// Matched on the name the dialog OPENED with. Using the returned name would
// leave the original entry in place and add a second one under the new
// name, which is a duplicate rather than a rename.
replaceSavedQuery(saved.name, dialog.savedQuery());
}
void MainWindow::deleteSavedQuery(const SavedQuery &saved)
{
// One of the few places in this application that confirms. The rule against
// confirmation dialogs covers tag mutations, which are undoable through the
// undo stack; this writes user config, is not on that stack, and cannot be
// taken back.
if (m_confirmDelete) {
const auto answer = QMessageBox::question(
this, tr("Delete saved query"),
tr("Delete the saved query '%1'?").arg(saved.name),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (answer != QMessageBox::Yes)
return;
}
replaceSavedQuery(saved.name, SavedQuery());
}
void MainWindow::replaceSavedQuery(const QString &originalName,
const SavedQuery &replacement)
{
QList<SavedQuery> queries = m_config.savedQueries();
const bool removing = replacement.name.isEmpty();
for (int i = 0; i < queries.size(); ++i) {
if (queries.at(i).name.compare(originalName, Qt::CaseInsensitive) != 0)
continue;
if (removing) {
queries.removeAt(i);
} else {
// The unknown fields belong to the STORED entry: a field written by
// a later build survives an edit made here rather than being
// dropped on the next save.
SavedQuery merged = replacement;
merged.unknown = queries.at(i).unknown;
queries[i] = merged;
}
break;
}
m_config.setSavedQueries(queries);
if (!m_config.saveSavedQueries()) {
QMessageBox::warning(this, tr("Saved queries"),
tr("Could not write the saved queries file."));
return;
}
rebuildSavedQueryRow();
statusBar()->showMessage(
removing ? tr("Deleted saved query '%1'.").arg(originalName)
: tr("Updated saved query '%1'.").arg(replacement.name),
kStatusMessageMs);
}
void MainWindow::runSavedQuery(const SavedQuery &saved)
{
// Through the dropdown, never by pre-scoping the text: runQuery() applies
// the selected account's path itself, so a scope baked in here would be
// applied twice. An unscoped query CLEARS the selection rather than
// inheriting whatever was there, which is the defect the rules preview hit.
const int index = saved.account.isEmpty()
? m_accountBox->findData(QString())
: m_accountBox->findData(saved.account);
if (index >= 0)
m_accountBox->setCurrentIndex(index);
// A generated entry has no stored query: the text is composed from the
// accounts now, so what lands in the bar is what actually ran and the user
// can see and edit it.
m_queryEdit->setText(saved.isGenerated() ? m_config.resolvedQuery(saved)
: saved.query);
// Flat for this query only. runQuery() sets the mode on EVERY run, so the
// flag cannot outlive the entry that asked for it, including for the same
// query typed by hand afterwards.
runQuery(saved.flat ? FlatResult::Yes : FlatResult::No);
}
void MainWindow::runFilter(const SavedQuery &filter)
{
// The account box is READ and never written. That is the whole difference
// from runSavedQuery(), and it is item 90's defect: a filter narrows what
// the user is already looking at, so the dropdown is its input rather than
// something it resets on the way past.
const QString accountKey = m_accountBox->currentData().toString();
// Resolved here, in the account's scope, and put in the bar so what ran is
// visible and editable. runQuery() is told not to scope it again.
m_queryEdit->setText(m_config.resolvedQuery(filter, accountKey));
runQuery(filter.flat ? FlatResult::Yes : FlatResult::No,
AccountScope::AlreadyScoped);
}
void MainWindow::updateFilterButtons()
{
const QString current = m_queryEdit->text().trimmed();
const QString accountKey = m_accountBox->currentData().toString();
for (auto it = m_filterButtons.constBegin();
it != m_filterButtons.constEnd(); ++it) {
const SavedQuery filter = Config::builtinFilter(it.key());
const QString resolved = m_config.resolvedQuery(filter, accountKey);
// An unresolvable filter must never match, or every filter would light
// up on an empty query bar. matchNothingQuery() is a real query string
// and would compare equal to itself.
const bool matches = !current.isEmpty()
&& resolved != Config::matchNothingQuery()
&& resolved == current;
// Blocked, because setChecked() on a checkable QToolButton emits
// toggled() and this runs from the query bar's own textChanged: a
// handler that ran runFilter() would re-enter the query path on every
// keystroke. Nothing connects toggled() today, so this is a guard
// against the obvious next edit rather than a fix for a live bug.
const QSignalBlocker blocker(it.value());
it.value()->setChecked(matches);
}
}
void MainWindow::saveCurrentQuery()
{
const QString query = m_queryEdit->text().trimmed();
if (query.isEmpty())
return;
SaveQueryDialog dialog(m_config, query,
m_accountBox->currentData().toString(), this);
if (dialog.exec() != QDialog::Accepted)
return;
QList<SavedQuery> queries = m_config.savedQueries();
const SavedQuery saved = dialog.savedQuery();
// Replacing by name keeps the dialog's overwrite offer honest, and keeps
// the entry where it already sat rather than moving it to the end.
bool replaced = false;
for (SavedQuery &existing : queries) {
if (existing.name.compare(saved.name, Qt::CaseInsensitive) == 0) {
// The unknown fields belong to the STORED entry, not to the
// dialog's fresh value, so a field a later build wrote survives
// being edited here.
SavedQuery merged = saved;
merged.unknown = existing.unknown;
existing = merged;
replaced = true;
break;
}
}
if (!replaced)
queries.append(saved);
m_config.setSavedQueries(queries);
if (!m_config.saveSavedQueries()) {
QMessageBox::warning(this, tr("Save query"),
tr("Could not write the saved queries file."));
return;
}
rebuildSavedQueryRow();
statusBar()->showMessage(tr("Saved query '%1'.").arg(saved.name),
kStatusMessageMs);
}
void MainWindow::rebuildSavedQueryRow()
{
// The row is rebuilt wholesale rather than patched: a new query can be
// pinned, unpinned, or replace an existing one, and each moves a different
// widget. Deleting and rebuilding is a handful of buttons and cannot get
// the three cases wrong.
auto *old = findChild<QWidget *>(QStringLiteral("savedQueryRow"));
if (!old)
return;
auto *layout = qobject_cast<QVBoxLayout *>(centralWidget()->layout());
if (!layout)
return;
const int index = layout->indexOf(old);
layout->removeWidget(old);
// Reparented out NOW, not merely scheduled for deletion. deleteLater()
// defers destruction to the event loop, so the old row goes on answering
// findChild() until it runs, and findChild returns the FIRST match: every
// lookup after a rebuild found the stale row and reported the state from
// before the edit. Nothing visible was wrong, which is why this only
// showed up as three tests failing on a row that had in fact been rebuilt.
old->setParent(nullptr);
old->deleteLater();
buildSavedQueryRow(centralWidget(), layout);
// buildSavedQueryRow appends; move it back to where the old row sat, or it
// lands under the thread list.
if (index >= 0) {
auto *item = layout->takeAt(layout->count() - 1);
layout->insertItem(index, item);
}
}
void MainWindow::runQuery(FlatResult flat, AccountScope scope)
{
// Set on EVERY run, not only when Yes. This is the line that stops flat
// mode leaking: any query that is not the Sent button restores the tree,
// so the flag cannot survive into the next view.
m_sentView = flat == FlatResult::Yes;
m_model->setFlatMode(m_sentView);
QString query = m_queryEdit->text().trimmed();
// A built-in filter arrives already resolved in the selected account's
// scope, because a generator has to be asked for the account's own query
// rather than have its all-accounts query wrapped. Scoping again here would
// put path:"work/Sent/**" inside path:"work/**".
const QString accountKey = m_accountBox->currentData().toString();
if (scope == AccountScope::Apply && !accountKey.isEmpty())
query = m_config.account(accountKey).scopedQuery(query);
if (query.isEmpty())
return;
// Kept so an expansion (loadThreadTree) and a refresh can be scoped to the
// query the visible list was built from, rather than to whatever the bar
// holds by the time they run.
m_lastQuery = query;
// A query the user ran abandons any recovery still in flight. Recovery
// spans two round-trips, so a query typed in the middle of one would
// otherwise have its result hijacked: the pending selection finds its
// thread in a result the user asked for something else from, and the view
// jumps. recoverStaleThread() sets the target AFTER calling this, so its
// own query does not clear it.
m_recoverThreadId.clear();
m_recoverMessageId.clear();
++m_generation;
m_model->clear();
m_messageView->clear();
showPlaceholderPane();
// Cleared WITH the pane, not merely alongside it. These three name what the
// pane is showing, and both selection handlers use them to decide whether a
// newly selected row is already displayed. Left set across a query they
// describe a pane that was just blanked, so a result containing that same
// thread is recognised as "already showing" and never loaded.
//
// That is not a corner case, it is the ordinary way an `id:` query is run:
// the id is copied out of the details dialog of the message being read, so
// the thread is current at the moment the query replaces the view, and its
// one card opens onto the placeholder. A query returning any OTHER thread
// hides it, which is why it took a screenshot to find.
m_currentThreadId.clear();
m_currentMessageId.clear();
m_currentMessageThreadId.clear();
// Undo entries refer to rows that are about to be discarded. The model
// update they invert would be a no-op against the new result set, leaving
// undo half-applied: the database would change and the list would not.
m_undoStack.clear();
m_pendingChange = {};
m_pendingThreadIds.clear();
m_statusLabel->setText(tr("Searching..."));
// The result set is incomplete from here until queryFinished arrives, so
// anything claiming to act on the whole view must wait.
m_queryComplete = false;
updateViewWideActions();
const auto sort = m_sortOrder->currentIndex() == 1
? NotmuchWorker::OldestFirst
: NotmuchWorker::NewestFirst;
// Recipients only for the Sent view: the fold reads message FILES, which
// is tens of seconds over an inbox. See ThreadSummary::recipients.
QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection,
Q_ARG(QString, query),
Q_ARG(quint64, m_generation),
Q_ARG(NotmuchWorker::SortOrder, sort),
Q_ARG(bool, m_sentView));
}
void MainWindow::onThreadsReady(const QVector<ThreadSummary> &threads,
quint64 generation)
{
if (generation != m_generation)
return; // Superseded by a newer query.
// A refresh accumulates instead of appending. Its batches must not reach
// the model one at a time: reconcile() decides what to REMOVE from what the
// result does not contain, so applying the first batch alone would delete
// every row after it, then the next batch would put some back. The list
// would churn and every expanded thread would collapse.
if (generation == m_refreshGeneration) {
m_refreshThreads.append(threads);
return;
}
m_model->appendBatch(threads);
// Item 74. "Searching..." was set once in runQuery() and cleared only on
// queryFinished, so it went on claiming the query was running for the whole
// walk while rows were visibly arriving behind it. Measured cold against a
// 1.1 GB index: first rows at 642 ms, done at 5714 ms, five seconds of a
// slow query reading as a frozen one.
//
// The count comes from the model rather than from a running total, since
// that is the number of rows the user can actually see. Nothing about the
// timing changes; this only stops the bar from lying.
m_statusLabel->setText(tr("Searching... %n thread(s)", "",
m_model->rowCount(QModelIndex())));
}
void MainWindow::onQueryFinished(int total, quint64 generation)
{
if (generation != m_generation)
return;
// The refresh's result is complete only now, so this is where it lands.
// One reconcile for the whole set, not one per batch.
if (generation == m_refreshGeneration) {
m_refreshGeneration = 0;
m_model->reconcile(m_refreshThreads);
m_refreshThreads.clear();
// The count in the status bar describes the current view and has just
// changed, but a refresh is meant to be silent, so it updates the
// FALLBACK text without stamping over whatever the bar is showing.
m_defaultStatus = tr("%n thread(s)", "", total);
// A refresh leaves the view complete exactly as a query does: every
// matching row is present, so view-wide actions stay honest.
m_queryComplete = true;
updateViewWideActions();
// The open thread may have stopped matching, which the user has to be
// told about: the pane keeps rendering it while the list no longer
// offers it anywhere.
updateStaleThreadNotice();
return;
}
// The query's own result is what the bar says when nothing more pressing
// is happening, so a transient message falls back to it rather than to
// nothing.
m_defaultStatus = tr("%n thread(s)", "", total);
m_statusLabel->setText(m_defaultStatus);
// The model now holds every row the query matched, so "the whole view" is
// a thing that can honestly be acted on.
m_queryComplete = true;
updateViewWideActions();
// A recovery's own thread:<id> query landing. The rows exist now, so the
// thread can be expanded; the message inside it is selected once its
// replies arrive.
applyPendingRecovery();
}
bool MainWindow::isShowingTrash() const
{
// Compared against the trash GENERATOR's query, not against the word
// "trash" or against a tag. The trash view is path-based so that mail
// trashed by another client shows up in it; deciding this from
// `tag:deleted` instead would disable Restore on exactly the messages
// that most need it, which is the case Restore's fallback exists for.
//
// Both scopes, because the view composes with the account dropdown like
// every other filter: one account's trash, or all of them.
const QString query = m_lastQuery.trimmed();
if (query.isEmpty())
return false;
const QString all = m_config.allTrashQuery().trimmed();
if (!all.isEmpty() && query == all)
return true;
for (const Account &account : m_config.accounts()) {
const QString trash = account.trashQuery().trimmed();
if (!trash.isEmpty() && query == trash)
return true;
}
return false;
}
void MainWindow::updateViewWideActions()
{
// Only meaningful on mail that is actually in a trash folder. An enabled
// action that does nothing is worse than an absent one, and Restore
// outside the trash has nothing to restore from.
if (QAction *action = m_actions.value(QStringLiteral("restore")))
action->setEnabled(isShowingTrash());
// Threads arrive in batches of kBatchSize, so before the query reports its
// total the model holds only what has landed. An action that says "all"
// must not run against a partial set and silently skip the rest, and a
// disabled control says so without a dialog.
if (QAction *action = m_actions.value(QStringLiteral("mark_all_read")))
action->setEnabled(m_queryComplete && m_model->rowCount() > 0);
}
void MainWindow::markAllRead()
{
// Every row, not the selection: this is the one action in the window that
// deliberately ignores what is selected.
QStringList threadIds;
const int rows = m_model->rowCount();
threadIds.reserve(rows);
for (int row = 0; row < rows; ++row) {
const ThreadSummary thread = m_model->threadAt(row);
// Only the threads that would actually change. Sending the rest would
// inflate the pending-edit count with writes that do nothing, and the
// quit prompt reads that count.
if (thread.isUnread())
threadIds.append(thread.threadId);
}
if (threadIds.isEmpty()) {
showTransientStatus(tr("Nothing unread in this view"));
return;
}
// An automatic mark-read armed for the open thread would fire after this
// and push a second, redundant command onto the stack.
m_markReadTimer->stop();
m_markReadMessageId.clear();
const QString description = tr("Mark all read");
sendThreadTagChange(threadIds, {}, { QStringLiteral("unread") },
description);
// ONE command for the batch, exactly as tagSelected does: a user who marks
// 400 threads read expects a single Ctrl+Z to put them back.
m_undoStack.push(new ThreadTagCommand(this, threadIds, {},
{ QStringLiteral("unread") },
description));
showTransientStatus(
tr("%1: %n thread(s)", "", threadIds.size()).arg(description));
}
void MainWindow::showThreadContextMenu(const QPoint &pos)
{
const QModelIndex index = m_threadView->indexAt(pos);
if (!index.isValid())
return; // Right-click on empty space below the rows.
// Right-clicking a row that is already part of the selection must leave
// that selection alone: the actions apply to every selected thread, so
// collapsing to the clicked row here would silently narrow a deliberate
// multi-row selection to one. Right-clicking outside it selects that row
// instead, which is what every other list does.
if (!m_threadView->selectionModel()->isSelected(index))
selectRowAt(index);
m_threadContextMenu->popup(m_threadView->viewport()->mapToGlobal(pos));
}
void MainWindow::onSelectionChanged()
{
const QModelIndexList rows = m_threadView->selectionModel()->selectedRows();
const int selected = rows.size();
if (selected == 1) {
// One row selected. With two kinds of row this is exactly where the
// scope became ambiguous: a thread root stands for every message in it,
// a message row for one, and the keypress looks identical. Naming it
// here is what this project does instead of a confirmation dialog,
// which CLAUDE.md rules out for tag mutations.
const ActionScope scope = m_model->scopeFor(rows);
if (scope.wholeThread) {
m_selectionMessage =
tr("1 thread selected (%n message(s))", "", scope.messageCount);
m_statusLabel->setText(m_selectionMessage);
m_statusTimer->stop();
m_transientMessage.clear();
} else {
// Reading one message is not a bulk action and gets no count.
if (m_statusLabel->text() == m_selectionMessage)
m_statusLabel->clear();
m_selectionMessage.clear();
}
// Collapsing a multi-row selection back to one row has to load that row
// here, and cannot be left to onThreadSelected: currentRowChanged is
// emitted BEFORE the selection model is updated (verified against
// Qt 6.11), so that handler still sees the old count and returns
// without loading anything.
//
// Compared per row kind: a message row is identified by its message id
// and a thread row by its thread id, which are different questions.
// threadFor() resolves the thread either way, so the row-number trap
// (item 88) cannot be re-entered here even if this branch changes.
const QModelIndex current = m_threadView->currentIndex();
if (current.isValid()) {
const bool changed =
m_model->isMessageRow(current)
? m_model->messageAt(current).messageId != m_currentMessageId
: m_model->threadFor(current).threadId != m_currentThreadId;
if (changed)
onThreadSelected(current, QModelIndex());
}
return;
}
if (selected < 1) {
// Nothing selected. Clearing unconditionally would wipe whatever the
// last action reported ("Archive: 3 threads"), which is the more useful
// message once the selection is gone, so only a count this function
// wrote is taken back.
if (m_statusLabel->text() == m_selectionMessage)
m_statusLabel->clear();
m_selectionMessage.clear();
return;
}
// The count is the part that actually teaches multi-select: it acknowledges
// the selection while it is being built, rather than only after an action
// has already been applied to it.
//
// Reported per row kind rather than as a bare row count, so a mixed
// selection says what it will really touch instead of calling three replies
// "3 threads".
const ActionScope scope = m_model->scopeFor(rows);
if (!scope.threadIds.isEmpty() && scope.messageIds.isEmpty()) {
m_selectionMessage =
tr("%n thread(s) selected (%1 messages)", "", scope.threadIds.size())
.arg(scope.messageCount);
} else if (scope.threadIds.isEmpty()) {
m_selectionMessage =
tr("%n message(s) selected", "", scope.messageIds.size());
} else {
m_selectionMessage =
tr("%n thread(s) and %1 message(s) selected", "",
scope.threadIds.size()).arg(scope.messageIds.size());
}
m_statusLabel->setText(m_selectionMessage);
// State, not an event: it must persist while the selection does. Cancel any
// transient message still counting down, or that timer fires and replaces a
// count that is still true.
m_statusTimer->stop();
m_transientMessage.clear();
// Ctrl+click and selectAll() reach a multi-row selection without moving
// current, so onThreadSelected never runs and its guard never fires. The
// pane and the pending timer have to be dealt with here as well.
m_markReadTimer->stop();
m_markReadMessageId.clear();
m_currentThreadId.clear();
m_currentMessageId.clear();
m_currentMessageThreadId.clear();
m_messageView->clear();
showPlaceholderPane();
}
void MainWindow::onThreadSelected(const QModelIndex ¤t,
const QModelIndex &)
{
if (!current.isValid())
return;
// A current index the user did not put there. QTreeView gives itself one
// when it takes FOCUS with none set (verified against Qt 6.11: inserting
// rows does not do it, focusing the view does), and it sets current WITHOUT
// selecting. Before item 35b nothing could reach that state, because a
// populated list always had a current row; now a refresh can drop mail into
// a view the user read empty, and coming back to the window from another
// desktop would open the new message and mark it read two seconds later
// without them ever having looked at it.
//
// Every real route here (a click, an arrow key, selectRowAt) selects the
// row as well, so requiring a selection separates the user's intent from
// Qt's housekeeping without weakening any of them.
if (!m_threadView->selectionModel()->isSelected(current))
return;
// The notice belongs to whatever the pane is showing, and it is about to
// show something else. Retired here rather than only in MessageView::clear()
// because selecting a row RE-RENDERS the pane instead of blanking it, so
// the bar would otherwise sit over a message it does not describe. That is
// the second half of the reported defect: the pane had moved on and the
// notice had not.
m_messageView->setStaleThread(QString(), QString());
// A selection spanning more than one row is aimed at a bulk action, not at
// reading. current follows the keyboard cursor as the selection extends, so
// without this every row swept through would be rendered and, worse,
// queued to be marked read: a selection gesture must not mutate mail.
//
// The count read here is deliberately not trusted on its own. This signal
// is emitted BEFORE the selection model is updated (verified against
// Qt 6.11), so a Ctrl+click that takes the selection from one row to two
// arrives here still reporting one. onSelectionChanged() always follows and
// sees the true count, and it is what finally blanks the pane and cancels
// the timer; this branch only catches the case where the count is already
// stale in the other direction.
//
// The stop() is not redundant with the guard. Clicking one row arms a timer
// legitimately and only then does the selection grow, so the timer already
// running for that first row has to be cancelled here or it fires behind a
// pane that no longer shows the thread.
if (m_threadView->selectionModel()->selectedRows().size() > 1) {
m_markReadTimer->stop();
m_markReadMessageId.clear();
m_currentThreadId.clear();
m_currentMessageId.clear();
m_currentMessageThreadId.clear();
m_messageView->clear();
showPlaceholderPane();
return;
}
// A message row renders that message ALONE, so the kind of row still has
// to be checked here: this is a different render path, not a different way
// of naming the same thread.
if (m_model->isMessageRow(current)) {
const MessageNode node = m_model->messageAt(current);
if (node.messageId.isEmpty())
return;
// Armed for a reply too, since item 87. It deliberately was not
// before, because the write was thread-scoped and reading one reply
// would have marked the whole conversation read. With the write scoped
// to one message that objection is gone, and leaving it unarmed would
// make the message the user is actually reading the one kind that
// never gets marked read.
m_markReadTimer->stop();
m_markReadMessageId.clear();
m_currentThreadId.clear();
m_currentMessageId = node.messageId;
// Remembered for the stale notice: the pane shows one message, but the
// thread it came from is what the refreshed list is checked against.
m_currentMessageThreadId = node.threadId;
m_messageView->setTags(node.tags);
// After m_currentMessageId is set: the handler compares against it to
// tell "still showing this" from "the selection moved on".
scheduleMarkRead(node.messageId, node.isUnread());
QMetaObject::invokeMethod(m_worker, "loadMessage", Qt::QueuedConnection,
Q_ARG(QString, node.messageId),
Q_ARG(quint64, m_generation));
return;
}
const ThreadSummary thread = m_model->threadFor(current);
m_currentThreadId = thread.threadId;
m_messageView->setTags(thread.tags);
// The message the card displays, not the thread. The summary's `unread` is
// a union over the conversation, so this can arm for a thread whose first
// message is already read; the write is scoped to that message either way,
// so the cost is a no-op rather than a wrong write. Narrowing it properly
// needs per-message state in ThreadSummary, which nothing carries yet.
scheduleMarkRead(thread.firstMessageId, thread.isUnread());
// The root card IS the thread's first message, so selecting it renders
// that message. Never the whole conversation: that path is gone (item 66).
//
// The id comes from the query now, so it is known on a fresh row and this
// does not depend on the thread having been expanded. It used to, which
// made a first click render the conversation and every later click render
// one message, from the identical gesture.
//
// In the Sent view a row stands for what the USER sent, which is not
// always the thread's opening message. Handled below rather than here,
// because the id that matters there comes from the query's match set and
// not from the thread's shape.
const QString firstId =
m_model->data(current, ThreadListModel::MessageIdRole).toString();
if (firstId.isEmpty()) {
// No id at all: a thread with no toplevel message is not something
// notmuch produces, but blanking the pane is the honest answer if it
// ever happens, rather than rendering something the row does not name.
m_currentMessageId.clear();
m_currentMessageThreadId.clear();
m_messageView->clear();
return;
}
m_currentMessageId = firstId;
QMetaObject::invokeMethod(m_worker, "loadMessage", Qt::QueuedConnection,
Q_ARG(QString, firstId),
Q_ARG(quint64, m_generation));
}
void MainWindow::onMessageLoaded(const QVector<MessageRef> &messages,
quint64 generation)
{
// A stale generation means the query moved on. A reply landing after the
// selection grew past one row would paint a message back over a pane that
// was deliberately blanked: loadMessage crosses to the worker on a queued
// connection, so the answer arrives after onSelectionChanged() has already
// run. Without this the pane would only look right once a third row made
// the count stale-proof.
if (generation != m_generation || messages.isEmpty())
return;
if (m_threadView->selectionModel()->selectedRows().size() > 1)
return;
// Nothing is currently meant to be on screen: a reply that lands after the
// pane was cleared must not repaint it.
if (m_currentMessageId.isEmpty())
return;
// The worker's answer is the authority on what THIS message carries, and
// it is the only place that truth arrives. Until it does, a thread row can
// only offer ThreadSummary::tags, which is notmuch's union over the
// conversation: a four-message thread whose third message is signed makes
// the root card and the pane both claim `signed` for a message that is not
// (item 110). Recording it here corrects the card and gives a
// message-scoped write something to update, which is why marking a root
// message read left the row bold before.
//
// A reply already has its own node from the thread tree, and
// setRootMessageTags ignores anything that is not a root.
for (const MessageRef &ref : messages)
m_model->setRootMessageTags(ref.messageId, ref.tags);
// The pane follows the same correction. setTags() at selection time can
// only have used the union.
if (messages.size() == 1)
m_messageView->setTags(messages.first().tags);
renderMessages(messages);
}
void MainWindow::onThreadExpanded(const QModelIndex &index)
{
if (!index.isValid() || m_model->isMessageRow(index))
return;
const QString threadId =
m_model->data(index, ThreadListModel::ThreadIdRole).toString();
if (threadId.isEmpty())
return;
QMetaObject::invokeMethod(m_worker, "loadThreadTree", Qt::QueuedConnection,
Q_ARG(QString, threadId),
Q_ARG(QString, m_lastQuery),
Q_ARG(quint64, m_generation));
}
void MainWindow::onThreadTreeLoaded(const QVector<MessageNode> &nodes,
quint64 generation)
{
// The same generation guard every other worker reply carries: an expansion
// whose query has since been replaced must not insert rows into the new
// result, where that thread may not even appear.
if (generation != m_generation || nodes.isEmpty())
return;
// Every node in one reply belongs to one thread, so the first one names it.
// Read from the node rather than remembered from the request: two
// expansions can be in flight at once, and pairing them by order would
// attach one thread's replies to the other.
m_model->setThreadMessages(nodes.first().threadId, nodes);
// A stale-thread recovery waits for exactly this: the message it wants to
// select does not exist as a row until the replies land.
applyPendingRecovery();
}
void MainWindow::renderMessages(const QVector<MessageRef> &messages)
{
// Guards live in the caller. This paints what it is given.
//
// Still takes a LIST, though every caller now passes exactly one message:
// MessageView renders a list of items, and collapsing that to a single
// message is a separate change to a class with its own tests. Item 66
// removed the whole-conversation render; it did not simplify the pane.
MimeParser parser;
QList<ThreadRenderItem> items;
items.reserve(messages.size());
for (int i = 0; i < messages.size(); ++i) {
const MessageRef &ref = messages.at(i);
ThreadRenderItem item;
item.message = parser.parse(ref.filePath);
if (!item.message.ok) {
// One unreadable message must not lose the rest of the thread, so
// it becomes an inline note rather than replacing the whole pane.
item.message = {};
item.message.ok = true;
item.message.from = tr("(unreadable message)");
item.message.subject = ref.filePath;
item.message.plainBody =
tr("This message could not be parsed.\n%1").arg(ref.filePath);
}
// Namespace prefix keeps cid: references distinct across the thread.
item.cidPrefix = cidPrefixForIndex(i);
// For the header's marks (item 70). From the REF's tags, since the
// parsed message carries only what was in the file.
item.flagged = ref.isFlagged();
// Matched messages open; the rest collapse to a stub. The last message
// always opens, so a thread never renders as nothing but stubs.
item.expanded = ref.matched || i == messages.size() - 1;
items.append(item);
}
m_messageView->showThread(items);
}
void MainWindow::revertPendingTagChange()
{
// Either scope can be in flight: a thread-scoped write names threads, a
// message-scoped one names messages, and both are now applied
// optimistically. Checking only the thread ids left a failed message write
// showing its optimistic state for good, with nothing to correct it until
// the next query.
if (m_pendingThreadIds.isEmpty() && m_pendingChange.messageIds.isEmpty())
return;
// Put the rows back the way they were. Only the model is touched: the
// worker never applied the change, so there is nothing to undo there.
for (const QString &threadId : m_pendingThreadIds) {
m_model->applyTagChange(threadId, m_pendingChange.removed,
m_pendingChange.added);
}
for (const QString &messageId : m_pendingChange.messageIds) {
m_model->applyMessageTagChange(messageId, m_pendingChange.removed,
m_pendingChange.added);
}
// The undo entry describes a change that never landed, so it would apply a
// spurious inverse if the user pressed undo.
//
// undo() alone, deliberately. This used to clear() the whole stack
// afterwards, which threw away every earlier step the user had built up
// because one later write was rejected: undoing an archive of fifty
// threads became impossible if the flag after it happened to land during a
// sync. undo() has already taken the failed command off the redo side of
// the stack, and the commands under it describe changes that did land.
if (m_undoStack.canUndo())
m_undoStack.undo();
m_pendingChange = {};
m_pendingThreadIds.clear();
}
void MainWindow::onWorkerError(const QString &message)
{
// Spec: the UI updates optimistically and reverts if the write fails.
// Without this the list would keep showing a tag the database never got.
//
// A running sync does NOT arrive here. The read-write open blocks on the
// lock and then succeeds rather than failing (measured; see the comment at
// the open in notmuchworker.cpp), so anything reaching this point is a real
// failure that waiting cannot fix. The stall a running sync does cause is
// avoided by not sending the write at all, in sendThreadTagChange().
revertPendingTagChange();
updatePendingIndicator();
m_statusLabel->setText(message);
}
bool MainWindow::aSyncHoldsTheWriteLock() const
{
// Both sources, exactly as updateSyncControls() reads them. A local sync
// holds the same exclusive lock a cron one does, so an edit made during it
// would block on precisely the same open.
return m_localSyncBusy || m_externalSyncBusy;
}
void MainWindow::flushHeldEdits()
{
// Moves first, and they are flushed even when no tag edit is waiting: the
// early return below used to be the whole guard, so a held move with an
// empty edit queue would never have been sent at all. That is item 106's
// data loss with a worse shape, since a dropped move leaves the file where
// the user asked it not to be.
if (!m_heldMoves.isEmpty()) {
const QVector<HeldMove> moves = m_heldMoves;
m_heldMoves.clear();
for (const HeldMove &move : moves) {
sendMove(move.messageIds, move.destFolder, move.add, move.remove,
move.description, move.fromUndo);
}
updatePendingIndicator();
}
if (m_heldEdits.isEmpty())
return;
// Taken by value and cleared first: sendThreadTagChange() writes
// m_pendingThreadIds, and re-entering partway through the queue must not
// find the same edits still waiting.
const QVector<HeldEdit> edits = m_heldEdits;
m_heldEdits.clear();
// Stamped for the ordering test. See flushGenerationForTesting().
m_flushGeneration = m_generation;
for (const HeldEdit &edit : edits) {
// Take the optimistic update back before sending, because the send
// applies it again. Both apply functions are idempotent per tag so the
// rows do not visibly flicker; without this the change is applied
// twice and a later revert undoes only one of them, leaving a row
// showing a tag the database never got.
for (const QString &threadId : edit.threadIds) {
m_model->applyTagChange(threadId, edit.change.removed,
edit.change.added);
}
for (const QString &messageId : edit.change.messageIds) {
m_model->applyMessageTagChange(messageId, edit.change.removed,
edit.change.added);
}
// By SCOPE. A held edit is one or the other, never both: a
// message-scoped edit carries no thread ids, so sending it through
// sendThreadTagChange() sent an empty list, which returns immediately.
// The edit was applied to the row, counted as unsynced and then
// dropped without ever being written, which is data loss with a
// pending count claiming the opposite.
//
// Escalating it to its thread instead would be worse: Delete on one
// reply would delete every message in the conversation.
if (!edit.threadIds.isEmpty()) {
sendThreadTagChange(edit.threadIds, edit.change.added,
edit.change.removed, edit.change.description);
}
if (!edit.change.messageIds.isEmpty()) {
sendMessageTagChange(edit.change.messageIds, edit.change.added,
edit.change.removed, edit.change.description);
}
}
// Held edits stop counting as held; what counts now is whatever
// onTagsApplied() confirms.
updatePendingIndicator();
showTransientStatus(
tr("%n held change(s) sent now that the sync has finished", "",
int(edits.size())));
}
void MainWindow::onSyncFinished(bool success, int exitCode)
{
setSyncBusy(false);
// The local sync no longer holds the write lock, whatever its outcome, so
// edits held during it can go now.
//
// The count below is safe: applyTagsToThreads is a QUEUED call, so the
// onTagsApplied() that records these edits arrives after this function has
// returned, and therefore after the success branch has cleared the map.
// They are counted, not wiped.
//
// These edits reach the index after the sync that would have carried them,
// so they go to the mail store on the NEXT run. That is the same one-run
// delay any edit made mid-sync gets, bounded by the cron interval.
const bool sentHeldEdits = !m_heldEdits.isEmpty();
// Snapshotted BEFORE the flush, and this ordering is load-bearing.
// flushHeldEdits() calls sendThreadTagChange(), which inserts into
// m_editedAccounts SYNCHRONOUSLY, unlike the pending-edit map below which
// is written on the worker's queued reply and so is safely counted rather
// than wiped. Clearing the whole set after the flush would therefore
// discard accounts whose edits this run did not carry, and those edits
// would sync only when some later edit happened to name the same account.
const QSet<QString> accountsThisRunCarried = m_editedAccounts;
flushHeldEdits();
if (success) {
// Only a SUCCESSFUL sync clears the count. Clearing on failure would
// assert the edits had reached the mail store when the sync is exactly
// what failed to put them there.
m_pendingTagEdits.clear();
m_unnettablePendingEdits = 0;
// Only what this run actually carried, per the snapshot above. An
// account added by flushHeldEdits() stays, because its edit reaches the
// index after the sync that would have taken it and goes out on the
// next run.
m_editedAccounts.subtract(accountsThisRunCarried);
m_lastSyncFailed = false;
updatePendingIndicator();
showTransientStatus(tr("Sync complete"));
if (m_syncingForExit) {
// Edits held during THIS sync were only just sent, on a queued
// connection, so they have not reached the index yet and this sync
// certainly did not carry them. Quitting here would discard exactly
// the work the prompt exists to protect. Tell the user and stay
// open; the indicator shows what is still outstanding.
if (sentHeldEdits) {
m_syncingForExit = false;
QMessageBox::information(
this, tr("Changes still to sync"),
tr("Changes you made while the sync was running have only "
"now been applied, so that sync did not carry them. "
"Sync once more before quitting."));
return;
}
// The work is safely across, so finish the quit the user asked for.
m_syncingForExit = false;
m_closeApproved = true;
close();
return;
}
// refreshCurrentQuery(), NOT runCurrentQuery(). A sync this window
// started is not a query the user asked to re-run: runCurrentQuery()
// clears the model, the undo stack and the message pane, so a sync
// landing while a message was open read the user out of it. The cron
// path has reconciled instead since item 35, and there was never a
// reason for the two to differ.
//
// Item 71 is what made it matter. A local sync used to happen only
// when the user clicked Sync, where blanking was at least explicable;
// the automatic one fires two seconds after a tag edit, which is
// precisely when the user is still reading the message they tagged.
// Reconciling keeps the pane, and updateStaleThreadNotice() then offers
// "Show it anyway" for a thread that has stopped matching the query,
// which is the reported case: reading in Unread, the thread is marked
// read, and it no longer belongs to the view it was opened from.
refreshCurrentQuery();
// A sync is the usual way new tags enter the database.
requestAllTags();
} else if (exitCode == kSyncSkippedExitCode) {
// Skipped means the lock was never ours: some other run holds it. If
// both started inside the same poll interval the monitor will have
// latched this lock period as local, which would swallow the report
// when that other run finishes. Hand it back.
m_localSyncHoldsLock = false;
// Not a failure: another run holds the lock and is doing the work.
// The user's cron fires every ten minutes, so a click landing inside
// one is routine and must not raise an error or the log pane.
showTransientStatus(tr("A sync is already running (started "
"elsewhere); this one was skipped"));
if (m_syncingForExit) {
// The other run is syncing, but this application cannot see when
// it finishes, so it cannot promise the changes are across. Leave
// the window open and say so rather than quitting on a guess.
m_syncingForExit = false;
QMessageBox::information(
this, tr("Sync already running"),
tr("Another sync was already in progress, so this one was "
"skipped. Your changes are most likely being carried over "
"by that run, but this window cannot see it finish, so it "
"has been left open."));
}
} else {
// Latched until a sync succeeds, so the placeholder's sync line still
// says so on the next blank pane rather than only in a status message
// the user may not have been looking at. A skipped run does not set
// this: it is a branch of its own above, and a skip means another
// process is doing the work rather than that the work failed.
m_lastSyncFailed = true;
m_statusLabel->setText(tr("Sync failed (exit %1)").arg(exitCode));
m_syncLogPane->show();
if (m_syncingForExit) {
// Do NOT quit: the edits are still unsynced and quitting now would
// discard the user's choice silently, which is the failure the
// whole prompt exists to prevent. Leave the window open with the
// log showing, so they can see what went wrong and decide.
m_syncingForExit = false;
QMessageBox::warning(
this, tr("Sync failed"),
tr("The sync failed (exit %1), so your changes are still "
"unsynced. The window has been left open.").arg(exitCode));
}
}
}
void MainWindow::onTagsApplied(const TagChange &change)
{
m_pendingChange = {};
m_pendingThreadIds.clear();
// Recorded here, where a write is CONFIRMED, rather than where one is sent:
// an optimistic update the worker later rejects must not leave the
// indicator claiming an edit that never landed.
//
// NET state, not a count of writes. An edit and its inverse leave the mail
// store where it started, so they must leave the indicator at zero: the
// automatic mark-read followed by Ctrl+U used to read as 2 unsynced
// changes when nothing was outstanding. What the user needs to know is
// whether quitting now would strand work.
//
// Keyed per (message, tag): removing `unread` and adding `flagged` on one
// message are two independent changes and must not cancel each other.
for (const QString &messageId : change.messageIds) {
for (const QString &tag : change.added)
recordPendingEdit(messageId, tag, true);
for (const QString &tag : change.removed)
recordPendingEdit(messageId, tag, false);
}
// A change carrying no message ids cannot be netted against anything, and
// must still register: losing an edit understates the indicator, which is
// the direction that costs the user work.
if (change.messageIds.isEmpty()
&& !(change.added.isEmpty() && change.removed.isEmpty())) {
++m_unnettablePendingEdits;
}
updatePendingIndicator();
// Item 71. Armed here, where a write is CONFIRMED and the pending count is
// already up to date, for the same reason recordPendingEdit() is called
// here: a sync scheduled for a write the worker went on to reject would run
// for nothing.
scheduleAutoSync();
// A tag the user has just created is the one they are most likely to type
// again, so do not wait for the next sync to offer it. A set membership
// test, not a query.
for (const QString &tag : change.added) {
if (!m_knownTags.contains(tag)) {
requestAllTags();
break;
}
}
}
void MainWindow::refreshCurrentQuery()
{
// The null guard is not defensive padding, it is a reachable path found by
// this item's own test crashing the constructor. SyncMonitor::start() polls
// SYNCHRONOUSLY (src/syncmonitor.cpp:52), so a machine whose lock file is
// idle at that moment emits stateChanged(Idle) from inside buildUi(), while
// m_model and the worker are still null. Nothing to refresh at that point
// anyway: the startup query has not run.
if (!m_model || !m_worker)
return;
// m_lastQuery, not the text in the query bar: the bar holds whatever the
// user has typed since, which may be a query they never ran. Refreshing to
// that would execute a search they did not ask for.
if (m_lastQuery.isEmpty())
return;
// Nothing is cleared. No m_model->clear(), no m_undoStack.clear(), no
// m_messageView->clear(): that list is exactly what runCurrentQuery()
// destroys and what makes it unusable on a cron timer.
m_refreshGeneration = ++m_generation;
m_refreshThreads.clear();
const auto sort = m_sortOrder->currentIndex() == 1
? NotmuchWorker::OldestFirst
: NotmuchWorker::NewestFirst;
// The SAME recipients flag the visible view was built with. A refresh that
// dropped it would quietly replace a Sent view's recipients with empty
// strings on the first background sync, while the user was reading it.
QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection,
Q_ARG(QString, m_lastQuery),
Q_ARG(quint64, m_refreshGeneration),
Q_ARG(NotmuchWorker::SortOrder, sort),
Q_ARG(bool, m_sentView));
}
void MainWindow::updateStaleThreadNotice()
{
// Which thread the pane is showing depends on what was selected: a thread
// row sets m_currentThreadId, a message row clears it and sets
// m_currentMessageId instead, so the message case has to be resolved back
// to its thread. Reading only m_currentThreadId would leave a reader who is
// three replies deep with no notice at all, which is the commonest way to
// be deep in a thread in the first place.
// The message id is carried whenever there IS one, whichever row kind put
// it there. A thread ROOT sets both: the root card is the thread's first
// message and the pane renders that message alone, so treating the message
// id as the message-row case only threw it away for the commonest way to
// open a thread, and recovery then had nothing to reopen.
QString threadId = m_currentThreadId;
const QString messageId = m_currentMessageId;
if (threadId.isEmpty())
threadId = m_currentMessageThreadId;
if (threadId.isEmpty()) {
m_messageView->setStaleThread(QString(), QString());
return;
}
// Present means matching: the model holds exactly the query's result after
// a reconcile.
for (int row = 0; row < m_model->rowCount(QModelIndex()); ++row) {
if (m_model->threadAt(row).threadId == threadId) {
m_messageView->setStaleThread(QString(), QString());
return;
}
}
m_messageView->setStaleThread(threadId, messageId);
}
void MainWindow::recoverStaleThread(const QString &threadId,
const QString &messageId)
{
if (threadId.isEmpty())
return;
// thread:<id> lists the WHOLE conversation rather than the single message,
// which is what the user asked for: eight messages, with the fourth
// selected, matching what the pane already shows.
m_queryEdit->setText(QStringLiteral("thread:%1").arg(threadId));
runCurrentQuery();
// Set AFTER the query, which clears any pending recovery: this one is the
// query's own reason for running and must survive it.
//
// Remembered across the two queued round-trips this takes: the query has to
// come back before the thread can be expanded, and the expansion before the
// message row exists to select.
m_recoverThreadId = threadId;
m_recoverMessageId = messageId;
}
void MainWindow::onRowDoubleClicked(const QModelIndex &index)
{
if (!index.isValid())
return;
// The whole thread in every case, and the double-clicked row's own message
// in the pane. A reply therefore drills to its THREAD with itself selected,
// never to itself alone: "double click on a reply in a thread should still
// load the whole thread expanded in a view by itself, with the reply I
// clicked on visible in the right pane" (item 91). An id: query on the
// reply is the obvious reading of "open it by itself" and is the wrong one.
//
// Reached through the INDEX rather than through index.row(): a tree numbers
// rows per parent, so a reply's row indexes its siblings and threadAt() on
// one answers about an unrelated thread.
QString threadId;
QString messageId;
if (m_model->isMessageRow(index)) {
const MessageNode node = m_model->messageAt(index);
threadId = node.threadId;
messageId = node.messageId;
} else {
threadId = m_model->data(index, ThreadListModel::ThreadIdRole).toString();
// The thread's first message, so the pane opens on it rather than on
// nothing. Empty is fine and means the same thing to the recovery: land
// on the root, which IS that message.
messageId = m_model->data(index, ThreadListModel::MessageIdRole).toString();
}
if (threadId.isEmpty())
return;
// The first click of the double-click already selected this row and armed
// the mark-read timer. The user is passing through on their way into the
// thread, and a gesture that navigates must not mutate mail, so the timer
// goes the same way it does for a multi-row selection.
//
// Not a correction of the single click's behaviour: the thread is about to
// be opened and its message read, which arms the timer again for the row
// the recovery selects. What is cancelled is the arming for a row the user
// is leaving.
m_markReadTimer->stop();
m_markReadMessageId.clear();
// Reuses the stale-thread recovery outright, which already runs thread:<id>,
// expands the thread when the row arrives, selects the target message once
// the replies land, and falls back to the root when the message has gone.
// Every one of item 91's three cases is one of those paths.
recoverStaleThread(threadId, messageId);
}
void MainWindow::applyPendingRecovery()
{
if (m_recoverThreadId.isEmpty())
return;
for (int row = 0; row < m_model->rowCount(QModelIndex()); ++row) {
const QModelIndex thread = m_model->index(row, 0, QModelIndex());
if (m_model->threadAt(row).threadId != m_recoverThreadId)
continue;
// Expanded in every case, and FIRST. The user was reading a
// conversation, so bringing it back collapsed hides the thing they
// asked to get back to, whether their message was the root or a reply.
// Expanding is also what asks the worker for the replies, so it has to
// happen before any attempt to find one.
m_threadView->expand(thread);
// The thread's first message IS the root card rather than a child row:
// setThreadMessages drops depth 0 because the root stands for it, so
// looking for it among the children finds nothing and the selection
// would silently land nowhere.
//
// selectRowAt(), not setCurrentIndex(): a current index without a
// selection is what QTreeView sets by itself on focus, and
// onThreadSelected() deliberately ignores that, so pointing at the row
// renders nothing and leaves the pane blank.
if (m_recoverMessageId.isEmpty()
|| m_model->data(thread, ThreadListModel::MessageIdRole).toString()
== m_recoverMessageId) {
selectRowAt(thread);
m_recoverThreadId.clear();
m_recoverMessageId.clear();
return;
}
// A reply cannot be selected until the replies exist. The expand above
// asked for them, and this runs again when they arrive.
//
// The thread is selected NOW rather than waiting, because a freshly
// queried row does not know its own first message either: the root's
// MessageIdRole is empty until the tree loads
// (`src/threadlistmodel.cpp`), so the root check above cannot match yet
// and returning here would leave the user looking at a collapsed thread
// and a blank pane until the replies happen to arrive. Selecting the
// thread renders its first message immediately, which is the right
// answer outright when that is what they were reading, and is refined
// to the correct reply on the next pass when it is not.
//
// The target is deliberately NOT cleared: this pass is provisional.
if (m_model->rowCount(thread) == 0) {
selectRowAt(thread);
return;
}
for (int child = 0; child < m_model->rowCount(thread); ++child) {
const QModelIndex reply = m_model->index(child, 0, thread);
if (m_model->messageAt(reply).messageId != m_recoverMessageId)
continue;
selectRowAt(reply);
m_recoverThreadId.clear();
m_recoverMessageId.clear();
return;
}
// The thread came back without the message: it was deleted, or moved
// between accounts. Land on the thread rather than leaving the user
// with nothing selected.
selectRowAt(thread);
m_recoverThreadId.clear();
m_recoverMessageId.clear();
return;
}
}
void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state)
{
if (state == SyncMonitor::State::Running) {
// A sync this window started is already reported by setSyncBusy().
// Remember that this particular lock period is ours, because the
// release at the end of it must be ignored too: the process exits, and
// therefore isRunning() goes false, BEFORE the monitor's next poll sees
// the lock gone. Testing isRunning() again on that poll would report a
// local sync as an external one, stamping "background sync completed"
// over the local run's own result up to two seconds later.
m_localSyncHoldsLock = (m_sync && m_sync->isRunning());
if (m_localSyncHoldsLock)
return;
m_externalSyncBusy = true;
updateSyncControls();
m_statusLabel->setText(tr("Background sync running..."));
m_announcedExternalSync = true;
return;
}
// The release of a lock this window took. onSyncFinished() has already
// said what happened, including for a failure, so there is nothing to add.
if (m_localSyncHoldsLock) {
m_localSyncHoldsLock = false;
m_externalSyncBusy = false;
updateSyncControls();
// A local sync releases the write lock exactly as a background one
// does, and an edit made during it is held the same way. Without this
// the held edits would wait for the NEXT sync to come and go.
flushHeldEdits();
return;
}
// Cleared for Idle AND for Unknown. Unknown means /proc/locks could not be
// read, so nothing is observed; leaving the button disabled there would
// strand it permanently on a platform that cannot see the lock at all.
m_externalSyncBusy = false;
updateSyncControls();
// Refreshes, unconditionally, and says nothing about it.
//
// 0.8.0 refused to refresh here because runCurrentQuery() clears the undo
// stack, the selection and the message pane, which is right for a query the
// user typed and hostile for one fired by a cron timer. The status bar
// asked the user to press Enter instead. That made the list quietly stale:
// new mail indexed by cron never appeared, and an Unread view read to the
// end stayed empty in front of it.
//
// The answer is not to weigh the cost, it is to remove it.
// refreshCurrentQuery() reconciles the result into the model instead of
// resetting it, so a surviving thread keeps its row, its expansion and its
// selection, and the message being read stays on screen. Nothing has to be
// preserved by declining to run.
//
// No status message: a refresh that changes nothing must be invisible, and
// one that adds mail is announced by the mail appearing. Six "sync
// completed" messages an hour are noise reporting the expected.
//
// Unknown is not refreshed. It means the lock table could not be read, so
// no sync was observed, and refreshing on it would re-query on every failed
// poll rather than after a sync.
// Retire our own running message, and only that one. The refresh below says
// nothing, which is right for a sync that changed nothing, but "says
// nothing" must not mean "leaves 'Background sync running...' on screen
// after it stopped". Anything else in the bar belongs to the user (a
// selection count, a tag result) and is left alone.
if (m_announcedExternalSync) {
m_announcedExternalSync = false;
m_statusLabel->setText(m_defaultStatus);
}
// BEFORE the refresh below, and the order is the whole of a defect. An edit
// made during a sync is held, because the worker's read-write open blocks
// on notmuch's exclusive lock. Refreshing first meant reading a database
// that still carried the old tag and reconciling that into the model, which
// overwrote the optimistic update; the flush then wrote the tag correctly,
// leaving the database right and the list wrong with nothing scheduled to
// re-read it. Reported by hand as a message going back to unread at the end
// of the sync it was read during.
//
// Flushing first also costs nothing when there is nothing held: the
// function returns immediately on an empty queue.
//
// OUTSIDE the Idle branch, deliberately, and this predates the reordering.
// Unknown clears the busy flag above, so writes resume from here on;
// leaving the flush inside Idle would let a new edit go straight out while
// the ones already held sat waiting for an Idle that a broken /proc/locks
// will never report.
flushHeldEdits();
if (state == SyncMonitor::State::Idle) {
refreshCurrentQuery();
// Item 54. A cron sync carries the edits to the mail store exactly as a
// local one does, so the count it cleared has to be cleared here too.
// Without this the indicator kept reporting work that had already
// shipped, and the exit prompt asked to sync for it.
//
// The outcome comes from the RUN END line the script writes, because
// the process that ran this sync is gone and its exit status with it.
// Anything other than a definite OK changes nothing: the local path's
// rule is that only a SUCCESSFUL sync may clear the count, and Unknown
// is the absence of evidence rather than evidence of success.
if (MailSync::lastRunOutcome(m_config.syncLog()) == SyncOutcome::Ok) {
m_pendingTagEdits.clear();
m_unnettablePendingEdits = 0;
// Cleared HERE, before flushHeldEdits() below, and the ordering is
// load-bearing for the reason spelled out on the local path at
// onSyncFinished(): the flush calls sendThreadTagChange(), which
// writes m_editedAccounts SYNCHRONOUSLY. Clearing after the flush
// would discard accounts whose edits this run did not carry, and
// those edits would then sync only when some later edit happened to
// name the same account. Running first, everything in the set at
// this moment is exactly what the finished sync carried, so the
// local path's snapshot-and-subtract collapses to a clear.
m_editedAccounts.clear();
updatePendingIndicator();
}
}
}
void MainWindow::showTransientStatus(const QString &text)
{
m_transientMessage = text;
m_statusLabel->setText(text);
m_statusTimer->start();
}
void MainWindow::feedSyncPhase(const QString &chunk)
{
// readAll() returns whatever happened to be buffered, which splits mid-line
// as often as not, so lines are reassembled here rather than in the tracker:
// a half-line fed to it would match nothing and the phase would stall.
m_syncLineBuffer += chunk;
int newline;
bool changed = false;
while ((newline = m_syncLineBuffer.indexOf(QLatin1Char('\n'))) >= 0) {
const QString line = m_syncLineBuffer.left(newline);
m_syncLineBuffer.remove(0, newline + 1);
if (m_syncPhase.feed(line))
changed = true;
}
// The tail without a newline is deliberately left in the buffer: mbsync can
// sit on a line for a while, and feeding a partial one would report a phase
// from half a word.
if (!changed)
return;
// Not showTransientStatus(): a phase is state, not an event, and must not
// expire out from under a sync that is still running. Writing the label
// directly also leaves m_transientMessage alone, so the timer will not
// reclaim a phase it did not arm.
m_statusLabel->setText(m_syncPhase.statusText());
}
void MainWindow::setSyncBusy(bool busy)
{
m_localSyncBusy = busy;
updateSyncControls();
// The phase tracker is reset in startSync(), before the process launches,
// not here: this runs after start() and a fast run has already produced
// output by then. Setting the label is still right, since the tracker has
// nothing to say until a line it recognises arrives.
if (busy && m_syncPhase.statusText().isEmpty())
m_statusLabel->setText(tr("Syncing..."));
}
void MainWindow::updateSyncControls()
{
// ONE function of both states, deliberately. Two independent assignments,
// one per sync path, means whichever fires second wins: a background sync
// ending would re-enable the button in the middle of a local run, and a
// local run ending would re-enable it while cron still holds the lock.
const bool busy = m_localSyncBusy || m_externalSyncBusy;
m_syncProgress->setVisible(busy);
// Disabled rather than left clickable: MailSync::start() already refuses a
// second run and the script exits 75 when another holds the lock, but a
// button that looks live and does nothing is worse than one that shows it
// is unavailable.
//
// Note this reads Running specifically, not "not Idle". Unknown means
// /proc/locks could not be read and nothing was observed, so the button
// stays usable: permanently disabling it where the lock cannot be seen is
// worse than occasionally offering a run that gets skipped.
// The QAction is the only Sync control now, and setEnabled on it reaches
// the toolbar button, the menu entry and the shortcut at once. Item 29
// originally set a separate QPushButton and missed the action entirely, so
// the toolbar stayed clickable through a background sync.
if (QAction *action = m_actions.value(QStringLiteral("sync")))
action->setEnabled(!busy && m_sync && m_sync->isAvailable());
}
void MainWindow::startSync()
{
// One handler for every route in: the toolbar, the menu, the shortcut and
// the button. They previously had two, and only the button's cleared the
// log, showed the pane and disabled the control, so a sync started from the
// toolbar ran with no visible sign it had.
if (!m_sync->isAvailable()) {
showTransientStatus(
tr("No sync command configured ([sync] command in qtmaildir.conf)"));
return;
}
// Fresh run, fresh output: leaving the previous run's lines in place
// makes a stale failure look like the current one.
m_syncLog->clear();
// BEFORE start(), not after. A short run can deliver its whole output
// before control returns here, and resetting afterwards would wipe the
// phase those lines had already produced, leaving a fast sync showing
// nothing between "Syncing..." and "Sync complete".
m_syncPhase.reset();
m_syncLineBuffer.clear();
if (!m_sync->start(pendingSyncChannels())) {
showTransientStatus(tr("Sync already running"));
return;
}
setSyncBusy(true);
}
void MainWindow::scheduleAutoSync()
{
// Negative disables the behaviour entirely, per the config key, and that is
// the pre-0.16.0 behaviour: edits wait for a manual sync or the user's cron
// job. Checked before anything else so a disabled delay arms nothing.
const int delay = m_config.autoSyncDelayMs();
if (delay < 0)
return;
// No sync command means the Sync action is already disabled and startSync()
// would only put "No sync command configured" in the status bar. Arming a
// timer to say that on a delay, for something the user did not ask for, is
// worse than staying quiet.
if (!m_sync || !m_sync->isAvailable())
return;
// Nothing outstanding, nothing to carry. An edit netted against its own
// inverse leaves the count at zero (item 28), and syncing for it would run
// mbsync over a mail store that is already where the server left it.
if (pendingEditCount() == 0)
return;
// Restart, not stack. Tagging a multi-row selection confirms one write per
// thread and "mark all read" confirms one per thread in the view, so an
// armed-per-edit timer would be exactly the storm of syncs a debounce is
// for. The last edit of a burst decides when the single sync happens.
m_autoSyncTimer->start(delay);
}
void MainWindow::runAutoSync()
{
// The user can have synced by hand, or undone the edit, in the delay. Both
// leave nothing to carry, and re-checking here rather than trusting the arm
// is what makes the debounce safe to restart freely.
if (pendingEditCount() == 0)
return;
// Skip rather than queue when a sync is already in flight, which item 71
// requires: the cron job holds the same lock, and mbsync's own answer to a
// second run is to fail on it. The edits are not lost by skipping. They stay
// pending, and the sync already running is very likely to carry them, since
// they reached the mail store at edit time.
//
// m_externalSyncBusy covers the cron job SyncMonitor can see. A lock taken
// between that poll and now is not visible here, and does not need to be:
// MailSync::start() fails on a second run and startSync() reports it.
//
// Re-armed rather than abandoned. Skipping is right; giving up is not. The
// running sync is only VERY LIKELY to carry the edit, since an edit made
// after mbsync has already passed that account's mailbox is not carried by
// it, and before this the timer had fired, nothing re-armed it, and the
// count sat non-zero until a manual sync or the next cron run.
//
// scheduleAutoSync() re-checks the delay, the sync command and the pending
// count on the way in, so this cannot arm a sync for nothing. Against a
// long external sync it re-arms once per debounce interval until the lock
// clears, which is the user's own interval and a timer, not a sync.
if (m_externalSyncBusy || (m_sync && m_sync->isRunning())) {
scheduleAutoSync();
return;
}
startSync();
}
void MainWindow::recordPendingEdit(const QString &messageId, const QString &tag,
bool added)
{
const QString key = messageId + QLatin1Char('\n') + tag;
// A tag put back the way it was is not an outstanding change. Erase rather
// than store the new direction, or the ledger grows without bound over a
// long session of tagging and untagging.
const auto existing = m_pendingTagEdits.constFind(key);
if (existing != m_pendingTagEdits.constEnd()) {
if (*existing != added)
m_pendingTagEdits.erase(m_pendingTagEdits.find(key));
return;
}
m_pendingTagEdits.insert(key, added);
}
QStringList MainWindow::pendingSyncChannels() const
{
// Nothing pending means this run is a FETCH, and a fetch must cover every
// account: narrowing it to wherever the last edit happened to be would
// quietly stop collecting mail everywhere else. Empty is the signal for
// that, and MailSync::start() appends nothing.
if (m_editedAccounts.isEmpty())
return {};
QStringList channels;
for (const Account &account : m_config.accounts()) {
if (m_editedAccounts.contains(account.key))
channels.append(account.syncChannel());
}
// An account tag with no matching [account.<key>] section yields no
// channel, and syncing a subset that omits it would leave its edits behind
// with nothing to say so. Fall back to a full sync, which is correct if
// wasteful; the alternative is silently stranding an edit.
if (channels.size() != m_editedAccounts.size())
return {};
// Stable order so a run is reproducible and the log reads the same way
// twice. QSet has no order of its own.
channels.sort();
return channels;
}
int MainWindow::pendingEditCount() const
{
// A held edit has NOT reached the index, so onTagsApplied() never counted
// it. It still has to count here: this is what the exit prompt reads, and
// an edit waiting on a lock is precisely the work quitting would lose.
// Each held edit counts as one whatever its size, since it carries thread
// ids rather than message ids and cannot be netted against the map.
const int held = int(m_heldEdits.size());
// Held MOVES count for exactly the same reason, and were missed. With no
// tag edit queued the count was 0, so the indicator stayed hidden and
// closeEvent()'s `pendingEditCount() > 0` guard never fired: a Delete
// pressed during a sync was discarded on quit with no prompt at all. That
// is item 106's data loss, and worse here, because a dropped move leaves
// the file in the folder the user asked it out of.
const int heldMoves = int(m_heldMoves.size());
return m_pendingTagEdits.size() + m_unnettablePendingEdits + held
+ heldMoves;
}
void MainWindow::updatePendingIndicator()
{
const int pending = pendingEditCount();
if (pending <= 0) {
m_pendingLabel->hide();
return;
}
// "Changes" and not "mutations": the unit the user thinks in is the tagging
// they did, not the writes it became.
m_pendingLabel->setText(tr("%n unsynced change(s)", "", pending));
m_pendingLabel->setToolTip(
tr("Changes made here that a sync has not yet carried to the mail "
"store. An external notmuch run can clear them without this count "
"noticing."));
m_pendingLabel->show();
}
void MainWindow::scheduleMarkRead(const QString &messageId, bool unread)
{
// Any pending timer belongs to a message that is no longer on screen.
// Stopping unconditionally is what makes this a restart rather than a
// stack: arrowing down ten rows must mark only the one still selected
// when the timer finally fires.
m_markReadTimer->stop();
m_markReadMessageId.clear();
// Negative disables the behaviour entirely, per the config key.
const int delay = m_config.markReadDelayMs();
if (delay < 0)
return;
// A row the model cannot name a message for. Marking its thread instead
// would be the escalation item 108 removed.
if (messageId.isEmpty())
return;
// Nothing to do for a message that is already read. Checked here rather
// than in the handler so no timer is even armed, which keeps a read
// message from arming one that would fire into a no-op write.
if (!unread)
return;
m_markReadMessageId = messageId;
// Zero means immediately, and a zero-interval timer still fires through
// the event loop rather than reentering the selection handler.
m_markReadTimer->start(delay);
}
void MainWindow::markCurrentThreadRead()
{
if (m_markReadMessageId.isEmpty())
return;
// The selection can have moved on between the timer being armed and it
// firing, and the message can have been marked read by hand in that
// window. Both mean this timer has nothing left to do.
//
// Compared against what the PANE is showing rather than against the
// selection: those are the same thing for both kinds of row, and the pane
// is what "the message the user is reading" means.
const QString showing = m_currentMessageId.isEmpty()
? currentThreadFirstMessageId()
: m_currentMessageId;
if (m_markReadMessageId != showing) {
m_markReadMessageId.clear();
return;
}
const QStringList messageIds = { m_markReadMessageId };
m_markReadMessageId.clear();
// sendMessageTagChange, NOT tagSelected: this deliberately does not go on
// the undo stack. The user never took this action, so hijacking Ctrl+Z to
// reverse it would undo something they did not do, and toggle_unread
// already gives them a direct way to put it back. Decided 2026-08-03.
//
// MESSAGE-scoped since item 87. The thread-wide write was coherent while a
// root card rendered the whole conversation; item 66 made it render one
// message and left the write alone, so reading one message marked replies
// read that had never been displayed. maildir.synchronize_flags is on, so
// that reached the server and nothing here could put it back.
//
// It still funnels through the one applyTags path, per CLAUDE.md; what
// differs is only whether the inverse is pushed, which is a window-level
// decision above the worker.
sendMessageTagChange(messageIds, {}, { QStringLiteral("unread") },
tr("Mark read"));
}
QString MainWindow::currentThreadFirstMessageId() const
{
// The message a selected THREAD row displays. m_currentThreadId is what
// the pane was opened from, so this resolves through the model rather than
// through the selection, which can have moved.
if (m_currentThreadId.isEmpty())
return {};
for (int row = 0; row < m_model->rowCount(QModelIndex()); ++row) {
const ThreadSummary thread = m_model->threadAt(row);
if (thread.threadId == m_currentThreadId)
return thread.firstMessageId;
}
return {};
}
bool MainWindow::everySelectedRowHasTag(const QString &tag,
TagScope scope) const
{
// What a toggle asks before choosing its direction, for both Delete and
// Toggle unread.
//
// Per ROW, and each row is asked about what it stands for: a reply row
// reports the message's tags, a thread row the thread's. Asking a reply's
// THREAD is the trap both toggles fell into. The write is message-scoped,
// so it never changes the thread's tags; the thread's answer therefore
// never moves however many times the key is pressed, and the toggle
// becomes one-way. On the second press it re-sends a tag the message
// already has, which is a no-op, and a no-op repaints nothing.
//
// One direction for the WHOLE selection, which is the rule Delete
// established: toggling each row independently would leave one keystroke
// with the selection in two states, which is worse than either outcome.
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
if (rows.isEmpty())
return false;
for (const QModelIndex &index : rows) {
QStringList tags;
if (scope == TagScope::Thread) {
tags = m_model->threadFor(index).tags;
} else if (m_model->isMessageRow(index)) {
tags = m_model->messageAt(index).tags;
} else {
// A thread row answers about the MESSAGE ITS CARD DISPLAYS, which
// is what it acts on. threadFor() already substitutes that
// message's own tags for the thread's union when they are known
// (item 110), so this reads the row's real state rather than a
// union over messages it does not stand for.
//
// This used to read the union deliberately, with a comment
// calling the imprecision bounded because no per-message tags
// existed in the model. They do now: ThreadSummary carries
// firstMessageTags from the query, so an UNEXPANDED row already
// knows its own tags, and the comment outlived the fact.
//
// The cost of the union was not bounded once Delete became a
// MOVE. Deleting the root of a three-message thread left the two
// replies undeleted, so the union carried no `deleted`, so a
// second press read the row as not-deleted and deleted it AGAIN:
// the message was moved trash-to-trash and came out carrying
// `deleted`, `deleted-from:inbox` and `deleted-from:Trash` at
// once, with no way back. A tag toggle merely re-applied a tag it
// already had; a move re-applies the MOVE.
//
// Resolved through messageById() on the row's own message, which
// is the id messageScopeFor() will act on. Asking the same
// question the write asks is what keeps the direction and the
// write from disagreeing; the union answered a question about a
// conversation when the row stands for one message.
const ThreadSummary summary = m_model->threadFor(index);
const MessageNode own =
m_model->messageById(summary.firstMessageId);
// messageById() and NOT summary.firstMessageTags, which is the
// value the QUERY delivered and is not refreshed by an optimistic
// update: applyMessageTagChange() writes the row's node, so after
// a delete the node reads `deleted, deleted-from:inbox` while the
// summary still reads `inbox, unread`. Measured, and preferring
// the summary left this defect exactly as it was.
tags = own.messageId.isEmpty() ? summary.firstMessageTags
: own.tags;
}
if (!tags.contains(tag))
return false;
}
return true;
}
ThreadSummary MainWindow::threadForCurrentRowForTesting() const
{
return m_model->threadFor(m_threadView->currentIndex());
}
QMenu *MainWindow::buildThreadActionsMenu(QWidget *parent)
{
// Built per call rather than shared. A QMenu belongs to one place in one
// menu tree, and adding the same instance to both the menu bar and the
// context menu gives whichever added it last the object. The ACTIONS are
// shared, which is what has to stay consistent; the menu holding them is
// just a container.
auto *menu = new QMenu(tr("&Whole thread"), parent);
menu->setObjectName(QStringLiteral("threadActionsMenu"));
menu->addAction(m_actions.value(QStringLiteral("archive_thread")));
menu->addAction(m_actions.value(QStringLiteral("delete_thread")));
menu->addAction(m_actions.value(QStringLiteral("spam_thread")));
menu->addSeparator();
menu->addAction(m_actions.value(QStringLiteral("toggle_unread_thread")));
menu->addAction(m_actions.value(QStringLiteral("flag_thread")));
return menu;
}
QHash<QString, int> MainWindow::selectionTagCounts() const
{
// How many of the selected rows carry each tag, which is what tells a tag
// that is on all of them from one that is on some. The dialog's tri-state
// checkboxes are built from this, so a wrong count offers to remove a tag
// the selection does not have.
//
// threadFor(index), NOT threadAt(index.row()): a reply's row number named
// an unrelated thread, so selecting one counted the tags of whichever
// thread sat at that position in the list (item 88).
QHash<QString, int> counts;
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
for (const QModelIndex &index : rows) {
const ThreadSummary thread = m_model->threadFor(index);
for (const QString &tag : thread.tags)
counts[tag] += 1;
}
return counts;
}
void MainWindow::editTagsOnSelection()
{
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
if (rows.isEmpty()) {
showTransientStatus(tr("Select a thread first"));
return;
}
const QHash<QString, int> counts = selectionTagCounts();
// m_knownTags is the same list the query completer uses, so the dialog
// offers every tag in the database without a round trip.
TagDialog dialog(m_knownTags, counts, rows.size(), this);
if (dialog.exec() != QDialog::Accepted)
return;
const QStringList add = dialog.tagsToAdd();
const QStringList remove = dialog.tagsToRemove();
if (add.isEmpty() && remove.isEmpty())
return; // Applied with nothing changed.
// Straight through tagSelected(), so this inherits undo, the optimistic
// model update, the one-query multi-row resolution, and the completer
// refresh for a tag that did not exist before.
tagSelected(add, remove, tr("Edit tags"));
}
void MainWindow::tagSelected(const QStringList &add, const QStringList &remove,
const QString &description, TagScope tagScope)
{
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
if (rows.isEmpty())
return;
// Resolved through the model rather than by mapping rows to threads here.
// A message row's row number indexes its siblings, so the old
// threadAt(index.row()) mapping silently acted on whichever thread sat at
// that position in the list.
//
// Message scope by default since item 108: a thread row displays one
// message, so acting on it acts on that message. Thread scope is what the
// "Whole thread" actions ask for explicitly.
const ActionScope scope = tagScope == TagScope::Thread
? m_model->scopeFor(rows)
: m_model->messageScopeFor(rows);
if (scope.isEmpty())
return;
if (!scope.threadIds.isEmpty()) {
sendThreadTagChange(scope.threadIds, add, remove, description);
// Pushed for undo. The inverse re-resolves the same threads, so it
// works whether or not those rows are still selected.
m_undoStack.push(new ThreadTagCommand(this, scope.threadIds, add,
remove, description));
}
if (!scope.messageIds.isEmpty()) {
sendMessageTagChange(scope.messageIds, add, remove, description);
m_undoStack.push(new MessageTagCommand(this, scope.messageIds, add,
remove, description));
}
// The scope named after the fact, since the selection may well be gone by
// the time the user reads it. This is what stands in for the confirmation
// dialog CLAUDE.md rules out: undo is the safety net, and undo is only
// usable if the user can tell that something larger than they meant has
// just happened.
showTransientStatus(
scope.wholeThread
? tr("%1: %n message(s) (whole thread)", "", scope.messageCount)
.arg(description)
: tr("%1: %n message(s)", "", scope.messageCount).arg(description));
}
void MainWindow::sendMessageTagChange(const QStringList &messageIds,
const QStringList &add,
const QStringList &remove,
const QString &description)
{
if (messageIds.isEmpty())
return;
// Optimistically applied to each MESSAGE's own row. applyTagChange is
// keyed by thread and would repaint the whole card as though every message
// in it had changed, which for a one-message edit is a lie; that is why
// this path had no optimistic update at all, and the cost was that Delete
// and Toggle unread on a reply moved the pending count and changed nothing
// the user could see. The reply's own row is where the feedback belongs.
for (const QString &messageId : messageIds)
m_model->applyMessageTagChange(messageId, add, remove);
// The strip shows the tags of the message ON DISPLAY, so it has to follow
// an edit to that message rather than waiting for the next selection. The
// thread path has carried this since the strip existed; without it here, a
// message-scoped edit repainted the list row and left the pane's chips
// describing the message as it was, until the user selected away and back.
//
// Keyed on m_currentMessageId, which is set only for a message row, so a
// write to some other reply cannot repaint the open one with its tags.
//
// Read by ID, not from currentIndex(): the two agree today, and a guard
// that depends on them agreeing would put the WRONG message's tags in the
// pane on the day they do not. The id is what the pane is actually
// showing.
if (!m_currentMessageId.isEmpty()
&& messageIds.contains(m_currentMessageId)) {
m_messageView->setTags(
m_model->messageById(m_currentMessageId).tags);
}
// The accounts this touches, resolved through the containing threads: the
// account is a property of the thread, and the sync needs the channel
// whether one message moved or seven.
for (const QString &messageId : messageIds) {
const QString threadId = m_model->threadIdForMessage(messageId);
if (threadId.isEmpty())
continue;
for (const QString &key : m_model->accountKeysForThread(threadId))
m_editedAccounts.insert(key);
}
// Held during a sync for exactly the reason the thread path is: the
// worker's read-write open BLOCKS on notmuch's exclusive lock rather than
// failing, so sending now would freeze the worker for the rest of the run.
if (aSyncHoldsTheWriteLock()) {
m_heldEdits.append(HeldEdit{
{}, TagChange{ messageIds, add, remove, description } });
m_statusLabel->setText(
tr("A sync is running; your change will be applied when it "
"finishes."));
updatePendingIndicator();
return;
}
m_pendingThreadIds.clear();
m_pendingChange = TagChange{ messageIds, add, remove, description };
QMetaObject::invokeMethod(m_worker, "applyTags", Qt::QueuedConnection,
Q_ARG(TagChange, m_pendingChange));
}
const QString &MainWindow::kOriginTagPlaceholder()
{
// Not wrapped in tr(). It is never displayed: onMessagesMoved() replaces
// it with a real tag before anything reaches the worker, and a translated
// placeholder would stop matching in the one locale that translated it,
// which is the trap CLAUDE.md records for startup_query.
static const QString placeholder =
QStringLiteral("\x01qtmaildir-origin-placeholder");
return placeholder;
}
Account MainWindow::accountForMessagePath(const QString &path) const
{
// From the PATH, not from the thread's account tag. The tag is optional
// config, so resolving through it would silently disable Delete for an
// account that never set one; a message's maildir prefix is what makes it
// belong to an account at all.
//
// Longest maildir wins, so nested account maildirs (`mail` and
// `mail/work`) resolve to the more specific one rather than to whichever
// happens to be listed first.
//
// BOTH path shapes are accepted, and that is not defensive coding. A
// thread row's path comes from ThreadSummary::firstMessagePath and is
// database-RELATIVE; a reply row's comes from MessageNode::filePath and is
// ABSOLUTE, because MimeParser has to open it. Matching only the relative
// form resolved every reply to no account, so Delete on a reply reported
// "no trash folder configured" and moved nothing, which is exactly the
// thread-row/reply-row asymmetry this file has been bitten by before.
//
// A `/` is required after the maildir in both cases, so `acctX` cannot
// match an account whose maildir is `acct`.
Account best;
int bestLength = -1;
for (const Account &account : m_config.accounts()) {
if (account.maildir.isEmpty())
continue;
const QString segment = QLatin1Char('/') + account.maildir
+ QLatin1Char('/');
const bool matches =
path.startsWith(account.maildir + QLatin1Char('/'))
|| path.contains(segment);
if (!matches)
continue;
if (account.maildir.length() > bestLength) {
best = account;
bestLength = account.maildir.length();
}
}
return best;
}
void MainWindow::trashSelected()
{
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
if (rows.isEmpty())
return;
// Message scope, exactly as tagSelected() uses by default: a thread row
// stands for the ONE message its card displays. Escalating to the thread
// would move a whole conversation into the trash because the user deleted
// one reply.
const ActionScope scope = m_model->messageScopeFor(rows);
if (scope.messageIds.isEmpty())
return;
QHash<QString, QString> pathById;
for (const QString &messageId : scope.messageIds)
pathById.insert(messageId, m_model->messageById(messageId).filePath);
trashMessages(scope.messageIds, pathById, scope.messageCount);
}
void MainWindow::trashMessages(const QStringList &messageIds,
const QHash<QString, QString> &pathById,
int messageCount,
const QStringList &wholeThreadIds)
{
if (messageIds.isEmpty())
return;
// Grouped by destination, because moveMessages() takes one folder per call
// and a selection can span accounts with different trash folders.
//
// Paths are passed IN rather than read from the model, because the thread
// path arrives with messages the model has never seen: a thread the user
// never expanded holds no node for its replies, so a lookup there returns
// nothing and every message resolves to no account.
QHash<QString, QStringList> byTrash;
QStringList unconfigured;
for (const QString &messageId : messageIds) {
const Account account =
accountForMessagePath(pathById.value(messageId));
if (account.trash.isEmpty()) {
unconfigured.append(messageId);
continue;
}
byTrash[account.maildir + QLatin1Char('/') + account.trash]
.append(messageId);
}
// Task 2 warns at config load; this is the second line of defence, for a
// user who never fixed it. Reported rather than silently doing nothing,
// and NOT tagged either: a `deleted` tag on a file still in the inbox is
// precisely the half-done state this item removes.
if (!unconfigured.isEmpty()) {
m_statusLabel->setText(
tr("%n message(s) could not be deleted: no trash folder is "
"configured for their account.", "", int(unconfigured.size())));
}
if (byTrash.isEmpty())
return;
for (auto it = byTrash.cbegin(); it != byTrash.cend(); ++it) {
sendMove(it.value(), it.key(),
{ QStringLiteral("deleted"), kOriginTagPlaceholder() }, {},
tr("Delete"), false, wholeThreadIds);
}
showTransientStatus(
tr("%1: %n message(s)", "", messageCount).arg(tr("Delete")));
}
QString MainWindow::originTagFor(const QString &dbRelativeFolder) const
{
// `acct/inbox` becomes `deleted-from:inbox`. The tag stores the folder
// relative to the ACCOUNT, never to the database: the account prefix is
// recomposed from the message's own path when it is read back, so storing
// it would duplicate it and would go stale the day the user renames a
// maildir.
//
// Shared by the two sites that need the tag, rather than derived twice.
// They disagreed once already: onMessagesMoved() resolved a placeholder
// from the folder the worker reported, which on a RESTORE is the trash
// rather than the origin, so the restore stripped `deleted-from:Trash`
// and left the real tag in place.
const Account account =
accountForMessagePath(dbRelativeFolder + QLatin1Char('/'));
QString accountRelative = dbRelativeFolder;
if (!account.maildir.isEmpty()
&& dbRelativeFolder.startsWith(account.maildir + QLatin1Char('/'))) {
accountRelative = dbRelativeFolder.mid(account.maildir.length() + 1);
}
if (accountRelative.isEmpty())
return QString();
return QStringLiteral("deleted-from:%1").arg(accountRelative);
}
QStringList MainWindow::selectedThreadIds() const
{
// A THREAD action on a reply row means that reply's conversation.
//
// scopeFor() reports a reply under messageIds and leaves threadIds empty,
// which is right for the mixed selections it was built for and wrong as
// the only input to a thread-scoped action: the early return on an empty
// threadIds made Delete thread do nothing at all when the selected row
// happened to be a reply. threadFor() resolves either kind of row.
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
QStringList threadIds;
for (const QModelIndex &index : rows) {
const QString threadId = m_model->threadFor(index).threadId;
if (!threadId.isEmpty() && !threadIds.contains(threadId))
threadIds.append(threadId);
}
return threadIds;
}
void MainWindow::trashSelectedThreads()
{
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
if (rows.isEmpty())
return;
const QStringList threadIds = selectedThreadIds();
if (threadIds.isEmpty())
return;
// Asked of the WORKER rather than resolved here. A thread the user never
// expanded has no nodes in the model for its replies, so the ids and the
// paths a move needs exist only in the database. applyTagsToThreads()
// solves the same problem the same way, for the same reason.
//
// Repainted HERE, synchronously, before the worker is asked.
//
// The move needs message ids and paths that only the database holds for an
// unexpanded thread, so the move itself is asynchronous. The DISPLAY must
// not wait for that round trip: the card is what the user watches, and
// holding it back is what made a deleted thread sit unchanged until it was
// clicked. It also keeps the toggle's direction readable immediately, so a
// second press restores rather than deleting again.
for (const QString &threadId : threadIds)
m_model->applyTagChange(threadId, { QStringLiteral("deleted") }, {});
m_pendingThreadScope = threadIds;
QMetaObject::invokeMethod(m_worker, "resolveThreadMessages",
Qt::QueuedConnection,
Q_ARG(QStringList, threadIds),
Q_ARG(QString, QStringLiteral("delete_thread")));
}
void MainWindow::onThreadMessagesResolved(const QStringList &messageIds,
const QStringList &paths,
const QStringList &tags,
const QString &requestTag)
{
if (messageIds.size() != paths.size() || messageIds.size() != tags.size())
return;
QHash<QString, QString> pathById;
for (int i = 0; i < messageIds.size(); ++i)
pathById.insert(messageIds.at(i), paths.at(i));
const QStringList threadScope = m_pendingThreadScope;
m_pendingThreadScope.clear();
if (requestTag == QStringLiteral("delete_thread")) {
trashMessages(messageIds, pathById, messageIds.size(), threadScope);
return;
}
if (requestTag == QStringLiteral("restore_messages")) {
restoreResolvedMessages(messageIds, paths, tags);
return;
}
if (requestTag != QStringLiteral("undelete_thread"))
return;
// Restore, resolved per message: each one goes back to the folder its own
// `deleted-from:` tag names, so a thread whose messages were deleted from
// different folders reassembles correctly rather than collapsing into one.
const QString prefix = QStringLiteral("deleted-from:");
QHash<QString, QStringList> byOrigin;
QStringList unknown;
for (int i = 0; i < messageIds.size(); ++i) {
// Split on TAB, matching resolveThreadMessages(). A space is not a
// safe separator: a folder name containing one produces a tag
// containing one, and splitting there silently truncates the origin
// to its first word.
const QStringList messageTags =
tags.at(i).split(QLatin1Char('\t'), Qt::SkipEmptyParts);
QString origin;
for (const QString &tag : messageTags) {
if (tag.startsWith(prefix)) {
origin = tag.mid(prefix.length());
break;
}
}
// A message with no `deleted` tag is not in the trash and has nothing
// to come back from. A thread-scoped restore reaches every message,
// including ones the user never deleted, and moving those would drag
// untouched mail out of whatever folder it legitimately sits in.
if (!messageTags.contains(QStringLiteral("deleted")))
continue;
const Account account =
accountForMessagePath(paths.at(i));
if (origin.isEmpty() || account.maildir.isEmpty()) {
unknown.append(messageIds.at(i));
continue;
}
byOrigin[account.maildir + QLatin1Char('/') + origin]
.append(messageIds.at(i));
}
if (!unknown.isEmpty()) {
// No origin recorded: deleted by an older version or tagged by hand.
// The tag comes off so the row stops claiming to be deleted, but no
// file moves, since guessing a folder would put the message somewhere
// the user never had it.
sendMessageTagChange(unknown, {}, { QStringLiteral("deleted") },
tr("Undelete thread"));
m_undoStack.push(new MessageTagCommand(this, unknown, {},
{ QStringLiteral("deleted") },
tr("Undelete thread")));
}
for (auto it = byOrigin.cbegin(); it != byOrigin.cend(); ++it) {
// The origin tag is named here, not left as the placeholder: on a
// restore the placeholder would resolve to the folder the message is
// coming FROM, which is the trash, and strip a tag never written.
const QString origin = originTagFor(it.key());
QStringList remove{ QStringLiteral("deleted") };
if (!origin.isEmpty())
remove.append(origin);
sendMove(it.value(), it.key(), {}, remove, tr("Undelete thread"),
false, threadScope);
}
showTransientStatus(tr("%1: %n message(s)", "", messageIds.size())
.arg(tr("Undelete thread")));
}
void MainWindow::restoreSelectedThreads()
{
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
if (rows.isEmpty())
return;
const QStringList threadIds = selectedThreadIds();
if (threadIds.isEmpty())
return;
// Repainted synchronously, as the delete direction is.
for (const QString &threadId : threadIds)
m_model->applyTagChange(threadId, {}, { QStringLiteral("deleted") });
m_pendingThreadScope = threadIds;
QMetaObject::invokeMethod(
m_worker, "resolveThreadMessages", Qt::QueuedConnection,
Q_ARG(QStringList, threadIds),
Q_ARG(QString, QStringLiteral("undelete_thread")));
}
QString MainWindow::inboxFolderFor(const Account &account) const
{
// Discovered from the account's OWN inbox query, never hardcoded.
//
// The casing is not ours to assume: the real Maildir has `Inbox` and a
// test fixture has `inbox`, and picking either would create a SECOND
// folder beside the real one on whichever side disagreed. That is exactly
// the failure a truncated origin folder caused on real mail this morning,
// and under mbsync's `Create Both` such a folder can reach the server.
//
// The inbox query is a generated `path:"<maildir>/<folder>/**"`, so the
// folder name is the part between the account prefix and the glob.
const QString query = account.inboxQuery();
const QString prefix =
QStringLiteral("path:\"") + account.maildir + QLatin1Char('/');
const QString suffix = QStringLiteral("/**\"");
if (query.startsWith(prefix) && query.endsWith(suffix)) {
const int from = prefix.length();
const int length = query.length() - from - suffix.length();
if (length > 0)
return query.mid(from, length);
}
// No inbox configured for this account. `Inbox` is the Maildir
// convention and is what mbsync's own `Inbox` directive defaults to.
return QStringLiteral("Inbox");
}
void MainWindow::restoreResolvedMessages(const QStringList &messageIds,
const QStringList &paths,
const QStringList &tags)
{
if (messageIds.size() != paths.size() || messageIds.size() != tags.size())
return;
const QString prefix = QStringLiteral("deleted-from:");
QHash<QString, QStringList> byOrigin;
QHash<QString, QStringList> byInbox;
QStringList stranded;
for (int i = 0; i < messageIds.size(); ++i) {
const QStringList messageTags =
tags.at(i).split(QLatin1Char('\t'), Qt::SkipEmptyParts);
QString origin;
for (const QString &tag : messageTags) {
if (tag.startsWith(prefix)) {
origin = tag.mid(prefix.length());
break;
}
}
const Account account = accountForMessagePath(paths.at(i));
if (account.maildir.isEmpty()) {
stranded.append(messageIds.at(i));
continue;
}
if (origin.isEmpty()) {
// Trashed by another client, so there is no record of where it
// belongs. Inbox is the documented fallback, and it is reported:
// a guess the user is not told about is worse than the guess.
byInbox[account.maildir + QLatin1Char('/')
+ account.inboxFolder()]
.append(messageIds.at(i));
continue;
}
byOrigin[account.maildir + QLatin1Char('/') + origin]
.append(messageIds.at(i));
}
for (auto it = byOrigin.cbegin(); it != byOrigin.cend(); ++it) {
// The origin tag is named here rather than left as the placeholder,
// which onMessagesMoved() would resolve to the folder the message is
// coming FROM, namely the trash.
const QString origin = originTagFor(it.key());
QStringList remove{ QStringLiteral("deleted") };
if (!origin.isEmpty())
remove.append(origin);
sendMove(it.value(), it.key(), {}, remove, tr("Restore"));
}
for (auto it = byInbox.cbegin(); it != byInbox.cend(); ++it) {
sendMove(it.value(), it.key(), {}, { QStringLiteral("deleted") },
tr("Restore"));
}
if (!byInbox.isEmpty()) {
m_statusLabel->setText(
tr("%n message(s) had no record of where they came from and were "
"moved to the inbox.", "", int(byInbox.size())));
}
if (!stranded.isEmpty()) {
m_statusLabel->setText(
tr("%n message(s) could not be restored: they belong to no "
"configured account.", "", int(stranded.size())));
}
}
void MainWindow::restoreSelectedFromTrash()
{
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
if (rows.isEmpty())
return;
const ActionScope scope = m_model->messageScopeFor(rows);
if (scope.messageIds.isEmpty())
return;
// Resolved by the WORKER, not read from the model.
//
// The model's tags come from the QUERY, and a row whose delete has not yet
// been re-queried still carries its pre-delete tags: measured
// `[inbox,unread]` on a message already in the trash, one run in three.
// The origin tag is then not found, the message falls into the
// no-origin branch, and Restore sends it to the INBOX instead of the
// folder it came from, silently and irreversibly.
//
// A restore has to be right about the destination or it is worse than
// doing nothing, so it asks the database rather than trusting a view that
// may be a moment behind. restoreSelectedThreads() already worked this
// way; this is the same reasoning applied to the message-scoped path.
m_pendingRestoreIds = scope.messageIds;
QMetaObject::invokeMethod(
m_worker, "resolveMessages", Qt::QueuedConnection,
Q_ARG(QStringList, scope.messageIds),
Q_ARG(QString, QStringLiteral("restore_messages")));
}
void MainWindow::showStrandedDeletedMail()
{
// Not scoped to the selected account, deliberately. The stranded mail is
// an artefact of an old version rather than a view of anything, and the
// user wants to see all of it at once; the account dropdown is still there
// to narrow it by hand afterwards.
const QString trash = m_config.allTrashQuery();
// No account configures a trash folder: everything tagged `deleted` is by
// definition stranded, since there is nowhere for it to have gone. An
// empty exclusion must never be written as `not ()`, which notmuch parses
// without complaint and matches nothing, reporting a clean database.
const QString query =
trash.isEmpty()
? QStringLiteral("tag:deleted")
: QStringLiteral("tag:deleted and not (%1)").arg(trash);
// Into the bar, like a filter: what ran is visible and editable, and
// AlreadyScoped stops runQuery() wrapping it in the selected account's
// path, which would hide every other account's stranded mail.
m_queryEdit->setText(query);
runQuery(FlatResult::No, AccountScope::AlreadyScoped);
// After runQuery(), which sets "Searching...": set before it, this would
// be overwritten and the user would be told nothing about what they are
// looking at.
m_statusLabel->setText(tr("Mail tagged deleted but not in a trash folder. "
"Select what should go and press Delete."));
}
void MainWindow::restoreSelected(bool fallbackToInbox)
{
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
if (rows.isEmpty())
return;
const ActionScope scope = m_model->messageScopeFor(rows);
if (scope.messageIds.isEmpty())
return;
// Where each message came from, read back off its own tag. This is what
// the tag exists for: the file has moved, so nothing on disk and nothing
// in notmuch still records the original folder.
const QString prefix = QStringLiteral("deleted-from:");
QHash<QString, QStringList> byOrigin;
QStringList unknown;
for (const QString &messageId : scope.messageIds) {
const MessageNode node = m_model->messageById(messageId);
QString origin;
for (const QString &tag : node.tags) {
if (tag.startsWith(prefix)) {
origin = tag.mid(prefix.length());
break;
}
}
// An account prefix is needed to name a folder to the worker, which
// works in database-relative paths. The origin tag stores the folder
// relative to the ACCOUNT, so the two are recomposed here.
const Account account = accountForMessagePath(node.filePath);
if (origin.isEmpty() || account.maildir.isEmpty()) {
unknown.append(messageId);
continue;
}
byOrigin[account.maildir + QLatin1Char('/') + origin].append(messageId);
}
if (!unknown.isEmpty()) {
// No origin recorded. Two quite different situations reach here and
// they want opposite things, which is what `fallbackToInbox` selects.
//
// From the TRASH VIEW the message is demonstrably in the trash, put
// there by another client, and refusing to move it leaves the user
// looking at a message they cannot get out. Inbox is the documented
// fallback, and it is reported, because a guess the user is not told
// about is worse than the guess itself.
//
// From a second press of Delete the message is NOT in the trash: it is
// sitting wherever it always was, wearing a stale `deleted` tag from
// an older version or from a hand-written notmuch command. Moving it
// to the inbox there would relocate mail the user never asked to move.
// The tag comes off and the file stays put.
if (fallbackToInbox) {
QHash<QString, QStringList> byInbox;
QStringList stranded;
for (const QString &messageId : unknown) {
const Account account =
accountForMessagePath(m_model->messageById(messageId).filePath);
if (account.maildir.isEmpty()) {
stranded.append(messageId);
continue;
}
byInbox[account.maildir + QLatin1Char('/')
+ inboxFolderFor(account)]
.append(messageId);
}
for (auto it = byInbox.cbegin(); it != byInbox.cend(); ++it) {
sendMove(it.value(), it.key(), {},
{ QStringLiteral("deleted") }, tr("Restore"));
}
if (!byInbox.isEmpty()) {
m_statusLabel->setText(
tr("%n message(s) had no record of where they came from "
"and were moved to the inbox.", "",
int(unknown.size() - stranded.size())));
}
if (!stranded.isEmpty()) {
m_statusLabel->setText(
tr("%n message(s) could not be restored: they belong to no "
"configured account.", "", int(stranded.size())));
}
} else {
sendMessageTagChange(unknown, {}, { QStringLiteral("deleted") },
tr("Undelete"));
m_undoStack.push(new MessageTagCommand(
this, unknown, {}, { QStringLiteral("deleted") },
tr("Undelete")));
}
}
for (auto it = byOrigin.cbegin(); it != byOrigin.cend(); ++it) {
// The origin tag is named HERE, not left as the placeholder.
//
// onMessagesMoved() resolves the placeholder from the origin the
// WORKER reports, which is where the message is coming FROM. On a
// delete that is the inbox and correct; on a restore it is the trash,
// so the placeholder resolved to `deleted-from:Trash` and asked to
// remove a tag that never existed, while the real `deleted-from:inbox`
// was never named. The message came home still claiming to have been
// deleted from somewhere, which then made Restore offer to move a
// message that was already back.
//
// A restore does not need the placeholder at all: the origin was just
// read off the message's own tag to decide where to send it, so the
// exact tag to strip is already known. Recomposed from the same
// account-relative form it was stored in.
const QString origin = originTagFor(it.key());
QStringList remove{ QStringLiteral("deleted") };
if (!origin.isEmpty())
remove.append(origin);
sendMove(it.value(), it.key(), {}, remove, tr("Undelete"));
}
showTransientStatus(
tr("%1: %n message(s)", "", scope.messageCount).arg(tr("Undelete")));
}
void MainWindow::sendMove(const QStringList &messageIds,
const QString &destFolder, const QStringList &add,
const QStringList &remove,
const QString &description, bool fromUndo,
const QStringList &wholeThreadIds)
{
if (messageIds.isEmpty() || destFolder.isEmpty())
return;
// Held during a sync for the same reason every tag write is: the worker's
// read-write open BLOCKS on notmuch's exclusive lock rather than failing,
// so sending now would freeze the worker for the rest of the run.
//
// A move is held as the MOVE it is, not decomposed into a tag edit. The
// held-edit queue carries tag changes only, so a move pushed through it
// would apply the tags and never move the file, which is worse than
// waiting: the message would read as deleted and still be in the inbox.
if (aSyncHoldsTheWriteLock()) {
m_heldMoves.append(HeldMove{ messageIds, destFolder, add, remove,
description, fromUndo });
m_statusLabel->setText(
tr("A sync is running; your change will be applied when it "
"finishes."));
updatePendingIndicator();
return;
}
// Repainted NOW, before the worker is asked.
//
// The write itself waits for the move to be confirmed, and must: tagging
// the database first would leave a message marked deleted in a folder it
// never left if the rename failed. The DISPLAY has no such constraint, and
// holding it back until the round trip finished is what made a deleted row
// sit there unchanged until the user clicked it. The reply rows repainted
// and the root did not, because the replies were separately tagged while
// the root's card reads its thread's summary.
//
// Reverted by revertPendingTagChange() if the write is rejected, exactly
// as the tag path's optimistic update is.
//
// The placeholder is dropped rather than displayed: the real origin is not
// known until the worker answers, and a chip reading the placeholder's
// literal name would be worse than one chip arriving a moment late.
QStringList displayAdd;
for (const QString &tag : add) {
if (tag != kOriginTagPlaceholder())
displayAdd.append(tag);
}
QStringList displayRemove;
for (const QString &tag : remove) {
if (tag != kOriginTagPlaceholder())
displayRemove.append(tag);
}
// A thread-scoped move already repainted its rows in
// trashSelectedThreads() / restoreSelectedThreads(), synchronously, before
// the worker was asked to resolve the threads at all. Repeating it here
// would be harmless but redundant; more importantly the caller there needs
// the repaint to happen WITHOUT a worker round trip, which is the whole
// reason it is not done from this function.
//
// applyTagChange() is what those callers use, and applyMessageTagChange()
// is what this one uses, and the difference is not a style choice: the
// former moves the thread's SUMMARY, which a thread row's card draws from,
// while the latter deliberately leaves a multi-message thread's summary
// alone because one message's edit does not describe the conversation.
if (wholeThreadIds.isEmpty()) {
for (const QString &messageId : messageIds)
m_model->applyMessageTagChange(messageId, displayAdd, displayRemove);
}
// What to tag once the move is CONFIRMED. Tagging now would leave a
// message marked deleted in a folder it never left if the rename failed.
//
// A QUEUE, not a map keyed on the destination: two Deletes in the same
// account before the first confirmation arrives both name `acct/Trash`,
// so the second insert overwrote the first and the second confirmation
// took an empty PendingMove. That file landed in the trash carrying
// neither `deleted` nor `deleted-from:`, which makes it unrestorable and
// invisible to a `tag:deleted` query. The worker handles one move at a
// time on its own thread and emits in the order it was asked, so a plain
// FIFO matches confirmations to requests without needing a key at all.
m_pendingMoves.enqueue(PendingMove{ add, remove, description, fromUndo });
QMetaObject::invokeMethod(m_worker, "moveMessages", Qt::QueuedConnection,
Q_ARG(QStringList, messageIds),
Q_ARG(QString, destFolder));
}
void MainWindow::onMessagesMoved(const QMap<QString, QString> &originByMessageId,
const QString &destFolder)
{
if (m_pendingMoves.isEmpty())
return;
const PendingMove pending = m_pendingMoves.dequeue();
if (originByMessageId.isEmpty())
return;
// The origin differs per message, so the tags do too: two messages deleted
// from different folders get different `deleted-from:` tags out of one
// gesture. Grouped by the resolved tag list so identical ones still travel
// as a single write.
QHash<QString, QStringList> byOrigin;
for (auto it = originByMessageId.cbegin(); it != originByMessageId.cend();
++it) {
byOrigin[it.value()].append(it.key());
}
for (auto it = byOrigin.cbegin(); it != byOrigin.cend(); ++it) {
// The origin tag names the folder relative to the ACCOUNT, not to the
// database: `inbox`, never `acct/inbox`. Restore recomposes the
// account prefix from the message's own path, so storing it here would
// duplicate it, and a stored account prefix would go stale the day the
// user renames a maildir.
//
// The worker reports `acct/inbox`; the account's own maildir is
// `acct`, so the stored tag is `inbox`. Resolved through the first
// message's path, which is still the account's whichever folder it
// sits in now.
const QString originTag = originTagFor(it.key());
auto resolve = [&](const QStringList &tags) {
QStringList out;
for (const QString &tag : tags) {
if (tag != kOriginTagPlaceholder()) {
out.append(tag);
continue;
}
if (!originTag.isEmpty())
out.append(originTag);
}
return out;
};
const QStringList resolvedAdd = resolve(pending.add);
const QStringList resolvedRemove = resolve(pending.remove);
sendMessageTagChange(it.value(), resolvedAdd, resolvedRemove,
pending.description);
// The undo entry carries the RESOLVED tags, and is pushed per origin
// group rather than once for the batch.
//
// It used to be handed pending.add straight, which still holds the
// unresolved placeholder: undo then asked to remove a tag by that
// literal name, which no message carries, so the removal was a silent
// no-op and `deleted-from:inbox` survived the undo. The file came home
// still claiming to have been deleted from somewhere. Same defect as
// the one the second-Delete path had, reached through Ctrl+Z instead.
//
// Per group because the placeholder resolves to a DIFFERENT tag per
// origin: one command for a batch spanning two folders could only
// carry one of them, so the other would be the wrong tag rather than
// merely an unresolved one.
if (!pending.fromUndo) {
QMap<QString, QString> groupOrigins;
for (const QString &messageId : it.value())
groupOrigins.insert(messageId, originByMessageId.value(messageId));
m_undoStack.push(new MoveCommand(this, groupOrigins, destFolder,
resolvedAdd, resolvedRemove,
pending.description));
}
}
// The undo entries are pushed inside the loop above, one per origin
// group, because the placeholder resolves per origin. Nothing is pushed
// for a move the undo stack itself started: a MoveCommand is confirmed
// through this same slot, so pushing unconditionally left the undo of a
// Delete putting a fresh command on the stack instead of consuming the
// one it undid, and a second press of undo re-deleted the message. The
// flag rides on PendingMove because the answer has to survive the queued
// round trip; a window-wide "am I undoing" flag would long since have
// been cleared by the time the worker replies.
}
void MainWindow::sendThreadTagChange(const QStringList &threadIds,
const QStringList &add,
const QStringList &remove,
const QString &description)
{
// Optimistic: the rows change now, so a bulk archive of hundreds of threads
// feels instant. Recorded so onWorkerError() can put them back.
for (const QString &threadId : threadIds)
m_model->applyTagChange(threadId, add, remove);
// Which accounts this touches, recorded HERE and not in onTagsApplied():
// TagChange carries message ids, while the account is a property of the
// thread, and by the time the worker confirms, the rows may be gone. A
// write that is later rejected leaves an account listed here that needed no
// sync, which costs one redundant channel on the next run; missing one
// would strand the user's edits, which is the failure worth avoiding.
for (const QString &threadId : threadIds) {
const QStringList keys = m_model->accountKeysForThread(threadId);
for (const QString &key : keys)
m_editedAccounts.insert(key);
}
// The strip shows the open thread's tags, so it has to follow a change to
// that thread rather than waiting for the next selection.
if (threadIds.contains(m_currentThreadId)) {
const QModelIndex current = m_threadView->currentIndex();
if (current.isValid())
m_messageView->setTags(m_model->threadFor(current).tags);
}
// A sync holds notmuch's exclusive write lock, and the worker's read-write
// open BLOCKS on it rather than failing: measured 9.158s against a 12s
// hold, returning SUCCESS. Sending now would freeze the worker thread for
// the rest of the sync, queueing every later query and thread load behind
// it. Hold the edit and send it when the lock frees.
//
// The rows keep the optimistic update applied above, which is honest: it is
// what the user asked for and it is going to be applied.
if (aSyncHoldsTheWriteLock()) {
m_heldEdits.append(HeldEdit{
threadIds, TagChange{ {}, add, remove, description } });
// NOT transient. This describes state that lasts until the sync ends,
// and a message that expired would leave the user with rows showing a
// tag the database has not got and no explanation of why.
m_statusLabel->setText(
tr("A sync is running; your change will be applied when it "
"finishes."));
// A held edit is outstanding work, so the indicator has to show it.
updatePendingIndicator();
return;
}
m_pendingThreadIds = threadIds;
m_pendingChange = TagChange{ {}, add, remove, description };
// The worker resolves thread ids to message ids: the UI does not hold
// message ids for rows it never opened.
QMetaObject::invokeMethod(m_worker, "applyTagsToThreads",
Qt::QueuedConnection,
Q_ARG(QStringList, threadIds),
Q_ARG(QStringList, add),
Q_ARG(QStringList, remove),
Q_ARG(QString, description));
}
|