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>
481 lines
14 KiB
Zig
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
|
|
}
|
|
}
|