diff --git a/data/tc_builds_sample.csv b/data/tc_builds_sample.csv new file mode 100644 index 00000000..639d94e1 --- /dev/null +++ b/data/tc_builds_sample.csv @@ -0,0 +1,201 @@ +BuildTypeId,Number,State,Status,StatusText,Branch,Agent,TriggeredBy,Comment,Project,Job,JobId,Started,Finished,DurationSec +Srv_Deploy_0,1000,finished,FAILURE,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4000,27/08/2026 09:00:00,27/08/2026 09:10:06,606 +Srv_Deploy_1,1001,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4001,27/08/2026 09:02:17,27/08/2026 09:05:46,209 +Srv_Deploy_2,1002,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4002,27/08/2026 09:04:34,27/08/2026 09:05:53,79 +Srv_Deploy_3,1003,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4003,27/08/2026 09:06:51,27/08/2026 09:20:31,820 +Srv_Deploy_4,1004,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4004,27/08/2026 09:09:08,27/08/2026 09:17:42,514 +Srv_Deploy_5,1005,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4005,27/08/2026 09:11:25,27/08/2026 09:24:52,807 +Srv_Deploy_6,1006,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4006,27/08/2026 09:13:42,27/08/2026 09:23:03,561 +Srv_Deploy_0,1007,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4007,27/08/2026 09:15:59,27/08/2026 09:23:03,424 +Srv_Deploy_1,1008,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4008,27/08/2026 09:18:16,27/08/2026 09:31:14,778 +Srv_Deploy_2,1009,finished,FAILURE,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4009,27/08/2026 09:20:33,27/08/2026 09:24:00,207 +Srv_Deploy_3,1010,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4010,27/08/2026 09:22:50,27/08/2026 09:27:08,258 +Srv_Deploy_4,1011,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4011,27/08/2026 09:25:07,27/08/2026 09:37:50,763 +Srv_Deploy_5,1012,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4012,27/08/2026 09:27:24,27/08/2026 09:29:49,145 +Srv_Deploy_6,1013,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4013,27/08/2026 09:29:41,27/08/2026 09:34:51,310 +Srv_Deploy_0,1014,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4014,27/08/2026 09:31:58,27/08/2026 09:44:30,752 +Srv_Deploy_1,1015,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4015,27/08/2026 09:34:15,27/08/2026 09:40:14,359 +Srv_Deploy_2,1016,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4016,27/08/2026 09:36:32,27/08/2026 09:49:02,750 +Srv_Deploy_3,1017,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4017,27/08/2026 09:38:49,27/08/2026 09:49:52,663 +Srv_Deploy_4,1018,finished,FAILURE,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4018,27/08/2026 09:41:06,27/08/2026 09:55:51,885 +Srv_Deploy_5,1019,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4019,27/08/2026 09:43:23,27/08/2026 09:46:52,209 +Srv_Deploy_6,1020,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4020,27/08/2026 09:45:40,27/08/2026 09:56:30,650 +Srv_Deploy_0,1021,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4021,27/08/2026 09:47:57,27/08/2026 09:55:24,447 +Srv_Deploy_1,1022,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4022,27/08/2026 09:50:14,27/08/2026 09:56:32,378 +Srv_Deploy_2,1023,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4023,27/08/2026 09:52:31,27/08/2026 10:06:48,857 +Srv_Deploy_3,1024,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4024,27/08/2026 09:54:48,27/08/2026 10:08:16,808 +Srv_Deploy_4,1025,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4025,27/08/2026 09:57:05,27/08/2026 10:06:01,536 +Srv_Deploy_5,1026,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4026,27/08/2026 09:59:22,27/08/2026 10:05:41,379 +Srv_Deploy_6,1027,finished,FAILURE,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4027,27/08/2026 10:01:39,27/08/2026 10:13:35,716 +Srv_Deploy_0,1028,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4028,27/08/2026 10:03:56,27/08/2026 10:16:43,767 +Srv_Deploy_1,1029,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4029,27/08/2026 10:06:13,27/08/2026 10:16:01,588 +Srv_Deploy_2,1030,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4030,27/08/2026 10:08:30,27/08/2026 10:17:33,543 +Srv_Deploy_3,1031,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4031,27/08/2026 10:10:47,27/08/2026 10:12:56,129 +Srv_Deploy_4,1032,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4032,27/08/2026 10:13:04,27/08/2026 10:27:07,843 +Srv_Deploy_5,1033,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4033,27/08/2026 10:15:21,27/08/2026 10:19:09,228 +Srv_Deploy_6,1034,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4034,27/08/2026 10:17:38,27/08/2026 10:19:14,96 +Srv_Deploy_0,1035,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4035,27/08/2026 10:19:55,27/08/2026 10:22:27,152 +Srv_Deploy_1,1036,finished,FAILURE,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4036,27/08/2026 10:22:12,27/08/2026 10:33:00,648 +Srv_Deploy_2,1037,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4037,27/08/2026 10:24:29,27/08/2026 10:31:37,428 +Srv_Deploy_3,1038,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4038,27/08/2026 10:26:46,27/08/2026 10:29:17,151 +Srv_Deploy_4,1039,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4039,27/08/2026 10:29:03,27/08/2026 10:42:54,831 +Srv_Deploy_5,1040,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4040,27/08/2026 10:31:20,27/08/2026 10:42:18,658 +Srv_Deploy_6,1041,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4041,27/08/2026 10:33:37,27/08/2026 10:39:33,356 +Srv_Deploy_0,1042,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4042,27/08/2026 10:35:54,27/08/2026 10:50:30,876 +Srv_Deploy_1,1043,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4043,27/08/2026 10:38:11,27/08/2026 10:43:23,312 +Srv_Deploy_2,1044,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4044,27/08/2026 10:40:28,27/08/2026 10:49:40,552 +Srv_Deploy_3,1045,finished,FAILURE,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4045,27/08/2026 10:42:45,27/08/2026 10:53:09,624 +Srv_Deploy_4,1046,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4046,27/08/2026 10:45:02,27/08/2026 10:59:45,883 +Srv_Deploy_5,1047,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4047,27/08/2026 10:47:19,27/08/2026 10:59:56,757 +Srv_Deploy_6,1048,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4048,27/08/2026 10:49:36,27/08/2026 10:55:45,369 +Srv_Deploy_0,1049,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4049,27/08/2026 10:51:53,27/08/2026 10:59:37,464 +Srv_Deploy_1,1050,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4050,27/08/2026 10:54:10,27/08/2026 11:02:14,484 +Srv_Deploy_2,1051,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4051,27/08/2026 10:56:27,27/08/2026 11:02:27,360 +Srv_Deploy_3,1052,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4052,27/08/2026 10:58:44,27/08/2026 11:00:46,122 +Srv_Deploy_4,1053,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4053,27/08/2026 11:01:01,27/08/2026 11:11:38,637 +Srv_Deploy_5,1054,finished,FAILURE,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4054,27/08/2026 11:03:18,27/08/2026 11:16:49,811 +Srv_Deploy_6,1055,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4055,27/08/2026 11:05:35,27/08/2026 11:14:56,561 +Srv_Deploy_0,1056,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4056,27/08/2026 11:07:52,27/08/2026 11:18:35,643 +Srv_Deploy_1,1057,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4057,27/08/2026 11:10:09,27/08/2026 11:20:59,650 +Srv_Deploy_2,1058,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4058,27/08/2026 11:12:26,27/08/2026 11:20:40,494 +Srv_Deploy_3,1059,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4059,27/08/2026 11:14:43,27/08/2026 11:23:00,497 +Srv_Deploy_4,1060,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4060,27/08/2026 11:17:00,27/08/2026 11:18:36,96 +Srv_Deploy_5,1061,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4061,27/08/2026 11:19:17,27/08/2026 11:30:08,651 +Srv_Deploy_6,1062,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4062,27/08/2026 11:21:34,27/08/2026 11:27:38,364 +Srv_Deploy_0,1063,finished,FAILURE,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4063,27/08/2026 11:23:51,27/08/2026 11:34:10,619 +Srv_Deploy_1,1064,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4064,27/08/2026 11:26:08,27/08/2026 11:41:04,896 +Srv_Deploy_2,1065,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4065,27/08/2026 11:28:25,27/08/2026 11:36:40,495 +Srv_Deploy_3,1066,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4066,27/08/2026 11:30:42,27/08/2026 11:38:08,446 +Srv_Deploy_4,1067,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4067,27/08/2026 11:32:59,27/08/2026 11:34:15,76 +Srv_Deploy_5,1068,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4068,27/08/2026 11:35:16,27/08/2026 11:37:23,127 +Srv_Deploy_6,1069,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4069,27/08/2026 11:37:33,27/08/2026 11:43:35,362 +Srv_Deploy_0,1070,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4070,27/08/2026 11:39:50,27/08/2026 11:52:20,750 +Srv_Deploy_1,1071,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4071,27/08/2026 11:42:07,27/08/2026 11:54:11,724 +Srv_Deploy_2,1072,finished,FAILURE,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4072,27/08/2026 11:44:24,27/08/2026 11:58:51,867 +Srv_Deploy_3,1073,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4073,27/08/2026 11:46:41,27/08/2026 11:48:30,109 +Srv_Deploy_4,1074,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4074,27/08/2026 11:48:58,27/08/2026 11:59:03,605 +Srv_Deploy_5,1075,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4075,27/08/2026 11:51:15,27/08/2026 12:03:57,762 +Srv_Deploy_6,1076,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4076,27/08/2026 11:53:32,27/08/2026 11:59:44,372 +Srv_Deploy_0,1077,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4077,27/08/2026 11:55:49,27/08/2026 12:07:43,714 +Srv_Deploy_1,1078,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4078,27/08/2026 11:58:06,27/08/2026 12:04:18,372 +Srv_Deploy_2,1079,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4079,27/08/2026 12:00:23,27/08/2026 12:08:25,482 +Srv_Deploy_3,1080,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4080,27/08/2026 12:02:40,27/08/2026 12:03:48,68 +Srv_Deploy_4,1081,finished,FAILURE,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4081,27/08/2026 12:04:57,27/08/2026 12:19:25,868 +Srv_Deploy_5,1082,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4082,27/08/2026 12:07:14,27/08/2026 12:12:24,310 +Srv_Deploy_6,1083,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4083,27/08/2026 12:09:31,27/08/2026 12:16:36,425 +Srv_Deploy_0,1084,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4084,27/08/2026 12:11:48,27/08/2026 12:24:29,761 +Srv_Deploy_1,1085,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4085,27/08/2026 12:14:05,27/08/2026 12:26:12,727 +Srv_Deploy_2,1086,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4086,27/08/2026 12:16:22,27/08/2026 12:30:57,875 +Srv_Deploy_3,1087,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4087,27/08/2026 12:18:39,27/08/2026 12:29:22,643 +Srv_Deploy_4,1088,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4088,27/08/2026 12:20:56,27/08/2026 12:30:11,555 +Srv_Deploy_5,1089,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4089,27/08/2026 12:23:13,27/08/2026 12:31:57,524 +Srv_Deploy_6,1090,finished,FAILURE,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4090,27/08/2026 12:25:30,27/08/2026 12:39:36,846 +Srv_Deploy_0,1091,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4091,27/08/2026 12:27:47,27/08/2026 12:28:44,57 +Srv_Deploy_1,1092,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4092,27/08/2026 12:30:04,27/08/2026 12:39:19,555 +Srv_Deploy_2,1093,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4093,27/08/2026 12:32:21,27/08/2026 12:37:57,336 +Srv_Deploy_3,1094,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4094,27/08/2026 12:34:38,27/08/2026 12:45:00,622 +Srv_Deploy_4,1095,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4095,27/08/2026 12:36:55,27/08/2026 12:38:00,65 +Srv_Deploy_5,1096,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4096,27/08/2026 12:39:12,27/08/2026 12:51:27,735 +Srv_Deploy_6,1097,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4097,27/08/2026 12:41:29,27/08/2026 12:50:12,523 +Srv_Deploy_0,1098,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4098,27/08/2026 12:43:46,27/08/2026 12:49:15,329 +Srv_Deploy_1,1099,finished,FAILURE,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4099,27/08/2026 12:46:03,27/08/2026 13:00:52,889 +Srv_Deploy_2,1100,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4100,27/08/2026 12:48:20,27/08/2026 12:57:34,554 +Srv_Deploy_3,1101,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4101,27/08/2026 12:50:37,27/08/2026 13:05:15,878 +Srv_Deploy_4,1102,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4102,27/08/2026 12:52:54,27/08/2026 12:55:02,128 +Srv_Deploy_5,1103,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4103,27/08/2026 12:55:11,27/08/2026 13:06:18,667 +Srv_Deploy_6,1104,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4104,27/08/2026 12:57:28,27/08/2026 12:59:21,113 +Srv_Deploy_0,1105,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4105,27/08/2026 12:59:45,27/08/2026 13:12:48,783 +Srv_Deploy_1,1106,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4106,27/08/2026 13:02:02,27/08/2026 13:16:24,862 +Srv_Deploy_2,1107,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4107,27/08/2026 13:04:19,27/08/2026 13:15:54,695 +Srv_Deploy_3,1108,finished,FAILURE,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4108,27/08/2026 13:06:36,27/08/2026 13:13:19,403 +Srv_Deploy_4,1109,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4109,27/08/2026 13:08:53,27/08/2026 13:23:27,874 +Srv_Deploy_5,1110,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4110,27/08/2026 13:11:10,27/08/2026 13:24:19,789 +Srv_Deploy_6,1111,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4111,27/08/2026 13:13:27,27/08/2026 13:22:35,548 +Srv_Deploy_0,1112,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4112,27/08/2026 13:15:44,27/08/2026 13:28:22,758 +Srv_Deploy_1,1113,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4113,27/08/2026 13:18:01,27/08/2026 13:22:07,246 +Srv_Deploy_2,1114,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4114,27/08/2026 13:20:18,27/08/2026 13:25:08,290 +Srv_Deploy_3,1115,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4115,27/08/2026 13:22:35,27/08/2026 13:31:21,526 +Srv_Deploy_4,1116,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4116,27/08/2026 13:24:52,27/08/2026 13:37:50,778 +Srv_Deploy_5,1117,finished,FAILURE,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4117,27/08/2026 13:27:09,27/08/2026 13:34:48,459 +Srv_Deploy_6,1118,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4118,27/08/2026 13:29:26,27/08/2026 13:42:32,786 +Srv_Deploy_0,1119,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4119,27/08/2026 13:31:43,27/08/2026 13:42:41,658 +Srv_Deploy_1,1120,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4120,27/08/2026 13:34:00,27/08/2026 13:46:02,722 +Srv_Deploy_2,1121,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4121,27/08/2026 13:36:17,27/08/2026 13:51:15,898 +Srv_Deploy_3,1122,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4122,27/08/2026 13:38:34,27/08/2026 13:41:15,161 +Srv_Deploy_4,1123,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4123,27/08/2026 13:40:51,27/08/2026 13:50:45,594 +Srv_Deploy_5,1124,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4124,27/08/2026 13:43:08,27/08/2026 13:47:01,233 +Srv_Deploy_6,1125,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4125,27/08/2026 13:45:25,27/08/2026 13:52:17,412 +Srv_Deploy_0,1126,finished,FAILURE,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4126,27/08/2026 13:47:42,27/08/2026 13:53:14,332 +Srv_Deploy_1,1127,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4127,27/08/2026 13:49:59,27/08/2026 13:51:16,77 +Srv_Deploy_2,1128,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4128,27/08/2026 13:52:16,27/08/2026 13:57:41,325 +Srv_Deploy_3,1129,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4129,27/08/2026 13:54:33,27/08/2026 14:03:09,516 +Srv_Deploy_4,1130,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4130,27/08/2026 13:56:50,27/08/2026 14:10:20,810 +Srv_Deploy_5,1131,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4131,27/08/2026 13:59:07,27/08/2026 14:05:04,357 +Srv_Deploy_6,1132,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4132,27/08/2026 14:01:24,27/08/2026 14:11:10,586 +Srv_Deploy_0,1133,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4133,27/08/2026 14:03:41,27/08/2026 14:08:54,313 +Srv_Deploy_1,1134,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4134,27/08/2026 14:05:58,27/08/2026 14:19:44,826 +Srv_Deploy_2,1135,finished,FAILURE,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4135,27/08/2026 14:08:15,27/08/2026 14:22:24,849 +Srv_Deploy_3,1136,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4136,27/08/2026 14:10:32,27/08/2026 14:15:31,299 +Srv_Deploy_4,1137,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4137,27/08/2026 14:12:49,27/08/2026 14:21:39,530 +Srv_Deploy_5,1138,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4138,27/08/2026 14:15:06,27/08/2026 14:28:03,777 +Srv_Deploy_6,1139,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4139,27/08/2026 14:17:23,27/08/2026 14:32:21,898 +Srv_Deploy_0,1140,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4140,27/08/2026 14:19:40,27/08/2026 14:32:05,745 +Srv_Deploy_1,1141,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4141,27/08/2026 14:21:57,27/08/2026 14:35:31,814 +Srv_Deploy_2,1142,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4142,27/08/2026 14:24:14,27/08/2026 14:28:00,226 +Srv_Deploy_3,1143,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4143,27/08/2026 14:26:31,27/08/2026 14:33:34,423 +Srv_Deploy_4,1144,finished,FAILURE,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4144,27/08/2026 14:28:48,27/08/2026 14:30:40,112 +Srv_Deploy_5,1145,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4145,27/08/2026 14:31:05,27/08/2026 14:46:02,897 +Srv_Deploy_6,1146,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4146,27/08/2026 14:33:22,27/08/2026 14:43:11,589 +Srv_Deploy_0,1147,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4147,27/08/2026 14:35:39,27/08/2026 14:38:11,152 +Srv_Deploy_1,1148,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4148,27/08/2026 14:37:56,27/08/2026 14:50:00,724 +Srv_Deploy_2,1149,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4149,27/08/2026 14:40:13,27/08/2026 14:42:26,133 +Srv_Deploy_3,1150,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4150,27/08/2026 14:42:30,27/08/2026 14:54:00,690 +Srv_Deploy_4,1151,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4151,27/08/2026 14:44:47,27/08/2026 14:55:51,664 +Srv_Deploy_5,1152,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4152,27/08/2026 14:47:04,27/08/2026 14:54:15,431 +Srv_Deploy_6,1153,finished,FAILURE,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4153,27/08/2026 14:49:21,27/08/2026 14:52:42,201 +Srv_Deploy_0,1154,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4154,27/08/2026 14:51:38,27/08/2026 15:00:29,531 +Srv_Deploy_1,1155,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4155,27/08/2026 14:53:55,27/08/2026 15:04:49,654 +Srv_Deploy_2,1156,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4156,27/08/2026 14:56:12,27/08/2026 15:03:03,411 +Srv_Deploy_3,1157,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4157,27/08/2026 14:58:29,27/08/2026 15:05:50,441 +Srv_Deploy_4,1158,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4158,27/08/2026 15:00:46,27/08/2026 15:11:25,639 +Srv_Deploy_5,1159,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4159,27/08/2026 15:03:03,27/08/2026 15:12:11,548 +Srv_Deploy_6,1160,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4160,27/08/2026 15:05:20,27/08/2026 15:13:35,495 +Srv_Deploy_0,1161,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4161,27/08/2026 15:07:37,27/08/2026 15:12:34,297 +Srv_Deploy_1,1162,finished,FAILURE,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4162,27/08/2026 15:09:54,27/08/2026 15:17:24,450 +Srv_Deploy_2,1163,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4163,27/08/2026 15:12:11,27/08/2026 15:19:07,416 +Srv_Deploy_3,1164,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4164,27/08/2026 15:14:28,27/08/2026 15:23:40,552 +Srv_Deploy_4,1165,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4165,27/08/2026 15:16:45,27/08/2026 15:19:32,167 +Srv_Deploy_5,1166,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4166,27/08/2026 15:19:02,27/08/2026 15:20:53,111 +Srv_Deploy_6,1167,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4167,27/08/2026 15:21:19,27/08/2026 15:26:47,328 +Srv_Deploy_0,1168,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4168,27/08/2026 15:23:36,27/08/2026 15:34:14,638 +Srv_Deploy_1,1169,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4169,27/08/2026 15:25:53,27/08/2026 15:33:00,427 +Srv_Deploy_2,1170,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4170,27/08/2026 15:28:10,27/08/2026 15:31:36,206 +Srv_Deploy_3,1171,finished,FAILURE,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4171,27/08/2026 15:30:27,27/08/2026 15:36:56,389 +Srv_Deploy_4,1172,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4172,27/08/2026 15:32:44,27/08/2026 15:43:06,622 +Srv_Deploy_5,1173,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4173,27/08/2026 15:35:01,27/08/2026 15:42:04,423 +Srv_Deploy_6,1174,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4174,27/08/2026 15:37:18,27/08/2026 15:47:55,637 +Srv_Deploy_0,1175,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4175,27/08/2026 15:39:35,27/08/2026 15:42:05,150 +Srv_Deploy_1,1176,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4176,27/08/2026 15:41:52,27/08/2026 15:51:17,565 +Srv_Deploy_2,1177,finished,SUCCESS,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4177,27/08/2026 15:44:09,27/08/2026 15:46:09,120 +Srv_Deploy_3,1178,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4178,27/08/2026 15:46:26,27/08/2026 15:49:23,177 +Srv_Deploy_4,1179,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4179,27/08/2026 15:48:43,27/08/2026 16:00:20,697 +Srv_Deploy_5,1180,finished,FAILURE,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4180,27/08/2026 15:51:00,27/08/2026 15:59:29,509 +Srv_Deploy_6,1181,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4181,27/08/2026 15:53:17,27/08/2026 16:07:54,877 +Srv_Deploy_0,1182,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4182,27/08/2026 15:55:34,27/08/2026 16:03:43,489 +Srv_Deploy_1,1183,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4183,27/08/2026 15:57:51,27/08/2026 16:08:58,667 +Srv_Deploy_2,1184,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4184,27/08/2026 16:00:08,27/08/2026 16:00:57,49 +Srv_Deploy_3,1185,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4185,27/08/2026 16:02:25,27/08/2026 16:13:10,645 +Srv_Deploy_4,1186,finished,SUCCESS,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4186,27/08/2026 16:04:42,27/08/2026 16:08:37,235 +Srv_Deploy_5,1187,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4187,27/08/2026 16:06:59,27/08/2026 16:16:06,547 +Srv_Deploy_6,1188,finished,SUCCESS,Success,main,agent-08,scheduler,,Payments,PatchServers,job-4188,27/08/2026 16:09:16,27/08/2026 16:16:10,414 +Srv_Deploy_0,1189,finished,FAILURE,Success,main,agent-09,scheduler,,ServerOps,DeployUpdate,job-4189,27/08/2026 16:11:33,27/08/2026 16:18:47,434 +Srv_Deploy_1,1190,finished,SUCCESS,Success,main,agent-10,scheduler,,Platform,RunMigrations,job-4190,27/08/2026 16:13:50,27/08/2026 16:26:46,776 +Srv_Deploy_2,1191,finished,SUCCESS,Success,main,agent-11,scheduler,,Payments,PatchServers,job-4191,27/08/2026 16:16:07,27/08/2026 16:22:19,372 +Srv_Deploy_3,1192,finished,SUCCESS,Success,main,agent-00,scheduler,,ServerOps,DeployUpdate,job-4192,27/08/2026 16:18:24,27/08/2026 16:32:36,852 +Srv_Deploy_4,1193,finished,SUCCESS,Success,main,agent-01,scheduler,,Platform,RunMigrations,job-4193,27/08/2026 16:20:41,27/08/2026 16:28:34,473 +Srv_Deploy_5,1194,finished,SUCCESS,Success,main,agent-02,scheduler,,Payments,PatchServers,job-4194,27/08/2026 16:22:58,27/08/2026 16:25:03,125 +Srv_Deploy_6,1195,finished,SUCCESS,Success,main,agent-03,scheduler,,ServerOps,DeployUpdate,job-4195,27/08/2026 16:25:15,27/08/2026 16:29:44,269 +Srv_Deploy_0,1196,finished,SUCCESS,Success,main,agent-04,scheduler,,Platform,RunMigrations,job-4196,27/08/2026 16:27:32,27/08/2026 16:41:48,856 +Srv_Deploy_1,1197,finished,SUCCESS,Success,main,agent-05,scheduler,,Payments,PatchServers,job-4197,27/08/2026 16:29:49,27/08/2026 16:31:10,81 +Srv_Deploy_2,1198,finished,FAILURE,Success,main,agent-06,scheduler,,ServerOps,DeployUpdate,job-4198,27/08/2026 16:32:06,27/08/2026 16:39:28,442 +Srv_Deploy_3,1199,finished,SUCCESS,Success,main,agent-07,scheduler,,Platform,RunMigrations,job-4199,27/08/2026 16:34:23,27/08/2026 16:36:36,133 diff --git a/src/app_state_container.rs b/src/app_state_container.rs index 1fb3e251..4b12ad02 100644 --- a/src/app_state_container.rs +++ b/src/app_state_container.rs @@ -1,3 +1,8 @@ +//! Shared application state driven by the TUI. +//! +//! Like `crate::ui`, this must not write to stdout/stderr - see `ui/mod.rs`. +#![deny(clippy::print_stdout, clippy::print_stderr)] + use crate::api_client::QueryResponse; use crate::buffer::{AppMode, BufferAPI, BufferManager, SortOrder}; use crate::debug_service::DebugLevel; @@ -17,7 +22,7 @@ use std::cell::RefCell; use std::collections::{HashMap, VecDeque}; use std::fmt; use std::time::{Duration, Instant}; -use tracing::{info, trace}; +use tracing::{debug, info, trace}; /// Platform type for key handling #[derive(Debug, Clone, PartialEq)] @@ -3607,8 +3612,9 @@ impl AppStateContainer { }) .collect(); - eprintln!( - "[DEBUG] Created {} matches in history_search", + debug!( + target: "history", + "Created {} matches in history_search", history_search.matches.len() ); diff --git a/src/data/data_view.rs b/src/data/data_view.rs index 941893e0..0cd499d3 100644 --- a/src/data/data_view.rs +++ b/src/data/data_view.rs @@ -628,6 +628,37 @@ impl DataView { self.visible_columns.clone() } + /// Translate a `DataTable` (source) column index into its visual position. + /// + /// The two index spaces only coincide when the view projects every source column in + /// source order (`SELECT *`). Under a narrower projection - `SELECT a, b, c` from a + /// wider table - `visible_columns` holds arbitrary source indices, so anything keyed + /// by visual position (column names, row values, column widths) must be looked up + /// through this translation rather than with the source index directly. + /// + /// Returns `None` if the column is not part of this view. + #[must_use] + pub fn visual_index_of_column(&self, datatable_index: usize) -> Option { + let real_position = self + .visible_columns + .iter() + .position(|&c| c == datatable_index)?; + + // Virtual columns occupy visual slots too, so shift past any that sort before + // this one. Mirrors the assembly order in `get_all_column_names`/`get_row`. + let shift = self + .virtual_columns + .iter() + .filter(|vcol| match vcol.position { + VirtualColumnPosition::Left => true, + VirtualColumnPosition::Index(idx) => idx <= real_position, + VirtualColumnPosition::Right => false, + }) + .count(); + + Some(real_position + shift) + } + /// Get display column names in order (pinned first, then visible) #[must_use] pub fn get_display_column_names(&self) -> Vec { diff --git a/src/help_text.rs b/src/help_text.rs index b1923994..cc904329 100644 --- a/src/help_text.rs +++ b/src/help_text.rs @@ -146,6 +146,7 @@ impl HelpText { Line::from(" : - Jump to row"), Line::from(" Space - Toggle viewport lock"), Line::from(" Ctrl+Space - Toggle viewport lock (alternative)"), + Line::from(" Ctrl+L - Redraw the screen"), Line::from(" x/X - Toggle cursor lock"), Line::from(" p - Pin/unpin column"), Line::from(" P - Clear all pins"), diff --git a/src/history.rs b/src/history.rs index 72803ea2..748c4f54 100644 --- a/src/history.rs +++ b/src/history.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; +use tracing::{debug, error, info, warn}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QueryMetadata { @@ -493,7 +494,7 @@ impl CommandHistory { // SAFETY: Create backup before clearing let current_count = self.entries.len(); if current_count > 0 { - eprintln!("[HISTORY WARNING] Clearing {current_count} entries - creating backup"); + warn!(target: "history", "Clearing {current_count} entries - creating backup"); if let Ok(content) = serde_json::to_string_pretty(&self.entries) { self.protection.backup_before_write(&content, current_count); } @@ -506,14 +507,14 @@ impl CommandHistory { fn load_from_file(&mut self) -> Result<()> { if !self.history_file.exists() { - eprintln!("[History] No history file found at {:?}", self.history_file); + debug!(target: "history", "No history file found at {:?}", self.history_file); return Ok(()); } - eprintln!("[History] Loading history from {:?}", self.history_file); + debug!(target: "history", "Loading history from {:?}", self.history_file); let content = fs::read_to_string(&self.history_file)?; if content.trim().is_empty() { - eprintln!("[History] History file is empty"); + debug!(target: "history", "History file is empty"); return Ok(()); } @@ -521,18 +522,19 @@ impl CommandHistory { let entries: Vec = match serde_json::from_str(&content) { Ok(entries) => entries, Err(e) => { - eprintln!("[History] ERROR: Failed to parse history file: {e}"); - eprintln!("[History] Attempting recovery from backup..."); + error!(target: "history", "Failed to parse history file: {e}"); + warn!(target: "history", "Attempting recovery from backup..."); // Try to recover from backup if let Some(backup_content) = self.protection.recover_from_backup() { - eprintln!("[History] Found backup, attempting to restore..."); + warn!(target: "history", "Found backup, attempting to restore..."); // Try to parse the backup match serde_json::from_str::>(&backup_content) { Ok(backup_entries) => { - eprintln!( - "[History] Successfully recovered {} entries from backup", + warn!( + target: "history", + "Successfully recovered {} entries from backup", backup_entries.len() ); @@ -545,30 +547,31 @@ impl CommandHistory { self.history_file.with_extension("json"), &corrupted_path, ); - eprintln!("[History] Corrupted file moved to {corrupted_path:?}"); + warn!(target: "history", "Corrupted file moved to {corrupted_path:?}"); backup_entries } Err(backup_err) => { - eprintln!("[History] Backup also corrupted: {backup_err}"); - eprintln!("[History] Starting with empty history"); + error!(target: "history", "Backup also corrupted: {backup_err}"); + warn!(target: "history", "Starting with empty history"); Vec::new() } } } else { - eprintln!("[History] No backup available, starting with empty history"); + warn!(target: "history", "No backup available, starting with empty history"); // Move the corrupted file for investigation let corrupted_path = self.history_file.with_extension("json.corrupted"); let _ = fs::copy(&self.history_file, &corrupted_path); - eprintln!("[History] Corrupted file copied to {corrupted_path:?}"); + warn!(target: "history", "Corrupted file copied to {corrupted_path:?}"); Vec::new() } } }; - eprintln!( - "[History] Loaded {} entries from history file", + debug!( + target: "history", + "Loaded {} entries from history file", entries.len() ); let original_count = entries.len(); @@ -594,7 +597,7 @@ impl CommandHistory { // Log if we removed duplicates (only on first load, not every save) let removed_count = original_count - deduplicated.len(); if removed_count > 0 { - eprintln!("[sql-cli] Cleaned {removed_count} duplicate commands from history"); + info!(target: "history", "Cleaned {removed_count} duplicate commands from history"); } // Rebuild command counts @@ -607,8 +610,9 @@ impl CommandHistory { } self.entries = deduplicated; - eprintln!( - "[History] Final history contains {} unique entries", + debug!( + target: "history", + "Final history contains {} unique entries", self.entries.len() ); Ok(()) @@ -635,7 +639,7 @@ impl CommandHistory { .protection .validate_write(old_count, new_count, &new_content) { - eprintln!("[HISTORY PROTECTION] Write blocked! Attempting recovery from backup..."); + error!(target: "history", "Write blocked! Attempting recovery from backup..."); if let Some(backup_content) = self.protection.recover_from_backup() { fs::write(&self.history_file, backup_content)?; return Ok(()); diff --git a/src/ui/enhanced_tui.rs b/src/ui/enhanced_tui.rs index 26b14be8..b0122204 100644 --- a/src/ui/enhanced_tui.rs +++ b/src/ui/enhanced_tui.rs @@ -443,6 +443,9 @@ pub struct EnhancedTuiApp { // Debug system pub(crate) debug_registry: DebugRegistry, pub(crate) memory_tracker: MemoryTracker, + + // Set by Ctrl+L; consumed by the event loop to clear before the next draw + force_redraw: bool, } impl DebugContext for EnhancedTuiApp { @@ -1547,6 +1550,7 @@ impl EnhancedTuiApp { query_orchestrator: QueryOrchestrator::with_behavior_config(config.behavior.clone()), debug_registry: DebugRegistry::new(), memory_tracker: MemoryTracker::new(100), + force_redraw: false, }; // Set up state dispatcher @@ -1948,6 +1952,11 @@ impl EnhancedTuiApp { if self.table_widget_manager.borrow().needs_render() { info!("TableWidgetManager needs render after key event"); } + if std::mem::take(&mut self.force_redraw) { + // Drop ratatui's notion of what is on screen so the next draw + // repaints every cell rather than diffing against a stale buffer. + terminal.clear()?; + } terminal.draw(|f| self.ui(f))?; self.table_widget_manager.borrow_mut().rendered(); @@ -2287,7 +2296,7 @@ impl EnhancedTuiApp { // Special handling for History mode - initialize history search if mode == AppMode::History { - eprintln!("[DEBUG] Using AppStateContainer for history search"); + debug!(target: "history", "Using AppStateContainer for history search"); let current_input = self.get_input_text(); // Start history search @@ -4895,8 +4904,8 @@ impl EnhancedTuiApp { static FILTER_DEPTH: AtomicUsize = AtomicUsize::new(0); let depth = FILTER_DEPTH.fetch_add(1, Ordering::SeqCst); if depth > 0 { - eprintln!( - "WARNING: apply_filter re-entrancy detected! depth={}, pattern='{}', thread={:?}", + warn!( + "apply_filter re-entrancy detected! depth={}, pattern='{}', thread={:?}", depth, pattern, std::thread::current().id() @@ -6854,32 +6863,31 @@ impl EnhancedTuiApp { )); } - // Find current column in display order - if let Some(display_idx) = display_columns - .iter() - .position(|&idx| idx == current_column) - { + // current_column is a VISUAL position, so it indexes display_columns + // directly - it is not a DataTable index to be searched for. + if let Some(&datatable_idx) = display_columns.get(current_column) { debug_info.push_str(&format!( - "Current column {} is at display index {}/{}\n", + "Current column: visual {}/{} -> DataTable [{}]\n", current_column, - display_idx, - display_columns.len() + display_columns.len(), + datatable_idx )); // Show what happens on next move - if display_idx + 1 < display_columns.len() { - let next_col = display_columns[display_idx + 1]; + if current_column + 1 < display_columns.len() { debug_info.push_str(&format!( - "Next 'l' press should move to column {} (display index {})\n", - next_col, - display_idx + 1 + "Next 'l' press should move to visual {} -> DataTable [{}]\n", + current_column + 1, + display_columns[current_column + 1] )); } else { debug_info.push_str("Next 'l' press should wrap to first column\n"); } } else { debug_info.push_str(&format!( - "WARNING: Current column {current_column} not found in display order!\n" + "WARNING: Visual column {} is out of range ({} columns)!\n", + current_column, + display_columns.len() )); } } @@ -7644,6 +7652,12 @@ impl ActionHandlerContext for EnhancedTuiApp { } } + fn request_force_redraw(&mut self) { + debug!("Force redraw requested (Ctrl+L)"); + self.force_redraw = true; + self.set_status_message("Screen refreshed"); + } + fn toggle_viewport_lock(&mut self) { // Toggle viewport lock in ViewportManager let is_locked = { diff --git a/src/ui/input/action_handlers.rs b/src/ui/input/action_handlers.rs index b70d1a41..65d1c5c4 100644 --- a/src/ui/input/action_handlers.rs +++ b/src/ui/input/action_handlers.rs @@ -132,6 +132,9 @@ pub trait ActionHandlerContext { // Viewport lock operations fn toggle_cursor_lock(&mut self); fn toggle_viewport_lock(&mut self); + + /// Request a full repaint on the next frame (Ctrl+L). + fn request_force_redraw(&mut self); } /// Handler for navigation actions (Up, Down, Left, Right, `PageUp`, etc.) @@ -669,6 +672,10 @@ impl ActionHandler for DebugViewportActionHandler { tui.toggle_viewport_lock(); Some(Ok(ActionResult::Handled)) } + Action::ForceRedraw => { + tui.request_force_redraw(); + Some(Ok(ActionResult::Handled)) + } _ => None, } } @@ -1072,6 +1079,9 @@ mod tests { fn toggle_viewport_lock(&mut self) { self.last_action = "toggle_viewport_lock".to_string(); } + fn request_force_redraw(&mut self) { + self.last_action = "request_force_redraw".to_string(); + } // Input and text editing fn move_input_cursor_left(&mut self) { diff --git a/src/ui/input/actions.rs b/src/ui/input/actions.rs index 2652fbf4..bfb8d235 100644 --- a/src/ui/input/actions.rs +++ b/src/ui/input/actions.rs @@ -116,6 +116,7 @@ pub enum Action { NavigateToViewportBottom, ToggleCursorLock, ToggleViewportLock, + ForceRedraw, // Ctrl+L - repaint the whole screen ToggleCaseInsensitive, ToggleKeyIndicator, ShowColumnStatistics, // For 'S' key diff --git a/src/ui/key_handling/mapper.rs b/src/ui/key_handling/mapper.rs index 56176aba..287c5c02 100644 --- a/src/ui/key_handling/mapper.rs +++ b/src/ui/key_handling/mapper.rs @@ -65,6 +65,10 @@ impl KeyMapper { // Force quit self.global_mappings .insert((Char('c'), Mod::CONTROL), Action::ForceQuit); + // Ctrl+L: repaint the screen, the universal terminal convention. An + // escape hatch for display corruption from anything outside our control. + self.global_mappings + .insert((Char('l'), Mod::CONTROL), Action::ForceRedraw); self.global_mappings .insert((Char('C'), Mod::CONTROL), Action::ForceQuit); } @@ -564,6 +568,44 @@ mod tests { assert_eq!(action, Some(Action::ShowHelp)); } + #[test] + fn ctrl_l_maps_to_force_redraw_in_every_mode() { + // Ctrl+L is the universal "repaint the screen" convention. It lives in the + // global map so it stays reachable regardless of mode, and must not collide + // with the readline bindings (Ctrl+W delete-word, Ctrl+P prev-history) or + // with viewport lock, which is Space / Ctrl+Space. + let ctrl_l = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL); + + for mode in [AppMode::Results, AppMode::Command] { + let mut mapper = KeyMapper::new(); + let context = ActionContext { + mode: mode.clone(), + selection_mode: SelectionMode::Row, + has_results: true, + has_filter: false, + has_search: false, + row_count: 10, + column_count: 10, + current_row: 0, + current_column: 0, + }; + + assert_eq!( + mapper.map_key(ctrl_l, &context), + Some(Action::ForceRedraw), + "Ctrl+L should force a redraw in {mode:?} mode" + ); + + // Plain 'l' must still navigate right, not redraw + let plain_l = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE); + assert_ne!( + mapper.map_key(plain_l, &context), + Some(Action::ForceRedraw), + "plain 'l' should not redraw in {mode:?} mode" + ); + } + } + #[test] fn test_command_mode_editing_actions() { let mut mapper = KeyMapper::new(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a155d373..b7e50fd6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,13 @@ //! User interface layer //! //! This module contains the main TUI application and related UI components. +//! +//! Nothing here may write to stdout/stderr directly. The TUI owns the terminal +//! via the alternate screen, and a stray `println!`/`eprintln!` injects a line +//! that scrolls the display out from under ratatui's diff - leaving artifacts +//! until something forces a full repaint. Use `tracing` instead; those records +//! reach the log file and the F5 debug view. +#![deny(clippy::print_stdout, clippy::print_stderr)] pub mod behaviors; pub mod debug; diff --git a/src/ui/viewport/column_width_calculator.rs b/src/ui/viewport/column_width_calculator.rs index e8c38800..229f4210 100644 --- a/src/ui/viewport/column_width_calculator.rs +++ b/src/ui/viewport/column_width_calculator.rs @@ -147,21 +147,37 @@ impl ColumnWidthCalculator { } } - /// Get cached column width for a specific `DataTable` column index + /// Get cached column width for a column's **visual position**. + /// + /// Widths are computed against `DataView`'s display order, so `visual_idx` must be a + /// visual position - not a `DataTable` source index. The two only coincide when the + /// view projects every source column in order; under a narrower projection a source + /// index runs off the end of the cache and silently returns `DEFAULT_COL_WIDTH`, + /// which truncates wide values. Callers holding a source index should translate it + /// via `DataView::visual_index_of_column` first. pub fn get_column_width( &mut self, dataview: &DataView, viewport_rows: &std::ops::Range, - col_idx: usize, + visual_idx: usize, ) -> u16 { if self.cache_dirty { self.recalculate_column_widths(dataview, viewport_rows); } self.column_widths - .get(col_idx) + .get(visual_idx) .copied() - .unwrap_or(DEFAULT_COL_WIDTH) + .unwrap_or_else(|| { + tracing::warn!( + target: "viewport_manager", + "get_column_width: visual index {} out of range ({} columns) - likely a DataTable index used as a visual position; falling back to {}w", + visual_idx, + self.column_widths.len(), + DEFAULT_COL_WIDTH + ); + DEFAULT_COL_WIDTH + }) } /// Get all cached column widths, ensuring they're up to date diff --git a/src/ui/viewport_manager.rs b/src/ui/viewport_manager.rs index 6a14f723..094a8d14 100644 --- a/src/ui/viewport_manager.rs +++ b/src/ui/viewport_manager.rs @@ -13,12 +13,12 @@ /// → Renderer (pixels on screen) use std::ops::Range; use std::sync::Arc; -use tracing::debug; +use tracing::{debug, warn}; use crate::data::data_view::DataView; use crate::data::datatable::DataRow; use crate::ui::viewport::column_width_calculator::{ - COLUMN_PADDING, MAX_COL_WIDTH, MAX_COL_WIDTH_DATA_FOCUS, MIN_COL_WIDTH, + COLUMN_PADDING, DEFAULT_COL_WIDTH, MAX_COL_WIDTH, MAX_COL_WIDTH_DATA_FOCUS, MIN_COL_WIDTH, }; use crate::ui::viewport::{ColumnPackingMode, ColumnWidthCalculator}; @@ -750,10 +750,29 @@ impl ViewportManager { .get_all_column_widths(&self.dataview, &self.viewport_rows) } - /// Get column width for a specific column - pub fn get_column_width(&mut self, col_idx: usize) -> u16 { + /// Get column width for a specific column, by **visual** position. + pub fn get_column_width(&mut self, visual_idx: usize) -> u16 { self.width_calculator - .get_column_width(&self.dataview, &self.viewport_rows, col_idx) + .get_column_width(&self.dataview, &self.viewport_rows, visual_idx) + } + + /// Get column width for a column identified by its **`DataTable` (source)** index. + /// + /// `ColumnWidthCalculator` keys its cache by visual position, so a source index has + /// to be translated first. Passing a source index straight through reads past the + /// end of the width vector under any projection narrower than the source table, and + /// silently yields `DEFAULT_COL_WIDTH` - which truncates wide values such as + /// timestamps to 15 characters. + fn get_column_width_by_datatable_index(&mut self, datatable_idx: usize) -> u16 { + let Some(visual_idx) = self.dataview.visual_index_of_column(datatable_idx) else { + warn!(target: "viewport_manager", + "get_column_width_by_datatable_index: DataTable column {} is not in this view, using default width", + datatable_idx); + return DEFAULT_COL_WIDTH; + }; + + self.width_calculator + .get_column_width(&self.dataview, &self.viewport_rows, visual_idx) } /// Get visible rows in the current viewport @@ -899,11 +918,7 @@ impl ViewportManager { } let datatable_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - datatable_idx, - ); + let width = self.get_column_width(visual_idx); // Always include pinned columns, even if they exceed available width used_width += width + separator_width; @@ -939,11 +954,7 @@ impl ViewportManager { // Get the DataTable index for this visual position let datatable_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - datatable_idx, - ); + let width = self.get_column_width(visual_idx); if used_width + width + separator_width <= available_width { used_width += width + separator_width; @@ -999,15 +1010,15 @@ impl ViewportManager { column_count.max(1) // Always show at least one column } - /// Get calculated widths for specific columns - /// This is useful for rendering when we know which columns will be displayed - pub fn get_column_widths_for(&mut self, column_indices: &[usize]) -> Vec { - column_indices + /// Get calculated widths for specific columns, by **visual** position. + /// This is useful for rendering when we know which columns will be displayed. + /// + /// Note: `calculate_visible_column_indices` returns `DataTable` indices, not visual + /// positions - feed those to `get_column_width_by_datatable_index` instead. + pub fn get_column_widths_for(&mut self, visual_indices: &[usize]) -> Vec { + visual_indices .iter() - .map(|&idx| { - self.width_calculator - .get_column_width(&self.dataview, &self.viewport_rows, idx) - }) + .map(|&visual_idx| self.get_column_width(visual_idx)) .collect() } @@ -1048,18 +1059,15 @@ impl ViewportManager { return 0; } - let pinned = self.dataview.get_pinned_columns(); + // Owned copy: the width lookups below take &mut self + let pinned = self.dataview.get_pinned_columns().to_vec(); let _pinned_count = pinned.len(); // Calculate how much width is used by pinned columns let mut pinned_width = 0u16; let separator_width = 1u16; - for &col_idx in pinned { - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + for &col_idx in &pinned { + let width = self.get_column_width_by_datatable_index(col_idx); pinned_width += width + separator_width; } @@ -1079,11 +1087,7 @@ impl ViewportManager { // Get the last scrollable column let last_col_idx = *scrollable_columns.last().unwrap(); - let last_col_width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - last_col_idx, - ); + let last_col_width = self.get_column_width_by_datatable_index(last_col_idx); tracing::debug!( "Starting calculation: last_col_idx={}, width={}w, available={}w, scrollable_cols={}", @@ -1098,11 +1102,7 @@ impl ViewportManager { // Now work backwards through scrollable columns to find how many more we can fit for (idx, &col_idx) in scrollable_columns.iter().enumerate().rev().skip(1) { - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width_by_datatable_index(col_idx); let width_with_separator = width + separator_width; @@ -1142,11 +1142,7 @@ impl ViewportManager { let mut can_see_last = false; for idx in best_offset..scrollable_columns.len() { let col_idx = scrollable_columns[idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width_by_datatable_index(col_idx); test_width += width + separator_width; if test_width > available_for_scrollable { @@ -1173,11 +1169,7 @@ impl ViewportManager { test_width = 0; for idx in best_offset..scrollable_columns.len() { let col_idx = scrollable_columns[idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width_by_datatable_index(col_idx); test_width += width + separator_width; if test_width > available_for_scrollable { @@ -1210,7 +1202,8 @@ impl ViewportManager { output.push_str("========== VIEWPORT MANAGER DEBUG ==========\n"); let total_cols = self.dataview.column_count(); - let pinned = self.dataview.get_pinned_columns(); + // Owned copy: the width lookups below take &mut self + let pinned = self.dataview.get_pinned_columns().to_vec(); let pinned_count = pinned.len(); output.push_str(&format!("Total columns: {total_cols}\n")); @@ -1326,12 +1319,8 @@ impl ViewportManager { // Calculate available width for scrollable columns let separator_width = 1u16; let mut pinned_width = 0u16; - for &col_idx in pinned { - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + for &col_idx in &pinned { + let width = self.get_column_width_by_datatable_index(col_idx); pinned_width += width + separator_width; } let available_for_scrollable = available_width.saturating_sub(pinned_width); @@ -1505,11 +1494,7 @@ impl ViewportManager { for &col_idx in &visible_indices { x_positions.push(current_x); - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width_by_datatable_index(col_idx); current_x += width + separator_width; } @@ -1543,11 +1528,7 @@ impl ViewportManager { // Process columns in DataView's order (pinned first, then display order) for &col_idx in &ordered_columns { - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width_by_datatable_index(col_idx); if used_width + width + separator_width <= available_width { visible_indices.push(col_idx); @@ -1709,10 +1690,7 @@ impl ViewportManager { // Get the actual calculated widths for the visible columns let widths: Vec = visible_column_indices .iter() - .map(|&dt_idx| { - self.width_calculator - .get_column_width(&self.dataview, &self.viewport_rows, dt_idx) - }) + .map(|&dt_idx| self.get_column_width_by_datatable_index(dt_idx)) .collect(); debug!(target: "viewport_manager", @@ -1807,11 +1785,7 @@ impl ViewportManager { let separator_width = 1u16; for &col_idx in &visible_indices { - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width_by_datatable_index(col_idx); used_width += width + separator_width; } @@ -1822,20 +1796,18 @@ impl ViewportManager { let wasted_space = available_width.saturating_sub(used_width); - // Find the next column that didn't fit - let next_column_width = if visible_indices.is_empty() { - None - } else { - let last_visible = *visible_indices.last().unwrap(); - if last_visible + 1 < self.dataview.column_count() { - Some(self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - last_visible + 1, - )) - } else { - None + // visible_indices holds DataTable indices; widths are keyed by visual position + let visible_visual: Vec = visible_indices + .iter() + .filter_map(|&dt_idx| self.dataview.visual_index_of_column(dt_idx)) + .collect(); + + // Find the next column that didn't fit (the one after the last visible, visually) + let next_column_width = match visible_visual.last() { + Some(&last_visual) if last_visual + 1 < self.dataview.column_count() => { + Some(self.get_column_width(last_visual + 1)) } + _ => None, }; // Find ALL columns that COULD fit in the wasted space @@ -1846,7 +1818,7 @@ impl ViewportManager { .get_all_column_widths(&self.dataview, &self.viewport_rows); for (idx, &width) in all_widths.iter().enumerate() { // Skip already visible columns - if !visible_indices.contains(&idx) && width + separator_width <= wasted_space { + if !visible_visual.contains(&idx) && width + separator_width <= wasted_space { columns_that_could_fit.push((idx, width)); } } @@ -1866,10 +1838,7 @@ impl ViewportManager { visible_columns: visible_indices.len(), column_widths: visible_indices .iter() - .map(|&idx| { - self.width_calculator - .get_column_width(&self.dataview, &self.viewport_rows, idx) - }) + .map(|&idx| self.get_column_width_by_datatable_index(idx)) .collect(), next_column_width, columns_that_could_fit, @@ -1979,13 +1948,8 @@ impl ViewportManager { // Calculate pinned width let mut pinned_width = 0u16; - for i in 0..pinned_count { - let col_idx = display_columns[i]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + for visual_idx in 0..pinned_count { + let width = self.get_column_width(visual_idx); pinned_width += width + 3; // separator width } @@ -1997,12 +1961,7 @@ impl ViewportManager { // Work backwards from the last column to find the best scroll position for visual_idx in (pinned_count..=last_visual_column).rev() { - let col_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - col_idx, - ); + let width = self.get_column_width(visual_idx); accumulated_width += width + 3; // separator width if accumulated_width > available_for_scrollable { @@ -3502,10 +3461,8 @@ impl ViewportManager { // This needs to calculate based on visual columns let display_columns = self.dataview.get_display_columns(); let mut total_width_needed = 0u16; - for &dt_idx in &display_columns { - let width = - self.width_calculator - .get_column_width(&self.dataview, &self.viewport_rows, dt_idx); + for visual_idx in 0..display_columns.len() { + let width = self.get_column_width(visual_idx); total_width_needed += width + 1; // +1 for separator } @@ -3575,12 +3532,7 @@ impl ViewportManager { // First account for pinned column widths for visual_idx in 0..pinned_count { if visual_idx < display_columns.len() { - let dt_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - dt_idx, - ); + let width = self.get_column_width(visual_idx); used_width += width + separator_width; } } @@ -3590,12 +3542,7 @@ impl ViewportManager { let visual_start = pinned_count + new_scroll_offset; for visual_idx in visual_start..display_columns.len() { - let dt_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - dt_idx, - ); + let width = self.get_column_width(visual_idx); if used_width + width + separator_width <= terminal_width { used_width += width + separator_width; scrollable_columns_that_fit += 1; @@ -3674,12 +3621,7 @@ impl ViewportManager { for visual_idx in 0..pinned_count { if visual_idx < display_columns.len() { - let dt_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - dt_idx, - ); + let width = self.get_column_width(visual_idx); pinned_width += width + separator_width; } } @@ -3719,12 +3661,7 @@ impl ViewportManager { for test_scrollable_idx in test_scroll_offset..max_scrollable_columns { let visual_idx = pinned_count + test_scrollable_idx; if visual_idx < display_columns.len() { - let dt_idx = display_columns[visual_idx]; - let width = self.width_calculator.get_column_width( - &self.dataview, - &self.viewport_rows, - dt_idx, - ); + let width = self.get_column_width(visual_idx); if used_width + width + separator_width <= available_for_scrollable { used_width += width + separator_width; diff --git a/src/widgets/help_widget.rs b/src/widgets/help_widget.rs index 9329e1e4..8ff7aa89 100644 --- a/src/widgets/help_widget.rs +++ b/src/widgets/help_widget.rs @@ -469,7 +469,9 @@ Selection Modes: Ctrl+A - Select all Viewport Control: - Ctrl+L - Lock/unlock viewport + Space - Lock/unlock viewport + Ctrl+Space - Lock/unlock viewport (alternative) + Ctrl+L - Redraw the screen z - Center current row zt - Current row to top zb - Current row to bottom" diff --git a/tests/main.rs b/tests/main.rs index 602ddd44..0609d904 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -216,3 +216,6 @@ mod tui_integration_test; #[path = "viewport_manager_test.rs"] mod viewport_manager_test; + +#[path = "projection_column_width_tests.rs"] +mod projection_column_width_tests; diff --git a/tests/projection_column_width_tests.rs b/tests/projection_column_width_tests.rs new file mode 100644 index 00000000..ab8a94c4 --- /dev/null +++ b/tests/projection_column_width_tests.rs @@ -0,0 +1,150 @@ +//! Regression tests for column widths under a narrower-than-source projection. +//! +//! `DataView` projections keep the original `DataTable` and record source column +//! indices in `visible_columns`. Anything keyed by visual position - notably the +//! column width cache - must translate those indices first. Using a source index +//! directly reads past the end of the width vector and silently falls back to +//! DEFAULT_COL_WIDTH (15), truncating wide values such as "27/08/2026 11:59:12". + +use std::sync::Arc; + +use sql_cli::data::data_view::DataView; +use sql_cli::data::datatable::{DataColumn, DataRow, DataTable, DataValue}; +use sql_cli::ui::viewport_manager::ViewportManager; + +const STARTED: &str = "27/08/2026 11:59:12"; +const FINISHED: &str = "27/08/2026 12:04:31"; + +/// A wide export (like a TeamCity build API dump) where the interesting columns +/// sit past the width of the projection that selects them. +fn wide_source_table() -> DataTable { + let headers = [ + "BuildTypeId", + "Number", + "State", + "Status", + "StatusText", + "Branch", + "Agent", + "TriggeredBy", + "Comment", + "Project", + "Job", + "JobId", + "Started", + "Finished", + "DurationSec", + ]; + + let mut table = DataTable::new("tc"); + for header in headers { + table.add_column(DataColumn::new(header)); + } + + for i in 0..20 { + table + .add_row(DataRow::new(vec![ + DataValue::String(format!("bt_{i}")), + DataValue::String(i.to_string()), + DataValue::String("finished".into()), + DataValue::String("SUCCESS".into()), + DataValue::String("Success".into()), + DataValue::String("main".into()), + DataValue::String(format!("agent-{i}")), + DataValue::String("scheduler".into()), + DataValue::String(String::new()), + DataValue::String("ServerOps".into()), + DataValue::String("DeployUpdate".into()), + DataValue::String(format!("job-{i}")), + DataValue::String(STARTED.into()), + DataValue::String(FINISHED.into()), + DataValue::String("319".into()), + ])) + .unwrap(); + } + + table +} + +/// SELECT Project, Job, JobId, Started, Finished, DurationSec FROM tc +fn projected_view() -> DataView { + DataView::new(Arc::new(wide_source_table())).with_columns(vec![9, 10, 11, 12, 13, 14]) +} + +#[test] +fn visual_index_of_column_maps_source_indices_to_display_positions() { + let view = projected_view(); + + assert_eq!(view.visual_index_of_column(9), Some(0), "Project"); + assert_eq!(view.visual_index_of_column(12), Some(3), "Started"); + assert_eq!(view.visual_index_of_column(14), Some(5), "DurationSec"); + + // Columns outside the projection have no visual position + assert_eq!(view.visual_index_of_column(0), None, "BuildTypeId"); + assert_eq!(view.visual_index_of_column(99), None, "out of range"); +} + +#[test] +fn projected_datetime_columns_are_not_truncated() { + let mut vm = ViewportManager::new(Arc::new(projected_view())); + vm.update_terminal_size(200, 30); + + let (headers, _rows, widths) = vm.get_visual_display(200, &[]); + + for (name, value) in [("Started", STARTED), ("Finished", FINISHED)] { + let pos = headers + .iter() + .position(|h| h == name) + .unwrap_or_else(|| panic!("{name} column missing from {headers:?}")); + + assert!( + widths[pos] >= value.len() as u16, + "{name} width {} truncates {value:?} ({} chars); widths={widths:?}", + widths[pos], + value.len() + ); + } +} + +#[test] +fn projected_widths_match_the_equivalent_unprojected_view() { + // The same six columns, but as the only columns in the source table, so that + // visual and DataTable indices coincide. Widths must agree either way. + let mut narrow = DataTable::new("tc_narrow"); + for header in [ + "Project", + "Job", + "JobId", + "Started", + "Finished", + "DurationSec", + ] { + narrow.add_column(DataColumn::new(header)); + } + for i in 0..20 { + narrow + .add_row(DataRow::new(vec![ + DataValue::String("ServerOps".into()), + DataValue::String("DeployUpdate".into()), + DataValue::String(format!("job-{i}")), + DataValue::String(STARTED.into()), + DataValue::String(FINISHED.into()), + DataValue::String("319".into()), + ])) + .unwrap(); + } + + let mut wide_vm = ViewportManager::new(Arc::new(projected_view())); + wide_vm.update_terminal_size(200, 30); + let (wide_headers, _, wide_widths) = wide_vm.get_visual_display(200, &[]); + + let mut narrow_vm = ViewportManager::new(Arc::new(DataView::new(Arc::new(narrow)))); + narrow_vm.update_terminal_size(200, 30); + let (narrow_headers, _, narrow_widths) = narrow_vm.get_visual_display(200, &[]); + + assert_eq!(wide_headers, narrow_headers); + assert_eq!( + wide_widths, narrow_widths, + "projection changed column widths: {wide_widths:?} vs {narrow_widths:?}" + ); +}