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>
632 lines
20 KiB
Zig
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;
|
|
}
|