use crate::{
model::{
session::ChatSession,
settings::Settings,
state::State,
update,
workspace::{Reloaded, Showing, Workspace},
},
view::{
board,
component::{
composer::{Composer, ComposerEvent},
menu::Menu,
meter,
ribbon::Ribbon,
},
confirm, create, info,
leaf::{Leaf, Pane},
menubar::CloseWindow,
settings::{self, Section, SettingsWindow},
sidebar::{Renaming, Row},
table,
},
};
use anyhow::Result;
use artifact::space::Member;
use bezel::{
gpui::{
self, AnyElement, App, Axis, Bounds, Context, Div, DragMoveEvent, Empty, Entity,
FocusHandle, Focusable, Hsla, KeyBinding, PathPromptOptions, Render, SharedString,
TitlebarOptions, UniformListScrollHandle, Window, WindowBounds, WindowHandle,
WindowOptions, actions, div, point, prelude::*, px, size,
},
motion::{Fade, Painter},
theme::{Material, TextStyle, Theme, Typeset, appearance},
ui::{
floating::Floating,
icons,
input::{FieldEvent, TextField},
menu::Cursor,
stats::Stats,
widgets::{ButtonStyle, Buttons, Content, SplitDrag},
},
};
actions!(
cydonia,
[
NewSession,
NewSessionNext,
NewBoard,
NewArticle,
NewTable,
OpenProject,
CloseProject,
OpenSettings,
ToggleSidebar,
ToggleTerminal,
ToggleChanges,
OpenFiles,
OpenReview,
CommitName,
DismissName,
NextEntry,
PrevEntry,
NextPane,
PrevPane,
ClosePane,
ZoomPane,
CopySelection
]
);
#[derive(Clone, Debug, PartialEq, gpui::Action)]
#[action(namespace = cydonia, no_json)]
pub struct NewSessionWith {
pub agent: String,
}
const RENAME_CONTEXT: &str = "CydoniaSessionName";
const SIDEBAR_WIDTH: f32 = 200.;
const SIDEBAR_WIDTH_MIN: f32 = 180.;
const SIDEBAR_WIDTH_MAX: f32 = 420.;
pub(crate) const SIDEBAR_GUTTER: f32 = 8.;
const SIDEBAR_MATERIAL: Material = Material::Thick;
const CONTENT_MATERIAL: Material = Material::UltraThick;
pub(crate) const HEADER_HEIGHT: f32 = 36.;
pub(crate) fn composer_height() -> f32 {
composer_disc() + 2. * COMPOSER_INSET
}
pub(crate) const COMPOSER_INSET: f32 = 4.;
pub(crate) fn composer_disc() -> f32 {
TextStyle::Body.painted_line_height().max(24.)
}
pub(crate) const COMPOSER_BOTTOM: f32 = 20.;
pub(crate) const COMPOSER_COLUMN: f32 = 720.;
pub(crate) const COMPOSER_MARGIN: f32 = 24.;
pub(crate) fn composer_width() -> f32 {
COMPOSER_COLUMN - 2. * COMPOSER_MARGIN
}
pub(crate) fn sidebar_bg(theme: &Theme) -> Hsla {
material(theme, SIDEBAR_MATERIAL).unwrap_or(theme.surface)
}
pub(crate) fn content_bg(theme: &Theme) -> Hsla {
material(theme, CONTENT_MATERIAL).unwrap_or(theme.bg)
}
fn material(theme: &Theme, thickness: Material) -> Option<Hsla> {
theme.vibrancy.then(|| Hsla {
a: thickness.opacity(),
..theme.vibrancy_tint()
})
}
const TRAFFIC_LIGHT_SIZE: f32 = 14.;
pub const TRAFFIC_LIGHT_X: f32 = 12.;
pub const TRAFFIC_LIGHT_Y: f32 = (HEADER_HEIGHT - TRAFFIC_LIGHT_SIZE) / 2.;
const TRAFFIC_LIGHT_SPACING: f32 = 23.;
pub(crate) const HEADER_INSET: f32 = 16.;
pub(crate) fn band() -> Div {
div()
.flex_none()
.h(px(HEADER_HEIGHT))
.flex()
.flex_row()
.items_center()
.px(px(HEADER_INSET))
}
pub(crate) const TOOLBAR_INSET: f32 = if cfg!(target_os = "macos") {
TRAFFIC_LIGHT_X + 2. * TRAFFIC_LIGHT_SPACING + TRAFFIC_LIGHT_SIZE + 6. + HEADER_INSET
} else {
HEADER_INSET
};
pub fn bindings() -> Vec<KeyBinding> {
vec![
KeyBinding::new("ctrl-tab", NextEntry, Some("Cydonia")),
KeyBinding::new("ctrl-shift-tab", PrevEntry, Some("Cydonia")),
KeyBinding::new("ctrl-alt-tab", NextPane, Some("Cydonia")),
KeyBinding::new("ctrl-alt-shift-tab", PrevPane, Some("Cydonia")),
KeyBinding::new("ctrl-alt-w", ClosePane, Some("Cydonia")),
KeyBinding::new("ctrl-alt-z", ZoomPane, Some("Cydonia")),
KeyBinding::new(
"secondary-c",
CopySelection,
Some(super::keymap::platform(
"Cydonia",
"Cydonia && !CydoniaTerminal",
)),
),
KeyBinding::new("enter", CommitName, Some(RENAME_CONTEXT)),
KeyBinding::new("escape", DismissName, Some(RENAME_CONTEXT)),
]
}
pub fn open(settings: Settings, state: State, cx: &mut App) -> Result<WindowHandle<Cydonia>> {
let bounds = Bounds::centered(None, size(px(1100.), px(760.)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(TitlebarOptions {
appears_transparent: true,
traffic_light_position: Some(point(px(TRAFFIC_LIGHT_X), px(TRAFFIC_LIGHT_Y))),
..Default::default()
}),
window_background: match super::chrome::decorations() {
Some(_) => bezel::gpui::WindowBackgroundAppearance::Transparent,
None => Theme::of(cx).window_background_appearance(),
},
window_min_size: Some(size(px(600.), px(320.))),
app_id: Some("cydonia".into()),
window_decorations: super::chrome::decorations(),
..Default::default()
},
|window, cx| {
appearance::observe_window(window, cx).detach();
cx.new(|cx| {
let mut root = Cydonia::new(settings, state, window, cx);
root.restore_panel_layout();
cx.on_release(|root: &mut Cydonia, cx| root.save_panel_layout(cx))
.detach();
cx.on_app_quit(|root: &mut Cydonia, cx| {
root.save_panel_layout(cx);
async {}
})
.detach();
root
})
},
)
}
fn stepped(at: Option<usize>, len: usize, step: isize) -> Option<usize> {
if len == 0 {
return None;
}
let at = at.unwrap_or(0) as isize;
Some((at + step).rem_euclid(len as isize) as usize)
}
pub struct Cydonia {
pub(crate) workspace: Entity<Workspace>,
pub(crate) leaves: Vec<Leaf>,
pub(crate) focused: usize,
pub(crate) sidebar_open: bool,
pub(crate) sidebar_width: f32,
pub(crate) terminal: Option<(bool, Entity<super::component::terminal::TerminalPanel>)>,
pub(crate) changes_open: bool,
pub(crate) changes_for: Option<std::path::PathBuf>,
pub(crate) changes_shown: std::collections::HashMap<std::path::PathBuf, bool>,
pub(crate) changes_width: Option<f32>,
pub(crate) panel_save: Option<bezel::gpui::Task<()>>,
pub(crate) terminal_height: Option<f32>,
pub(crate) changes: Option<Entity<super::component::panel::Panel>>,
pub(crate) drag: bezel::ui::titlebar::DragState,
pub(crate) right_panels:
std::collections::HashMap<std::path::PathBuf, Entity<super::component::panel::Panel>>,
pub(crate) card_marks: board::Marks,
pub(crate) card_docs: board::Docs,
pub(crate) boards: board::Scrolls,
settings_window: Option<WindowHandle<SettingsWindow>>,
pub(crate) confirming: Option<confirm::Confirming>,
pub(crate) collapsed_spaces: std::collections::HashSet<String>,
pub(crate) pane_landing: Option<(Member, super::arrangement::Landing)>,
pub(crate) fronts: std::collections::HashMap<SharedString, Member>,
pub(crate) tab_history: Vec<Member>,
pub(crate) info: Option<info::BoardInfo>,
pub(crate) making: Option<create::Making>,
pub(crate) info_pressed: bool,
pub(crate) menu: Option<Menu>,
pub(crate) menu_point: Option<gpui::Point<gpui::Pixels>>,
pub(crate) sidebar_hovered: Option<Menu>,
pub(crate) list_hovered: Option<String>,
pub(crate) menu_cursor: Cursor,
pub(crate) menu_pressed: bool,
pub(crate) renaming: Option<Renaming>,
pub(crate) name_field: Entity<TextField>,
meter: Entity<Stats>,
meter_at: Floating,
pub(crate) rail: UniformListScrollHandle,
pub(crate) focus: FocusHandle,
}
impl Cydonia {
pub(crate) fn leaf(&self) -> &Leaf {
let at = self.focused.min(self.leaves.len().saturating_sub(1));
&self.leaves[at]
}
fn pane_parts(
on: Option<Member>,
window: &mut Window,
cx: &mut Context<Self>,
) -> (
Entity<Composer>,
Entity<TextField>,
Entity<TextField>,
Entity<TextField>,
) {
let composer = cx.new(Composer::new);
cx.subscribe_in(
&composer,
window,
move |this, _, event: &ComposerEvent, window, cx| {
if let Some(on) = on.clone()
&& !matches!(event, ComposerEvent::Draft(..))
{
this.focus_pane(&on, window, cx);
}
match event {
ComposerEvent::Submit(text, attachments) => {
this.submit(text.clone(), attachments.clone(), cx)
}
ComposerEvent::Draft(id, draft) => {
this.workspace.update(cx, |workspace, cx| {
workspace.set_draft(*id, draft.clone(), cx)
});
}
ComposerEvent::Cancel => this.cancel_turn(cx),
ComposerEvent::Reconnect => {
this.workspace.update(cx, |workspace, cx| {
if let Some(id) = workspace.active_id() {
workspace.select_session(id, cx);
}
});
}
ComposerEvent::Terminal => this.show_terminal(window, cx),
ComposerEvent::Changes => this.show_changes(window, cx),
ComposerEvent::Files => this.show_files(window, cx),
ComposerEvent::Switch(id, value) => this.switch(id, value, cx),
}
},
)
.detach();
let find = board::find_field(cx);
cx.subscribe(&find, |_, _, _: &FieldEvent, cx| cx.notify())
.detach();
(composer, board::field(cx), table::field(cx), find)
}
fn publish_shown(&self, cx: &App) {
let kind = |kind: artifact::space::Kind| match kind {
artifact::space::Kind::Session => None,
artifact::space::Kind::Board => Some("board"),
artifact::space::Kind::Article => Some("article"),
artifact::space::Kind::Table => Some("table"),
};
let shown = if self.leaves.iter().any(|leaf| leaf.entry.is_some()) {
self.leaves
.iter()
.enumerate()
.filter_map(|(ix, leaf)| {
let entry = leaf.entry.as_ref()?;
Some(mcp::rail::Shown {
project: entry.project.clone(),
kind: kind(entry.kind)?,
id: entry.id.clone(),
focused: ix == self.focused,
})
})
.collect::<Vec<_>>()
} else {
let workspace = self.workspace.read(cx);
workspace
.landed()
.and_then(|(project, entry)| {
let kind = match entry.kind {
crate::model::state::Kind::Session => None,
crate::model::state::Kind::Board => Some("board"),
crate::model::state::Kind::Article => Some("article"),
crate::model::state::Kind::Table => Some("table"),
}?;
Some(mcp::rail::Shown {
project: project.to_path_buf(),
kind,
id: entry.id.clone(),
focused: true,
})
})
.into_iter()
.collect()
};
let lone = shown.len() == 1;
let shown = shown
.into_iter()
.map(|mut entry| {
if entry.kind == "article" {
entry.id = artifact::article::id_of(&entry.project.join(&entry.id));
}
entry.focused |= lone;
entry
})
.collect();
mcp::rail::set_shown(shown);
}
pub(crate) fn sync_leaves(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let members = self.arrangement(cx).map(|space| space.entries());
let Some(members) = members else {
let front = self.focused.min(self.leaves.len().saturating_sub(1));
if front > 0 {
self.leaves.swap(0, front);
}
self.leaves.truncate(1);
self.focused = 0;
self.leaf_mut().entry = None;
return;
};
let focused = self.leaf().entry.clone();
let mut kept: Vec<Leaf> = Vec::with_capacity(members.len());
for entry in &members {
match self
.leaves
.iter()
.position(|leaf| leaf.entry.as_ref() == Some(entry))
{
Some(at) => kept.push(self.leaves.remove(at)),
None => {
let (composer, card_field, cell_field, find_field) =
Self::pane_parts(Some(entry.clone()), window, cx);
let mut leaf = Leaf::new(
cx.focus_handle(),
composer,
card_field,
cell_field,
find_field,
Ribbon::new(cx),
);
leaf.entry = Some(entry.clone());
kept.push(leaf);
}
}
}
self.leaves = kept;
self.focused = focused
.and_then(|on| {
self.leaves
.iter()
.position(|leaf| leaf.entry.as_ref() == Some(&on))
})
.unwrap_or(0);
}
pub(crate) fn enter_member(
&mut self,
member: Option<Member>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let held = member.as_ref().and_then(|member| {
let at = self.workspace.read(cx).space_holding(member)?;
Some((at, member.clone()))
});
let Some((at, member)) = held else {
if self.workspace.read(cx).active_space().is_some() {
self.workspace
.update(cx, |workspace, _| workspace.leave_space());
self.sync_leaves(window, cx);
}
return false;
};
if self.workspace.read(cx).space != Some(at) {
self.open_space(at, window, cx);
}
let stack = self.workspace.read(cx).stack_of(&member);
let pane = stack.first().cloned().unwrap_or_else(|| member.clone());
self.show_tab(&pane, &member, window, cx);
true
}
pub(crate) fn focus_pane(
&mut self,
entry: &Member,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(at) = self
.leaves
.iter()
.position(|leaf| leaf.entry.as_ref() == Some(entry))
else {
self.focused = self.focused.min(self.leaves.len().saturating_sub(1));
return;
};
let moved = self.focused != at;
self.focused = at;
let Some((project, showing)) = self.workspace.read(cx).showing_of(entry) else {
window.focus(&self.focus, cx);
cx.notify();
return;
};
if moved {
self.leaf_mut().pane = match showing {
Showing::Session(_) => Pane::Chat,
Showing::Board(_) => Pane::Board,
Showing::Article(_) => Pane::Article,
Showing::Table(_) => Pane::Table,
};
self.workspace.update(cx, |workspace, cx| {
workspace.select_showing(project, showing, cx);
});
}
self.sync_composer(cx);
let caret = match showing {
Showing::Session(id) => self
.workspace
.read(cx)
.session(id)
.is_some_and(ChatSession::resumable)
.then(|| self.composer_focus_handle(cx)),
Showing::Article(at) => self
.workspace
.read(cx)
.article_in(project, at)
.and_then(|article| article.editor.clone())
.map(|editor| editor.focus_handle(cx)),
Showing::Board(_) => self
.leaf()
.editing
.is_some()
.then(|| self.leaf().card_field.read(cx).focus_handle(cx)),
Showing::Table(_) => self
.leaf()
.cell
.is_some()
.then(|| self.leaf().cell_field.read(cx).focus_handle(cx)),
};
let here = self.leaf().focus.clone();
window.focus(caret.as_ref().unwrap_or(&here), cx);
cx.notify();
}
pub(crate) fn leaf_of(&self, on: Option<&Member>) -> &Leaf {
on.and_then(|on| {
self.leaves
.iter()
.find(|leaf| leaf.entry.as_ref() == Some(on))
})
.unwrap_or_else(|| self.leaf())
}
pub(crate) fn leaf_of_mut(&mut self, on: Option<&Member>) -> &mut Leaf {
match on.and_then(|on| {
self.leaves
.iter()
.position(|leaf| leaf.entry.as_ref() == Some(on))
}) {
Some(at) => &mut self.leaves[at],
None => self.leaf_mut(),
}
}
pub(crate) fn leaf_mut(&mut self) -> &mut Leaf {
let at = self.focused.min(self.leaves.len().saturating_sub(1));
&mut self.leaves[at]
}
pub fn new(
settings: Settings,
state: State,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let (composer, card_field, cell_field, find_field) = Self::pane_parts(None, window, cx);
let name_field = cx.new(|cx| {
TextField::new(cx)
.with_frame(false)
.with_key_context(RENAME_CONTEXT)
.with_placeholder("name this session…")
});
let workspace = cx.new(|cx| Workspace::new(settings, state, cx));
cx.observe_in(&workspace, window, |this, _, window, cx| {
let previous = this.leaf().composer.read(cx).session();
this.sync_composer(cx);
let current = this.leaf().composer.read(cx).session();
if current.is_some() && current != previous {
window.focus(&this.composer_focus_handle(cx), cx);
}
})
.detach();
if let Some(updater) = update::of(cx) {
cx.observe(&updater, |_, _, cx| cx.notify()).detach();
}
cx.subscribe_in(&workspace, window, |this, _, _: &Reloaded, window, cx| {
this.drop_stale_edit(cx);
this.rest_ribbon(cx);
this.leaf_mut().cell = None;
this.leaf()
.cell_field
.update(cx, |field, cx| field.clear(cx));
this.follow_article(window, cx);
cx.notify();
})
.detach();
let mut this = Self {
meter: cx.new(Stats::new),
meter_at: Floating::new(Painter::of(cx)),
workspace,
leaves: vec![Leaf::new(
cx.focus_handle(),
composer,
card_field,
cell_field,
find_field,
Ribbon::new(cx),
)],
focused: 0,
sidebar_open: true,
sidebar_width: SIDEBAR_WIDTH,
terminal: None,
changes_open: false,
changes_for: None,
changes_shown: Default::default(),
changes_width: None,
panel_save: None,
terminal_height: None,
changes: None,
drag: Default::default(),
right_panels: Default::default(),
boards: Default::default(),
card_marks: Default::default(),
card_docs: Default::default(),
settings_window: None,
collapsed_spaces: Default::default(),
pane_landing: None,
fronts: Default::default(),
tab_history: Vec::new(),
confirming: None,
info: None,
making: None,
info_pressed: false,
menu: None,
menu_point: None,
sidebar_hovered: None,
list_hovered: None,
menu_cursor: Cursor::default(),
menu_pressed: false,
renaming: None,
name_field,
rail: UniformListScrollHandle::new(),
focus: cx.focus_handle(),
};
cx.on_focus_lost(window, |this, window, cx| window.focus(&this.focus, cx))
.detach();
cx.observe_window_activation(window, |this, window, cx| {
if window.is_window_active() {
this.workspace
.update(cx, |workspace, cx| workspace.reload_projects(cx));
}
})
.detach();
this.land(cx);
this.sync_composer(cx);
let composer = this
.workspace
.read(cx)
.active_session()
.is_some_and(ChatSession::resumable)
.then(|| this.composer_focus_handle(cx));
window.focus(composer.as_ref().unwrap_or(&this.focus), cx);
this
}
pub(crate) fn new_session_action(
&mut self,
_: &NewSession,
window: &mut Window,
cx: &mut Context<Self>,
) {
let asked = self.workspace.read(cx).preferred_agent();
self.enter_member(None, window, cx);
self.leaf_mut().asked_session = asked.is_none();
self.show_pane(Pane::Chat, cx);
if let Some(entry) = asked {
self.workspace
.update(cx, |workspace, cx| workspace.new_session(entry, None, cx));
}
}
pub(crate) fn new_session_with_action(
&mut self,
action: &NewSessionWith,
window: &mut Window,
cx: &mut Context<Self>,
) {
let at = self
.workspace
.read(cx)
.settings
.agents
.iter()
.position(|agent| agent.name == action.agent);
if let Some(at) = at {
self.pick_agent(at, window, cx);
}
}
pub(crate) fn new_session_next_action(
&mut self,
_: &NewSessionNext,
window: &mut Window,
cx: &mut Context<Self>,
) {
let workspace = self.workspace.read(cx);
let agents = &workspace.settings.agents;
if agents.is_empty() {
return self.new_session_action(&NewSession, window, cx);
}
let at = workspace
.preferred_agent()
.and_then(|preferred| agents.iter().position(|agent| agent.name == preferred.name))
.map_or(0, |ix| (ix + 1) % agents.len());
self.pick_agent(at, window, cx);
}
fn copy_selection(&mut self, _: &CopySelection, _: &mut Window, cx: &mut Context<Self>) {
if self.showing(cx) != Some(Pane::Chat) {
return;
}
self.workspace
.update(cx, |workspace, cx| workspace.copy_selection(cx));
}
pub(crate) fn next_entry(
&mut self,
_: &NextEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.cycle_entry(1, window, cx);
}
pub(crate) fn prev_entry(
&mut self,
_: &PrevEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.cycle_entry(-1, window, cx);
}
fn cycle_entry(&mut self, step: isize, window: &mut Window, cx: &mut Context<Self>) {
if self.cycle_tab(step, window, cx) {
return;
}
if self.cycle_arranged(step, window, cx) {
return;
}
let Some(pane) = self.showing(cx) else {
return;
};
let workspace = self.workspace.read(cx);
let Some(project) = workspace.active else {
return;
};
let Some(open) = workspace.projects.get(project) else {
return;
};
let showing = match pane {
Pane::Chat => open.active.map(|id| Row::Session { project, id }),
Pane::Board => open.board.map(|ix| Row::Board { project, ix }),
Pane::Article => open.article.map(|ix| Row::Article { project, ix }),
Pane::Table => open.table.map(|ix| Row::Table { project, ix }),
};
let ring: Vec<Row> = self
.entries(project, cx)
.into_iter()
.filter(|row| !matches!(row, Row::Archive(_)))
.collect();
let at = showing.and_then(|row| ring.iter().position(|entry| *entry == row));
let Some(landing) = stepped(at, ring.len(), step).map(|ix| ring[ix]) else {
return;
};
self.open_row(landing, window, cx);
self.reveal(landing, cx);
}
fn cycle_tab(&mut self, step: isize, window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(front) = self.leaf().entry.clone() else {
return false;
};
let stack = self.workspace.read(cx).stack_of(&front);
if stack.len() < 2 {
return false;
}
let Some(at) = stack.iter().position(|tab| *tab == front) else {
return false;
};
let landing = (at as isize + step).rem_euclid(stack.len() as isize) as usize;
let (Some(pane), Some(tab)) = (stack.first().cloned(), stack.get(landing).cloned()) else {
return false;
};
self.show_tab(&pane, &tab, window, cx);
true
}
fn cycle_arranged(&mut self, step: isize, window: &mut Window, cx: &mut Context<Self>) -> bool {
let ring = match self.arrangement(cx) {
Some(space) => space.entries(),
None => return false,
};
if ring.len() < 2 {
return true;
}
let at = self
.leaf()
.entry
.clone()
.and_then(|front| ring.iter().position(|member| *member == front))
.unwrap_or(0);
let landing = (at as isize + step).rem_euclid(ring.len() as isize) as usize;
let Some(tab) = ring.get(landing).cloned() else {
return true;
};
let stack = self.workspace.read(cx).stack_of(&tab);
let pane = stack.first().cloned().unwrap_or_else(|| tab.clone());
self.show_tab(&pane, &tab, window, cx);
true
}
pub(crate) fn select_project(&mut self, ix: usize, cx: &mut Context<Self>) {
self.commit(cx);
self.workspace
.update(cx, |workspace, cx| workspace.select_project(ix, cx));
self.land(cx);
}
fn land(&mut self, cx: &mut Context<Self>) {
if let Some(kind) = self.workspace.read(cx).landing() {
self.leaf_mut().pane = Pane::of(kind);
}
}
pub(crate) fn close_project(&mut self, ix: usize, cx: &mut Context<Self>) {
self.commit(cx);
self.workspace
.update(cx, |workspace, cx| workspace.close_project(ix, cx));
}
pub(crate) fn select_session(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
let member = self.workspace.read(cx).member_of_session(id);
if self.enter_member(member, window, cx) {
return;
}
self.show_pane(Pane::Chat, cx);
self.workspace
.update(cx, |workspace, cx| workspace.select_session(id, cx));
self.sync_composer(cx);
if self
.workspace
.read(cx)
.session(id)
.is_some_and(ChatSession::resumable)
{
window.focus(&self.composer_focus_handle(cx), cx);
}
}
pub(crate) fn open_settings_action(
&mut self,
_: &OpenSettings,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.open_settings(Section::General, cx);
}
pub(crate) fn toggle_sidebar(&mut self, cx: &mut Context<Self>) {
self.sidebar_open = !self.sidebar_open;
cx.notify();
}
pub(crate) fn toggle_sidebar_action(
&mut self,
_: &ToggleSidebar,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.toggle_sidebar(cx);
}
pub(crate) fn close_project_action(
&mut self,
_: &CloseProject,
_: &mut Window,
cx: &mut Context<Self>,
) {
let Some(ix) = self.workspace.read(cx).active else {
return;
};
self.close_project(ix, cx);
}
pub(crate) fn open_settings(&mut self, section: Section, cx: &mut Context<Self>) {
let workspace = self.workspace.clone();
self.settings_window = settings::open(workspace, self.settings_window, section, cx);
}
pub(crate) fn open_project_action(
&mut self,
_: &OpenProject,
_: &mut Window,
cx: &mut Context<Self>,
) {
let picked = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: None,
});
cx.spawn(async move |this, cx| {
let Ok(Ok(Some(paths))) = picked.await else {
return;
};
let Some(path) = paths.into_iter().next() else {
return;
};
let _ = this.update(cx, |this, cx| {
this.workspace
.update(cx, |workspace, cx| workspace.open_project(path, cx));
});
})
.detach();
}
pub(crate) fn showing(&self, cx: &App) -> Option<Pane> {
if self.has_pane(self.leaf().pane, cx) {
return Some(self.leaf().pane);
}
[Pane::Chat, Pane::Board, Pane::Article, Pane::Table]
.into_iter()
.find(|&pane| self.has_pane(pane, cx))
}
pub(crate) fn has_pane(&self, pane: Pane, cx: &App) -> bool {
let workspace = self.workspace.read(cx);
match pane {
Pane::Chat => {
workspace.active_session().is_some()
|| (self.leaf().asked_session && workspace.preferred_agent().is_none())
}
Pane::Board => workspace.active_board().is_some(),
Pane::Article => workspace.active_article().is_some(),
Pane::Table => workspace.active_table().is_some(),
}
}
pub(crate) fn no_project(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
let painter = Painter::of(cx);
theme
.empty_state(
icons::files::Folder,
"No project open",
"An agent runs in a directory. Pick one to start.",
)
.flex_1()
.child(
theme
.button(
"Open folder…",
ButtonStyle::Prominent,
Some(Fade::new(painter, "open-project-empty")),
)
.id("open-project-empty")
.on_click(cx.listener(|this, _, window, cx| {
this.open_project_action(&OpenProject, window, cx);
})),
)
.into_any_element()
}
}
impl Render for Cydonia {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.sync_leaves(window, cx);
self.sync_changes(cx);
self.publish_shown(cx);
let theme = Theme::of(cx).clone();
let root = div()
.key_context("Cydonia")
.size_full()
.relative()
.flex()
.flex_row()
.font_family(theme.font_sans.clone())
.text_color(theme.text)
.text_style(TextStyle::Body)
.on_action(cx.listener(|this, _: &NextPane, window, cx| this.step_pane(1, window, cx)))
.on_action(cx.listener(|this, _: &PrevPane, window, cx| this.step_pane(-1, window, cx)))
.on_action(
cx.listener(|this, _: &ClosePane, window, cx| this.close_focused_pane(window, cx)),
)
.on_action(cx.listener(|this, _: &CloseWindow, window, cx| {
match this.leaf().entry.is_some() {
true => this.close_focused_pane(window, cx),
false => window.remove_window(),
}
}))
.on_action(cx.listener(|this, _: &ZoomPane, _, cx| this.zoom_focused_pane(cx)))
.on_action(cx.listener(Self::toggle_changes))
.on_action(cx.listener(Self::open_session_file))
.on_action(
cx.listener(|this, _: &OpenReview, window, cx| this.show_changes(window, cx)),
)
.on_action(cx.listener(|this, _: &OpenFiles, window, cx| this.toggle_files(window, cx)))
.on_action(cx.listener(Self::copy_selection))
.on_action(cx.listener(Self::commit_cell_action))
.on_action(cx.listener(Self::dismiss_cell))
.on_action(cx.listener(Self::commit_name))
.on_action(cx.listener(Self::commit_info))
.on_action(cx.listener(Self::dismiss_info))
.on_action(cx.listener(Self::make_board))
.on_action(cx.listener(Self::dismiss_new_board))
.on_action(cx.listener(Self::dismiss_name))
.map(|root| self.commands(root, cx))
.on_drag_move(
cx.listener(|this, event: &DragMoveEvent<SplitDrag>, _, cx| {
this.sidebar_width = f32::from(event.event.position.x)
.clamp(SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX);
cx.notify();
}),
)
.child(div().track_focus(&self.focus))
.when(self.sidebar_open, |root| {
root.child(self.sidebar(window, cx))
})
.child(self.detail(window, cx))
.when(self.sidebar_open, |root| {
root.child(
crate::view::component::divider::divider(&theme, Axis::Horizontal)
.id("sidebar-split")
.absolute()
.top_0()
.left(px(
self.sidebar_width - crate::view::component::divider::HIT / 2.
))
.on_drag(SplitDrag, |_, _, _, cx| cx.new(|_| Empty)),
)
})
.children(
self.workspace
.read(cx)
.meter
.then(|| meter::panel("app-meter", &self.meter_at, &self.meter, window)),
)
.children(self.confirm_delete(cx))
.children(self.new_board_dialog(cx));
bezel::ui::window::frame(root, window, cx)
}
}