nimue-term 0.1.6

Terminal emulator with multiplexer capabilities designed for maximum productivity, git worktrees and agentic engineering.
use std::path::PathBuf;

use freya::{
    prelude::*,
    radio::{
        use_radio,
        use_radio_station,
    },
    router::*,
};
use rfd::AsyncFileDialog;

use crate::state::{
    AppChannel,
    AppState,
    spawn_thinking_watcher,
};

#[derive(PartialEq)]
pub struct InstanceSidebar;

impl Component for InstanceSidebar {
    fn render(&self) -> impl IntoElement {
        let mut radio = use_radio::<AppState, AppChannel>(AppChannel::App);

        let on_open_project = move |_: Event<PressEventData>| {
            spawn(async move {
                let folder = AsyncFileDialog::new().pick_folder().await;
                if let Some(folder) = folder {
                    let path = folder.path().to_path_buf();
                    let name = path
                        .file_name()
                        .map(|n| n.to_string_lossy().to_string())
                        .unwrap_or_else(|| "Project".to_string());
                    radio.write().add_project(name, path);
                }
            });
        };

        rect()
            .height(Size::fill())
            .content(Content::Flex)
            .padding(6.)
            .child(
                ScrollView::new()
                    .height(Size::flex(1.))
                    .spacing(4.)
                    .children(radio.read().projects.iter().map(|project| {
                        ProjectGroup {
                            project_id: project.id,
                            project_name: project.name.clone(),
                            project_path: project.path.clone(),
                        }
                        .into()
                    })),
            )
            .child(
                Button::new()
                    .width(Size::fill())
                    .on_press(on_open_project)
                    .rounded_xl()
                    .child("Open Project"),
            )
    }
}

#[derive(PartialEq)]
struct ProjectGroup {
    project_id: usize,
    project_name: String,
    project_path: PathBuf,
}

impl Component for ProjectGroup {
    fn render(&self) -> impl IntoElement {
        let mut radio = use_radio::<AppState, AppChannel>(AppChannel::App);
        let project_id = self.project_id;
        let project_name = self.project_name.clone();
        let project_path = self.project_path.clone();

        let instances: Vec<_> = radio
            .read()
            .instances
            .iter()
            .filter(|i| i.project_id == project_id)
            .map(|i| (i.id, i.name.clone()))
            .collect();

        let on_secondary_press = {
            let project_path = project_path.clone();
            move |_: Event<PressEventData>| {
                let subdirs =
                    crate::state::Project::new(project_id, String::new(), project_path.clone())
                        .list_subdirectories();

                let router = RouterContext::get();

                let mut menu = ScrollView::new()
                    .width(Size::px(300.))
                    .height(Size::auto())
                    .max_height(Size::px(400.));

                for dir in subdirs {
                    let dir_name = dir
                        .file_name()
                        .map(|n| n.to_string_lossy().to_string())
                        .unwrap_or_default();
                    let dir_path = dir.clone();
                    let name_for_label = dir_name.clone();
                    menu = menu.child(
                        MenuButton::new()
                            .on_press(move |_| {
                                ContextMenu::close();
                                let instance_id = radio.write().add_instance(
                                    project_id,
                                    dir_name.clone(),
                                    dir_path.clone(),
                                );
                                router.push(crate::Route::Instance { id: instance_id });
                            })
                            .child(name_for_label),
                    );
                }

                menu = menu.child(
                    rect()
                        .height(Size::px(1.))
                        .width(Size::fill())
                        .background((35, 35, 35)),
                );
                menu = menu.child(
                    MenuButton::new()
                        .on_press(move |_| {
                            ContextMenu::close();
                            radio.write().remove_project(project_id);
                            router.replace(crate::Route::Home);
                        })
                        .child("Remove Project"),
                );

                ContextMenu::open(Menu::new().child(menu));
            }
        };

        rect()
            .width(Size::fill())
            .spacing(4.)
            .child(
                rect()
                    .width(Size::fill())
                    .padding((6., 8.))
                    .on_secondary_press(on_secondary_press)
                    .child(
                        label()
                            .font_size(12.)
                            .color((170, 170, 170))
                            .text(project_name.to_uppercase()),
                    ),
            )
            .children(instances.iter().map(|(id, name)| {
                InstanceSidebarItem {
                    id: *id,
                    name: name.clone(),
                }
                .into()
            }))
    }
}

#[derive(PartialEq)]
struct InstanceSidebarItem {
    id: usize,
    name: String,
}

impl Component for InstanceSidebarItem {
    fn render(&self) -> impl IntoElement {
        let mut radio = use_radio::<AppState, AppChannel>(AppChannel::Instance(self.id));
        let station = use_radio_station::<AppState, AppChannel>();
        let instance_id = self.id;
        let name = self.name.clone();
        let mut renaming = use_state(|| false);
        let mut rename_value = use_state(String::new);
        let focus = use_focus();
        let focus_status = use_focus_status(focus);

        use_hook(|| {
            let handle = radio
                .read()
                .instances
                .iter()
                .find(|i| i.id == instance_id)
                .and_then(|inst| inst.terminals.first())
                .and_then(|term| term.handle.clone());
            if let Some(handle) = handle {
                spawn_thinking_watcher(station, instance_id, 0, handle);
            }
        });

        let is_thinking = {
            let state = radio.read();
            state
                .instances
                .iter()
                .find(|i| i.id == instance_id)
                .and_then(|inst| inst.terminals.first())
                .map(|term| term.is_thinking)
                .unwrap_or(false)
        };

        use_side_effect(move || {
            if focus_status() == FocusStatus::Not {
                renaming.set(false);
            }
        });

        let on_secondary_press = {
            let name = name.clone();
            move |_: Event<PressEventData>| {
                let name = name.clone();
                let router = RouterContext::get();
                ContextMenu::open(
                    Menu::new()
                        .child(
                            MenuButton::new()
                                .on_press(move |e: Event<PressEventData>| {
                                    e.prevent_default();
                                    e.stop_propagation();
                                    ContextMenu::close();
                                    *rename_value.write() = name.clone();
                                    renaming.set(true);
                                })
                                .child("Rename"),
                        )
                        .child(
                            MenuButton::new()
                                .on_press(move |_| {
                                    ContextMenu::close();
                                    radio
                                        .write_channel(AppChannel::App)
                                        .remove_instance(instance_id);
                                    router.replace(crate::Route::Home);
                                })
                                .child("Close"),
                        ),
                );
            }
        };

        let on_rename_submit = move |submitted: String| {
            if let Some(inst) = radio
                .write_channel(AppChannel::App)
                .instances
                .iter_mut()
                .find(|i| i.id == instance_id)
            {
                inst.name = submitted;
            }
            renaming.set(false);
        };

        let route = crate::Route::Instance { id: instance_id };

        let sidebar_item = SideBarItem::new()
            .active_background((75, 75, 75))
            .background((45, 45, 45))
            .padding(if renaming() { (4., 6.) } else { (8., 12.) })
            .child(if renaming() {
                Input::new(rename_value)
                    .a11y_id(focus.a11y_id())
                    .on_submit(on_rename_submit)
                    .flat()
                    .compact()
                    .background(Color::TRANSPARENT)
                    .hover_background(Color::TRANSPARENT)
                    .auto_focus(true)
                    .width(Size::fill())
                    .into_element()
            } else {
                rect()
                    .horizontal()
                    .width(Size::fill())
                    .content(Content::Flex)
                    .child(
                        label()
                            .width(Size::flex(1.))
                            .max_lines(1)
                            .text_overflow(TextOverflow::Ellipsis)
                            .text(name.clone()),
                    )
                    .child(
                        rect()
                            .width(Size::px(16.))
                            .height(Size::px(16.))
                            .maybe(is_thinking, |el| {
                                el.child(
                                    CircularLoader::new()
                                        .size(16.)
                                        .primary_color((220, 220, 220)),
                                )
                            })
                            .into_element(),
                    )
                    .into_element()
            });

        rect()
            .maybe(!renaming(), |el| el.on_secondary_press(on_secondary_press))
            .child(ActivableRoute::new(
                crate::Route::Instance { id: instance_id },
                Link::new(route).child(
                    TooltipContainer::new(Tooltip::new(name.clone()))
                        .position(TooltipPosition::Besides)
                        .child(sidebar_item),
                ),
            ))
            .into_element()
    }
}