add session save and session load functionality

New [key-bindings]:
- session-save: captures cwd and foreground process argv to ~/.local/share/foot/state/{name}.json
- session-save-secure: prompts for a password, encrypts the scrollback with argon2id + XChaCha20-Poly1305 (libsodium) and writes it to {name}.scrollback.enc(stores up to 1Mb scrollback buffer).
- session-load: a minimal fuzzy picker that displays saved sessions (both secure and vanilla), UI piggybacks on search bar subsurface. use arrows to navigate and delete to delete a previously saved session.
This commit is contained in:
entailz 2026-05-21 14:08:33 -07:00
parent 05ee680778
commit cabddb26e6
16 changed files with 1947 additions and 49 deletions

229
render.c
View file

@ -3635,6 +3635,21 @@ static void render_search_box(struct terminal *term) {
const size_t text_len = term->search.len;
#endif
/* Password modes: replace every codepoint with a mask glyph for display. */
char32_t *masked_text = NULL;
char32_t *ime_alloc = NULL;
#if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED
ime_alloc = text; /* remember the IME-allocated buffer for cleanup */
#endif
if (term->search.mode == SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD ||
term->search.mode == SEARCH_MODE_SESSION_LOAD_PASSWORD) {
masked_text = xmalloc((text_len + 1) * sizeof(char32_t));
for (size_t i = 0; i < text_len; i++)
masked_text[i] = U'';
masked_text[text_len] = U'\0';
text = masked_text;
}
/* Calculate the width of each character */
int widths[text_len + 1];
for (size_t i = 0; i < text_len; i++)
@ -3642,7 +3657,16 @@ static void render_search_box(struct terminal *term) {
widths[text_len] = 0;
const size_t total_cells = c32swidth(text, text_len);
const size_t wanted_visible_cells = max(20, total_cells);
size_t wanted_visible_cells = max(20, total_cells);
if (term->search.mode == SEARCH_MODE_SESSION_LOAD) {
/* Make room for the longest session name */
for (size_t i = 0; i < term->session_picker.all_count; i++) {
size_t n = strlen(term->session_picker.all[i]);
if (n > wanted_visible_cells)
wanted_visible_cells = n;
}
wanted_visible_cells = max(wanted_visible_cells, 30);
}
/*
* Build status string: " Aa Wb Re 3/17 ↻"
@ -3655,36 +3679,70 @@ static void render_search_box(struct terminal *term) {
char32_t status[64];
size_t st_len = 0;
if (term->search.case_mode == SEARCH_CASE_SENSITIVE) {
status[st_len++] = U'A';
status[st_len++] = U'a';
status[st_len++] = U' ';
} else if (term->search.case_mode == SEARCH_CASE_INSENSITIVE) {
status[st_len++] = U'a';
status[st_len++] = U'a';
status[st_len++] = U' ';
}
if (term->search.whole_word) {
status[st_len++] = U'W';
status[st_len++] = U'b';
status[st_len++] = U' ';
}
if (term->search.regex) {
status[st_len++] = U'.';
status[st_len++] = U'*';
status[st_len++] = U' ';
}
if (term->search.wrapped) {
status[st_len++] = U'~';
status[st_len++] = U' ';
}
if (term->search.len > 0 && term->search.total_count > 0) {
char tmp[32];
snprintf(tmp, sizeof(tmp), "%zu/%zu", term->search.current_idx,
term->search.total_count);
for (size_t i = 0; tmp[i] != '\0' && st_len < ALEN(status) - 1; i++)
status[st_len++] = (char32_t)tmp[i];
status[st_len++] = U' ';
if (term->search.mode != SEARCH_MODE_NORMAL) {
char label_buf[128];
const char *label;
switch (term->search.mode) {
case SEARCH_MODE_SESSION_OVERWRITE_CONFIRM:
snprintf(label_buf, sizeof(label_buf), "Overwrite '%s'? (y/n) ",
term->session_pending_name != NULL
? term->session_pending_name
: "");
label = label_buf;
break;
case SEARCH_MODE_SESSION_SAVE:
label = "Save State As: ";
break;
case SEARCH_MODE_SESSION_SAVE_SECURE_NAME:
label = "Secure Save As: ";
break;
case SEARCH_MODE_SESSION_SAVE_SECURE_PASSWORD:
label = "Password (new): ";
break;
case SEARCH_MODE_SESSION_LOAD:
label = "Load State: ";
break;
case SEARCH_MODE_SESSION_LOAD_PASSWORD:
label = "Decrypt Password: ";
break;
default:
label = "";
break;
}
for (const char *p = label; *p != '\0' && st_len < ALEN(status) - 1; p++)
status[st_len++] = (char32_t)(unsigned char)*p;
} else {
if (term->search.case_mode == SEARCH_CASE_SENSITIVE) {
status[st_len++] = U'A';
status[st_len++] = U'a';
status[st_len++] = U' ';
} else if (term->search.case_mode == SEARCH_CASE_INSENSITIVE) {
status[st_len++] = U'a';
status[st_len++] = U'a';
status[st_len++] = U' ';
}
if (term->search.whole_word) {
status[st_len++] = U'W';
status[st_len++] = U'b';
status[st_len++] = U' ';
}
if (term->search.regex) {
status[st_len++] = U'.';
status[st_len++] = U'*';
status[st_len++] = U' ';
}
if (term->search.wrapped) {
status[st_len++] = U'~';
status[st_len++] = U' ';
}
if (term->search.len > 0 && term->search.total_count > 0) {
char tmp[32];
snprintf(tmp, sizeof(tmp), "%zu/%zu", term->search.current_idx,
term->search.total_count);
for (size_t i = 0; tmp[i] != '\0' && st_len < ALEN(status) - 1; i++)
status[st_len++] = (char32_t)tmp[i];
status[st_len++] = U' ';
}
}
status[st_len] = U'\0';
const size_t status_cells = c32swidth(status, st_len);
@ -3704,8 +3762,20 @@ static void render_search_box(struct terminal *term) {
const size_t want_box_w = chrome_w + wanted_visible_cells * term->cell_width;
size_t width = min(want_box_w, max_box_w);
size_t height =
min(term->height - 2 * outer_margin, margin + term->cell_height + margin);
/* Session-load picker: grow box to fit a few rows of session names */
size_t list_rows = 0;
if (term->search.mode == SEARCH_MODE_SESSION_LOAD) {
const size_t max_visible = 10;
list_rows = min(max_visible, term->session_picker.filtered_count);
/* Reserve at least one row so empty state is visible */
if (list_rows == 0)
list_rows = 1;
}
size_t height = min(
term->height - 2 * outer_margin,
margin + (1 + list_rows) * term->cell_height + margin);
width = roundf(scale * ceilf(width / scale));
height = roundf(scale * ceilf(height / scale));
@ -3732,12 +3802,14 @@ static void render_search_box(struct terminal *term) {
uint32_t bg_hex = term->colors.bg;
uint32_t fg_hex = term->colors.fg;
if (term->search.len > 0 && term->search.match_len == 0) {
bg_hex = term->colors.table[1];
fg_hex = term->colors.table[0];
} else if (term->search.wrapped) {
bg_hex = term->colors.table[3];
fg_hex = term->colors.table[0];
if (term->search.mode == SEARCH_MODE_NORMAL) {
if (term->search.len > 0 && term->search.match_len == 0) {
bg_hex = term->colors.table[1];
fg_hex = term->colors.table[0];
} else if (term->search.wrapped) {
bg_hex = term->colors.table[3];
fg_hex = term->colors.table[0];
}
}
const pixman_color_t color = color_hex_to_pixman(bg_hex, gamma_correct);
@ -3973,6 +4045,80 @@ static void render_search_box(struct terminal *term) {
}
}
/* Session-load list */
if (term->search.mode == SEARCH_MODE_SESSION_LOAD) {
const pixman_color_t sel_bg = color_hex_to_pixman(
term->colors.table[4], gamma_correct);
const size_t name_x = margin;
const size_t name_w = width > 2 * margin ? width - 2 * margin : 0;
if (term->session_picker.filtered_count == 0) {
const char32_t *msg = U"(no sessions)";
int rx = (int)name_x;
const int ry = (int)margin + (int)term->cell_height;
for (size_t i = 0; msg[i] != U'\0'; i++) {
const struct fcft_glyph *g =
fcft_rasterize_char_utf32(font, msg[i], term->font_subpixel);
int gw = max(1, c32width(msg[i]));
if (g != NULL && !g->is_color_glyph) {
pixman_image_t *src = pixman_image_create_solid_fill(&fg);
pixman_image_composite32(PIXMAN_OP_OVER, src, g->pix, buf->pix[0], 0,
0, 0, 0, rx + x_ofs + g->x,
ry + term->font_baseline - g->y, g->width,
g->height);
pixman_image_unref(src);
}
rx += gw * (int)term->cell_width;
}
} else {
const size_t shown = min(list_rows, term->session_picker.filtered_count);
/* Scroll the list so the selection is visible */
size_t first = 0;
if (term->session_picker.sel >= shown)
first = term->session_picker.sel - shown + 1;
for (size_t r = 0; r < shown; r++) {
size_t fi = first + r;
if (fi >= term->session_picker.filtered_count)
break;
const char *name =
term->session_picker.all[term->session_picker.filtered[fi]];
const int ry = (int)margin + (int)(1 + r) * (int)term->cell_height;
const bool selected = fi == term->session_picker.sel;
if (selected) {
pixman_image_fill_rectangles(
PIXMAN_OP_SRC, buf->pix[0], &sel_bg, 1,
&(pixman_rectangle16_t){(int16_t)name_x, (int16_t)ry,
(uint16_t)name_w,
(uint16_t)term->cell_height});
}
const pixman_color_t row_fg = selected
? color_hex_to_pixman(term->colors.bg, gamma_correct)
: fg;
int rx = (int)name_x;
for (const char *p = name; *p != '\0'; p++) {
char32_t ch = (char32_t)(unsigned char)*p;
const struct fcft_glyph *g =
fcft_rasterize_char_utf32(font, ch, term->font_subpixel);
if (g != NULL && !g->is_color_glyph) {
pixman_image_t *src = pixman_image_create_solid_fill(&row_fg);
pixman_image_composite32(PIXMAN_OP_OVER, src, g->pix, buf->pix[0],
0, 0, 0, 0, rx + x_ofs + g->x,
ry + term->font_baseline - g->y,
g->width, g->height);
pixman_image_unref(src);
}
rx += (int)term->cell_width;
if (rx > (int)(name_x + name_w))
break;
}
}
}
}
quirk_weston_subsurface_desync_on(term->window->search.sub);
/* TODO: this is only necessary on a window resize */
@ -3998,9 +4144,8 @@ static void render_search_box(struct terminal *term) {
wl_surface_commit(term->window->search.surface.surf);
quirk_weston_subsurface_desync_off(term->window->search.sub);
#if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED
free(text);
#endif
free(ime_alloc);
free(masked_text);
#undef WINDOW_X
#undef WINDOW_Y
}