///|
/// Filer model - file tree state and data operations
// =============================================================================
// Types
// =============================================================================
///|
/// Git status indicator
enum GitStatus {
None // Not tracked or unchanged
Modified // M - modified
Added // A - added/staged
Deleted // D - deleted
Untracked // ? - untracked
Renamed // R - renamed
}
///|
fn GitStatus::to_char(self : GitStatus) -> String {
match self {
None => " "
Modified => "M"
Added => "A"
Deleted => "D"
Untracked => "?"
Renamed => "R"
}
}
///|
fn GitStatus::color(self : GitStatus) -> String {
match self {
None => "rgb(180,180,180)"
Modified => "rgb(255,200,100)" // Yellow/orange
Added => "rgb(100,255,100)" // Green
Deleted => "rgb(255,100,100)" // Red
Untracked => "rgb(150,150,150)" // Gray
Renamed => "rgb(100,200,255)" // Cyan
}
}
///|
/// Visible tree item (used for rendering)
struct TreeItem {
path : String // Full path
name : String // Display name
is_dir : Bool
git_status : GitStatus
depth : Int // Indentation level
is_expanded : Bool // For directories
}
///|
struct FilerState {
mut current_dir : String
visible_items : Array[TreeItem] // Flat list for rendering
expanded_dirs : Map[String, Bool] // Tracks expanded directories
git_status : Map[String, String] // path -> status code
mut selected_idx : Int
mut scroll_offset : Int
mut git_only_mode : Bool
mut is_git_repo : Bool
mut preview_file : String
mut preview_content : String
mut preview_is_diff : Bool
mut preview_scroll_offset : Int
}
// =============================================================================
// FilerState Methods
// =============================================================================
///|
fn FilerState::new(initial_dir : String) -> FilerState {
{
current_dir: initial_dir,
visible_items: [],
expanded_dirs: {},
git_status: {},
selected_idx: 0,
scroll_offset: 0,
git_only_mode: false,
is_git_repo: false,
preview_file: "",
preview_content: "",
preview_is_diff: false,
preview_scroll_offset: 0,
}
}
///|
fn FilerState::refresh(self : FilerState) -> Unit {
// Load git status first
self.load_git_status()
// Rebuild visible items
self.build_visible_items()
// Adjust selection
if self.selected_idx >= self.visible_items.length() {
self.selected_idx = if self.visible_items.length() > 0 {
self.visible_items.length() - 1
} else {
0
}
}
}
///|
/// Build the flat list of visible items by walking the tree
fn FilerState::build_visible_items(self : FilerState) -> Unit {
self.visible_items.clear()
self.build_items_recursive(self.current_dir, 0)
}
///|
fn is_filtered_entry(name : String) -> Bool {
name == ".gitignore" || name == "_build"
}
///|
/// Recursively build visible items
fn FilerState::build_items_recursive(
self : FilerState,
dir_path : String,
depth : Int,
) -> Unit {
let json = @ai.js_list_dir_json(dir_path)
let entries : Array[(String, Bool, GitStatus)] = [] // (name, is_dir, git_status)
parse_dir_entries(json, entries, self.git_status)
for entry in entries {
let (name, is_dir, git_status) = entry
if is_filtered_entry(name) {
continue
}
// Filter by git status if in git_only_mode
if self.git_only_mode && self.is_git_repo {
match git_status {
GitStatus::None =>
if !(is_dir) {
continue // Skip unchanged files
}
_ => ()
}
}
let full_path = @ai.js_path_join(dir_path, name)
let is_expanded = self.expanded_dirs.get(full_path).unwrap_or(false)
self.visible_items.push({
path: full_path,
name,
is_dir,
git_status,
depth,
is_expanded,
})
// If directory is expanded, add its children
if is_dir && is_expanded {
self.build_items_recursive(full_path, depth + 1)
}
}
}
///|
fn FilerState::load_git_status(self : FilerState) -> Unit {
self.git_status.clear()
let json = @ai.js_git_status(self.current_dir)
let (files, is_repo) = parse_git_status_json(json)
self.is_git_repo = is_repo
for k, v in files {
self.git_status[k] = v
}
}
///|
/// Toggle git-only mode
fn FilerState::toggle_git_mode(self : FilerState) -> Unit {
self.git_only_mode = !(self.git_only_mode)
self.refresh()
}
///|
/// Toggle expand/collapse for selected directory
/// Returns: Some(file_path) if a file was selected, None if directory was toggled
fn FilerState::toggle_selected(self : FilerState) -> String? {
if self.selected_idx < self.visible_items.length() {
let item = self.visible_items[self.selected_idx]
if item.is_dir {
// Toggle expansion
let currently_expanded = self.expanded_dirs
.get(item.path)
.unwrap_or(false)
self.expanded_dirs[item.path] = !(currently_expanded)
self.build_visible_items()
None
} else {
// Return file path
Some(item.path)
}
} else {
None
}
}
///|
/// Collapse selected directory (or parent if file/collapsed dir selected)
fn FilerState::collapse_selected(self : FilerState) -> Unit {
if self.selected_idx < self.visible_items.length() {
let item = self.visible_items[self.selected_idx]
if item.is_dir && item.is_expanded {
// Collapse this directory
self.expanded_dirs[item.path] = false
self.build_visible_items()
} else if item.depth > 0 {
// Find and select parent directory
let parent_path = @ai.js_path_dirname(item.path)
for i, vi in self.visible_items {
if vi.path == parent_path {
self.selected_idx = i
break
}
}
}
}
}
///|
/// Enter the selected directory (change root) or return file path
fn FilerState::enter_selected(self : FilerState) -> String? {
if self.selected_idx < self.visible_items.length() {
let item = self.visible_items[self.selected_idx]
if item.is_dir {
// Change to this directory as new root
self.current_dir = item.path
self.expanded_dirs.clear()
self.selected_idx = 0
self.scroll_offset = 0
self.refresh()
None
} else {
Some(item.path)
}
} else {
None
}
}
///|
/// Load file content for preview (shows diff if file has git changes)
fn FilerState::load_preview(self : FilerState, file_path : String) -> Unit {
self.preview_file = file_path
self.preview_scroll_offset = 0 // Reset scroll on new file
if self.is_git_repo {
let diff = @ai.js_git_diff_file(file_path)
if diff.length() > 0 {
self.preview_content = diff
self.preview_is_diff = true
return
}
}
self.preview_content = @ai.js_read_file_content(file_path)
self.preview_is_diff = false
}
///|
/// Clear preview
fn FilerState::clear_preview(self : FilerState) -> Unit {
self.preview_file = ""
self.preview_content = ""
self.preview_is_diff = false
self.preview_scroll_offset = 0
}
///|
/// Scroll preview by delta lines (positive = down, negative = up)
fn FilerState::scroll_preview(
self : FilerState,
delta : Int,
visible_height : Int,
) -> Unit {
let total_lines = self.preview_content.split("\n").count()
let max_offset = if total_lines > visible_height {
total_lines - visible_height
} else {
0
}
let new_offset = self.preview_scroll_offset + delta
self.preview_scroll_offset = if new_offset < 0 {
0
} else if new_offset > max_offset {
max_offset
} else {
new_offset
}
}
///|
/// Load preview for currently selected item (if it's a file)
fn FilerState::load_selected_preview(self : FilerState) -> Unit {
if self.selected_idx < self.visible_items.length() {
let item = self.visible_items[self.selected_idx]
if !(item.is_dir) {
self.load_preview(item.path)
} else {
self.clear_preview()
}
} else {
self.clear_preview()
}
}
///|
/// Navigate to parent directory
fn FilerState::go_up(self : FilerState) -> Unit {
let parent = @ai.js_path_dirname(self.current_dir)
if parent != self.current_dir {
self.current_dir = parent
self.expanded_dirs.clear()
self.selected_idx = 0
self.scroll_offset = 0
self.refresh()
}
}
///|
/// Move selection by delta (-1 for up, +1 for down)
fn FilerState::move_selection(self : FilerState, delta : Int) -> Unit {
let new_idx = self.selected_idx + delta
if new_idx >= 0 && new_idx < self.visible_items.length() {
self.selected_idx = new_idx
}
}
///|
/// Handle click at row position (0 = header, 1 = .., 2+ = items)
fn FilerState::handle_click(self : FilerState, row : Int) -> String? {
if row == 0 {
None
} else if row == 1 {
self.go_up()
None
} else {
let item_idx = self.scroll_offset + (row - 2)
if item_idx >= 0 && item_idx < self.visible_items.length() {
self.selected_idx = item_idx
let item = self.visible_items[item_idx]
if item.is_dir {
// Toggle expansion on click
let currently_expanded = self.expanded_dirs
.get(item.path)
.unwrap_or(false)
self.expanded_dirs[item.path] = !(currently_expanded)
self.build_visible_items()
None
} else {
Some(item.path)
}
} else {
None
}
}
}
// =============================================================================
// Utilities
// =============================================================================
///|
/// Parse directory entries from JSON
fn parse_dir_entries(
json : String,
entries : Array[(String, Bool, GitStatus)],
git_status : Map[String, String],
) -> Unit {
let chars : Array[Char] = json.iter().collect()
let mut i = 0
let len = chars.length()
fn skip_ws() -> Unit {
while i < len &&
(
chars[i] == ' ' ||
chars[i] == '\n' ||
chars[i] == '\r' ||
chars[i] == '\t'
) {
i = i + 1
}
}
fn parse_string() -> String {
let buf = StringBuilder::new()
if i < len && chars[i] == '"' {
i = i + 1
while i < len && chars[i] != '"' {
if chars[i] == '\\' && i + 1 < len {
i = i + 1
}
buf.write_char(chars[i])
i = i + 1
}
if i < len {
i = i + 1
}
}
buf.to_string()
}
fn parse_bool() -> Bool {
if i + 4 <= len &&
chars[i] == 't' &&
chars[i + 1] == 'r' &&
chars[i + 2] == 'u' &&
chars[i + 3] == 'e' {
i = i + 4
return true
}
if i + 5 <= len &&
chars[i] == 'f' &&
chars[i + 1] == 'a' &&
chars[i + 2] == 'l' &&
chars[i + 3] == 's' &&
chars[i + 4] == 'e' {
i = i + 5
return false
}
false
}
skip_ws()
if i < len && chars[i] == '[' {
i = i + 1
while i < len {
skip_ws()
if chars[i] == ']' {
break
}
if chars[i] == ',' {
i = i + 1
continue
}
if chars[i] == '{' {
i = i + 1
let mut name = ""
let mut is_dir = false
while i < len && chars[i] != '}' {
skip_ws()
if chars[i] == '"' {
let key = parse_string()
skip_ws()
if i < len && chars[i] == ':' {
i = i + 1
}
skip_ws()
if key == "name" {
name = parse_string()
} else if key == "is_dir" {
is_dir = parse_bool()
}
}
skip_ws()
if i < len && chars[i] == ',' {
i = i + 1
}
}
if i < len {
i = i + 1
}
let status = match git_status.get(name) {
Some(s) => parse_git_status_code(s)
None => GitStatus::None
}
entries.push((name, is_dir, status))
}
}
}
}
///|
fn parse_git_status_code(code : String) -> GitStatus {
if code.length() == 0 {
return GitStatus::None
}
match code[0] {
'M' => GitStatus::Modified
'A' => GitStatus::Added
'D' => GitStatus::Deleted
'?' => GitStatus::Untracked
'R' => GitStatus::Renamed
_ => GitStatus::None
}
}
///|
fn parse_git_status_json(json : String) -> (Map[String, String], Bool) {
let files : Map[String, String] = {}
let mut is_repo = false
let chars : Array[Char] = json.iter().collect()
let mut i = 0
let len = chars.length()
fn skip_ws() -> Unit {
while i < len &&
(
chars[i] == ' ' ||
chars[i] == '\n' ||
chars[i] == '\r' ||
chars[i] == '\t'
) {
i = i + 1
}
}
fn parse_string() -> String {
let buf = StringBuilder::new()
if i < len && chars[i] == '"' {
i = i + 1
while i < len && chars[i] != '"' {
if chars[i] == '\\' && i + 1 < len {
i = i + 1
}
buf.write_char(chars[i])
i = i + 1
}
if i < len {
i = i + 1
}
}
buf.to_string()
}
fn parse_bool() -> Bool {
if i + 4 <= len &&
chars[i] == 't' &&
chars[i + 1] == 'r' &&
chars[i + 2] == 'u' &&
chars[i + 3] == 'e' {
i = i + 4
return true
}
if i + 5 <= len &&
chars[i] == 'f' &&
chars[i + 1] == 'a' &&
chars[i + 2] == 'l' &&
chars[i + 3] == 's' &&
chars[i + 4] == 'e' {
i = i + 5
return false
}
false
}
skip_ws()
if i < len && chars[i] == '{' {
i = i + 1
while i < len && chars[i] != '}' {
skip_ws()
if chars[i] == '"' {
let key = parse_string()
skip_ws()
if i < len && chars[i] == ':' {
i = i + 1
}
skip_ws()
if key == "is_repo" {
is_repo = parse_bool()
} else if key == "files" {
if i < len && chars[i] == '{' {
i = i + 1
while i < len && chars[i] != '}' {
skip_ws()
if chars[i] == '"' {
let file_path = parse_string()
skip_ws()
if i < len && chars[i] == ':' {
i = i + 1
}
skip_ws()
let status = parse_string()
files[file_path] = status
}
skip_ws()
if i < len && chars[i] == ',' {
i = i + 1
}
}
if i < len {
i = i + 1
}
}
}
}
skip_ws()
if i < len && chars[i] == ',' {
i = i + 1
}
}
}
(files, is_repo)
}