1use qframe::date::DateTime;
12use qframe::prelude::*;
13
14use crate::profile::history::Conversation;
15use crate::ui::page;
16
17use super::history::{self, HistoryKey, NEWEST, Shown};
18use super::{Msg, OpenWorkspace, TabKey, TabKind, WorkspaceScreen};
19
20pub(super) const CHOICES_ID: &str = "workspace-choices";
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Choice {
26 Shell,
28 NewChat(String),
30 Resume(String, String),
32 Window(String),
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38enum Row {
39 Shell,
41 Gap,
43 Profile(usize),
45 NewChat(String),
47 Window(String),
49 Chat(String, Conversation),
51 ShowAll(HistoryKey, usize),
53 NoChats,
55 Reading,
57 Unread(String),
59 MakeProfile,
61}
62
63impl Row {
64 fn selectable(&self) -> bool {
66 !matches!(self, Self::Gap | Self::Profile(_))
67 }
68
69 fn message(&self, tab: TabKey) -> Option<Msg> {
72 match self {
73 Self::Shell => Some(Msg::Choose(tab, Choice::Shell)),
74 Self::NewChat(name) => Some(Msg::Choose(tab, Choice::NewChat(name.clone()))),
75 Self::Window(name) => Some(Msg::Choose(tab, Choice::Window(name.clone()))),
76 Self::Chat(name, conversation) => {
77 Some(Msg::Choose(tab, Choice::Resume(name.clone(), conversation.id.clone())))
78 }
79 Self::ShowAll(key, _) => Some(Msg::ShowAll(key.clone())),
80 Self::MakeProfile => Some(Msg::ManageProfiles),
81 Self::Gap | Self::Profile(_) | Self::NoChats | Self::Reading | Self::Unread(_) => None,
82 }
83 }
84
85 fn same(&self, other: &Self) -> bool {
88 match (self, other) {
89 (Self::Chat(profile, conversation), Self::Chat(other_profile, other_conversation)) => {
90 profile == other_profile && conversation.id == other_conversation.id
91 }
92 (Self::ShowAll(key, _), Self::ShowAll(other_key, _)) => key == other_key,
93 _ => self == other,
94 }
95 }
96}
97
98fn rows(screen: &WorkspaceScreen, workspace: &OpenWorkspace) -> Vec<Row> {
103 let mut rows = vec![Row::Shell];
104 if workspace.profiles().is_empty() {
105 rows.extend([Row::Gap, Row::MakeProfile]);
106 }
107 for (index, profile) in workspace.profiles().iter().enumerate() {
108 let name = profile.name.as_str();
109 if profile.harness.desktop().is_some() {
112 rows.extend([Row::Gap, Row::Profile(index), Row::Window(name.to_owned())]);
113 continue;
114 }
115 rows.extend([Row::Gap, Row::Profile(index), Row::NewChat(name.to_owned())]);
116 if screen.engine().is_none() {
117 continue;
118 }
119 let Some(shelf) = workspace.history.get(name) else { continue };
120 match shelf.shown() {
121 Shown::Nothing => {}
122 Shown::Reading => rows.push(Row::Reading),
123 Shown::Failed(reason) => rows.push(Row::Unread(reason.to_owned())),
124 Shown::Conversations([]) => rows.push(Row::NoChats),
125 Shown::Conversations(found) => {
126 let found: Vec<&Conversation> =
131 found.iter().filter(|conversation| !shown_in_a_tab(workspace, name, &conversation.id)).collect();
132 if found.is_empty() {
134 continue;
135 }
136 let key = HistoryKey { workspace: workspace.id().to_owned(), profile: name.to_owned() };
137 let count = if screen.expanded.contains(&key) { found.len() } else { found.len().min(NEWEST) };
138 rows.extend(
139 found[..count].iter().map(|conversation| Row::Chat(name.to_owned(), (*conversation).clone())),
140 );
141 if count < found.len() {
142 rows.push(Row::ShowAll(key, found.len() - count));
143 }
144 }
145 }
146 }
147 rows
148}
149
150fn shown_in_a_tab(workspace: &OpenWorkspace, profile: &str, id: &str) -> bool {
152 workspace
153 .tabs()
154 .iter()
155 .any(|tab| matches!(tab.kind(), TabKind::Profile(name) if name == profile) && tab.conversation() == Some(id))
156}
157
158fn resting_row(rows: &[Row], wanted: usize) -> usize {
161 if rows.get(wanted).is_some_and(Row::selectable) { wanted } else { 0 }
162}
163
164pub(super) fn keep_row(screen: &mut WorkspaceScreen, change: impl FnOnce(&mut WorkspaceScreen)) {
168 let page = |screen: &WorkspaceScreen| {
169 screen
170 .workspace()
171 .filter(|workspace| workspace.active_tab().is_some_and(|tab| tab.kind() == &TabKind::New))
172 .map(|workspace| rows(screen, workspace))
173 };
174 let before = page(screen).and_then(|rows| rows.get(screen.blank_row).cloned());
175 change(screen);
176 let (Some(before), Some(after)) = (before, page(screen)) else { return };
177 if let Some(index) = after.iter().position(|row| row.same(&before)) {
178 screen.blank_row = index;
179 }
180}
181
182fn indent(ui: &View<'_, Msg>) -> String {
185 let glyph = ui.env().icons().glyph("add");
186 " ".repeat(usize::from(qframe::text::width(&glyph)) + 1)
187}
188
189fn item(workspace: &OpenWorkspace, row: &Row, look: &Look, ready: bool) -> ListItem {
193 let dot = look.dot.as_str();
194 match row {
195 Row::Shell => ListItem::new(t!("workspace.tab.shell"))
196 .icon("prompt", None)
197 .detail(t!("workspace.choose.shell-detail"))
198 .faint(!ready),
199 Row::Gap => ListItem::gap(),
200 Row::Profile(index) => {
201 let Some(profile) = workspace.profiles().get(*index) else { return ListItem::gap() };
202 let name = profile.name.as_str();
203 let harness = profile.harness.record().display_name;
204 ListItem::header(if workspace.carries(name) {
207 t!("workspace.choose.section", profile = name, harness = harness, dot = dot)
208 } else {
209 t!("workspace.choose.section-adds", profile = name, harness = harness, dot = dot)
210 })
211 }
212 Row::NewChat(_) => ListItem::new(t!("workspace.choose.new-chat")).icon("add", None).faint(!ready),
213 Row::Window(_) => ListItem::new(t!("workspace.choose.window"))
214 .icon("window-maximize", None)
215 .detail(t!("workspace.choose.window-detail"))
216 .faint(!ready),
217 Row::Chat(_, conversation) => {
218 let title = conversation.title.clone().unwrap_or_else(|| t!("workspace.history.untitled"));
219 ListItem::new(format!("{}{title}", look.indent))
220 .detail(history::when(conversation.used_ms, look.now))
221 .faint(!ready)
222 }
223 Row::ShowAll(_, more) => {
224 ListItem::new(format!("{}{}", look.indent, t!("workspace.history.show-all", more = *more, dot = dot)))
225 .faint(!ready)
226 }
227 Row::NoChats => ListItem::new(format!("{}{}", look.indent, t!("workspace.history.none"))).faint(true),
228 Row::Reading => ListItem::new(format!("{}{}", look.indent, t!("workspace.history.reading"))).faint(true),
229 Row::Unread(reason) => ListItem::new(format!("{}{}", look.indent, t!("workspace.history.unread")))
230 .detail(reason.clone())
231 .faint(true),
232 Row::MakeProfile => ListItem::new(t!("workspace.make-profile"))
233 .icon("add", None)
234 .detail(t!("workspace.no-profiles"))
235 .faint(!ready),
236 }
237}
238
239struct Look {
241 dot: String,
243 indent: String,
245 now: DateTime,
247}
248
249pub(super) fn view(screen: &WorkspaceScreen, workspace: &OpenWorkspace, tab: TabKey, ui: &mut View<'_, Msg>) {
253 let ready = screen.engine().is_some();
254 let rows = rows(screen, workspace);
255 let look =
256 Look { dot: ui.env().icons().glyph("bullet").into_owned(), indent: indent(ui), now: DateTime::now_local() };
257 let items: Vec<ListItem> = rows.iter().map(|row| item(workspace, row, &look, ready)).collect();
258 let selected = resting_row(&rows, screen.blank_row);
259 page::column(ui, page::WIDTH, |ui| {
260 ui.column(|ui| {
261 ui.add(Text::new(t!("workspace.choose.title")).role("title"));
262 if !ready {
263 ui.add(Text::new(t!("workspace.no-engine")).role("secondary"));
264 }
265 let list = List::new(items)
267 .label_first(true)
268 .selected(Some(selected))
269 .on_select(Msg::HighlightChoice)
270 .on_activate(move |index| {
274 rows.get(index).and_then(|row| row.message(tab)).unwrap_or(Msg::HighlightChoice(index))
275 });
276 ui.add(list.wrap(true)).id(CHOICES_ID).fill();
277 })
278 .gap(1)
279 .padding(Padding::symmetric(1, 2))
280 .fill()
281 .id("workspace-blank");
282 });
283}