//! Directory Tree widget for file system navigation. //! //! A specialized tree view for browsing directories and files. //! Features auto-expansion, filtering, icons, and file info display. //! //! ## Example //! //! ```zig //! var tree = try DirectoryTree.init(allocator, "/home/user"); //! defer tree.deinit(); //! //! // Navigate //! tree.moveDown(); //! tree.toggleExpand(); //! //! // Render //! tree.render(area, buf); //! ``` const std = @import("std"); const fs = std.fs; 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; /// File type for styling and icons pub const FileKind = enum { directory, file, symlink, executable, hidden, special, pub fn fromEntry(entry: fs.Dir.Entry) FileKind { return switch (entry.kind) { .directory => .directory, .sym_link => .symlink, .file => .file, else => .special, }; } }; /// Icons for different file types pub const FileIcons = struct { directory: []const u8 = "📁", directory_open: []const u8 = "📂", file: []const u8 = "📄", symlink: []const u8 = "🔗", executable: []const u8 = "⚙️", hidden: []const u8 = "👁", special: []const u8 = "❓", // File extension icons zig: []const u8 = "⚡", rust: []const u8 = "🦀", python: []const u8 = "🐍", javascript: []const u8 = "📜", markdown: []const u8 = "📝", image: []const u8 = "🖼", archive: []const u8 = "📦", config: []const u8 = "⚙️", git: []const u8 = "🔀", pub const default: FileIcons = .{}; pub const ascii: FileIcons = .{ .directory = "[D]", .directory_open = "[D]", .file = " ", .symlink = "[@]", .executable = "[*]", .hidden = "[.]", .special = "[?]", .zig = "[Z]", .rust = "[R]", .python = "[P]", .javascript = "[J]", .markdown = "[M]", .image = "[I]", .archive = "[A]", .config = "[C]", .git = "[G]", }; pub fn forFile(self: FileIcons, name: []const u8, kind: FileKind, expanded: bool) []const u8 { // Check for hidden files if (name.len > 0 and name[0] == '.') { // Git directory if (std.mem.eql(u8, name, ".git")) return self.git; } // By file kind switch (kind) { .directory => return if (expanded) self.directory_open else self.directory, .symlink => return self.symlink, .executable => return self.executable, .hidden => return self.hidden, .special => return self.special, .file => { // By extension if (getExtension(name)) |ext| { if (std.mem.eql(u8, ext, "zig")) return self.zig; if (std.mem.eql(u8, ext, "rs")) return self.rust; if (std.mem.eql(u8, ext, "py")) return self.python; if (std.mem.eql(u8, ext, "js") or std.mem.eql(u8, ext, "ts")) return self.javascript; if (std.mem.eql(u8, ext, "md")) return self.markdown; if (std.mem.eql(u8, ext, "png") or std.mem.eql(u8, ext, "jpg") or std.mem.eql(u8, ext, "gif") or std.mem.eql(u8, ext, "svg")) return self.image; if (std.mem.eql(u8, ext, "zip") or std.mem.eql(u8, ext, "tar") or std.mem.eql(u8, ext, "gz") or std.mem.eql(u8, ext, "7z")) return self.archive; if (std.mem.eql(u8, ext, "json") or std.mem.eql(u8, ext, "toml") or std.mem.eql(u8, ext, "yaml") or std.mem.eql(u8, ext, "yml")) return self.config; } return self.file; }, } } }; fn getExtension(name: []const u8) ?[]const u8 { const idx = std.mem.lastIndexOfScalar(u8, name, '.'); if (idx) |i| { // Must have content after the dot, and dot can't be at start (hidden files) if (i > 0 and i + 1 < name.len) return name[i + 1 ..]; } return null; } /// A node in the directory tree pub const DirNode = struct { name: []const u8, path: []const u8, kind: FileKind, depth: u16, expanded: bool = false, loaded: bool = false, children_start: usize = 0, children_count: usize = 0, size: u64 = 0, }; /// Theme for directory tree pub const DirTreeTheme = struct { directory: Style = Style.default.fg(Color.blue).add_modifier(.{ .bold = true }), file: Style = Style.default, symlink: Style = Style.default.fg(Color.cyan), executable: Style = Style.default.fg(Color.green), hidden: Style = Style.default.fg(Color.indexed(245)), special: Style = Style.default.fg(Color.yellow), selected: Style = Style.default.bg(Color.indexed(236)), tree_guide: Style = Style.default.fg(Color.indexed(240)), size: Style = Style.default.fg(Color.indexed(245)), pub const default: DirTreeTheme = .{}; }; /// Tree drawing symbols pub const TreeSymbols = struct { branch: []const u8 = "├── ", last_branch: []const u8 = "└── ", vertical: []const u8 = "│ ", space: []const u8 = " ", collapsed: []const u8 = "▸ ", expanded: []const u8 = "▾ ", pub const default: TreeSymbols = .{}; pub const ascii: TreeSymbols = .{ .branch = "|-- ", .last_branch = "`-- ", .vertical = "| ", .space = " ", .collapsed = "+ ", .expanded = "- ", }; }; /// Directory tree widget pub const DirectoryTree = struct { allocator: std.mem.Allocator, root_path: []const u8, nodes: std.ArrayListUnmanaged(DirNode), flat_view: std.ArrayListUnmanaged(usize), // Indices into nodes for visible items selected: usize = 0, scroll_offset: u16 = 0, theme: DirTreeTheme = DirTreeTheme.default, symbols: TreeSymbols = TreeSymbols.default, icons: FileIcons = FileIcons.default, show_hidden: bool = false, show_icons: bool = true, show_size: bool = false, filter: ?[]const u8 = null, /// Creates a new directory tree rooted at the given path pub fn init(allocator: std.mem.Allocator, root_path: []const u8) !DirectoryTree { var tree = DirectoryTree{ .allocator = allocator, .root_path = try allocator.dupe(u8, root_path), .nodes = .{}, .flat_view = .{}, }; // Add root node try tree.nodes.append(allocator, .{ .name = try allocator.dupe(u8, std.fs.path.basename(root_path)), .path = tree.root_path, .kind = .directory, .depth = 0, .expanded = true, .loaded = false, }); // Load root directory try tree.loadChildren(0); try tree.rebuildFlatView(); return tree; } /// Frees all resources pub fn deinit(self: *DirectoryTree) void { for (self.nodes.items) |node| { if (node.name.ptr != node.path.ptr) { self.allocator.free(node.name); } if (node.depth > 0) { self.allocator.free(node.path); } } self.nodes.deinit(self.allocator); self.flat_view.deinit(self.allocator); self.allocator.free(self.root_path); } /// Loads children for a directory node fn loadChildren(self: *DirectoryTree, node_idx: usize) !void { var node = &self.nodes.items[node_idx]; if (node.loaded or node.kind != .directory) return; var dir = fs.openDirAbsolute(node.path, .{ .iterate = true }) catch { node.loaded = true; return; }; defer dir.close(); const children_start = self.nodes.items.len; var children_count: usize = 0; var iter = dir.iterate(); while (try iter.next()) |entry| { // Filter hidden files if (!self.show_hidden and entry.name.len > 0 and entry.name[0] == '.') { continue; } // Apply filter if set if (self.filter) |f| { if (std.mem.indexOf(u8, entry.name, f) == null) { continue; } } const full_path = try fs.path.join(self.allocator, &.{ node.path, entry.name }); const name = try self.allocator.dupe(u8, entry.name); try self.nodes.append(self.allocator, .{ .name = name, .path = full_path, .kind = FileKind.fromEntry(entry), .depth = node.depth + 1, }); children_count += 1; } // Sort children: directories first, then alphabetically const children = self.nodes.items[children_start..]; std.mem.sort(DirNode, children, {}, struct { fn lessThan(_: void, a: DirNode, b: DirNode) bool { // Directories first if (a.kind == .directory and b.kind != .directory) return true; if (a.kind != .directory and b.kind == .directory) return false; // Then alphabetical (case-insensitive) return std.ascii.lessThanIgnoreCase(a.name, b.name); } }.lessThan); node.children_start = children_start; node.children_count = children_count; node.loaded = true; } /// Rebuilds the flat view based on expanded state fn rebuildFlatView(self: *DirectoryTree) !void { self.flat_view.clearRetainingCapacity(); try self.addToFlatView(0); } fn addToFlatView(self: *DirectoryTree, node_idx: usize) !void { try self.flat_view.append(self.allocator, node_idx); const node = self.nodes.items[node_idx]; if (node.expanded and node.loaded) { const children_end = node.children_start + node.children_count; for (node.children_start..children_end) |child_idx| { try self.addToFlatView(child_idx); } } } // Navigation methods pub fn moveUp(self: *DirectoryTree) void { if (self.selected > 0) { self.selected -= 1; self.ensureVisible(); } } pub fn moveDown(self: *DirectoryTree) void { if (self.selected + 1 < self.flat_view.items.len) { self.selected += 1; self.ensureVisible(); } } pub fn pageUp(self: *DirectoryTree, page_size: u16) void { if (self.selected > page_size) { self.selected -= page_size; } else { self.selected = 0; } self.ensureVisible(); } pub fn pageDown(self: *DirectoryTree, page_size: u16) void { self.selected = @min(self.selected + page_size, self.flat_view.items.len -| 1); self.ensureVisible(); } pub fn goToTop(self: *DirectoryTree) void { self.selected = 0; self.scroll_offset = 0; } pub fn goToBottom(self: *DirectoryTree) void { self.selected = self.flat_view.items.len -| 1; self.ensureVisible(); } fn ensureVisible(self: *DirectoryTree) void { const sel = @as(u16, @intCast(self.selected)); if (sel < self.scroll_offset) { self.scroll_offset = sel; } // Will be adjusted during render based on area height } /// Toggles expansion of the selected directory pub fn toggleExpand(self: *DirectoryTree) !void { if (self.flat_view.items.len == 0) return; const node_idx = self.flat_view.items[self.selected]; var node = &self.nodes.items[node_idx]; if (node.kind != .directory) return; if (!node.loaded) { try self.loadChildren(node_idx); } node.expanded = !node.expanded; try self.rebuildFlatView(); // Adjust selected if it's now out of range if (self.selected >= self.flat_view.items.len) { self.selected = self.flat_view.items.len -| 1; } } /// Expands the selected directory pub fn expand(self: *DirectoryTree) !void { if (self.flat_view.items.len == 0) return; const node_idx = self.flat_view.items[self.selected]; var node = &self.nodes.items[node_idx]; if (node.kind != .directory or node.expanded) return; if (!node.loaded) { try self.loadChildren(node_idx); } node.expanded = true; try self.rebuildFlatView(); } /// Collapses the selected directory pub fn collapse(self: *DirectoryTree) !void { if (self.flat_view.items.len == 0) return; const node_idx = self.flat_view.items[self.selected]; var node = &self.nodes.items[node_idx]; if (node.kind == .directory and node.expanded) { node.expanded = false; try self.rebuildFlatView(); } else if (node.depth > 0) { // Go to parent self.goToParent(); } } /// Navigates to the parent directory pub fn goToParent(self: *DirectoryTree) void { if (self.flat_view.items.len == 0) return; const node_idx = self.flat_view.items[self.selected]; const node = self.nodes.items[node_idx]; if (node.depth == 0) return; // Find parent in flat view for (self.flat_view.items, 0..) |idx, i| { const n = self.nodes.items[idx]; if (n.depth == node.depth - 1 and n.children_start <= node_idx and node_idx < n.children_start + n.children_count) { self.selected = i; self.ensureVisible(); break; } } } /// Returns the currently selected node pub fn getSelected(self: *const DirectoryTree) ?DirNode { if (self.flat_view.items.len == 0) return null; return self.nodes.items[self.flat_view.items[self.selected]]; } /// Returns the path of the selected item pub fn getSelectedPath(self: *const DirectoryTree) ?[]const u8 { if (self.getSelected()) |node| { return node.path; } return null; } /// Toggles hidden file visibility pub fn toggleHidden(self: *DirectoryTree) !void { self.show_hidden = !self.show_hidden; // Reload all expanded directories for (self.nodes.items) |*node| { if (node.expanded) { node.loaded = false; } } // Clear and reload self.nodes.shrinkRetainingCapacity(1); self.nodes.items[0].loaded = false; self.nodes.items[0].children_count = 0; try self.loadChildren(0); try self.rebuildFlatView(); self.selected = @min(self.selected, self.flat_view.items.len -| 1); } // Builder methods pub fn setTheme(self: DirectoryTree, t: DirTreeTheme) DirectoryTree { var tree = self; tree.theme = t; return tree; } pub fn setSymbols(self: DirectoryTree, s: TreeSymbols) DirectoryTree { var tree = self; tree.symbols = s; return tree; } pub fn setIcons(self: DirectoryTree, i: FileIcons) DirectoryTree { var tree = self; tree.icons = i; return tree; } pub fn setShowHidden(self: DirectoryTree, show: bool) DirectoryTree { var tree = self; tree.show_hidden = show; return tree; } pub fn setShowIcons(self: DirectoryTree, show: bool) DirectoryTree { var tree = self; tree.show_icons = show; return tree; } pub fn setShowSize(self: DirectoryTree, show: bool) DirectoryTree { var tree = self; tree.show_size = show; return tree; } /// Renders the directory tree pub fn render(self: *DirectoryTree, area: Rect, buf: *Buffer) void { if (area.isEmpty() or self.flat_view.items.len == 0) return; // Adjust scroll to keep selected visible const sel = @as(u16, @intCast(self.selected)); if (sel >= self.scroll_offset + area.height) { self.scroll_offset = sel - area.height + 1; } if (sel < self.scroll_offset) { self.scroll_offset = sel; } var y: u16 = 0; var visible_idx: u16 = 0; for (self.flat_view.items) |node_idx| { if (visible_idx < self.scroll_offset) { visible_idx += 1; continue; } if (y >= area.height) break; const node = self.nodes.items[node_idx]; const is_selected = visible_idx == @as(u16, @intCast(self.selected)); self.renderNode(node, is_selected, area.x, area.y + y, area.width, buf); y += 1; visible_idx += 1; } } fn renderNode( self: *const DirectoryTree, node: DirNode, is_selected: bool, x: u16, y: u16, width: u16, buf: *Buffer, ) void { var pos = x; // Selection highlight (fill entire line) if (is_selected) { var fill_x = x; while (fill_x < x + width) : (fill_x += 1) { if (buf.getPtr(fill_x, y)) |cell| { cell.bg = self.theme.selected.background orelse Color.indexed(236); } } } // Indentation with tree guides const indent = node.depth * 4; pos += @intCast(indent); // Expand/collapse indicator for directories if (node.kind == .directory) { const indicator = if (node.expanded) self.symbols.expanded else self.symbols.collapsed; pos = buf.setString(pos, y, indicator, self.theme.tree_guide); } // Icon if (self.show_icons) { const icon = self.icons.forFile(node.name, node.kind, node.expanded); pos = buf.setString(pos, y, icon, self.getStyleForKind(node.kind)); pos = buf.setString(pos, y, " ", Style.default); } // Name const name_style = if (is_selected) self.getStyleForKind(node.kind).bg(self.theme.selected.background orelse Color.indexed(236)) else self.getStyleForKind(node.kind); _ = buf.setString(pos, y, node.name, name_style); } fn getStyleForKind(self: *const DirectoryTree, kind: FileKind) Style { return switch (kind) { .directory => self.theme.directory, .file => self.theme.file, .symlink => self.theme.symlink, .executable => self.theme.executable, .hidden => self.theme.hidden, .special => self.theme.special, }; } }; // ============================================================================ // Tests // ============================================================================ test "FileIcons forFile" { const icons = FileIcons.default; try std.testing.expectEqualStrings("📁", icons.forFile("src", .directory, false)); try std.testing.expectEqualStrings("📂", icons.forFile("src", .directory, true)); try std.testing.expectEqualStrings("⚡", icons.forFile("main.zig", .file, false)); try std.testing.expectEqualStrings("🐍", icons.forFile("app.py", .file, false)); try std.testing.expectEqualStrings("📄", icons.forFile("readme.txt", .file, false)); } test "getExtension" { try std.testing.expectEqualStrings("zig", getExtension("main.zig").?); try std.testing.expectEqualStrings("rs", getExtension("lib.rs").?); try std.testing.expectEqualStrings("gz", getExtension("archive.tar.gz").?); try std.testing.expect(getExtension("noextension") == null); try std.testing.expect(getExtension(".hidden") == null); } test "FileKind fromEntry" { // Basic type detection (can't easily test without real fs entries) _ = FileKind.directory; _ = FileKind.file; _ = FileKind.symlink; }