Skip to content

Commit 6af81f1

Browse files
committed
Recent files panel, favorites context menu, drag from favorites/recent
1 parent e03628a commit 6af81f1

3 files changed

Lines changed: 280 additions & 1 deletion

File tree

crates/renzora_asset_browser/src/lib.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ impl EditorPanel for AssetBrowserPanel {
6565
state.project_root = Some(project.path.clone());
6666
state.current_folder = Some(project.path.clone());
6767
state.load_favorites();
68+
state.load_recent();
6869
}
6970
}
7071

@@ -571,6 +572,9 @@ impl EditorPanel for AssetBrowserPanel {
571572
// in the same frame, since tree-only mode is the only context where
572573
// the tree is the sole source of drags.
573574
if let Some(payload) = tree_drag_payload.or(grid_result.drag_payload) {
575+
if !payload.path.is_dir() {
576+
state.track_recent(payload.path.as_path());
577+
}
574578
if let Some(cmds) = world.get_resource::<EditorCommands>() {
575579
cmds.push(move |world: &mut bevy::prelude::World| {
576580
world.insert_resource(payload);
@@ -597,8 +601,27 @@ impl EditorPanel for AssetBrowserPanel {
597601
}
598602
}
599603

604+
// Double-click on a recent file in the tree
605+
if let Some(path) = state.double_clicked_recent.take() {
606+
state.track_recent(&path);
607+
let is_editable = path.extension()
608+
.and_then(|e| e.to_str())
609+
.map(|e| matches!(e.to_lowercase().as_str(),
610+
"lua" | "rhai" | "rs" | "py" | "js" | "ts" | "wgsl" | "glsl" | "json" | "toml" | "yaml" | "yml" | "txt" | "md"
611+
))
612+
.unwrap_or(false);
613+
if is_editable {
614+
if let Some(cmds) = world.get_resource::<EditorCommands>() {
615+
cmds.push(move |world: &mut bevy::prelude::World| {
616+
world.insert_resource(renzora::core::OpenCodeEditorFile { path });
617+
});
618+
}
619+
}
620+
}
621+
600622
// Double-click on a file opens it in the code editor
601623
if let Some(path) = grid_result.double_clicked_file {
624+
state.track_recent(&path);
602625
let is_editable = path.extension()
603626
.and_then(|e| e.to_str())
604627
.map(|e| matches!(e.to_lowercase().as_str(),

crates/renzora_asset_browser/src/state.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,14 @@ pub struct AssetBrowserState {
139139
/// Pinned/starred folders that appear at the top of the tree.
140140
pub favorites: Vec<PathBuf>,
141141

142+
// === Recent files ===
143+
/// Recently opened files, most recent first.
144+
pub recent_files: Vec<PathBuf>,
145+
/// Set by tree UI when a recent file is double-clicked.
146+
pub double_clicked_recent: Option<PathBuf>,
147+
/// Whether the recent files section is expanded in the tree.
148+
pub recent_expanded: bool,
149+
142150
// === Sort ===
143151
/// Current sort mode for the file grid/list.
144152
pub sort_mode: SortMode,
@@ -185,6 +193,9 @@ impl Default for AssetBrowserState {
185193
move_drop_target: None,
186194
pending_move: None,
187195
favorites: Vec::new(),
196+
recent_files: Vec::new(),
197+
double_clicked_recent: None,
198+
recent_expanded: false,
188199
sort_mode: SortMode::default(),
189200
sort_direction: SortDirection::default(),
190201
}
@@ -386,6 +397,41 @@ impl AssetBrowserState {
386397
.filter(|p| p.exists())
387398
.collect();
388399
}
400+
401+
/// Track a file as recently opened. Moves it to the front if already present.
402+
pub fn track_recent(&mut self, path: &std::path::Path) {
403+
self.recent_files.retain(|p| p != path);
404+
self.recent_files.insert(0, path.to_path_buf());
405+
const MAX_RECENT: usize = 20;
406+
self.recent_files.truncate(MAX_RECENT);
407+
self.save_recent();
408+
}
409+
410+
pub fn remove_from_recent(&mut self, path: &std::path::Path) {
411+
self.recent_files.retain(|p| p != path);
412+
self.save_recent();
413+
}
414+
415+
fn save_recent(&self) {
416+
let Some(ref root) = self.project_root else { return };
417+
let editor_dir = root.join(".editor");
418+
let _ = std::fs::create_dir_all(&editor_dir);
419+
let content: String = self.recent_files.iter().filter_map(|p| {
420+
p.strip_prefix(root).ok().map(|r| r.to_string_lossy().replace('\\', "/"))
421+
}).collect::<Vec<_>>().join("\n");
422+
let _ = std::fs::write(editor_dir.join("recent"), content);
423+
}
424+
425+
pub fn load_recent(&mut self) {
426+
let Some(ref root) = self.project_root else { return };
427+
let recent_path = root.join(".editor").join("recent");
428+
let Ok(content) = std::fs::read_to_string(&recent_path) else { return };
429+
self.recent_files = content.lines()
430+
.filter(|l| !l.trim().is_empty())
431+
.map(|l| root.join(l.trim()))
432+
.filter(|p| p.exists())
433+
.collect();
434+
}
389435
}
390436

391437
/// Format a file size in bytes as a human-readable string.

crates/renzora_asset_browser/src/tree.rs

Lines changed: 211 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ pub fn tree_ui(
9898

9999
// Render each favorite (only if there are any)
100100
let favorites_snapshot: Vec<std::path::PathBuf> = state.favorites.clone();
101+
let mut fav_to_remove: Option<PathBuf> = None;
101102
for fav_path in &favorites_snapshot {
102103
let fav_name = fav_path
103104
.file_name()
@@ -113,7 +114,7 @@ pub fn tree_ui(
113114

114115
let (rect, response) = ui.allocate_exact_size(
115116
Vec2::new(ui.available_width(), ROW_HEIGHT),
116-
Sense::click(),
117+
Sense::click_and_drag(),
117118
);
118119

119120
if response.hovered() {
@@ -153,6 +154,35 @@ pub fn tree_ui(
153154
if response.clicked() {
154155
state.current_folder = Some(fav_path.clone());
155156
}
157+
158+
// Right-click context menu
159+
let fav_path_clone = fav_path.clone();
160+
response.context_menu(|ui| {
161+
if ui.button(format!("{} Remove from Favorites", regular::STAR)).clicked() {
162+
fav_to_remove = Some(fav_path_clone.clone());
163+
ui.close();
164+
}
165+
});
166+
167+
// Drag from favorites
168+
if response.drag_started() {
169+
state.drag_moving = vec![fav_path.clone()];
170+
let origin = ui.ctx().pointer_latest_pos().unwrap_or_default();
171+
state.pending_drag_payload = Some(renzora_editor_framework::AssetDragPayload {
172+
path: fav_path.clone(),
173+
paths: vec![fav_path.clone()],
174+
name: fav_name.clone(),
175+
icon: FOLDER.to_string(),
176+
color: fav_icon_color,
177+
origin,
178+
is_detached: false,
179+
drag_count: 1,
180+
});
181+
}
182+
}
183+
184+
if let Some(path) = fav_to_remove {
185+
state.toggle_favorite(&path);
156186
}
157187

158188
// Separator after favorites (only if there are items)
@@ -168,6 +198,186 @@ pub fn tree_ui(
168198
}
169199
}
170200

201+
// Recent files section (collapsible)
202+
{
203+
let recent_count = state.recent_files.len();
204+
if recent_count > 0 {
205+
let text_muted = theme.text.muted.to_color32();
206+
let text_secondary = theme.text.secondary.to_color32();
207+
let selection_bg = theme.semantic.selection.to_color32();
208+
let item_hover = theme.panels.item_hover.to_color32();
209+
210+
// Collapsible header with caret + badge
211+
let (header_rect, header_resp) = ui.allocate_exact_size(
212+
Vec2::new(ui.available_width(), 18.0),
213+
Sense::click(),
214+
);
215+
if header_resp.hovered() {
216+
ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
217+
}
218+
219+
let caret = if state.recent_expanded { CARET_DOWN } else { CARET_RIGHT };
220+
ui.painter().text(
221+
Pos2::new(header_rect.min.x + 4.0, header_rect.center().y),
222+
Align2::LEFT_CENTER,
223+
caret,
224+
FontId::proportional(9.0),
225+
text_muted,
226+
);
227+
ui.painter().text(
228+
Pos2::new(header_rect.min.x + 16.0, header_rect.center().y),
229+
Align2::LEFT_CENTER,
230+
"Recent",
231+
FontId::proportional(10.0),
232+
text_muted,
233+
);
234+
235+
// Badge with count
236+
let badge_text = format!("{}", recent_count);
237+
let badge_font = FontId::proportional(9.0);
238+
let badge_galley = ui.painter().layout_no_wrap(badge_text.clone(), badge_font.clone(), text_muted);
239+
let badge_w = badge_galley.size().x + 8.0;
240+
let badge_h = 14.0;
241+
let badge_rect = egui::Rect::from_center_size(
242+
Pos2::new(header_rect.min.x + 52.0 + badge_w * 0.5, header_rect.center().y),
243+
Vec2::new(badge_w, badge_h),
244+
);
245+
ui.painter().rect_filled(badge_rect, 3.0, theme.widgets.border.to_color32());
246+
ui.painter().text(
247+
badge_rect.center(),
248+
Align2::CENTER_CENTER,
249+
badge_text,
250+
badge_font,
251+
text_secondary,
252+
);
253+
254+
if header_resp.clicked() {
255+
state.recent_expanded = !state.recent_expanded;
256+
}
257+
258+
// Items (only when expanded)
259+
if state.recent_expanded {
260+
let recent_snapshot: Vec<PathBuf> = state.recent_files.clone();
261+
let mut to_remove: Option<PathBuf> = None;
262+
263+
for recent_path in &recent_snapshot {
264+
let name = recent_path
265+
.file_name()
266+
.and_then(|n| n.to_str())
267+
.unwrap_or("???")
268+
.to_string();
269+
270+
let is_selected = state.selected_assets.contains(recent_path);
271+
let (icon, icon_color) = file_icon(recent_path);
272+
273+
let (rect, response) = ui.allocate_exact_size(
274+
Vec2::new(ui.available_width(), ROW_HEIGHT),
275+
Sense::click_and_drag(),
276+
);
277+
278+
let hovered = response.hovered();
279+
if hovered {
280+
ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
281+
}
282+
283+
if is_selected {
284+
ui.painter().rect_filled(rect, 2.0, selection_bg);
285+
} else if hovered {
286+
ui.painter().rect_filled(rect, 2.0, item_hover);
287+
}
288+
289+
// File icon
290+
ui.painter().text(
291+
Pos2::new(rect.min.x + 14.0, rect.center().y),
292+
Align2::LEFT_CENTER,
293+
icon,
294+
FontId::proportional(12.0),
295+
icon_color,
296+
);
297+
298+
// Delete button (right side, only on hover)
299+
let delete_w = 16.0;
300+
let has_delete = hovered;
301+
let text_right = if has_delete { rect.max.x - delete_w - 4.0 } else { rect.max.x - 4.0 };
302+
303+
if has_delete {
304+
let del_rect = egui::Rect::from_min_size(
305+
Pos2::new(rect.max.x - delete_w - 2.0, rect.min.y),
306+
Vec2::new(delete_w, rect.height()),
307+
);
308+
let del_resp = ui.allocate_rect(del_rect, Sense::click());
309+
ui.painter().text(
310+
del_rect.center(),
311+
Align2::CENTER_CENTER,
312+
regular::X,
313+
FontId::proportional(9.0),
314+
if del_resp.hovered() { text_secondary } else { text_muted },
315+
);
316+
if del_resp.clicked() {
317+
to_remove = Some(recent_path.clone());
318+
}
319+
}
320+
321+
// File name
322+
let text_x = rect.min.x + 30.0;
323+
let max_w = (text_right - text_x).max(0.0);
324+
let text_y = rect.center().y - 11.0 * 0.5;
325+
paint_truncated_text(ui.painter(), Pos2::new(text_x, text_y), &name, FontId::proportional(11.0), text_secondary, max_w);
326+
327+
// Hover tooltip with folder path
328+
let response = if let Some(parent) = recent_path.parent() {
329+
if let Some(ref root) = state.project_root {
330+
if let Ok(rel) = parent.strip_prefix(root) {
331+
response.on_hover_text(rel.to_string_lossy().to_string())
332+
} else { response }
333+
} else { response }
334+
} else { response };
335+
336+
if response.clicked() {
337+
if let Some(parent) = recent_path.parent() {
338+
state.current_folder = Some(parent.to_path_buf());
339+
}
340+
state.selected_assets.clear();
341+
state.selected_assets.insert(recent_path.clone());
342+
state.selected_path = Some(recent_path.clone());
343+
}
344+
if response.double_clicked() {
345+
state.double_clicked_recent = Some(recent_path.clone());
346+
}
347+
348+
// Drag to viewport
349+
if response.drag_started() {
350+
state.drag_moving = vec![recent_path.clone()];
351+
let origin = ui.ctx().pointer_latest_pos().unwrap_or_default();
352+
state.pending_drag_payload = Some(renzora_editor_framework::AssetDragPayload {
353+
path: recent_path.clone(),
354+
paths: vec![recent_path.clone()],
355+
name: name.clone(),
356+
icon: icon.to_string(),
357+
color: icon_color,
358+
origin,
359+
is_detached: false,
360+
drag_count: 1,
361+
});
362+
}
363+
}
364+
365+
if let Some(path) = to_remove {
366+
state.remove_from_recent(&path);
367+
}
368+
}
369+
370+
ui.add_space(2.0);
371+
let sep_rect = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)).1;
372+
ui.painter().hline(
373+
(sep_rect.min.x + 6.0)..=(sep_rect.max.x - 6.0),
374+
sep_rect.center().y,
375+
egui::Stroke::new(1.0, theme.widgets.border.to_color32()),
376+
);
377+
ui.add_space(2.0);
378+
}
379+
}
380+
171381
// Root node
172382
let is_expanded = state.expanded_folders.contains(&root);
173383
let is_current = state.current_folder.as_ref() == Some(&root);

0 commit comments

Comments
 (0)