From 2dab7e556644fa472e5d05c837f082db8391e44a Mon Sep 17 00:00:00 2001 From: mmxgn <> Date: Tue, 11 Aug 2026 21:45:53 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20fork=20session=20=E2=80=94=20clone?= =?UTF-8?q?=20Claude=20conversation=20via=20/fork,=20persist=20and=20resto?= =?UTF-8?q?re=20forks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the 'Show Diff' row button with a Fork button (^⇧K / Ctrl+Shift+K) - Fork button sends /fork to the Claude terminal; GFileMonitor on ~/.claude/projects// detects the new .jsonl and spawns claude --resume as a child session with its own VTE - Fork sessions are indented under their parent in the sidebar, selectable, and have their own terminal/stack entry (unlike robots) - sessions.txt gains a 4th pipe field (fork_parent_dir) so forks survive restart; two-pass startup restores parent→fork hierarchy - Clicking a recent directory also restores any saved forks for that dir - app.show-diff (Ctrl+Shift+D) remains accessible; not shown in shortcut bar --- src/main.c | 242 +++++++++++++++++++++++++++++++++++++++++-- src/session.h | 2 + src/sessions_store.c | 25 ++++- src/sessions_store.h | 1 + src/ui/sidebar.c | 48 ++++++++- src/ui/sidebar.h | 2 + 6 files changed, 299 insertions(+), 21 deletions(-) diff --git a/src/main.c b/src/main.c index c7ec33b..5305255 100644 --- a/src/main.c +++ b/src/main.c @@ -388,9 +388,10 @@ on_session_cleanup(GtkWidget *term, int status, gpointer data) /* -- session creation -- */ -static void -add_session_named(GtkWidget *split, const char *cmd, const char *working_dir, - const char *preserved_name) +/* Create, spawn, and wire up a session without adding it to the sidebar. + Caller sets is_fork / fork_parent_id before calling sidebar_add_session. */ +static Session * +spawn_session(const char *cmd, const char *working_dir, const char *preserved_name) { char name[64] = "shell"; if (preserved_name && *preserved_name) { @@ -406,37 +407,228 @@ add_session_named(GtkWidget *split, const char *cmd, const char *working_dir, } Session *s = session_create(&sessions, name); if (!s) - return; + return NULL; s->on_child_spawned = on_child_spawned; s->on_child_spawned_data = NULL; s->on_child_exited = on_child_exited; s->on_child_exited_data = NULL; if (cmd && *cmd) g_strlcpy(s->cmd, cmd, sizeof(s->cmd)); - if (working_dir) { + if (working_dir) g_strlcpy(s->cwd, working_dir, sizeof(s->cwd)); - recents_add(working_dir); - } session_spawn(s, (cmd && *cmd) ? cmd : NULL, working_dir); GattnPrefs prefs = prefs_load(); apply_prefs_to_terminal(VTE_TERMINAL(s->terminal), &prefs); g_signal_connect(s->terminal, "child-exited", G_CALLBACK(on_session_cleanup), s); state_detector_start(s); notify_watch(s, app.overlay, app.gapp); + return s; +} + +static void +add_session_named(GtkWidget *split, const char *cmd, const char *working_dir, + const char *preserved_name) +{ + Session *s = spawn_session(cmd, working_dir, preserved_name); + if (!s) + return; + if (working_dir) + recents_add(working_dir); + sidebar_add_session(split, s); + sessions_save(&sessions); +} + +/* -- fork session -- */ + +typedef struct { + Session *parent; + GtkWidget *split; + char **existing; /* NULL-terminated snapshot of .jsonl files before /fork */ + GFileMonitor *monitor; + guint timeout_id; +} ForkWatchCtx; + +static void add_fork_session(GtkWidget *split, Session *parent, const char *uuid); + +static void +fork_watch_cleanup(ForkWatchCtx *ctx) +{ + if (ctx->timeout_id) + g_source_remove(ctx->timeout_id); + g_file_monitor_cancel(ctx->monitor); + g_object_unref(ctx->monitor); + g_strfreev(ctx->existing); + g_free(ctx); +} + +static void +on_fork_dir_changed(GFileMonitor *monitor, GFile *file, GFile *other, + GFileMonitorEvent event, gpointer data) +{ + (void)monitor; + (void)other; + ForkWatchCtx *ctx = data; + if (event != G_FILE_MONITOR_EVENT_CREATED) + return; + char *name = g_file_get_basename(file); + if (!g_str_has_suffix(name, ".jsonl")) { + g_free(name); + return; + } + for (int i = 0; ctx->existing && ctx->existing[i]; i++) { + if (strcmp(ctx->existing[i], name) == 0) { + g_free(name); + return; /* pre-existing file */ + } + } + char *uuid = g_strndup(name, strlen(name) - 6); /* strip .jsonl */ + g_free(name); + + Session *parent = ctx->parent; + GtkWidget *split = ctx->split; + ctx->timeout_id = 0; /* prevent double-remove in cleanup */ + fork_watch_cleanup(ctx); + + add_fork_session(split, parent, uuid); + g_free(uuid); +} + +static gboolean +on_fork_timeout(gpointer data) +{ + ForkWatchCtx *ctx = data; + ctx->timeout_id = 0; + fork_watch_cleanup(ctx); + return G_SOURCE_REMOVE; +} + +static void +add_fork_session(GtkWidget *split, Session *parent, const char *uuid) +{ + char cmd[192]; + g_snprintf(cmd, sizeof(cmd), "claude --resume %s", uuid); + Session *s = spawn_session(cmd, parent->cwd, NULL); + if (!s) + return; + s->is_fork = TRUE; + s->fork_parent_id = parent->id; sidebar_add_session(split, s); sessions_save(&sessions); } +static void +on_fork_session(GSimpleAction *a, GVariant *p, gpointer d) +{ + (void)a; + (void)p; + (void)d; + Session *parent = selected_session(); + if (!parent || !parent->cwd[0] || !parent->terminal) + return; + + /* Encode cwd to locate the claude projects directory */ + char *encoded = g_strdup(parent->cwd); + for (char *c = encoded; *c; c++) + if (*c == '/') + *c = '-'; + char *proj_dir + = g_strdup_printf("%s/.claude/projects/%s", g_get_home_dir(), encoded); + g_free(encoded); + + /* Snapshot existing .jsonl files */ + GPtrArray *snap = g_ptr_array_new_with_free_func(g_free); + GDir *dir = g_dir_open(proj_dir, 0, NULL); + if (dir) { + const char *entry; + while ((entry = g_dir_read_name(dir))) + if (g_str_has_suffix(entry, ".jsonl")) + g_ptr_array_add(snap, g_strdup(entry)); + g_dir_close(dir); + } + g_ptr_array_add(snap, NULL); + + /* Start monitoring before sending /fork so we don't miss a fast response */ + GFile *proj_gfile = g_file_new_for_path(proj_dir); + GFileMonitor *monitor + = g_file_monitor_directory(proj_gfile, G_FILE_MONITOR_WATCH_MOVES, NULL, NULL); + g_object_unref(proj_gfile); + g_free(proj_dir); + + if (!monitor) { + g_strfreev((char **)g_ptr_array_free(snap, FALSE)); + return; + } + + ForkWatchCtx *ctx = g_new0(ForkWatchCtx, 1); + ctx->parent = parent; + ctx->split = app.split; + ctx->existing = (char **)g_ptr_array_free(snap, FALSE); + ctx->monitor = monitor; + ctx->timeout_id = g_timeout_add_seconds(30, on_fork_timeout, ctx); + g_signal_connect(monitor, "changed", G_CALLBACK(on_fork_dir_changed), ctx); + + /* Send /fork to the Claude terminal */ + vte_terminal_feed_child(VTE_TERMINAL(parent->terminal), "/fork\n", -1); +} + static inline void add_session(GtkWidget *split, const char *cmd, const char *working_dir) { add_session_named(split, cmd, working_dir, NULL); } +static void +on_fork_requested(Session *parent, GtkWidget *split, gpointer data) +{ + (void)split; + (void)data; + /* Re-use the action handler so keyboard shortcut and button share the same path. + We temporarily override selected_session by relying on the fact that the row + button sits on the parent's row which is selected on hover-reveal. If the + session is not selected, select it now so selected_session() returns it. */ + GtkListBox *lb = get_lb(); + for (int i = 0;; i++) { + GtkListBoxRow *row = gtk_list_box_get_row_at_index(lb, i); + if (!row) + break; + if (g_object_get_data(G_OBJECT(row), "gattn-session") == parent) { + gtk_list_box_select_row(lb, row); + break; + } + } + on_fork_session(NULL, NULL, NULL); +} + static void on_session_picked(const char *cmd, const char *dir, gpointer data) { add_session((GtkWidget *)data, cmd, dir); + + /* Restore any saved fork sessions that belong to this directory */ + SavedSession saved[32]; + int nsaved = sessions_load(saved, 32); + for (int i = 0; i < nsaved; i++) { + if (!saved[i].fork_parent_dir[0] || strcmp(saved[i].fork_parent_dir, dir) != 0) + continue; + /* Find the parent session we just created (matching cwd) */ + Session *parent = NULL; + for (int j = 0; j < sessions.count; j++) { + Session *s = sessions.items[j]; + if (s && !s->is_fork && !s->is_robot && strcmp(s->cwd, dir) == 0) { + parent = s; + break; + } + } + if (!parent) + continue; + Session *fork_s = spawn_session(saved[i].cmd, saved[i].dir, + saved[i].name[0] ? saved[i].name : NULL); + if (!fork_s) + continue; + fork_s->is_fork = TRUE; + fork_s->fork_parent_id = parent->id; + sidebar_add_session((GtkWidget *)data, fork_s); + } } static void @@ -773,6 +965,7 @@ on_activate(AdwApplication *app_obj, gpointer data) app.overlay = ADW_TOAST_OVERLAY(adw_toast_overlay_new()); app.split = sidebar_new(&sessions, on_new_session, NULL); + sidebar_set_fork_fn(app.split, on_fork_requested, NULL); sidebar_set_shell_here(app.split, on_shell_here, NULL); app.zoom_label = GTK_LABEL(g_object_get_data(G_OBJECT(app.split), "gattn-zoom-label")); @@ -798,6 +991,7 @@ on_activate(AdwApplication *app_obj, gpointer data) register_action(map, "jump-last", G_CALLBACK(on_jump_last), NULL); register_action(map, "raise", G_CALLBACK(on_raise), app_obj); register_action(map, "show-diff", G_CALLBACK(on_show_diff), NULL); + register_action(map, "fork-session", G_CALLBACK(on_fork_session), NULL); register_action(map, "open-folder", G_CALLBACK(on_open_folder), NULL); register_action(map, "open-shell-here", G_CALLBACK(on_open_shell_here), NULL); register_action(map, "resume-claude", G_CALLBACK(on_resume_claude), NULL); @@ -873,10 +1067,35 @@ on_activate(AdwApplication *app_obj, gpointer data) SavedSession saved[32]; int nsaved = sessions_load(saved, 32); if (nsaved > 0) { - for (int i = 0; i < nsaved; i++) - add_session_named(app.split, saved[i].cmd[0] ? saved[i].cmd : NULL, - saved[i].dir[0] ? saved[i].dir : NULL, - saved[i].name[0] ? saved[i].name : NULL); + /* Pass 1: spawn non-fork sessions */ + for (int i = 0; i < nsaved; i++) { + if (!saved[i].fork_parent_dir[0]) + add_session_named(app.split, saved[i].cmd[0] ? saved[i].cmd : NULL, + saved[i].dir[0] ? saved[i].dir : NULL, + saved[i].name[0] ? saved[i].name : NULL); + } + /* Pass 2: spawn fork sessions, linking each to its parent by cwd */ + for (int i = 0; i < nsaved; i++) { + if (!saved[i].fork_parent_dir[0]) + continue; + Session *parent = NULL; + for (int j = 0; j < sessions.count; j++) { + Session *s = sessions.items[j]; + if (s && !s->is_fork && !s->is_robot + && strcmp(s->cwd, saved[i].fork_parent_dir) == 0) { + parent = s; + break; + } + } + Session *fork_s = spawn_session(saved[i].cmd[0] ? saved[i].cmd : NULL, + saved[i].dir[0] ? saved[i].dir : NULL, + saved[i].name[0] ? saved[i].name : NULL); + if (!fork_s) + continue; + fork_s->is_fork = TRUE; + fork_s->fork_parent_id = parent ? parent->id : 0; + sidebar_add_session(app.split, fork_s); + } } else { session_picker_show(GTK_WIDGET(win), on_session_picked, app.split, NULL); } @@ -906,6 +1125,7 @@ main(int argc, char *argv[]) { "app.jump-last", "Next" }, { "app.exit-grid", "g" }, { "app.show-diff", "d" }, + { "app.fork-session", "k" }, { "app.open-folder", "o" }, { "app.open-shell-here", "t" }, { "app.resume-claude", "r" }, diff --git a/src/session.h b/src/session.h index efaac19..213113b 100644 --- a/src/session.h +++ b/src/session.h @@ -36,6 +36,8 @@ struct Session { guint child_poll_id; gint64 last_user_input_us; /* suppress contents-changed while user types */ gboolean user_renamed; /* TRUE once user explicitly renames; disables auto-rename */ + gboolean is_fork; /* TRUE for sessions created via the Fork button */ + int fork_parent_id; /* id of the session this was forked from; 0 if not a fork */ int seen_child_pids[32]; int seen_child_count; SessionStateChangedFn on_state_changed; diff --git a/src/sessions_store.c b/src/sessions_store.c index 2a58e34..0a59caf 100644 --- a/src/sessions_store.c +++ b/src/sessions_store.c @@ -26,14 +26,17 @@ sessions_load(SavedSession *out, int max) for (int i = 0; lines[i] && n < max; i++) { if (!lines[i][0]) continue; - /* Format: cmd|dir[|name]. Older files omit the name field. */ - char **parts = g_strsplit(lines[i], "|", 3); + /* Format: cmd|dir[|name[|fork_parent_dir]]. Older files omit trailing fields. */ + char **parts = g_strsplit(lines[i], "|", 4); if (parts[0] && parts[1]) { g_strlcpy(out[n].cmd, parts[0], sizeof(out[n].cmd)); g_strlcpy(out[n].dir, parts[1], sizeof(out[n].dir)); - out[n].name[0] = '\0'; + out[n].name[0] = '\0'; + out[n].fork_parent_dir[0] = '\0'; if (parts[2]) g_strlcpy(out[n].name, parts[2], sizeof(out[n].name)); + if (parts[3]) + g_strlcpy(out[n].fork_parent_dir, parts[3], sizeof(out[n].fork_parent_dir)); n++; } g_strfreev(parts); @@ -53,9 +56,21 @@ sessions_save(SessionList *list) GString *buf = g_string_new(NULL); for (int i = 0; i < list->count; i++) { Session *s = list->items[i]; - if (!s || s->parent_id != 0 || !s->cwd[0]) + if (!s || s->is_robot || !s->cwd[0]) continue; - g_string_append_printf(buf, "%s|%s|%s\n", s->cmd, s->cwd, s->name); + if (s->is_fork) { + /* Find parent's cwd to save as fork_parent_dir */ + const char *parent_cwd = ""; + for (int j = 0; j < list->count; j++) { + if (list->items[j] && list->items[j]->id == s->fork_parent_id) { + parent_cwd = list->items[j]->cwd; + break; + } + } + g_string_append_printf(buf, "%s|%s|%s|%s\n", s->cmd, s->cwd, s->name, parent_cwd); + } else { + g_string_append_printf(buf, "%s|%s|%s\n", s->cmd, s->cwd, s->name); + } } g_file_set_contents(path, buf->str, -1, NULL); g_string_free(buf, TRUE); diff --git a/src/sessions_store.h b/src/sessions_store.h index f6bd5af..2c4d0a1 100644 --- a/src/sessions_store.h +++ b/src/sessions_store.h @@ -5,6 +5,7 @@ typedef struct { char cmd[128]; char dir[512]; char name[64]; + char fork_parent_dir[512]; /* non-empty if this session is a fork; value = parent's cwd */ } SavedSession; int sessions_load(SavedSession *out, int max); diff --git a/src/ui/sidebar.c b/src/ui/sidebar.c index f9d985b..3c497c6 100644 --- a/src/ui/sidebar.c +++ b/src/ui/sidebar.c @@ -805,6 +805,20 @@ sidebar_set_shell_here(GtkWidget *split, SidebarShellHereFn fn, gpointer data) g_object_set_data_full(G_OBJECT(split), "gattn-shell-here-ctx", ctx, g_free); } +typedef struct { + SidebarForkFn fn; + gpointer data; +} ForkCtx; + +void +sidebar_set_fork_fn(GtkWidget *split, SidebarForkFn fn, gpointer data) +{ + ForkCtx *ctx = g_new(ForkCtx, 1); + ctx->fn = fn; + ctx->data = data; + g_object_set_data_full(G_OBJECT(split), "gattn-fork-ctx", ctx, g_free); +} + static void on_terminal_btn_clicked(GtkButton *btn, gpointer data) { @@ -821,6 +835,16 @@ on_diff_btn_clicked(GtkButton *btn, gpointer data) sidebar_show_diff((Session *)data, GTK_WIDGET(btn)); } +static void +on_fork_btn_clicked(GtkButton *btn, gpointer data) +{ + Session *s = data; + GtkWidget *split = gtk_widget_get_ancestor(GTK_WIDGET(btn), ADW_TYPE_NAVIGATION_SPLIT_VIEW); + ForkCtx *ctx = split ? g_object_get_data(G_OBJECT(split), "gattn-fork-ctx") : NULL; + if (ctx && ctx->fn) + ctx->fn(s, split, ctx->data); +} + static void on_close_btn_clicked(GtkButton *btn, gpointer data) { @@ -882,7 +906,7 @@ static GtkWidget * make_row(Session *s) { GtkWidget *box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); - gtk_widget_set_margin_start(box, s->is_robot ? 24 : 8); + gtk_widget_set_margin_start(box, (s->is_robot || s->is_fork) ? 24 : 8); gtk_widget_set_margin_end(box, 4); gtk_widget_set_margin_top(box, 4); gtk_widget_set_margin_bottom(box, 4); @@ -966,8 +990,8 @@ make_row(Session *s) gtk_box_append(GTK_BOX(actions), make_icon_btn("utilities-terminal-symbolic", "Open shell here", G_CALLBACK(on_terminal_btn_clicked), s)); - gtk_box_append(GTK_BOX(actions), make_icon_btn("document-edit-symbolic", "Show diff", - G_CALLBACK(on_diff_btn_clicked), s)); + gtk_box_append(GTK_BOX(actions), make_icon_btn("media-playlist-shuffle-symbolic", "Fork session", + G_CALLBACK(on_fork_btn_clicked), s)); gtk_box_append(GTK_BOX(actions), make_icon_btn("window-close-symbolic", "Close session", G_CALLBACK(on_close_btn_clicked), s)); /* Actions float on top of the row via GtkOverlay so they don't steal @@ -1202,7 +1226,7 @@ sidebar_add_session(GtkWidget *split, Session *s) GtkWidget *row = make_row(s); if (s->parent_id != 0) { - /* insert after parent row and any existing siblings */ + /* robot child: insert after parent row and any existing siblings */ int pos = -1; for (int i = 0;; i++) { GtkListBoxRow *r = gtk_list_box_get_row_at_index(lb, i); @@ -1213,6 +1237,20 @@ sidebar_add_session(GtkWidget *split, Session *s) pos = i + 1; } gtk_list_box_insert(lb, row, pos); + } else if (s->is_fork && s->fork_parent_id != 0) { + /* fork: insert after the parent row and any existing fork siblings */ + int pos = -1; + for (int i = 0;; i++) { + GtkListBoxRow *r = gtk_list_box_get_row_at_index(lb, i); + if (!r) + break; + Session *rs = g_object_get_data(G_OBJECT(r), "gattn-session"); + if (rs && (rs->id == s->fork_parent_id + || (rs->is_fork && rs->fork_parent_id == s->fork_parent_id) + || rs->parent_id == s->fork_parent_id)) + pos = i + 1; + } + gtk_list_box_insert(lb, row, pos); } else { gtk_list_box_append(lb, row); } @@ -1264,7 +1302,7 @@ make_shortcut_bar(void) static const Hint nav[] = { { "^N", "New" }, { "^⇧W", "Close" }, { "M-j/k", "Next/Prev" }, { "M-h/l", "Side/Term" }, { "^⇧A", "Unattended" }, { "^G", "Grid" }, - { "^⇧D", "Diff" }, { "^F", "Search" }, + { "^⇧K", "Fork" }, { "^F", "Search" }, }; static const Hint open[] = { { "^⇧R", "Resume session" }, { "^⇧O", "Open folder" }, { "^⇧T", "Open shell here" }, diff --git a/src/ui/sidebar.h b/src/ui/sidebar.h index e17f74a..9def934 100644 --- a/src/ui/sidebar.h +++ b/src/ui/sidebar.h @@ -3,7 +3,9 @@ typedef void (*SidebarNewFn)(GtkWidget *split, gpointer data); typedef void (*SidebarShellHereFn)(GtkWidget *split, const char *cwd, gpointer data); +typedef void (*SidebarForkFn)(Session *parent, GtkWidget *split, gpointer data); void sidebar_set_shell_here(GtkWidget *split, SidebarShellHereFn fn, gpointer data); +void sidebar_set_fork_fn(GtkWidget *split, SidebarForkFn fn, gpointer data); GtkWidget *sidebar_new(SessionList *sessions, SidebarNewFn on_new, gpointer on_new_data); void sidebar_add_session(GtkWidget *split, Session *s); From 4964c93ea5786ef31d8b97c2bb53134ca87b2fd0 Mon Sep 17 00:00:00 2001 From: mmxgn <> Date: Tue, 11 Aug 2026 22:16:58 +0200 Subject: [PATCH 2/2] fix: fork session inherits parent context via --resume Replace GFileMonitor/polling detection with direct lookup: find the most-recently-modified .jsonl in the parent's claude projects dir and spawn `claude --resume `, giving the fork the full conversation history. Drop the dead on_diff_btn_clicked handler from sidebar.c. --- src/main.c | 145 ++++++++++++----------------------------------- src/ui/sidebar.c | 6 -- 2 files changed, 36 insertions(+), 115 deletions(-) diff --git a/src/main.c b/src/main.c index 5305255..bded2f6 100644 --- a/src/main.c +++ b/src/main.c @@ -11,6 +11,7 @@ #include "ui/sidebar.h" #include #include +#include #include #include #include @@ -440,73 +441,44 @@ add_session_named(GtkWidget *split, const char *cmd, const char *working_dir, /* -- fork session -- */ -typedef struct { - Session *parent; - GtkWidget *split; - char **existing; /* NULL-terminated snapshot of .jsonl files before /fork */ - GFileMonitor *monitor; - guint timeout_id; -} ForkWatchCtx; - -static void add_fork_session(GtkWidget *split, Session *parent, const char *uuid); - static void -fork_watch_cleanup(ForkWatchCtx *ctx) +add_fork_session(GtkWidget *split, Session *parent) { - if (ctx->timeout_id) - g_source_remove(ctx->timeout_id); - g_file_monitor_cancel(ctx->monitor); - g_object_unref(ctx->monitor); - g_strfreev(ctx->existing); - g_free(ctx); -} + /* Find the most-recently-modified .jsonl in the parent's claude project dir. + The active session is writing to it, so it will always be the freshest. */ + char cmd[256] = "claude"; -static void -on_fork_dir_changed(GFileMonitor *monitor, GFile *file, GFile *other, - GFileMonitorEvent event, gpointer data) -{ - (void)monitor; - (void)other; - ForkWatchCtx *ctx = data; - if (event != G_FILE_MONITOR_EVENT_CREATED) - return; - char *name = g_file_get_basename(file); - if (!g_str_has_suffix(name, ".jsonl")) { - g_free(name); - return; - } - for (int i = 0; ctx->existing && ctx->existing[i]; i++) { - if (strcmp(ctx->existing[i], name) == 0) { - g_free(name); - return; /* pre-existing file */ + char *encoded = g_strdup(parent->cwd); + for (char *c = encoded; *c; c++) + if (*c == '/') *c = '-'; + char *proj_dir = g_strdup_printf("%s/.claude/projects/%s", + g_get_home_dir(), encoded); + g_free(encoded); + + GDir *dir = g_dir_open(proj_dir, 0, NULL); + if (dir) { + const char *entry; + char best[64] = ""; + time_t best_mt = 0; + while ((entry = g_dir_read_name(dir))) { + if (!g_str_has_suffix(entry, ".jsonl")) + continue; + char *full = g_build_filename(proj_dir, entry, NULL); + GStatBuf st; + if (g_stat(full, &st) == 0 && st.st_mtime > best_mt) { + best_mt = st.st_mtime; + g_strlcpy(best, entry, sizeof(best)); + } + g_free(full); + } + g_dir_close(dir); + if (best[0]) { + best[strlen(best) - 6] = '\0'; /* strip .jsonl */ + g_snprintf(cmd, sizeof(cmd), "claude --resume %s", best); } } - char *uuid = g_strndup(name, strlen(name) - 6); /* strip .jsonl */ - g_free(name); - - Session *parent = ctx->parent; - GtkWidget *split = ctx->split; - ctx->timeout_id = 0; /* prevent double-remove in cleanup */ - fork_watch_cleanup(ctx); - - add_fork_session(split, parent, uuid); - g_free(uuid); -} - -static gboolean -on_fork_timeout(gpointer data) -{ - ForkWatchCtx *ctx = data; - ctx->timeout_id = 0; - fork_watch_cleanup(ctx); - return G_SOURCE_REMOVE; -} + g_free(proj_dir); -static void -add_fork_session(GtkWidget *split, Session *parent, const char *uuid) -{ - char cmd[192]; - g_snprintf(cmd, sizeof(cmd), "claude --resume %s", uuid); Session *s = spawn_session(cmd, parent->cwd, NULL); if (!s) return; @@ -519,56 +491,11 @@ add_fork_session(GtkWidget *split, Session *parent, const char *uuid) static void on_fork_session(GSimpleAction *a, GVariant *p, gpointer d) { - (void)a; - (void)p; - (void)d; + (void)a; (void)p; (void)d; Session *parent = selected_session(); - if (!parent || !parent->cwd[0] || !parent->terminal) + if (!parent || !parent->cwd[0]) return; - - /* Encode cwd to locate the claude projects directory */ - char *encoded = g_strdup(parent->cwd); - for (char *c = encoded; *c; c++) - if (*c == '/') - *c = '-'; - char *proj_dir - = g_strdup_printf("%s/.claude/projects/%s", g_get_home_dir(), encoded); - g_free(encoded); - - /* Snapshot existing .jsonl files */ - GPtrArray *snap = g_ptr_array_new_with_free_func(g_free); - GDir *dir = g_dir_open(proj_dir, 0, NULL); - if (dir) { - const char *entry; - while ((entry = g_dir_read_name(dir))) - if (g_str_has_suffix(entry, ".jsonl")) - g_ptr_array_add(snap, g_strdup(entry)); - g_dir_close(dir); - } - g_ptr_array_add(snap, NULL); - - /* Start monitoring before sending /fork so we don't miss a fast response */ - GFile *proj_gfile = g_file_new_for_path(proj_dir); - GFileMonitor *monitor - = g_file_monitor_directory(proj_gfile, G_FILE_MONITOR_WATCH_MOVES, NULL, NULL); - g_object_unref(proj_gfile); - g_free(proj_dir); - - if (!monitor) { - g_strfreev((char **)g_ptr_array_free(snap, FALSE)); - return; - } - - ForkWatchCtx *ctx = g_new0(ForkWatchCtx, 1); - ctx->parent = parent; - ctx->split = app.split; - ctx->existing = (char **)g_ptr_array_free(snap, FALSE); - ctx->monitor = monitor; - ctx->timeout_id = g_timeout_add_seconds(30, on_fork_timeout, ctx); - g_signal_connect(monitor, "changed", G_CALLBACK(on_fork_dir_changed), ctx); - - /* Send /fork to the Claude terminal */ - vte_terminal_feed_child(VTE_TERMINAL(parent->terminal), "/fork\n", -1); + add_fork_session(app.split, parent); } static inline void diff --git a/src/ui/sidebar.c b/src/ui/sidebar.c index 3c497c6..52a904b 100644 --- a/src/ui/sidebar.c +++ b/src/ui/sidebar.c @@ -829,12 +829,6 @@ on_terminal_btn_clicked(GtkButton *btn, gpointer data) ctx->fn(split, s->cwd[0] ? s->cwd : g_get_home_dir(), ctx->data); } -static void -on_diff_btn_clicked(GtkButton *btn, gpointer data) -{ - sidebar_show_diff((Session *)data, GTK_WIDGET(btn)); -} - static void on_fork_btn_clicked(GtkButton *btn, gpointer data) {