//! File picker widget for directory/file selection. //! //! Provides a file browser interface using the Tree widget. //! //! ## Example //! //! ```zig //! var picker = try FilePicker.init(allocator, "/home/user"); //! defer picker.deinit(); //! //! picker.render(area, buf); //! //! // Handle events //! switch (event.key.code) { //! .up => picker.selectPrev(), //! .down => picker.selectNext(), //! .enter => { //! if (picker.getSelectedPath()) |path| { //! // Use selected file/directory //! } //! }, //! } //! ``` const std = @import("std"); const fs = std.fs; const Allocator = std.mem.Allocator; const buffer_mod = @import("../buffer.zig"); const Buffer = buffer_mod.Buffer; const Rect = buffer_mod.Rect; const style_mod = @import("../style.zig"); const Style = style_mod.Style; const Color = style_mod.Color; const block_mod = @import("block.zig"); const Block = block_mod.Block; const Borders = block_mod.Borders; // ============================================================================ // FileEntry // ============================================================================ /// Type of file entry. pub const FileType = enum { file, directory, symlink, other, }; /// A file or directory entry. pub const FileEntry = struct { /// Entry name (not full path). name: []const u8, /// Full path. path: []const u8, /// Entry type. file_type: FileType, /// File size (for files). size: u64 = 0, /// Whether expanded (for directories). expanded: bool = false, /// Child entries (loaded lazily for directories). children: std.ArrayList(FileEntry), /// Whether children have been loaded. children_loaded: bool = false, /// Allocator for this entry. allocator: Allocator, /// Creates a new file entry. pub fn init(allocator: Allocator, name: []const u8, path: []const u8, file_type: FileType) !FileEntry { return .{ .name = try allocator.dupe(u8, name), .path = try allocator.dupe(u8, path), .file_type = file_type, .children = std.ArrayList(FileEntry).init(allocator), .allocator = allocator, }; } /// Frees all resources. pub fn deinit(self: *FileEntry) void { for (self.children.items) |*child| { child.deinit(); } self.children.deinit(); self.allocator.free(self.name); self.allocator.free(self.path); } /// Loads children from filesystem. pub fn loadChildren(self: *FileEntry) !void { if (self.children_loaded or self.file_type != .directory) return; var dir = fs.openDirAbsolute(self.path, .{ .iterate = true }) catch return; defer dir.close(); var iter = dir.iterate(); while (try iter.next()) |entry| { const entry_type: FileType = switch (entry.kind) { .file => .file, .directory => .directory, .sym_link => .symlink, else => .other, }; // Build full path const full_path = try fs.path.join(self.allocator, &.{ self.path, entry.name }); defer self.allocator.free(full_path); var child = try FileEntry.init( self.allocator, entry.name, full_path, entry_type, ); // Get file size for files if (entry_type == .file) { if (dir.statFile(entry.name)) |stat| { child.size = stat.size; } else |_| {} } try self.children.append(child); } // Sort: directories first, then alphabetically std.mem.sort(FileEntry, self.children.items, {}, struct { fn lessThan(_: void, a: FileEntry, b: FileEntry) bool { // Directories first if (a.file_type == .directory and b.file_type != .directory) return true; if (a.file_type != .directory and b.file_type == .directory) return false; // Then alphabetically (case-insensitive) return std.ascii.lessThanIgnoreCase(a.name, b.name); } }.lessThan); self.children_loaded = true; } /// Toggles expanded state. pub fn toggle(self: *FileEntry) !void { if (self.file_type == .directory) { self.expanded = !self.expanded; if (self.expanded and !self.children_loaded) { try self.loadChildren(); } } } }; // ============================================================================ // FilePicker // ============================================================================ /// File picker icons. pub const FileIcons = struct { directory: []const u8 = "📁 ", directory_open: []const u8 = "📂 ", file: []const u8 = "📄 ", symlink: []const u8 = "🔗 ", other: []const u8 = "❓ ", /// ASCII icons. pub const ascii = FileIcons{ .directory = "[D] ", .directory_open = "[D] ", .file = "[F] ", .symlink = "[L] ", .other = "[?] ", }; }; /// File picker widget. pub const FilePicker = struct { /// Allocator. allocator: Allocator, /// Root entry. root: FileEntry, /// Current path. current_path: []const u8, /// Selected item index (flattened). selected: usize = 0, /// Scroll offset. offset: usize = 0, /// Block wrapper. block: ?Block = null, /// Base style. style: Style = Style.default, /// Highlight style. highlight_style: Style = Style.default.bg(Color.blue).fg(Color.white), /// Directory style. dir_style: Style = Style.default.fg(Color.cyan).bold(), /// File icons. icons: FileIcons = .{}, /// Show hidden files (starting with .). show_hidden: bool = false, /// Filter pattern (glob-like). filter: ?[]const u8 = null, /// Creates a file picker rooted at the given path. pub fn init(allocator: Allocator, root_path: []const u8) !FilePicker { // Normalize path const abs_path = try fs.realpathAlloc(allocator, root_path); var root = try FileEntry.init(allocator, fs.path.basename(abs_path), abs_path, .directory); root.expanded = true; try root.loadChildren(); return .{ .allocator = allocator, .root = root, .current_path = abs_path, }; } /// Frees all resources. pub fn deinit(self: *FilePicker) void { self.root.deinit(); self.allocator.free(self.current_path); } /// Sets the block wrapper. pub fn setBlock(self: *FilePicker, blk: Block) void { self.block = blk; } /// Sets whether to show hidden files. pub fn setShowHidden(self: *FilePicker, show: bool) void { self.show_hidden = show; } /// Sets file icons. pub fn setIcons(self: *FilePicker, icn: FileIcons) void { self.icons = icn; } /// Uses ASCII icons. pub fn useAsciiIcons(self: *FilePicker) void { self.icons = FileIcons.ascii; } /// Counts total visible entries. pub fn countVisible(self: *FilePicker) usize { return self.countVisibleEntry(&self.root); } fn countVisibleEntry(self: *FilePicker, entry: *FileEntry) usize { var count: usize = 1; if (entry.expanded) { for (entry.children.items) |*child| { if (!self.show_hidden and child.name.len > 0 and child.name[0] == '.') { continue; } count += self.countVisibleEntry(child); } } return count; } /// Selects the next entry. pub fn selectNext(self: *FilePicker) void { const total = self.countVisible(); if (self.selected < total - 1) { self.selected += 1; } } /// Selects the previous entry. pub fn selectPrev(self: *FilePicker) void { if (self.selected > 0) { self.selected -= 1; } } /// Toggles the selected entry (expand/collapse for directories). pub fn toggleSelected(self: *FilePicker) !void { var index: usize = 0; _ = try self.toggleAtIndex(&self.root, &index); } fn toggleAtIndex(self: *FilePicker, entry: *FileEntry, index: *usize) !bool { if (index.* == self.selected) { try entry.toggle(); return true; } index.* += 1; if (entry.expanded) { for (entry.children.items) |*child| { if (!self.show_hidden and child.name.len > 0 and child.name[0] == '.') { continue; } if (try self.toggleAtIndex(child, index)) { return true; } } } return false; } /// Gets the selected entry. pub fn getSelectedEntry(self: *FilePicker) ?*FileEntry { var index: usize = 0; return self.getEntryAtIndex(&self.root, &index); } fn getEntryAtIndex(self: *FilePicker, entry: *FileEntry, index: *usize) ?*FileEntry { if (index.* == self.selected) { return entry; } index.* += 1; if (entry.expanded) { for (entry.children.items) |*child| { if (!self.show_hidden and child.name.len > 0 and child.name[0] == '.') { continue; } if (self.getEntryAtIndex(child, index)) |found| { return found; } } } return null; } /// Gets the path of the selected entry. pub fn getSelectedPath(self: *FilePicker) ?[]const u8 { if (self.getSelectedEntry()) |entry| { return entry.path; } return null; } /// Renders the file picker. pub fn render(self: *FilePicker, area: Rect, buf: *Buffer) void { // Clear area buf.setStyle(area, self.style); // Render block var picker_area = area; if (self.block) |blk| { blk.render(area, buf); picker_area = blk.inner(area); } if (picker_area.width < 4 or picker_area.height == 0) return; // Adjust scroll const visible = self.countVisible(); if (self.selected >= self.offset + picker_area.height) { self.offset = self.selected - picker_area.height + 1; } if (self.selected < self.offset) { self.offset = self.selected; } // Render entries var y: u16 = picker_area.y; var index: usize = 0; self.renderEntry(picker_area, buf, &self.root, 0, &y, &index); _ = visible; } fn renderEntry( self: *FilePicker, area: Rect, buf: *Buffer, entry: *FileEntry, depth: u16, y: *u16, index: *usize, ) void { // Skip hidden files if (!self.show_hidden and depth > 0 and entry.name.len > 0 and entry.name[0] == '.') { return; } // Check if within visible range if (index.* >= self.offset and y.* < area.y + area.height) { var x = area.x; // Indentation const indent = depth * 2; x += indent; // Icon const icon = switch (entry.file_type) { .directory => if (entry.expanded) self.icons.directory_open else self.icons.directory, .file => self.icons.file, .symlink => self.icons.symlink, .other => self.icons.other, }; _ = buf.setString(x, y.*, icon, self.style); x += @intCast(@min(icon.len, 4)); // Style var entry_style = switch (entry.file_type) { .directory => self.dir_style, else => self.style, }; // Highlight selected if (index.* == self.selected) { entry_style = self.highlight_style; // Fill line var hx = area.x; while (hx < area.x + area.width) : (hx += 1) { if (buf.getCell(hx, y.*)) |cell| { cell.setStyle(self.highlight_style); } } } // Name const max_width = (area.x + area.width) -| x; const name_len: u16 = @intCast(@min(entry.name.len, max_width)); if (name_len > 0) { _ = buf.setString(x, y.*, entry.name[0..name_len], entry_style); } y.* += 1; } index.* += 1; // Render children if (entry.expanded) { for (entry.children.items) |*child| { self.renderEntry(area, buf, child, depth + 1, y, index); } } } }; // ============================================================================ // Tests // ============================================================================ test "FileEntry basic" { const allocator = std.testing.allocator; var entry = try FileEntry.init(allocator, "test.txt", "/tmp/test.txt", .file); defer entry.deinit(); try std.testing.expectEqualStrings("test.txt", entry.name); try std.testing.expectEqualStrings("/tmp/test.txt", entry.path); try std.testing.expectEqual(FileType.file, entry.file_type); } test "FilePicker init current dir" { const allocator = std.testing.allocator; // Try to init with current directory if (FilePicker.init(allocator, ".")) |*picker| { defer picker.deinit(); try std.testing.expect(picker.countVisible() >= 1); try std.testing.expect(picker.getSelectedPath() != null); } else |_| { // Skip if directory cannot be opened } }