diff --git a/src/main.c b/src/main.c index c7ec33b..bded2f6 100644 --- a/src/main.c +++ b/src/main.c @@ -11,6 +11,7 @@ #include "ui/sidebar.h" #include #include +#include #include #include #include @@ -388,9 +389,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 +408,154 @@ 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 -- */ + +static void +add_fork_session(GtkWidget *split, Session *parent) +{ + /* 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"; + + 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); + } + } + g_free(proj_dir); + + 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]) + return; + add_fork_session(app.split, parent); +} + 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 +892,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 +918,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 +994,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 +1052,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..52a904b 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) { @@ -816,9 +830,13 @@ on_terminal_btn_clicked(GtkButton *btn, gpointer data) } static void -on_diff_btn_clicked(GtkButton *btn, gpointer data) +on_fork_btn_clicked(GtkButton *btn, gpointer data) { - sidebar_show_diff((Session *)data, GTK_WIDGET(btn)); + 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 @@ -882,7 +900,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 +984,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 +1220,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 +1231,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 +1296,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);