Repository navigation
Expand file tree
/
Copy pathmain_gui.cpp
More file actions
1893 lines (1699 loc) · 75.6 KB
/
Copy pathmain_gui.cpp
File metadata and controls
1893 lines (1699 loc) · 75.6 KB
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
// OpenCode Session Manager - GUI (Dear ImGui + Win32 + OpenGL3)
//
// Browse old OpenCode sessions & snapshots, copy the resume command, jump into
// a session, and reclaim disk space -- without ever blocking the UI thread.
//
// No bundled DLLs: uses only Windows system libraries (opengl32.dll etc.).
// Build (MinGW-w64 + sqlite3 from C:\MinGW\opt):
// g++ -std=c++17 -O2 -I imgui -I imgui/backends
// -I "C:\MinGW\opt\include"
// main_gui.cpp opencode_data.cpp
// imgui/imgui.cpp imgui/imgui_draw.cpp imgui/imgui_tables.cpp
// imgui/imgui_widgets.cpp
// imgui/backends/imgui_impl_win32.cpp imgui/backends/imgui_impl_opengl3.cpp
// -L "C:\MinGW\opt\lib" -lsqlite3 -lopengl32 -ldwmapi -lshell32 -lole32
// -mwindows -o opencode-session-manager-gui.exe
//
// Optional env overrides (handy for testing):
// OPENCODE_DATA_DIR=<dir containing opencode.db, storage/, snapshot/>
// OPENCODE_SM_NO_RECYCLE=1 -> delete permanently instead of using the bin
#define WIN32_LEAN_AND_MEAN 1
// WM_DPICHANGED needs the Windows 8.1 SDK level.
#ifndef WINVER
#define WINVER 0x0603
#endif
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0603
#endif
#include <windows.h>
#include "imgui.h"
#include "imgui_impl_win32.h"
#include "imgui_impl_opengl3.h"
#include <GL/gl.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <set>
#include <string>
#include <vector>
#include "opencode_data.hpp"
#include "db_maintenance.hpp"
#include "session_view.hpp"
#include "version.h"
using std::string;
using std::wstring;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static const string GUI_DB_PATH = db_path();
static const string GUI_DIFF_DIR = diff_dir();
static const string GUI_SNAPSHOT_DIR = snapshot_dir();
static string u8(const wstring& w) { return wide_to_utf8(w); }
static string u8fmt_time(long long ts) { return u8(format_time(ts)); }
static string u8fmt_size(long long sz) { return u8(format_size(sz)); }
// ---------------------------------------------------------------------------
// Application state
// ---------------------------------------------------------------------------
static const int TAB_SESSIONS = 0;
static const int TAB_SNAPSHOTS = 1;
static const int TAB_DATABASE = 2;
// How many snapshot sizes we walk per frame. Sizing a snapshot directory means
// recursing the whole tree; doing all of them in one go is what used to freeze
// the window on every refresh.
static const int SIZE_SCAN_PER_FRAME = 6;
static int g_tab = TAB_SESSIONS;
static std::vector<Session> g_sessions;
static std::vector<Snapshot> g_snapshots;
static std::set<string> g_selected; // session id, or snapshot path
static std::vector<int> g_view_sessions; // indices into g_sessions, filtered+sorted
static std::vector<int> g_view_snapshots;
static char g_filter[128] = "";
static string g_filter_prev;
static bool g_orphans_only = false;
static bool g_orphans_only_prev = false;
static int g_sort_col[2] = {5, 3}; // sessions: Created, snapshots: Size
static bool g_sort_asc[2] = {false, false};
static bool g_view_dirty = true;
static size_t g_size_scan_next = 0;
static size_t g_size_scan_pending = 0;
static string g_message, g_error, g_toast;
static double g_toast_expire = 0.0;
static bool g_cleanup_on_exit = false;
static bool g_confirm_request = false;
static bool g_confirm_open = false;
static bool g_focus_filter = false;
// ---------------------------------------------------------------------------
// Rule based selection, session preview, and database maintenance state
// ---------------------------------------------------------------------------
static int g_older_days_i = 0; // 0 = no age bound
static int g_max_msgs_i = -1; // -1 = no bound
static bool g_show_preview = false;
static std::vector<PreviewMessage> g_preview;
static string g_preview_for; // session id the preview belongs to
static string g_preview_error;
static MaintenanceJob g_job;
static bool g_job_seen = false; // a finished job has been reported already
// A maintenance action waiting for confirmation.
static int g_maint_action = 0;
static bool g_maint_modal = false;
static bool g_maint_keep_backup = false;
static string g_maint_backup_path;
enum { MA_NONE = 0, MA_CLEAN_ORPHANS, MA_COMPACT, MA_VACUUM, MA_BACKUP, MA_CLEAN_AUX };
// Files next to the database (log/, tool-output/), refreshed by a ScanAux job.
static std::vector<AuxItem> g_aux;
static bool g_aux_scan_done = false;
static string timestamped_backup_path() {
time_t now = time(nullptr);
struct tm tmv;
#ifdef _WIN32
localtime_s(&tmv, &now);
#else
localtime_r(&now, &tmv);
#endif
char buf[64];
snprintf(buf, sizeof(buf), "%04d%02d%02d-%02d%02d%02d", tmv.tm_year + 1900, tmv.tm_mon + 1,
tmv.tm_mday, tmv.tm_hour, tmv.tm_min, tmv.tm_sec);
return GUI_DB_PATH + ".backup-" + buf;
}
// Keeps the preview panel in sync with the (single) selected session.
static void sync_preview() {
if (!g_show_preview || g_tab != TAB_SESSIONS) {
g_preview.clear();
g_preview_for.clear();
return;
}
string want;
if (g_selected.size() == 1) want = *g_selected.begin();
if (want == g_preview_for) return;
g_preview.clear();
g_preview_error.clear();
g_preview_for = want;
if (want.empty()) return;
try {
g_preview = load_session_preview(GUI_DB_PATH, want, 12);
} catch (const std::exception& e) {
g_preview_error = e.what();
}
}
static void start_maintenance(int action) {
switch (action) {
case MA_CLEAN_ORPHANS: g_job.start(JobType::CleanupOrphans, GUI_DB_PATH); break;
case MA_COMPACT: g_job.start(JobType::CompactEvents, GUI_DB_PATH, g_maint_backup_path); break;
case MA_VACUUM: g_job.start(JobType::Vacuum, GUI_DB_PATH); break;
case MA_BACKUP: g_job.start(JobType::Backup, GUI_DB_PATH, g_maint_backup_path); break;
case MA_CLEAN_AUX: g_job.start(JobType::CleanAux, GUI_DB_PATH); break;
default: break;
}
g_job_seen = false;
}
// ---------------------------------------------------------------------------
// Text size
//
// Dear ImGui 1.92 computes GetFontSize() as
// style.FontSizeBase * style.FontScaleMain * style.FontScaleDpi
// The previous build handed an already-DPI-scaled pixel size to the font atlas
// *and* set style.FontScaleDpi, so every glyph rendered at scale^2: on a 150%
// display that is 16 * 1.5 * 1.5 = 36px instead of 24px. The bug is having two
// factors, so the fix is to apply the monitor scale exactly once -- but doing
// only that visibly shrinks the text people are used to, so the size is now a
// real setting: Ctrl+Minus / Ctrl+Plus / Ctrl+0, persisted, and defaulting to
// the size this app has always rendered at.
// ---------------------------------------------------------------------------
static const float kStandardTextPx = 16.0f; // the size a 100% display uses
static float g_dpi_scale = 1.0f;
static float g_text_px = 0.0f; // logical text size; 0 = not resolved yet
static bool g_text_px_dirty = false; // has an unsaved change
static string settings_file_path() {
const char* appdata = getenv("APPDATA");
if (!appdata || !*appdata) return "";
return path_join(path_join(appdata, "opencode-session-manager"), "settings.ini");
}
// %APPDATA% may contain non-ASCII characters, so never use plain fopen().
static FILE* open_file_utf8(const string& path, const wchar_t* mode) {
return _wfopen(utf8_to_wide(path).c_str(), mode);
}
static void load_settings() {
g_text_px = 0.0f;
string f = settings_file_path();
if (f.empty() || !is_file(f)) return;
FILE* fp = open_file_utf8(f, L"rb");
if (!fp) return;
char line[256];
while (fgets(line, sizeof(line), fp)) {
float v = 0.0f;
if (sscanf(line, "text_px=%f", &v) == 1 && v >= 10.0f && v <= 48.0f) g_text_px = v;
}
fclose(fp);
}
static void save_settings() {
string f = settings_file_path();
if (f.empty()) return;
size_t slash = f.find_last_of("/\\");
if (slash != string::npos) make_dir(f.substr(0, slash));
FILE* fp = open_file_utf8(f, L"wb");
if (!fp) return;
fprintf(fp, "text_px=%.0f\n", g_text_px);
fclose(fp);
g_text_px_dirty = false;
}
// The size every previous build rendered at (they applied the DPI factor twice).
static float default_text_px() { return kStandardTextPx * g_dpi_scale; }
static void set_text_px(float px) {
if (px < 10.0f) px = 10.0f;
if (px > 48.0f) px = 48.0f;
if (px == g_text_px) return;
g_text_px = px;
g_text_px_dirty = true; // picked up by the next apply_dpi_scale() call
}
static size_t total_selected_size() {
long long total = 0;
for (auto& s : g_snapshots)
if (g_selected.count(s.path)) total += s.size;
return (size_t)total;
}
// ---------------------------------------------------------------------------
// View building: filter -> sort, shared with the TUI (session_view.cpp) so the
// two front ends cannot drift apart. Sorting is an index permutation rather than
// a reordering of the underlying vectors, so row identity -- and therefore the
// selection, which is keyed by id/path -- never moves.
// ---------------------------------------------------------------------------
static SortField session_sort_field(int column) {
switch (column) {
case 1: return SORT_ID;
case 2: return SORT_NAME;
case 3: return SORT_LOCATION;
case 4: return SORT_MESSAGES;
case 6: return SORT_UPDATED;
case 5:
default: return SORT_CREATED;
}
}
static SortField snapshot_sort_field(int column) {
switch (column) {
case 1: return SORT_STATUS;
case 2: return SORT_LOCATION;
case 4: return SORT_NAME;
case 3:
default: return SORT_SIZE;
}
}
static void rebuild_views() {
// Rule based bounds shared by both views (they only apply to sessions).
long long created_before = 0;
if (g_older_days_i > 0) {
time_t now = time(nullptr);
created_before = (long long)(now - (time_t)g_older_days_i * 24 * 3600) * 1000;
}
ViewQuery sq;
sq.filter = g_filter;
sq.field = session_sort_field(g_sort_col[TAB_SESSIONS]);
sq.descending = !g_sort_asc[TAB_SESSIONS];
sq.created_before_ms = created_before;
sq.max_messages = g_max_msgs_i;
g_view_sessions = build_session_view(g_sessions, sq);
ViewQuery pq;
pq.filter = g_filter;
pq.orphans_only = g_orphans_only;
pq.field = snapshot_sort_field(g_sort_col[TAB_SNAPSHOTS]);
pq.descending = !g_sort_asc[TAB_SNAPSHOTS];
g_view_snapshots = build_snapshot_view(g_snapshots, pq);
}
static void ensure_view() {
if (!g_view_dirty) return;
rebuild_views();
g_view_dirty = false;
}
static size_t view_size() {
return g_tab == TAB_SESSIONS ? g_view_sessions.size() : g_view_snapshots.size();
}
static string key_of_view_row(int row) {
if (g_tab == TAB_SESSIONS) {
if (row < 0 || row >= (int)g_view_sessions.size()) return string();
return g_sessions[(size_t)g_view_sessions[(size_t)row]].id;
}
if (row < 0 || row >= (int)g_view_snapshots.size()) return string();
return g_snapshots[(size_t)g_view_snapshots[(size_t)row]].path;
}
static void select_all_visible() {
for (size_t i = 0; i < view_size(); ++i) g_selected.insert(key_of_view_row((int)i));
}
static void clear_selection() { g_selected.clear(); }
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
static void refresh_data(bool keep_view) {
g_error.clear();
try {
g_sessions = load_sessions(GUI_DB_PATH);
// Sizes are filled in a few per frame by pump_size_scan(), so the
// window stays interactive no matter how large the snapshot dirs are.
g_snapshots = load_snapshots(GUI_DB_PATH, GUI_SNAPSHOT_DIR, /*with_sizes=*/false);
g_size_scan_next = 0;
g_size_scan_pending = g_snapshots.size();
} catch (const std::exception& e) {
g_error = string("Failed to read OpenCode data: ") + e.what();
g_message = "Error";
return;
}
if (!keep_view) {
g_selected.clear();
g_message.clear();
} else {
// Drop selections whose row no longer exists.
std::set<string> alive;
for (auto& s : g_sessions) alive.insert(s.id);
for (auto& s : g_snapshots) alive.insert(s.path);
for (auto it = g_selected.begin(); it != g_selected.end();) {
if (!alive.count(*it)) it = g_selected.erase(it);
else ++it;
}
}
g_view_dirty = true;
}
// Computes a few unknown snapshot sizes per frame.
static void pump_size_scan(int budget) {
const size_t n = g_snapshots.size();
if (!n || g_size_scan_pending == 0) return;
int done = 0;
int scanned = 0;
while (done < budget && scanned < (int)n) {
size_t i = g_size_scan_next % n;
g_size_scan_next = (i + 1) % n;
++scanned;
if (g_snapshots[i].size_known) continue;
g_snapshots[i].size = total_dir_size(g_snapshots[i].path);
g_snapshots[i].size_known = true;
if (g_size_scan_pending) g_size_scan_pending--;
++done;
}
// Re-sort only once the scan is complete: otherwise rows would shuffle
// under the cursor while sizes trickle in.
if (g_size_scan_pending == 0 && g_sort_col[TAB_SNAPSHOTS] == 3) g_view_dirty = true;
}
// ---------------------------------------------------------------------------
// Delete actions
// ---------------------------------------------------------------------------
static void do_delete() {
if (g_selected.empty()) return;
if (g_tab == TAB_SESSIONS) {
std::vector<string> ids(g_selected.begin(), g_selected.end());
try {
int n = delete_sessions(GUI_DB_PATH, ids);
std::vector<string> failed;
auto orphans = cleanup_orphan_diffs(GUI_DB_PATH, GUI_DIFF_DIR, &failed);
g_message = "Deleted " + std::to_string(n) + " session(s)";
if (!orphans.empty())
g_message += (delete_to_recycle() ? ", moved " : ", removed ") +
std::to_string(orphans.size()) + " orphan diff(s)";
if (!failed.empty())
g_message += ", " + std::to_string(failed.size()) + " orphan diff(s) failed";
g_selected.clear();
refresh_data(true);
} catch (const std::exception& e) {
g_error = string("Delete failed: ") + e.what();
g_message = "Error";
}
return;
}
std::vector<string> paths;
long long total = 0;
for (auto& s : g_snapshots) {
if (g_selected.count(s.path)) {
paths.push_back(s.path);
total += s.size;
}
}
if (paths.empty()) return;
size_t failed = 0;
string first_error;
for (auto& p : paths) {
try {
remove_tree(p);
} catch (const std::exception& e) {
if (failed == 0) first_error = e.what();
++failed;
}
}
bool recycled = last_delete_went_to_recycle();
if (failed == 0) {
g_message = (recycled ? "Moved " : "Removed ") + std::to_string(paths.size()) +
" snapshot(s)" + (recycled ? " to the Recycle Bin (" : " (") +
u8fmt_size(total) + (recycled ? ")" : " freed)");
} else if (failed == paths.size()) {
g_error = first_error;
g_message = "Error";
} else {
g_message = "Removed " + std::to_string(paths.size() - failed) + " snapshot(s), " +
std::to_string(failed) + " failed: " + utf8_ellipsize(first_error, 60);
}
g_selected.clear();
refresh_data(true);
}
static void do_cleanup_orphans() {
try {
std::vector<string> failed;
auto orphans = cleanup_orphan_diffs(GUI_DB_PATH, GUI_DIFF_DIR, &failed);
if (orphans.empty() && failed.empty())
g_message = "No orphan session_diff files to clean.";
else
g_message = (delete_to_recycle() ? "Moved " : "Removed ") +
std::to_string(orphans.size()) + " orphan diff file(s)" +
(failed.empty() ? "" : ", " + std::to_string(failed.size()) + " failed");
} catch (const std::exception& e) {
g_error = string("Cleanup failed: ") + e.what();
g_message = "Error";
}
}
// ---------------------------------------------------------------------------
// Column widths derived from the live font and style.
// Hard-coded pixel values break the moment the DPI or the text size changes --
// that is exactly what clipped the "dir" button (fixed 150px column, while
// copy+jump+dir actually needed 171px at 150% scaling).
// ---------------------------------------------------------------------------
static float sel_column_width() {
const ImGuiStyle& st = ImGui::GetStyle();
return ImGui::GetFrameHeight() + st.CellPadding.x * 2.0f + 4.0f;
}
static float button_column_width(const char* a, const char* b, const char* c) {
const ImGuiStyle& st = ImGui::GetStyle();
float text = ImGui::CalcTextSize(a).x + ImGui::CalcTextSize(b).x;
float buttons = 2.0f;
if (c && *c) {
text += ImGui::CalcTextSize(c).x;
buttons = 3.0f;
}
return text + 2.0f * buttons * st.FramePadding.x + (buttons - 1.0f) * st.ItemSpacing.x +
st.CellPadding.x * 2.0f + 8.0f;
}
// ---------------------------------------------------------------------------
// UI: toolbar
// ---------------------------------------------------------------------------
static void draw_toolbar() {
if (ImGui::Button("Refresh (F5)")) refresh_data(true);
ImGui::SameLine();
if (ImGui::Button("Select All")) select_all_visible();
ImGui::SameLine();
if (ImGui::Button("Clear Sel")) clear_selection();
ImGui::SameLine();
bool can_delete = !g_selected.empty();
ImGui::BeginDisabled(!can_delete);
if (ImGui::Button("Delete Selected (Del)")) g_confirm_request = true;
ImGui::EndDisabled();
if (!can_delete && ImGui::IsItemHovered())
ImGui::SetTooltip("Select at least one row first");
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
if (ImGui::Button("Clean orphan diffs")) do_cleanup_orphans();
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Removes session_diff/*.json files whose session no longer exists");
ImGui::SameLine();
ImGui::Checkbox("Cleanup on exit", &g_cleanup_on_exit);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Also clean orphan diffs when the window closes (off by default)");
// Second row: search and filters. Kept separate so the bar cannot overflow
// the window when the text size is turned up.
ImGui::TextUnformatted("Search:");
ImGui::SameLine();
if (g_focus_filter) {
ImGui::SetKeyboardFocusHere();
g_focus_filter = false;
}
ImGui::SetNextItemWidth(ImGui::GetFontSize() * 14.0f);
ImGui::InputTextWithHint("##filter", "id / title / project / path", g_filter, sizeof(g_filter));
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Ctrl+F focuses this box, Esc clears it");
ImGui::SameLine();
ImGui::Checkbox("Orphans only", &g_orphans_only);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Snapshots view: only directories with no session referencing them");
// Rule based selection: filter, then grab everything that matches.
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
ImGui::TextUnformatted("Older than");
ImGui::SameLine();
ImGui::SetNextItemWidth(ImGui::GetFontSize() * 3.2f);
if (ImGui::InputInt("##olderdays", &g_older_days_i, 0, 0)) {
if (g_older_days_i < 0) g_older_days_i = 0;
g_view_dirty = true;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Sessions view: only sessions created more than N days ago (0 = off)");
ImGui::SameLine();
ImGui::TextUnformatted("days");
ImGui::SameLine();
ImGui::TextUnformatted("max msgs");
ImGui::SameLine();
ImGui::SetNextItemWidth(ImGui::GetFontSize() * 3.2f);
if (ImGui::InputInt("##maxmsgs", &g_max_msgs_i, 0, 0)) {
if (g_max_msgs_i < -1) g_max_msgs_i = -1;
g_view_dirty = true;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Sessions view: only sessions with at most N messages (-1 = off)");
ImGui::SameLine();
if (ImGui::Button("Select matching")) select_all_visible();
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Select every row the current filter shows (then Delete Selected)");
ImGui::SameLine();
ImGui::Checkbox("Preview", &g_show_preview);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Sessions view: show the tail of the selected session");
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
ImGui::Text("Selected: %d/%d", (int)g_selected.size(), (int)view_size());
// Text size lives here rather than in the menu bar so neither bar can
// overflow when the text is turned up.
ImGui::SameLine(0, 32);
ImGui::TextDisabled("|");
ImGui::SameLine();
ImGui::Text("Text: %.0f px", g_text_px);
ImGui::SameLine();
if (ImGui::SmallButton("-##font")) set_text_px(g_text_px - 2.0f);
ImGui::SameLine();
if (ImGui::SmallButton("+##font")) set_text_px(g_text_px + 2.0f);
ImGui::SameLine();
if (ImGui::SmallButton("reset##font")) set_text_px(default_text_px());
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Back to the size this app has always used\n(standard would be %.0f px at this DPI)",
kStandardTextPx);
}
// ---------------------------------------------------------------------------
// UI: sessions tab
// ---------------------------------------------------------------------------
// Height the preview panel occupies. The sessions table subtracts this from its
// own height so the panel cannot be pushed below the status bar.
static float preview_panel_height() {
return ImGui::GetTextLineHeightWithSpacing() * 8.0f + ImGui::GetFrameHeightWithSpacing();
}
// Height a scrollable table should get when it is the only thing left in the
// content region. Tables bottom-align ((0,0) size) otherwise, which silently
// eats the widgets drawn after them.
static float fill_height() {
float h = ImGui::GetContentRegionAvail().y;
const float min_h = ImGui::GetTextLineHeightWithSpacing() * 3.0f;
return h > min_h ? h : min_h;
}
static void draw_sessions_tab() {
if (g_sessions.empty()) {
ImGui::TextDisabled("No sessions found in %s", GUI_DB_PATH.c_str());
return;
}
const ImGuiTableFlags flags = ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY |
ImGuiTableFlags_Sortable | ImGuiTableFlags_SizingStretchProp;
float table_h = fill_height();
if (g_show_preview) table_h -= preview_panel_height() + ImGui::GetStyle().ItemSpacing.y;
const float min_h = ImGui::GetTextLineHeightWithSpacing() * 3.0f;
if (table_h < min_h) table_h = min_h;
if (!ImGui::BeginTable("sessions", 8, flags, ImVec2(0, table_h))) return;
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableSetupColumn("Sel", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoSort,
sel_column_width());
ImGui::TableSetupColumn("Session id", ImGuiTableColumnFlags_WidthStretch, 2.4f);
ImGui::TableSetupColumn("Title", ImGuiTableColumnFlags_WidthStretch, 3.2f);
ImGui::TableSetupColumn("Project / Worktree", ImGuiTableColumnFlags_WidthStretch, 2.8f);
ImGui::TableSetupColumn("Msgs", ImGuiTableColumnFlags_WidthStretch, 0.8f);
ImGui::TableSetupColumn("Created", ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_DefaultSort |
ImGuiTableColumnFlags_PreferSortDescending,
1.8f);
ImGui::TableSetupColumn("Updated", ImGuiTableColumnFlags_WidthStretch, 1.8f);
ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoSort,
button_column_width("copy", "jump", "dir"));
ImGui::TableHeadersRow();
if (ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs()) {
if (specs->SpecsCount > 0) {
int col = specs->Specs[0].ColumnIndex;
bool asc = specs->Specs[0].SortDirection == ImGuiSortDirection_Ascending;
if (col != g_sort_col[TAB_SESSIONS] || asc != g_sort_asc[TAB_SESSIONS]) {
g_sort_col[TAB_SESSIONS] = col;
g_sort_asc[TAB_SESSIONS] = asc;
g_view_dirty = true;
}
}
specs->SpecsDirty = false;
}
ensure_view();
if (g_view_sessions.empty()) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::TextDisabled(g_filter[0] ? "No session matches the search." : "No sessions.");
ImGui::EndTable();
return;
}
for (size_t row = 0; row < g_view_sessions.size(); ++row) {
Session& s = g_sessions[(size_t)g_view_sessions[row]];
bool is_selected = g_selected.count(s.id) > 0;
ImGui::TableNextRow();
ImGui::PushID((int)row);
ImGui::TableSetColumnIndex(0);
if (ImGui::Checkbox("##sel", &is_selected)) {
if (is_selected) g_selected.insert(s.id);
else g_selected.erase(s.id);
}
ImGui::TableSetColumnIndex(1);
ImGui::TextUnformatted(s.id.c_str());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("opencode -s %s", s.id.c_str());
ImGui::TableSetColumnIndex(2);
ImGui::TextUnformatted(s.title.empty() ? "(untitled)" : u8(s.title).c_str());
ImGui::TableSetColumnIndex(3);
{
string proj = s.project_name.empty() ? s.worktree : s.project_name;
ImGui::TextUnformatted(proj.empty() ? "-" : proj.c_str());
if (ImGui::IsItemHovered() && !s.worktree.empty())
ImGui::SetTooltip("%s", s.worktree.c_str());
}
ImGui::TableSetColumnIndex(4);
ImGui::Text("%lld", s.msg_count);
ImGui::TableSetColumnIndex(5);
ImGui::TextUnformatted(u8fmt_time(s.time_created).c_str());
ImGui::TableSetColumnIndex(6);
ImGui::TextUnformatted(u8fmt_time(s.time_updated).c_str());
ImGui::TableSetColumnIndex(7);
if (ImGui::SmallButton("copy")) {
string cmd = "opencode -s " + s.id;
ImGui::SetClipboardText(cmd.c_str());
g_toast = "Copied: " + cmd;
g_toast_expire = ImGui::GetTime() + 2.0;
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Copy the resume command");
ImGui::SameLine();
if (ImGui::SmallButton("jump")) {
string err;
if (launch_opencode_session(s.id, s.worktree, &err))
g_message = "Resumed session " + s.id;
else
g_error = err.empty() ? "Failed to launch opencode" : err;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Resume this session in a new console\n(worktree: %s)",
s.worktree.empty() ? "-" : s.worktree.c_str());
ImGui::SameLine();
ImGui::BeginDisabled(!is_dir(s.worktree));
if (ImGui::SmallButton("dir")) {
string err;
if (!reveal_in_explorer(s.worktree, &err)) g_error = err;
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered())
ImGui::SetTooltip(is_dir(s.worktree) ? "Open the worktree in Explorer"
: "Worktree no longer exists");
ImGui::PopID();
}
ImGui::EndTable();
}
// ---------------------------------------------------------------------------
// UI: snapshots tab
// ---------------------------------------------------------------------------
static void draw_snapshots_tab() {
if (g_snapshots.empty()) {
ImGui::TextDisabled("No snapshots found in %s", GUI_SNAPSHOT_DIR.c_str());
return;
}
const ImGuiTableFlags flags = ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY |
ImGuiTableFlags_Sortable | ImGuiTableFlags_SizingStretchProp;
if (!ImGui::BeginTable("snapshots", 6, flags, ImVec2(0, fill_height()))) return;
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableSetupColumn("Sel", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoSort,
sel_column_width());
ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_NoSort, 1.4f);
ImGui::TableSetupColumn("Worktree", ImGuiTableColumnFlags_WidthStretch, 4.2f);
ImGui::TableSetupColumn("Size", ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_DefaultSort |
ImGuiTableColumnFlags_PreferSortDescending,
1.2f);
ImGui::TableSetupColumn("Project", ImGuiTableColumnFlags_WidthStretch, 2.4f);
ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoSort,
button_column_width("open", "copy path", nullptr));
ImGui::TableHeadersRow();
if (ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs()) {
if (specs->SpecsCount > 0) {
int col = specs->Specs[0].ColumnIndex;
bool asc = specs->Specs[0].SortDirection == ImGuiSortDirection_Ascending;
if (col != g_sort_col[TAB_SNAPSHOTS] || asc != g_sort_asc[TAB_SNAPSHOTS]) {
g_sort_col[TAB_SNAPSHOTS] = col;
g_sort_asc[TAB_SNAPSHOTS] = asc;
g_view_dirty = true;
}
}
specs->SpecsDirty = false;
}
ensure_view();
if (g_view_snapshots.empty()) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::TextDisabled("No snapshot matches the current filter.");
ImGui::EndTable();
return;
}
for (size_t row = 0; row < g_view_snapshots.size(); ++row) {
Snapshot& s = g_snapshots[(size_t)g_view_snapshots[row]];
bool is_orphan = !s.active_sessions;
bool is_selected = g_selected.count(s.path) > 0;
ImGui::TableNextRow();
ImGui::PushID((int)row);
ImGui::TableSetColumnIndex(0);
if (ImGui::Checkbox("##sel", &is_selected)) {
if (is_selected) g_selected.insert(s.path);
else g_selected.erase(s.path);
}
ImGui::TableSetColumnIndex(1);
if (is_orphan)
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.42f, 1.0f), "no session");
else
ImGui::TextColored(ImVec4(0.42f, 0.85f, 0.5f, 1.0f), "in use");
if (ImGui::IsItemHovered())
ImGui::SetTooltip(is_orphan ? "No session references this project any more"
: "A session still references this project");
ImGui::TableSetColumnIndex(2);
ImGui::TextUnformatted(s.worktree.c_str());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", s.worktree.c_str());
ImGui::TableSetColumnIndex(3);
if (s.size_known)
ImGui::TextUnformatted(u8fmt_size(s.size).c_str());
else
ImGui::TextDisabled("...");
ImGui::TableSetColumnIndex(4);
ImGui::TextUnformatted(u8(s.name).c_str());
ImGui::TableSetColumnIndex(5);
if (ImGui::SmallButton("open")) {
string err;
if (!reveal_in_explorer(s.path, &err)) g_error = err;
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Open this snapshot folder in Explorer");
ImGui::SameLine();
if (ImGui::SmallButton("copy path")) {
ImGui::SetClipboardText(s.path.c_str());
g_toast = "Copied: " + s.path;
g_toast_expire = ImGui::GetTime() + 2.0;
}
ImGui::PopID();
}
ImGui::EndTable();
}
// ---------------------------------------------------------------------------
// UI: session preview panel
// ---------------------------------------------------------------------------
static void draw_preview_panel() {
if (!g_show_preview || g_tab != TAB_SESSIONS) return;
float h = preview_panel_height();
ImGui::BeginChild("##preview", ImVec2(0, h), true);
ImGui::TextDisabled("Preview");
ImGui::SameLine();
if (g_preview_for.empty())
ImGui::TextDisabled("- select exactly one session");
else
ImGui::TextDisabled("%s (%d item(s) shown)", g_preview_for.c_str(), (int)g_preview.size());
if (!g_preview_error.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.42f, 0.42f, 1.0f), "%s", g_preview_error.c_str());
} else if (!g_preview_for.empty() && g_preview.empty()) {
ImGui::TextDisabled("No parts recorded for this session.");
}
ImGui::Separator();
for (auto& m : g_preview) {
bool user = (m.role == "user");
ImGui::TextColored(user ? ImVec4(0.55f, 0.78f, 1.0f, 1.0f) : ImVec4(0.6f, 0.9f, 0.6f, 1.0f),
"%s", m.role.empty() ? m.kind.c_str() : m.role.c_str());
ImGui::SameLine();
ImGui::TextDisabled("%s", u8fmt_time(m.time_created).c_str());
ImGui::TextWrapped("%s", m.text.c_str());
}
ImGui::EndChild();
}
// ---------------------------------------------------------------------------
// UI: database space report and maintenance
// ---------------------------------------------------------------------------
static void draw_usage_table(const char* id, const char* col0, const char* col1,
const std::vector<UsageEntry>& rows, bool show_extra) {
if (!ImGui::BeginTable(id, show_extra ? 4 : 3,
ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY,
ImVec2(0, ImGui::GetTextLineHeightWithSpacing() * 7.0f)))
return;
ImGui::TableSetupColumn(col0, ImGuiTableColumnFlags_WidthStretch, 3.0f);
ImGui::TableSetupColumn(col1, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFontSize() * 9.0f);
ImGui::TableSetupColumn("size", ImGuiTableColumnFlags_WidthFixed, ImGui::GetFontSize() * 7.0f);
if (show_extra) ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthStretch, 1.6f);
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableHeadersRow();
for (auto& e : rows) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::TextUnformatted(e.name.c_str());
ImGui::TableSetColumnIndex(1);
ImGui::Text("%d", (int)e.rows);
ImGui::TableSetColumnIndex(2);
ImGui::TextUnformatted(u8fmt_size(e.bytes).c_str());
if (show_extra) {
ImGui::TableSetColumnIndex(3);
ImGui::TextUnformatted(e.extra.c_str());
}
}
ImGui::EndTable();
}
static void draw_job_progress() {
JobStatus st = g_job.status();
if (!st.running) return;
if (st.percent >= 0)
ImGui::ProgressBar(st.percent / 100.0f, ImVec2(-1.0f, 0.0f), st.stage.c_str());
else {
ImGui::ProgressBar(-1.0f * (float)ImGui::GetTime(), ImVec2(-1.0f, 0.0f), "");
ImGui::TextDisabled("%s", st.stage.c_str());
}
}
static void draw_database_tab() {
JobStatus st = g_job.status();
const bool busy = st.running;
// opencode v2 migrated the event log away (the rows are gone, only the free
// pages remain). The three event buttons have nothing to act on then, so
// they are disabled with a reason instead of silently doing nothing.
const bool no_events = st.report.file_bytes > 0 && st.report.event_rows == 0;
ImGui::BeginDisabled(busy);
if (ImGui::Button("Scan database")) g_job.start(JobType::Report, GUI_DB_PATH);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Read-only. Shows where the disk space goes\n(this takes a minute or two on a large database)");
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
ImGui::BeginDisabled(no_events);
if (ImGui::Button("Analyse event log")) g_job.start(JobType::CompactEvents, GUI_DB_PATH);
ImGui::EndDisabled();
if (ImGui::IsItemHovered())
ImGui::SetTooltip(no_events
? "No event log in this database (opencode v2 migrated it away)"
: "Dry run: how much would keeping only the newest\nversion of each part reclaim?");
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
ImGui::BeginDisabled(no_events);
if (ImGui::Button("Clean orphan events")) {
g_maint_action = MA_CLEAN_ORPHANS;
g_maint_backup_path.clear();
g_maint_modal = true;
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered())
ImGui::SetTooltip(no_events
? "No event log in this database (opencode v2 migrated it away)"
: "Remove event rows belonging to sessions that are already deleted.\nSafe: nothing can replay a deleted aggregate.");
ImGui::SameLine();
ImGui::BeginDisabled(no_events);
if (ImGui::Button("Compact event log")) {
g_maint_action = MA_COMPACT;
g_maint_keep_backup = true;
g_maint_backup_path = timestamped_backup_path();
g_maint_modal = true;
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered())
ImGui::SetTooltip(no_events
? "No event log in this database (opencode v2 migrated it away)"
: "Keep only the newest snapshot of each message part.\nThis rewrites the opencode event log - back up first.");
ImGui::SameLine();
if (ImGui::Button("VACUUM")) {
g_maint_action = MA_VACUUM;
g_maint_backup_path.clear();
g_maint_modal = true;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Rewrite the file to hand free pages back to the filesystem.\nNeeds temp space and a few minutes; close opencode first.");
ImGui::SameLine();
if (ImGui::Button("Backup database")) {
g_maint_action = MA_BACKUP;
g_maint_backup_path = timestamped_backup_path() + ".bak";
g_maint_modal = true;
}
ImGui::EndDisabled();
if (no_events && !busy) {
ImGui::Spacing();
ImGui::TextDisabled("No event log in this database: opencode v2 migrated it away. What it "
"left behind is file space, not rows - VACUUM hands it back.");
}
if (busy) {
ImGui::Spacing();
draw_job_progress();
} else if (st.finished) {
ImGui::Spacing();
if (st.type == JobType::CleanupOrphans || st.type == JobType::CompactEvents) {
if (st.ok)
ImGui::Text("%s: removed %d rows (%s)", job_type_name(st.type), (int)st.result.rows,
u8fmt_size(st.result.bytes).c_str());
else
ImGui::TextColored(ImVec4(1.0f, 0.42f, 0.42f, 1.0f), "%s failed: %s",
job_type_name(st.type), st.error.c_str());
if (st.ok && st.result.file_before == st.result.file_after)
ImGui::TextDisabled("The file only shrinks after a VACUUM.");
} else if (st.type == JobType::Vacuum) {
if (st.ok)
ImGui::Text("VACUUM: %s -> %s (freed %s)",
u8fmt_size(st.result.file_before).c_str(),
u8fmt_size(st.result.file_after).c_str(),