//! Browse the local filesystem. Renders one entry per line with //! permissions, size, and name; cursor selects an entry; Open enters //! a directory or marks a file as selected; Back pops up a level. //! //! Directory reads are synchronous via `setCurrentDirectory` — caller //! supplies `Allocator` + `std.Io`. Entries are owned by the picker; //! `deinit` frees them. //! //! Skipped from bubbles for v1: symlink target resolution (kind shown //! but not the `→ /target` suffix), AutoHeight wiring (call `setHeight` //! from your WindowSize handler), hidden-detection by platform attribute //! (only `.`-prefix is treated as hidden), humanize unit prefix beyond //! KiB/MiB/GiB/TiB. const std = @import("std"); const matcha = @import("matcha"); const blush = @import("blush"); const key = @import("key.zig"); const util = @import("util.zig"); const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const Cmd = matcha.Cmd; const Msg = matcha.Msg; const Io = std.Io; const ArrayList = std.ArrayList; const FilePicker = @This(); pub const KeyMap = struct { goto_top: key.Binding = .{ .keys = &.{key.rune('g')}, .help = .{ .key = "g", .desc = "first" } }, goto_last: key.Binding = .{ .keys = &.{key.shift('G')}, .help = .{ .key = "G", .desc = "last" } }, down: key.Binding = .{ .keys = &.{ key.rune('j'), key.special(.down), key.ctrl('n') }, .help = .{ .key = "j/↓", .desc = "down" }, }, up: key.Binding = .{ .keys = &.{ key.rune('k'), key.special(.up), key.ctrl('p') }, .help = .{ .key = "k/↑", .desc = "up" }, }, page_up: key.Binding = .{ .keys = &.{ key.shift('K'), key.special(.page_up) }, .help = .{ .key = "pgup", .desc = "page up" }, }, page_down: key.Binding = .{ .keys = &.{ key.shift('J'), key.special(.page_down) }, .help = .{ .key = "pgdn", .desc = "page down" }, }, back: key.Binding = .{ .keys = &.{ key.rune('h'), key.special(.backspace), key.special(.left), key.special(.escape) }, .help = .{ .key = "h/←", .desc = "back" }, }, open: key.Binding = .{ .keys = &.{ key.rune('l'), key.special(.right), key.special(.enter) }, .help = .{ .key = "l/→", .desc = "open" }, }, select: key.Binding = .{ .keys = &.{key.special(.enter)}, .help = .{ .key = "enter", .desc = "select" }, }, }; pub const Styles = struct { cursor: ?blush.Style = null, disabled_cursor: ?blush.Style = null, symlink: ?blush.Style = null, directory: ?blush.Style = null, file: ?blush.Style = null, disabled_file: ?blush.Style = null, permission: ?blush.Style = null, selected: ?blush.Style = null, disabled_selected: ?blush.Style = null, file_size: ?blush.Style = null, empty_directory: ?blush.Style = null, }; pub const default_styles: Styles = .{ .cursor = .{ .foreground = .{ .ansi256 = 212 } }, .disabled_cursor = .{ .foreground = .{ .ansi256 = 247 } }, .symlink = .{ .foreground = .{ .ansi256 = 36 } }, .directory = .{ .foreground = .{ .ansi256 = 99 } }, .file = null, .disabled_file = .{ .foreground = .{ .ansi256 = 243 } }, .disabled_selected = .{ .foreground = .{ .ansi256 = 247 } }, .permission = .{ .foreground = .{ .ansi256 = 244 } }, .selected = .{ .foreground = .{ .ansi256 = 212 }, .bold = true }, .file_size = .{ .foreground = .{ .ansi256 = 240 } }, .empty_directory = .{ .foreground = .{ .ansi256 = 240 } }, }; pub const Kind = enum { file, directory, sym_link, other }; pub const Entry = struct { name: []u8, kind: Kind, size: u64, mode: u32, }; const StackFrame = struct { selected: u16, min: u16, max: u16 }; const file_size_width: u16 = 7; model: matcha.Model = .{ .vtable = &vtable }, /// Path the user has selected via `Select`. Empty until they pick a file. path: []u8 = "", /// Directory currently being browsed. current_directory: []u8 = "", key_map: KeyMap = .{}, styles: Styles = default_styles, cursor_text: []const u8 = ">", allowed_types: []const []const u8 = &.{}, show_permissions: bool = true, show_size: bool = true, show_hidden: bool = false, dir_allowed: bool = false, file_allowed: bool = true, height: u16 = 10, selected_index: u16 = 0, min_idx: u16 = 0, max_idx: u16 = 0, entries: ArrayList(Entry) = .empty, stack: ArrayList(StackFrame) = .empty, const vtable: matcha.Model.VTable = .{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, }; fn vtableInit(_: *matcha.Model) ?Cmd { return null; } fn vtableUpdate(m: *matcha.Model, msg: Msg) ?Cmd { const self: *FilePicker = @fieldParentPtr("model", m); return self.update(m.gpa, m.io, msg); } fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { const self: *FilePicker = @fieldParentPtr("model", m); try self.view(w); return .{}; } pub fn deinit(self: *FilePicker, allocator: Allocator) void { self.freeEntries(allocator); self.entries.deinit(allocator); self.stack.deinit(allocator); if (self.current_directory.len > 0) allocator.free(self.current_directory); if (self.path.len > 0) allocator.free(self.path); } fn freeEntries(self: *FilePicker, allocator: Allocator) void { for (self.entries.items) |e| allocator.free(e.name); self.entries.clearRetainingCapacity(); } // ---- public API ---- pub fn setCurrentDirectory( self: *FilePicker, allocator: Allocator, io: Io, new_path: []const u8, ) !void { const dup = try allocator.dupe(u8, new_path); if (self.current_directory.len > 0) allocator.free(self.current_directory); self.current_directory = dup; try self.readDir(allocator, io); self.selected_index = 0; self.min_idx = 0; self.max_idx = if (self.height > 0) self.height - 1 else 0; } pub fn setHeight(self: *FilePicker, h: u16) void { self.height = h; if (self.max_idx > h -| 1) self.max_idx = self.min_idx + (h -| 1); } /// Current cursor's path joined onto `current_directory`. Caller owns /// the returned buffer; free with `allocator.free`. pub fn highlightedPathAlloc(self: *const FilePicker, allocator: Allocator) Allocator.Error!?[]u8 { if (self.entries.items.len == 0) return null; const entry = self.entries.items[self.selected_index]; return try std.fs.path.join(allocator, &.{ self.current_directory, entry.name }); } // ---- update ---- pub fn update(self: *FilePicker, allocator: Allocator, io: Io, msg: Msg) ?Cmd { switch (msg) { .key_press => |k| { const total: u16 = @intCast(@min(self.entries.items.len, std.math.maxInt(u16))); if (self.key_map.goto_top.matches(k)) { self.selected_index = 0; self.min_idx = 0; self.max_idx = self.height -| 1; } else if (self.key_map.goto_last.matches(k)) { if (total == 0) return null; self.selected_index = total - 1; self.max_idx = total - 1; self.min_idx = if (total > self.height) total - self.height else 0; } else if (self.key_map.down.matches(k)) { if (total == 0) return null; if (self.selected_index + 1 >= total) return null; self.selected_index += 1; if (self.selected_index > self.max_idx) { self.min_idx += 1; self.max_idx += 1; } } else if (self.key_map.up.matches(k)) { if (self.selected_index == 0) return null; self.selected_index -= 1; if (self.selected_index < self.min_idx) { self.min_idx -= 1; self.max_idx -= 1; } } else if (self.key_map.page_down.matches(k)) { if (total == 0) return null; self.selected_index +|= self.height; if (self.selected_index >= total) self.selected_index = total - 1; self.min_idx +|= self.height; self.max_idx +|= self.height; if (self.max_idx >= total) { self.max_idx = total - 1; self.min_idx = if (total > self.height) total - self.height else 0; } } else if (self.key_map.page_up.matches(k)) { self.selected_index = if (self.selected_index > self.height) self.selected_index - self.height else 0; self.min_idx = if (self.min_idx > self.height) self.min_idx - self.height else 0; self.max_idx = self.min_idx + self.height -| 1; } else if (self.key_map.back.matches(k)) { self.goBack(allocator, io) catch {}; return null; } else if (self.key_map.open.matches(k)) { self.openSelected(allocator, io) catch {}; return null; } }, else => {}, } return null; } fn goBack(self: *FilePicker, allocator: Allocator, io: Io) !void { const parent = std.fs.path.dirname(self.current_directory) orelse "/"; const dup = try allocator.dupe(u8, parent); allocator.free(self.current_directory); self.current_directory = dup; try self.readDir(allocator, io); if (self.stack.items.len > 0) { const frame = self.stack.pop().?; self.selected_index = frame.selected; self.min_idx = frame.min; self.max_idx = frame.max; } else { self.selected_index = 0; self.min_idx = 0; self.max_idx = self.height -| 1; } } fn openSelected(self: *FilePicker, allocator: Allocator, io: Io) !void { if (self.entries.items.len == 0) return; const entry = self.entries.items[self.selected_index]; const is_dir = entry.kind == .directory; const can_select = (!is_dir and self.file_allowed and self.canSelect(entry.name)) or (is_dir and self.dir_allowed); if (can_select) { if (self.path.len > 0) allocator.free(self.path); self.path = try std.fs.path.join(allocator, &.{ self.current_directory, entry.name }); } if (!is_dir) return; try self.stack.append(allocator, .{ .selected = self.selected_index, .min = self.min_idx, .max = self.max_idx, }); const new_dir = try std.fs.path.join(allocator, &.{ self.current_directory, entry.name }); defer allocator.free(new_dir); try self.setCurrentDirectory(allocator, io, new_dir); } // ---- directory read ---- fn readDir(self: *FilePicker, allocator: Allocator, io: Io) !void { self.freeEntries(allocator); var dir = std.Io.Dir.cwd().openDir(io, self.current_directory, .{ .iterate = true }) catch return; defer dir.close(io); var iter = dir.iterate(); while (true) { const maybe = iter.next(io) catch break; const e = maybe orelse break; if (!self.show_hidden and e.name.len > 0 and e.name[0] == '.') continue; const name_owned = try allocator.dupe(u8, e.name); const kind = switch (e.kind) { .file => Kind.file, .directory => Kind.directory, .sym_link => Kind.sym_link, else => Kind.other, }; var size: u64 = 0; var mode: u32 = 0; if (dir.statFile(io, e.name, .{ .follow_symlinks = false })) |s| { size = s.size; mode = @intCast(@intFromEnum(s.permissions)); } else |_| {} try self.entries.append(allocator, .{ .name = name_owned, .kind = kind, .size = size, .mode = mode, }); } std.mem.sort(Entry, self.entries.items, {}, entryLessThan); } fn entryLessThan(_: void, a: Entry, b: Entry) bool { const ad = a.kind == .directory; const bd = b.kind == .directory; if (ad != bd) return ad; return std.mem.lessThan(u8, a.name, b.name); } pub fn canSelect(self: *const FilePicker, name: []const u8) bool { if (self.allowed_types.len == 0) return true; for (self.allowed_types) |ext| { if (std.mem.endsWith(u8, name, ext)) return true; } return false; } // ---- view ---- pub fn view(self: *FilePicker, w: *Writer) Writer.Error!void { if (self.entries.items.len == 0) { try util.writeInline(w, self.styles.empty_directory, " Bummer. No Files Found."); return; } var rendered: u16 = 0; var i: u16 = 0; while (i < self.entries.items.len) : (i += 1) { if (i < self.min_idx or i > self.max_idx) continue; if (rendered > 0) try w.writeByte('\n'); try self.renderEntry(w, i); rendered += 1; } while (rendered <= self.height) : (rendered += 1) { try w.writeByte('\n'); } } fn renderEntry(self: *FilePicker, w: *Writer, idx: u16) Writer.Error!void { const e = self.entries.items[idx]; const is_selected = idx == self.selected_index; const disabled = !self.canSelect(e.name) and e.kind != .directory; var size_buf: [16]u8 = undefined; const size_text = formatSize(e.size, &size_buf); var perm_buf: [16]u8 = undefined; const perm_text = formatMode(e.mode, e.kind, &perm_buf); if (is_selected) { // Cursor + selected row in one styled blob. try util.writeInline(w, if (disabled) self.styles.disabled_cursor else self.styles.cursor, self.cursor_text); const sel_style = if (disabled) self.styles.disabled_selected else self.styles.selected; if (self.show_permissions) { try util.writeInline(w, sel_style, " "); try util.writeInline(w, sel_style, perm_text); } if (self.show_size) { try util.writeInline(w, sel_style, " "); try writePaddedSize(w, sel_style, size_text); } try util.writeInline(w, sel_style, " "); try util.writeInline(w, sel_style, e.name); return; } // Unselected: leading space for the cursor column, then perms/size/name. try w.writeByte(' '); if (self.show_permissions) { try w.writeByte(' '); try util.writeInline(w, self.styles.permission, perm_text); } if (self.show_size) { try w.writeByte(' '); try writePaddedSize(w, self.styles.file_size, size_text); } try w.writeByte(' '); const name_style: ?blush.Style = switch (e.kind) { .directory => self.styles.directory, .sym_link => self.styles.symlink, else => if (disabled) self.styles.disabled_file else self.styles.file, }; try util.writeInline(w, name_style, e.name); } fn writePaddedSize(w: *Writer, style: ?blush.Style, size_text: []const u8) Writer.Error!void { const w_cells: u16 = file_size_width; const cells = blush.measure.cellWidth(size_text); var pad: u16 = if (w_cells > cells) w_cells - cells else 0; while (pad > 0) : (pad -= 1) try w.writeByte(' '); try util.writeInline(w, style, size_text); } fn formatSize(size: u64, buf: []u8) []const u8 { const units = [_][]const u8{ "B", "K", "M", "G", "T" }; var value: f64 = @floatFromInt(size); var unit: usize = 0; while (value >= 1024.0 and unit + 1 < units.len) { value /= 1024.0; unit += 1; } if (unit == 0) return std.fmt.bufPrint(buf, "{d}B", .{size}) catch buf[0..0]; return std.fmt.bufPrint(buf, "{d:.1}{s}", .{ value, units[unit] }) catch buf[0..0]; } fn formatMode(mode: u32, kind: Kind, buf: []u8) []const u8 { if (buf.len < 10) return buf[0..0]; buf[0] = switch (kind) { .directory => 'd', .sym_link => 'l', else => '-', }; const bits = [_]struct { mask: u32, ch: u8 }{ .{ .mask = 0o400, .ch = 'r' }, .{ .mask = 0o200, .ch = 'w' }, .{ .mask = 0o100, .ch = 'x' }, .{ .mask = 0o040, .ch = 'r' }, .{ .mask = 0o020, .ch = 'w' }, .{ .mask = 0o010, .ch = 'x' }, .{ .mask = 0o004, .ch = 'r' }, .{ .mask = 0o002, .ch = 'w' }, .{ .mask = 0o001, .ch = 'x' }, }; for (bits, 0..) |b, i| { buf[i + 1] = if (mode & b.mask != 0) b.ch else '-'; } return buf[0..10]; } // ---- tests ---- const testing = std.testing; test "formatSize humanizes bytes" { var buf: [16]u8 = undefined; try testing.expectEqualStrings("512B", formatSize(512, &buf)); try testing.expectEqualStrings("1.0K", formatSize(1024, &buf)); try testing.expectEqualStrings("1.5K", formatSize(1536, &buf)); try testing.expectEqualStrings("2.0M", formatSize(2 * 1024 * 1024, &buf)); } test "formatMode renders rwx" { var buf: [16]u8 = undefined; try testing.expectEqualStrings("drwxr-xr-x", formatMode(0o755, .directory, &buf)); try testing.expectEqualStrings("-rw-r--r--", formatMode(0o644, .file, &buf)); try testing.expectEqualStrings("lrwxrwxrwx", formatMode(0o777, .sym_link, &buf)); } test "entryLessThan: dirs first then name" { const a: Entry = .{ .name = @constCast("zeta"), .kind = .directory, .size = 0, .mode = 0 }; const b: Entry = .{ .name = @constCast("alpha"), .kind = .file, .size = 0, .mode = 0 }; try testing.expect(entryLessThan({}, a, b)); try testing.expect(!entryLessThan({}, b, a)); }