zcatui/src/widgets/filepicker.zig
reugenio 5ac74ebff5 feat: Add terminal extensions and new widgets
Hyperlinks (OSC 8):
- Clickable hyperlinks with URL and display text
- ID support for link grouping
- URL encoding utility

Notifications (OSC 9/777):
- iTerm2-style (OSC 9) and urxvt-style (OSC 777)
- NotificationManager with title/body support

Image display (Kitty/iTerm2):
- Kitty Graphics Protocol with chunked transmission
- iTerm2 inline images
- File and raw data display

Tree widget:
- Hierarchical tree view with expand/collapse
- Customizable symbols (Unicode/ASCII)
- TreeState for selection navigation

FilePicker widget:
- File browser using Tree widget
- Lazy-loading directory contents
- Hidden files toggle, file icons

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 17:34:33 +01:00

481 lines
14 KiB
Zig

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