//! 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. //! //! Not ported from bubbles: symlink target resolution (kind is shown but a //! symlinked directory isn't descended into, and there's no `→ /target` //! suffix), AutoHeight wiring (call `setHeight` from your WindowSize //! handler), hidden-detection by platform attribute (only a `.`-prefix is //! treated as hidden), and humanize unit prefixes beyond KiB/MiB/GiB/TiB. const std = @import("std"); const tea = @import("blacktea"); const matte = @import("matte"); const util = @import("util.zig"); const t = std.testing; const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const Io = std.Io; const ArrayList = std.ArrayList; const FilePicker = @This(); const KeyMap = struct { const key = @import("key.zig"); 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" }, }, }; const Styles = struct { cursor: matte.Style = .{ .foreground = .{ .ansi256 = 212 } }, disabled_cursor: matte.Style = .{ .foreground = .{ .ansi256 = 247 } }, symlink: matte.Style = .{ .foreground = .{ .ansi256 = 36 } }, directory: matte.Style = .{ .foreground = .{ .ansi256 = 99 } }, file: matte.Style = .none, disabled_file: matte.Style = .{ .foreground = .{ .ansi256 = 243 } }, permission: matte.Style = .{ .foreground = .{ .ansi256 = 244 } }, selected: matte.Style = .{ .foreground = .{ .ansi256 = 212 }, .bold = true }, disabled_selected: matte.Style = .{ .foreground = .{ .ansi256 = 247 } }, file_size: matte.Style = .{ .foreground = .{ .ansi256 = 240 } }, empty_directory: matte.Style = .{ .foreground = .{ .ansi256 = 240 } }, }; const Kind = enum { file, directory, sym_link, other }; 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; /// 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 = .{}, 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, model: tea.Model = .{ .vtable = &.{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, }, }, fn vtableInit(_: *tea.Model) ?tea.Cmd { return null; } fn vtableUpdate(model: *tea.Model, msg: tea.Msg) ?tea.Cmd { const self: *FilePicker = @fieldParentPtr("model", model); return self.update(model.io, model.gpa, msg); } fn vtableView(model: *tea.Model, writer: *Writer) Writer.Error!tea.View { const self: *FilePicker = @fieldParentPtr("model", model); try self.view(writer); 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) |entry| allocator.free(entry.name); self.entries.clearRetainingCapacity(); } pub fn setCurrentDirectory( self: *FilePicker, io: Io, allocator: Allocator, 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(io, allocator); 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 }); } pub fn update(self: *FilePicker, io: Io, allocator: Allocator, msg: tea.Msg) ?tea.Cmd { switch (msg) { .key_press => |key| { const total: u16 = @intCast(@min(self.entries.items.len, std.math.maxInt(u16))); if (self.key_map.goto_top.matches(key)) { self.selected_index = 0; self.min_idx = 0; self.max_idx = self.height -| 1; } else if (self.key_map.goto_last.matches(key)) { 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(key)) { 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(key)) { 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(key)) { 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(key)) { 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(key)) { self.goBack(io, allocator) catch {}; return null; } else if (self.key_map.open.matches(key)) { self.openSelected(io, allocator, key) catch {}; return null; } }, else => {}, } return null; } fn goBack(self: *FilePicker, io: Io, allocator: Allocator) !void { // filepath.Dir keeps relative paths relative: Dir(".") and Dir("foo") // are ".", only an absolute path reaches "/" (filepicker.go:322). const dir = std.fs.path.dirname(self.current_directory); const parent: []const u8 = if (dir) |d| d else if (std.fs.path.isAbsolute(self.current_directory)) "/" else "."; const dup = try allocator.dupe(u8, parent); allocator.free(self.current_directory); self.current_directory = dup; try self.readDir(io, allocator); 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, io: Io, allocator: Allocator, key: tea.Msg.Key) !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); // Path is only recorded when the key also matches Select (i.e. Enter), // matching filepicker.go:355-360. if (can_select and self.key_map.select.matches(key)) { 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(io, allocator, new_dir); } fn readDir(self: *FilePicker, io: Io, allocator: Allocator) !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 (iter.next(io) catch null) |entry| { if (!self.show_hidden and entry.name.len > 0 and entry.name[0] == '.') continue; const name_owned = try allocator.dupe(u8, entry.name); const kind = switch (entry.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, entry.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; } pub fn view(self: *FilePicker, writer: *Writer) Writer.Error!void { if (self.entries.items.len == 0) { try util.writeInline(writer, 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 writer.writeByte('\n'); try self.renderEntry(writer, i); rendered += 1; } while (rendered <= self.height) : (rendered += 1) { try writer.writeByte('\n'); } } fn renderEntry(self: *FilePicker, writer: *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(writer, 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(writer, sel_style, " "); try util.writeInline(writer, sel_style, perm_text); } if (self.show_size) { try util.writeInline(writer, sel_style, " "); try writePaddedSize(writer, sel_style, size_text); } try util.writeInline(writer, sel_style, " "); try util.writeInline(writer, sel_style, e.name); return; } // Unselected: leading space for the cursor column, then perms/size/name. try writer.writeByte(' '); if (self.show_permissions) { try writer.writeByte(' '); try util.writeInline(writer, self.styles.permission, perm_text); } if (self.show_size) { try writer.writeByte(' '); try writePaddedSize(writer, self.styles.file_size, size_text); } try writer.writeByte(' '); const name_style: ?matte.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(writer, name_style, e.name); } fn writePaddedSize(writer: *Writer, style: ?matte.Style, size_text: []const u8) Writer.Error!void { const w_cells: u16 = 7; //file size width const cells = matte.measure.cellWidth(size_text); var pad: u16 = if (w_cells > cells) w_cells - cells else 0; while (pad > 0) : (pad -= 1) try writer.writeByte(' '); try util.writeInline(writer, 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]; } test "formatSize humanizes bytes" { var buf: [16]u8 = undefined; try t.expectEqualStrings("512B", formatSize(512, &buf)); try t.expectEqualStrings("1.0K", formatSize(1024, &buf)); try t.expectEqualStrings("1.5K", formatSize(1536, &buf)); try t.expectEqualStrings("2.0M", formatSize(2 * 1024 * 1024, &buf)); } test "formatMode renders rwx" { var buf: [16]u8 = undefined; try t.expectEqualStrings("drwxr-xr-x", formatMode(0o755, .directory, &buf)); try t.expectEqualStrings("-rw-r--r--", formatMode(0o644, .file, &buf)); try t.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 t.expect(entryLessThan({}, a, b)); try t.expect(!entryLessThan({}, b, a)); } test "openSelected records path only on the Select key" { var fp: FilePicker = .{}; defer fp.deinit(t.allocator); fp.current_directory = try t.allocator.dupe(u8, "dir"); try fp.entries.append(t.allocator, .{ .name = try t.allocator.dupe(u8, "file.txt"), .kind = .file, .size = 0, .mode = 0, }); // A non-Select open key ('l') must not record a path. const open_l: tea.Msg.Key = .{ .kind = .rune, .rune = 'l' }; try fp.openSelected(t.io, t.allocator, open_l); try t.expectEqual(@as(usize, 0), fp.path.len); // Enter matches Select, so the path is recorded. const enter: tea.Msg.Key = .{ .kind = .enter }; try fp.openSelected(t.io, t.allocator, enter); try t.expectEqualStrings("dir/file.txt", fp.path); }