Luigit
repositories / termux-janitor

termux-janitor

Interactive cleanup assistant for Termux: transparent, safe, confirmed disk reclamation.

owned by admin

src/tui.zig

Raw
//! Minimal interactive TUI spine owned by `spec/UI_GUIDELINES.md` and
//! `spec/PRODUCT.md` section 9. Raw mode, alternate screen, and restoration
//! follow the terminal contract; every exit path restores the saved termios
//! in reverse order. The renderer writes changed frames only when visible
//! state changes; input acknowledgement precedes optional work.
const std = @import("std");
const posix = std.posix;
const scan_mod = @import("scan.zig");
const classify = @import("classify.zig");
const plan_mod = @import("plan.zig");
const runlog_mod = @import("runlog.zig");

pub const terminal_input_buffer_bytes: u32 = @intCast(@import("spec_data").limit_value.terminal_input_buffer_bytes);

pub const Screen = enum {
    checklist,
    help,
    filters,
    plan,
    confirmation,
    executing,
    report,
};

pub const Termios = struct {
    original: posix.termios,
    saved: bool = false,

    pub fn enterRaw(fd: posix.fd_t) !Termios {
        const state: Termios = .{ .original = try posix.tcgetattr(fd), .saved = true };
        var raw = state.original;
        raw.lflag.ECHO = false;
        raw.lflag.ICANON = false;
        raw.lflag.ISIG = false;
        raw.iflag.IXON = false;
        raw.iflag.ICRNL = false;
        raw.cc[@intFromEnum(std.os.linux.V.MIN)] = 1;
        raw.cc[@intFromEnum(std.os.linux.V.TIME)] = 0;
        try posix.tcsetattr(fd, .NOW, raw);
        return state;
    }

    pub fn restore(self: *Termios, fd: posix.fd_t) void {
        if (!self.saved) return;
        posix.tcsetattr(fd, .NOW, self.original) catch {};
        self.saved = false;
    }
};

pub const Tui = struct {
    stdin_fd: posix.fd_t,
    stdout_fd: posix.fd_t,
    termios: Termios = .{ .original = undefined },
    screen: Screen = .checklist,
    focus: u32 = 0,
    confirm: plan_mod.Confirm = .{},
    message: []const u8 = "",
    /// Presentation and availability inputs owned by the persistent summary
    /// (`UI_GUIDELINES.md` interaction): the separator follows the ascii
    /// option and a broken run log makes every selection unavailable.
    ascii: bool = false,
    log_broken: bool = false,
    /// Reviewed plan shown on the confirmation screen (`TJ-PLAN-11`).
    plan: ?*const plan_mod.Plan = null,
    /// Execution outcome counters shown on the report screen.
    report_ok: u32 = 0,
    report_failed: u32 = 0,
    /// Search input state (`TJ-UI` interaction): an open prompt collects
    /// raw query bytes; the active query filters the checklist by raw-name
    /// substring. Focus indexes the visible list.
    search_open: bool = false,
    search_query: [64]u8 = undefined,
    search_len: u32 = 0,
    /// Finding indexes visible under the active search, recomputed before
    /// every render and key dispatch.
    visible: [scan_mod.retained_findings_max]u32 = undefined,
    visible_len: u32 = 0,
    /// Active category filter: one bit per compiled category; an empty mask
    /// leaves the dimension inactive (`TJ-UI` interaction). `filters_draft`
    /// holds the overlay state until Enter applies it.
    category_filter: u32 = 0,
    filters_draft: u32 = 0,
    /// Active selection-state filter values; both flags off leaves the
    /// dimension inactive.
    selection_filter_selected: bool = false,
    selection_filter_unselected: bool = false,
    selection_draft_selected: bool = false,
    selection_draft_unselected: bool = false,
    /// Focus row inside the filters overlay: category values first, then
    /// the two selection-state values.
    filter_focus: u32 = 0,
    filter_summary_buffer: [96]u8 = undefined,
    io: std.Io,

    pub fn init(io: std.Io, stdin_fd: posix.fd_t, stdout_fd: posix.fd_t) !Tui {
        return .{ .stdin_fd = stdin_fd, .stdout_fd = stdout_fd, .io = io };
    }

    pub fn enterAlternateScreen(self: *Tui) void {
        _ = self;
        writeAll("\x1b[?1049h\x1b[H\x1b[2J");
    }

    pub fn leaveAlternateScreen(self: *Tui) void {
        _ = self;
        writeAll("\x1b[?1049l");
    }

    pub fn writeAll(text: []const u8) void {
        var stdout = std.Io.File.stdout();
        stdout.writeStreamingAll(std.Io.Threaded.global_single_threaded.io(), text) catch {};
    }

    /// Reads one decoded logical key with a bounded buffer. Escape sequences
    /// for arrows map to navigation; everything else surfaces raw bytes. A
    /// lone Escape not followed within a short window by sequence bytes
    /// surfaces as the Escape byte itself (`TJ-PLAN-10`).
    pub fn readKey(self: *Tui) ?Key {
        var byte_buffer: [1]u8 = undefined;
        const stdin = std.Io.File{ .handle = self.stdin_fd, .flags = .{ .nonblocking = false } };
        const read = stdin.readStreaming(self.io, &.{&byte_buffer}) catch return null;
        if (read == 0) return null;
        const byte = byte_buffer[0];
        if (byte != 0x1B) return .{ .byte = byte };
        var escape_probe = [_]posix.pollfd{.{ .fd = self.stdin_fd, .events = posix.POLL.IN, .revents = 0 }};
        const pending = posix.poll(&escape_probe, 25) catch return .{ .byte = 0x1B };
        if (pending == 0) return .{ .byte = 0x1B };
        const sequence = stdin.readStreaming(self.io, &.{&byte_buffer}) catch return null;
        if (sequence == 0) return .{ .byte = 0x1B };
        if (byte_buffer[0] != '[') return .{ .byte = byte_buffer[0] };
        const final = stdin.readStreaming(self.io, &.{&byte_buffer}) catch return null;
        if (final == 0) return null;
        return switch (byte_buffer[0]) {
            'A' => .up,
            'B' => .down,
            'C' => .right,
            'D' => .left,
            else => .{ .byte = byte_buffer[0] },
        };
    }

    /// Renders the checklist: selection marker, name, size, category. The
    /// 40x12 baseline fits persistent status and help rows. The summary is
    /// `selected X <sep> hidden Y <sep> unavailable Z` (`TJ-UI-02`): every
    /// selected retained finding counts in `X`, `Y` holds selections hidden
    /// by filters or search (none exist in this increment, so zero), and `Z`
    /// counts selections that cannot enter a confirmable plan: a broken run
    /// log makes every mutation unavailable, directory actions stay
    /// unconfirmable until descendant manifests exist, and other entry
    /// kinds never enter a plan. Counts never exceed the retained-findings
    /// bound, so no display saturation is reachable.
    pub fn render(self: *Tui, scan: *const scan_mod.Scan, paths: [][]const u8) void {
        var out: [8192]u8 = undefined;
        var writer = std.Io.Writer.fixed(&out);
        writer.writeAll("\x1b[H\x1b[2J") catch return;
        switch (self.screen) {
            .checklist => self.renderChecklist(scan, paths, &writer),
            .help => self.renderHelp(scan, &writer),
            .filters => self.renderFilters(scan, &writer),
            .confirmation => self.renderConfirmation(scan, paths, &writer),
            .report => self.renderReport(&writer),
            .executing, .plan => self.renderChecklist(scan, paths, &writer),
        }
        self.writeFrame(out[0..writer.end]);
    }

    fn renderHelp(self: *Tui, scan: *const scan_mod.Scan, writer: *std.Io.Writer) void {
        writer.print("Help  findings={d}  warnings={d}{s}\r\n", .{
            scan.findings_len,
            scan.warnings_len,
            if (scan.complete) "" else " incomplete",
        }) catch return;
        writer.writeAll(" j k up down    space select\r\n") catch return;
        writer.writeAll(" / search  + sel visible  - clear\r\n") catch return;
        writer.writeAll(" ? close help   q quit\r\n") catch return;
        writer.writeAll(" c confirm plan\r\n") catch return;
        writer.writeAll(" unavailable: f filters, s sorting\r\n") catch return;
        if (self.log_broken) {
            writer.writeAll(" review-only: run log unavailable\r\n") catch return;
        }
        writer.writeAll(" terminal limit: unmarked paste is\r\n") catch return;
        writer.writeAll(" indistinguishable from typed keys\r\n") catch return;
    }

    /// Filters overlay (`TJ-UI` interaction): category values combine with
    /// OR, selection-state values combine with OR, and the two dimensions
    /// combine with AND; Enter applies the draft, Escape discards it.
    fn renderFilters(self: *Tui, scan: *const scan_mod.Scan, writer: *std.Io.Writer) void {
        writer.print("Filters  findings={d}  warnings={d}{s}\r\n", .{
            scan.findings_len,
            scan.warnings_len,
            if (scan.complete) "" else " incomplete",
        }) catch return;
        const categories = @typeInfo(classify.Category).@"enum".fields;
        var rows: u32 = 1;
        inline for (categories, 0..) |field, index| {
            if (rows >= 9) break;
            const bit = @as(u32, 1) << @intCast(index);
            const active = self.filters_draft & bit != 0;
            const cursor: []const u8 = if (self.filter_focus == index) ">" else " ";
            writer.print("{s}{s} {s}\r\n", .{ cursor, if (active) "[x]" else "[ ]", field.name }) catch break;
            rows += 1;
        }
        const selection_base: u32 = categories.len;
        const cursor_sel: []const u8 = if (self.filter_focus == selection_base) ">" else " ";
        const cursor_unsel: []const u8 = if (self.filter_focus == selection_base + 1) ">" else " ";
        writer.print("{s}{s} selected\r\n", .{ cursor_sel, if (self.selection_draft_selected) "[x]" else "[ ]" }) catch return;
        writer.print("{s}{s} unselected\r\n", .{ cursor_unsel, if (self.selection_draft_unselected) "[x]" else "[ ]" }) catch return;
        writer.writeAll(" space toggle  Enter apply  Esc cancel\r") catch return;
    }

    fn renderReport(self: *Tui, writer: *std.Io.Writer) void {
        writer.print("Report  ok={d}  failed={d}\r\n", .{ self.report_ok, self.report_failed }) catch return;
        if (self.message.len > 0) {
            writer.print(" {s}\r\n", .{self.message}) catch return;
        }
        writer.writeAll(" q quit\r\n") catch return;
    }

    fn renderConfirmation(self: *Tui, scan: *const scan_mod.Scan, paths: [][]const u8, writer: *std.Io.Writer) void {
        var selected_count: u32 = 0;
        var unavailable_count: u32 = 0;
        var counted: u32 = 0;
        while (counted < scan.findings_len) : (counted += 1) {
            const finding = scan.findings[counted];
            if (!finding.selected) continue;
            selected_count += 1;
            const plan_ineligible = finding.kind != .file and
                finding.kind != .symlink and finding.kind != .directory;
            if (self.log_broken or plan_ineligible) unavailable_count += 1;
        }
        const separator: []const u8 = if (self.ascii) "|" else "\u{00b7}";
        writer.print("Confirm  selected {d} {s} hidden 0 {s} unavailable {d}\r\n", .{
            selected_count, separator, separator, unavailable_count,
        }) catch return;
        var rows: u32 = 1;
        if (self.plan) |plan| {
            var index: u32 = 0;
            while (index < plan.actions_len and rows < 7) : (index += 1) {
                const action = plan.actions[index];
                const finding = scan.findings[action.finding];
                const kind_letter: []const u8 = switch (finding.kind) {
                    .file => "f",
                    .symlink => "l",
                    .directory => "d",
                    .other => "?",
                };
                var label_buffer: [scan_mod.raw_path_bytes_max]u8 = undefined;
                const label = displayPath(&label_buffer, paths[action.finding], finding, action.finding, 28, self.ascii);
                writer.print(" {s} {s} {s}\r\n", .{ kind_letter, if (action.complete) " " else "!", label }) catch break;
                rows += 1;
            }
        }
        while (rows < 7) : (rows += 1) writer.writeAll("\r\n") catch return;
        writer.writeAll(" warning: direct filesystem deletion is\r\n") catch return;
        writer.writeAll(" non-atomic. A concurrent same-permission\r\n") catch return;
        writer.writeAll(" replacement or ancestor move after\r\n") catch return;
        writer.writeAll(" revalidation can delete outside the\r\n") catch return;
        writer.writeAll(" reviewed identity or location.\r\n") catch return;
        writer.print(" confirm: {s: <7}  Enter accept  Esc cancel\r\n", .{self.confirm.text()}) catch return;
    }

    /// Recomputes the visible finding list under the active search query:
    /// raw-name substring match; an empty query shows every retained
    /// finding. Focus always indexes this list (`TJ-UI` interaction).
    /// Recomputes the visible finding list under the active search query:
    /// raw-name substring match; an empty query shows every retained
    /// finding. The list carries the canonical ordering owned by
    /// `UI_GUIDELINES.md`: known reliable allocated size descending with
    /// unknown estimates after known ones, then compiled category
    /// identifier ascending, then raw path bytes ascending. Focus always
    /// indexes this list (`TJ-UI` interaction).
    pub fn rebuildVisibility(self: *Tui, scan: *const scan_mod.Scan, paths: [][]const u8) void {
        const query = self.search_query[0..self.search_len];
        const category_active = self.category_filter != 0;
        const selection_active = self.selection_filter_selected or self.selection_filter_unselected;
        self.visible_len = 0;
        var index: u32 = 0;
        while (index < scan.findings_len) : (index += 1) {
            if (category_active and self.category_filter & (@as(u32, 1) << @intCast(scan.findings[index].category)) == 0) continue;
            if (selection_active) {
                const want_selected = if (scan.findings[index].selected) self.selection_filter_selected else self.selection_filter_unselected;
                if (!want_selected) continue;
            }
            if (query.len > 0 and std.mem.indexOf(u8, scan.findings[index].name, query) == null) continue;
            if (self.visible_len >= self.visible.len) break;
            self.visible[self.visible_len] = index;
            self.visible_len += 1;
        }
        const Order = struct {
            scan: *const scan_mod.Scan,
            paths: [][]const u8,
            fn sizeKnown(finding: scan_mod.Finding) bool {
                return finding.blocks_known and finding.allocated_bytes != scan_mod.unknown_size;
            }
            fn lessThan(ctx: @This(), a: u32, b: u32) bool {
                const first = ctx.scan.findings[a];
                const second = ctx.scan.findings[b];
                const first_known = sizeKnown(first);
                const second_known = sizeKnown(second);
                if (first_known != second_known) return first_known;
                if (first_known and first.allocated_bytes != second.allocated_bytes) {
                    return first.allocated_bytes > second.allocated_bytes;
                }
                if (first.category != second.category) return first.category < second.category;
                return std.mem.order(u8, ctx.paths[a], ctx.paths[b]) == .lt;
            }
        };
        std.mem.sort(u32, self.visible[0..self.visible_len], Order{ .scan = scan, .paths = paths }, Order.lessThan);
    }

    /// Bounded active-filter summary for the checklist footer.
    fn filterSummary(self: *Tui) []const u8 {
        var buffer: [96]u8 = undefined;
        var writer = std.Io.Writer.fixed(&buffer);
        if (self.category_filter != 0) {
            writer.print("category#{x:0>8}", .{self.category_filter}) catch {};
        }
        if (self.selection_filter_selected) {
            if (writer.end > 0) writer.writeAll("+") catch {};
            writer.writeAll("sel") catch {};
        }
        if (self.selection_filter_unselected) {
            if (writer.end > 0) writer.writeAll("+") catch {};
            writer.writeAll("unsel") catch {};
        }
        const written = buffer[0..writer.end];
        const stored = self.filter_summary_buffer[0..written.len];
        @memcpy(stored, written);
        return stored;
    }

    fn visibleContains(self: *Tui, finding: u32) bool {
        var position: u32 = 0;
        while (position < self.visible_len) : (position += 1) {
            if (self.visible[position] == finding) return true;
        }
        return false;
    }

    fn renderChecklist(self: *Tui, scan: *const scan_mod.Scan, paths: [][]const u8, writer: *std.Io.Writer) void {
        self.rebuildVisibility(scan, paths);
        if (self.visible_len == 0) self.focus = 0;
        if (self.visible_len > 0 and self.focus >= self.visible_len) self.focus = self.visible_len - 1;
        const query = self.search_query[0..self.search_len];
        var selected_count: u32 = 0;
        var unavailable_count: u32 = 0;
        var hidden_count: u32 = 0;
        var counted: u32 = 0;
        while (counted < scan.findings_len) : (counted += 1) {
            const finding = scan.findings[counted];
            if (!finding.selected) continue;
            selected_count += 1;
            const plan_ineligible = finding.kind != .file and
                finding.kind != .symlink and finding.kind != .directory;
            if (self.log_broken or plan_ineligible) unavailable_count += 1;
            if (!self.visibleContains(counted)) hidden_count += 1;
        }
        const separator: []const u8 = if (self.ascii) "|" else "\u{00b7}";
        writer.print(
            "Janitor  findings={d}  warnings={d}{s}  selected {d} {s} hidden {d} {s} unavailable {d}\r\n",
            .{
                scan.findings_len,
                scan.warnings_len,
                if (scan.complete) "" else " incomplete",
                selected_count,
                separator,
                hidden_count,
                separator,
                unavailable_count,
            },
        ) catch return;
        var rows: u32 = 1;
        if (self.visible_len == 0) {
            writer.writeAll(" no matches for the active search\r\n") catch return;
            rows += 1;
        }
        var position: u32 = self.focus;
        var shown: u32 = 0;
        while (shown < 8 and position < self.visible_len) : (shown += 1) {
            const index = self.visible[position];
            const finding = scan.findings[index];
            const cursor: []const u8 = if (position == self.focus) ">" else " ";
            const selection: []const u8 = if (finding.selected) "[x]" else "[ ]";
            const path = paths[index];
            const size_text: []const u8 = if (finding.blocks_known)
                "known"
            else
                "???B";
            writer.print(
                "{s}{s} {s} {s} {s}\r\n",
                .{ cursor, selection, classify.Category.label(@enumFromInt(finding.category)), size_text, path },
            ) catch break;
            rows += 1;
            position += 1;
        }
        while (rows < 10) : (rows += 1) writer.writeAll("\r\n") catch return;
        if (self.search_open) {
            writer.print(" search: {s}_  Enter accept  Esc clear\r", .{query}) catch return;
        } else if (query.len > 0 or self.category_filter != 0 or
            self.selection_filter_selected or self.selection_filter_unselected)
        {
            const parts = self.filterSummary();
            writer.print(" filter: {s}  f edit\r", .{parts}) catch return;
        } else {
            writer.writeAll("? help\r") catch return;
        }
    }

    fn writeFrame(self: *Tui, bytes: []const u8) void {
        _ = self;
        var stdout = std.Io.File.stdout();
        stdout.writeStreamingAll(std.Io.Threaded.global_single_threaded.io(), bytes) catch {};
    }
};

/// Component-aligned digest-marked display truncation owned by
/// `spec/FILESYSTEM_CAPABILITIES.md#display-truncation`: when a raw path
/// does not fit the budget, keep the longest component-aligned prefix and
/// suffix with the marker `...#NNNNNNNN` (ASCII; `…` otherwise) between
/// them, where the digits are the first eight lowercase hexadecimal digits
/// of BLAKE3 over the full raw path plus its stable finding identity. A
/// label that cannot fit degrades to the stable finding index `item #N`.
fn displayPath(
    buffer: []u8,
    path: []const u8,
    finding: scan_mod.Finding,
    index: u32,
    budget: usize,
    ascii: bool,
) []const u8 {
    if (path.len <= budget) {
        @memcpy(buffer[0..path.len], path);
        return buffer[0..path.len];
    }
    var digest_input: [scan_mod.raw_path_bytes_max + 16]u8 = undefined;
    @memcpy(digest_input[0..path.len], path);
    std.mem.writeInt(u64, digest_input[path.len..][0..8], finding.ino, .little);
    std.mem.writeInt(u32, digest_input[path.len + 8 ..][0..4], finding.dev_major, .little);
    std.mem.writeInt(u32, digest_input[path.len + 12 ..][0..4], finding.dev_minor, .little);
    var digest: [32]u8 = undefined;
    std.crypto.hash.Blake3.hash(digest_input[0 .. path.len + 16], &digest, .{});
    var marker_buffer: [16]u8 = undefined;
    const ellipsis: []const u8 = if (ascii) "..." else "\u{2026}";
    const marker = std.fmt.bufPrint(&marker_buffer, "{s}#{x:0>2}{x:0>2}{x:0>2}{x:0>2}", .{
        ellipsis, digest[0], digest[1], digest[2], digest[3],
    }) catch {
        return std.fmt.bufPrint(buffer, "item #{d}", .{index}) catch path[0..@min(path.len, budget)];
    };
    const room: usize = if (budget > marker.len) budget - marker.len else 0;
    if (room < 4) {
        return std.fmt.bufPrint(buffer, "item #{d}", .{index}) catch path[0..@min(path.len, budget)];
    }
    // Longest component-aligned prefix: cut after a separator, keeping at
    // most half the room.
    var prefix_end: usize = 0;
    var scan_pos: usize = 0;
    while (scan_pos < room / 2 and scan_pos < path.len) : (scan_pos += 1) {
        if (path[scan_pos] == '/') prefix_end = scan_pos + 1;
    }
    // Longest component-aligned suffix: the earliest separator-aligned cut
    // that fits the remaining room.
    const tail_room: usize = room - prefix_end;
    var suffix_start: usize = path.len - @min(tail_room, path.len);
    while (suffix_start < path.len and !(suffix_start == 0 or path[suffix_start - 1] == '/')) {
        suffix_start += 1;
    }
    if (prefix_end == 0 and suffix_start >= path.len) {
        return std.fmt.bufPrint(buffer, "item #{d}", .{index}) catch path[0..@min(path.len, budget)];
    }
    var written: usize = 0;
    @memcpy(buffer[written..][0..prefix_end], path[0..prefix_end]);
    written += prefix_end;
    @memcpy(buffer[written..][0..marker.len], marker);
    written += marker.len;
    const suffix = path[suffix_start..];
    @memcpy(buffer[written..][0..suffix.len], suffix);
    written += suffix.len;
    return buffer[0..written];
}

pub const Key = union(enum) {
    byte: u8,
    up,
    down,
    left,
    right,
};