Skip to main content

logicaffeine_web/ui/components/
file_browser.rs

1//! File browser sidebar with tree navigation.
2//!
3//! Displays a hierarchical file tree for the Studio's virtual file system.
4//! Supports collapsible directories, file selection, and responsive layout.
5//!
6//! # Props
7//!
8//! - `tree` - Root [`FileNode`] containing the file tree
9//! - `selected_path` - Currently selected file path
10//! - `on_select` - Callback when a file is selected
11//! - `on_toggle_dir` - Callback when a directory is expanded/collapsed
12//! - `on_new_file` - Callback for new file button
13//!
14//! # File Icons
15//!
16//! Icons are chosen by file extension:
17//! - `.logic` files: ∀ (forall symbol)
18//! - `.logos` files: λ (lambda symbol)
19//! - `.math` files: π (pi symbol)
20//! - Other files: document icon
21
22use dioxus::prelude::*;
23use crate::ui::state::FileNode;
24use crate::ui::components::icon::{Icon, IconVariant, IconSize};
25
26const FILE_BROWSER_STYLE: &str = r#"
27.file-browser {
28    display: flex;
29    flex-direction: column;
30    height: 100%;
31    width: 100%;
32    background: #12161c;
33    overflow: hidden;
34}
35
36.file-browser-header {
37    display: flex;
38    justify-content: space-between;
39    align-items: center;
40    padding: 0 14px;
41    height: 52px;
42    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
43    background: rgba(255, 255, 255, 0.02);
44    flex-shrink: 0;
45}
46
47.file-browser-title {
48    font-size: 12px;
49    font-weight: 600;
50    text-transform: uppercase;
51    letter-spacing: 0.5px;
52    color: rgba(255, 255, 255, 0.6);
53}
54
55.file-browser-actions {
56    display: flex;
57    gap: 4px;
58}
59
60.file-browser-btn {
61    padding: 4px 8px;
62    border: none;
63    background: transparent;
64    color: rgba(255, 255, 255, 0.5);
65    font-size: 14px;
66    cursor: pointer;
67    border-radius: 4px;
68    transition: all 0.15s ease;
69}
70
71.file-browser-btn:hover {
72    background: rgba(255, 255, 255, 0.08);
73    color: rgba(255, 255, 255, 0.9);
74}
75
76.file-browser-btn.coming-soon {
77    position: relative;
78}
79
80.coming-soon-toast {
81    position: fixed;
82    top: 80px;
83    left: 50%;
84    transform: translateX(-50%);
85    background: linear-gradient(135deg, #00d4ff 0%, #818cf8 100%);
86    color: white;
87    padding: 12px 20px;
88    border-radius: 8px;
89    font-size: 14px;
90    font-weight: 500;
91    box-shadow: 0 4px 20px rgba(0, 212, 255, 0.3);
92    z-index: 1000;
93    animation: toast-fade 2.5s ease-out forwards;
94}
95
96@keyframes toast-fade {
97    0% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
98    10% { opacity: 1; transform: translateX(-50%) translateY(0); }
99    80% { opacity: 1; }
100    100% { opacity: 0; }
101}
102
103.file-tree {
104    flex: 1;
105    overflow: auto;
106    padding: 8px 0;
107}
108
109.file-tree-node {
110    -webkit-user-select: none;
111    user-select: none;
112}
113
114.file-tree-item {
115    display: flex;
116    align-items: center;
117    gap: 6px;
118    padding: 6px 14px;
119    cursor: pointer;
120    color: rgba(255, 255, 255, 0.8);
121    font-size: 13px;
122    transition: background 0.1s ease;
123}
124
125.file-tree-item:hover {
126    background: rgba(255, 255, 255, 0.04);
127}
128
129.file-tree-item.selected {
130    background: rgba(0, 212, 255, 0.12);
131    color: #00d4ff;
132}
133
134.file-tree-item .icon {
135    font-size: 14px;
136    opacity: 0.7;
137    flex-shrink: 0;
138}
139
140.file-tree-item .name {
141    flex: 1;
142    overflow: hidden;
143    text-overflow: ellipsis;
144    white-space: nowrap;
145}
146
147.file-tree-item .chevron {
148    font-size: 10px;
149    opacity: 0.5;
150    transition: transform 0.15s ease;
151}
152
153.file-tree-item .chevron.expanded {
154    transform: rotate(90deg);
155}
156
157.file-tree-children {
158    padding-left: 16px;
159}
160
161.file-tree-empty {
162    padding: 20px;
163    text-align: center;
164    color: rgba(255, 255, 255, 0.4);
165    font-size: 13px;
166}
167
168/* Mobile: fill the wrapper, which handles positioning */
169@media (max-width: 768px) {
170    .file-browser {
171        width: 100%;
172        min-width: 100%;
173        max-width: 100%;
174    }
175
176    .file-tree-item {
177        color: rgba(255, 255, 255, 0.9);
178        -webkit-text-fill-color: rgba(255, 255, 255, 0.9);
179    }
180
181    .file-tree-item.selected {
182        color: #00d4ff;
183        -webkit-text-fill-color: #00d4ff;
184    }
185
186    .file-tree-item .name {
187        color: inherit;
188        -webkit-text-fill-color: inherit;
189    }
190}
191"#;
192
193#[component]
194pub fn FileBrowser(
195    tree: FileNode,
196    selected_path: Option<String>,
197    on_select: EventHandler<String>,
198    on_toggle_dir: EventHandler<String>,
199    on_new_file: EventHandler<()>,
200    #[props(default = false)] show_private_mode: bool,
201) -> Element {
202    let mut show_toast = use_signal(|| false);
203    let mut toast_key = use_signal(|| 0u32);
204
205    rsx! {
206        style { "{FILE_BROWSER_STYLE}" }
207
208        // Coming Soon toast notification
209        if *show_toast.read() {
210            div {
211                key: "{toast_key}",
212                class: "coming-soon-toast",
213                "Coming Soon!"
214            }
215        }
216
217        aside { class: "file-browser",
218            // Header
219            div { class: "file-browser-header",
220                span { class: "file-browser-title", "Files" }
221                div { class: "file-browser-actions",
222                    button {
223                        class: "file-browser-btn coming-soon",
224                        onclick: move |_| {
225                            // Increment key to restart animation if clicked again
226                            toast_key.set(toast_key() + 1);
227                            show_toast.set(true);
228                        },
229                        title: "New file (Coming Soon)",
230                        "+"
231                    }
232                }
233            }
234
235            // Private mode notice (shown when using IndexedDB fallback)
236            if show_private_mode {
237                div {
238                    style: "background: rgba(37, 99, 235, 0.12); color: rgba(147, 197, 253, 0.85); padding: 5px 14px; font-size: 10px; border-bottom: 1px solid rgba(37, 99, 235, 0.2); flex-shrink: 0;",
239                    "⚡ Private mode – temporary storage"
240                }
241            }
242
243            // File tree
244            div { class: "file-tree",
245                if tree.children.is_empty() {
246                    div { class: "file-tree-empty",
247                        "No files yet"
248                    }
249                } else {
250                    for child in tree.children.iter() {
251                        FileTreeNode {
252                            key: "{child.path}",
253                            node: child.clone(),
254                            selected_path: selected_path.clone(),
255                            depth: 0,
256                            on_select: on_select.clone(),
257                            on_toggle_dir: on_toggle_dir.clone(),
258                        }
259                    }
260                }
261            }
262        }
263    }
264}
265
266#[component]
267fn FileTreeNode(
268    node: FileNode,
269    selected_path: Option<String>,
270    depth: usize,
271    on_select: EventHandler<String>,
272    on_toggle_dir: EventHandler<String>,
273) -> Element {
274    let is_selected = selected_path.as_ref() == Some(&node.path);
275    let item_class = if is_selected {
276        "file-tree-item selected"
277    } else {
278        "file-tree-item"
279    };
280
281    let icon_variant = if node.is_directory {
282        if node.expanded { IconVariant::FolderOpen } else { IconVariant::Folder }
283    } else {
284        IconVariant::File
285    };
286
287    // File-specific display text for logic/math files
288    let file_symbol: Option<&'static str> = if !node.is_directory {
289        match node.name.rsplit('.').next() {
290            Some("logic") => Some("∀"),
291            Some("logos") => Some("λ"),
292            Some("math") => Some("π"),
293            _ => None,
294        }
295    } else {
296        None
297    };
298
299    let chevron_class = if node.expanded {
300        "chevron expanded"
301    } else {
302        "chevron"
303    };
304
305    let path = node.path.clone();
306    let path_for_toggle = node.path.clone();
307
308    rsx! {
309        div { class: "file-tree-node",
310            div {
311                class: "{item_class}",
312                style: "padding-left: {14 + depth * 16}px;",
313                onclick: move |_| {
314                    if node.is_directory {
315                        on_toggle_dir.call(path_for_toggle.clone());
316                    } else {
317                        on_select.call(path.clone());
318                    }
319                },
320
321                // Chevron for directories
322                if node.is_directory {
323                    span { class: "{chevron_class}", "\u{276F}" }
324                }
325
326                span { class: "icon",
327                    if let Some(sym) = file_symbol {
328                        span { "{sym}" }
329                    } else {
330                        Icon { variant: icon_variant, size: IconSize::Small }
331                    }
332                }
333                span { class: "name", "{node.name}" }
334            }
335
336            // Children (if directory is expanded)
337            if node.is_directory && node.expanded {
338                div { class: "file-tree-children",
339                    for child in node.children.iter() {
340                        FileTreeNode {
341                            key: "{child.path}",
342                            node: child.clone(),
343                            selected_path: selected_path.clone(),
344                            depth: depth + 1,
345                            on_select: on_select.clone(),
346                            on_toggle_dir: on_toggle_dir.clone(),
347                        }
348                    }
349                }
350            }
351        }
352    }
353}