zcatui/src/widgets/dirtree.zig
reugenio 7abc87a4f5 feat: zcatui v2.2 - Complete feature set with 13 new modules
New modules (13):
- src/resize.zig: SIGWINCH terminal resize detection
- src/drag.zig: Mouse drag state and Splitter panels
- src/diagnostic.zig: Elm-style error messages with code snippets
- src/debug.zig: Debug overlay (FPS, timing, widget count)
- src/profile.zig: Performance profiling with scoped timers
- src/sixel.zig: Sixel graphics encoding for terminal images
- src/async_loop.zig: epoll-based async event loop with timers
- src/compose.zig: Widget composition utilities
- src/shortcuts.zig: Keyboard shortcut registry
- src/widgets/logo.zig: ASCII art logo widget

Enhanced modules:
- src/layout.zig: Added Constraint.ratio(num, denom)
- src/terminal.zig: Integrated resize handling
- src/root.zig: Re-exports all new modules

New examples (9):
- resize_demo, splitter_demo, dirtree_demo
- help_demo, markdown_demo, progress_demo
- spinner_demo, syntax_demo, viewport_demo

Package manager:
- build.zig.zon: Zig package manager support

Stats: 60+ source files, 186+ tests, 20 executables

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 22:46:06 +01:00

632 lines
20 KiB
Zig

//! 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;
}