1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::runtime::{AgentState, PaneId, PaneLayout, SessionId, TerminalId};
5use serde::Serialize;
6
7#[derive(Debug, Clone, Serialize)]
8pub struct WorkspaceState {
9 pub projects: Vec<Project>,
10}
11
12#[derive(Debug, Clone, Serialize)]
13pub struct Project {
14 pub name: String,
15 pub path: PathBuf,
16 pub default_branch: String,
17 pub last_agent_active_unix_ms: Option<u64>,
18 pub last_terminal_active_unix_ms: Option<u64>,
19 pub worktrees: Vec<WorktreeInfo>,
20 #[serde(skip)]
21 pub routines: Vec<asched_core::routine::ipc::RoutineView>,
22 #[serde(skip)]
23 pub routine_revision: u64,
24 #[serde(skip)]
25 pub routines_expanded: bool,
26 #[serde(skip)]
27 pub config: Option<ProjectConfig>,
28 #[serde(skip)]
29 pub expanded: bool,
30 #[serde(skip)]
31 pub missing: bool,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct ProjectConfig {
36 pub post_create: Option<String>,
37 pub copy_includes: Vec<String>,
38 pub copy_excludes: Vec<String>,
39 pub git_subtrees: Vec<PathBuf>,
41 pub notice: Option<String>,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct PaneInfo {
47 pub pane_id: PaneId,
48 pub terminal_id: TerminalId,
49 pub label: String,
50 pub agent: Option<String>,
51 pub agent_status: AgentState,
52 pub revision: u64,
53 pub exited: bool,
54 pub listening_ports: Vec<u16>,
55 pub foreground_job: bool,
56 #[serde(skip)]
58 pub outcome_acknowledged: bool,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct SessionInfo {
63 pub session_id: SessionId,
64 pub pane_id: PaneId,
65 pub terminal_id: TerminalId,
66 pub agent: Option<String>,
68 pub display_name: String,
69 pub agent_status: AgentState,
70 pub revision: u64,
71 pub layout: PaneLayout,
72 pub panes: Vec<PaneInfo>,
73 #[serde(skip)]
74 pub muted: bool,
75 #[serde(skip)]
77 pub outcome_acknowledged: bool,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize)]
81pub enum FetchFailReason {
82 Auth, Timeout, Network, }
86
87impl SessionInfo {
88 pub fn has_foreground_job(&self) -> bool {
89 self.panes.iter().any(|pane| pane.foreground_job)
90 }
91
92 pub fn is_agentic(&self) -> bool {
93 self.agent.is_some() || self.panes.iter().any(|pane| pane.agent.is_some())
94 }
95
96 pub fn listening_ports(&self) -> Vec<u16> {
97 let mut ports = self
98 .panes
99 .iter()
100 .flat_map(|pane| pane.listening_ports.iter().copied())
101 .collect::<Vec<_>>();
102 ports.sort_unstable();
103 ports.dedup();
104 ports
105 }
106}
107
108#[derive(Debug, Clone, Serialize)]
109pub struct WorktreeInfo {
110 pub name: String,
111 pub branch: String,
112 pub path: PathBuf,
113 pub is_main: bool,
114 pub alias: Option<String>,
115 pub sessions: Vec<SessionInfo>,
116 #[serde(skip)]
117 pub expanded: bool,
118 pub git_info: Option<GitInfo>,
119 pub fetch_failed: bool,
120 pub fetch_fail_count: u32,
121 pub fetch_fail_reason: Option<FetchFailReason>,
122 #[serde(skip)]
123 pub last_fetched: Option<std::time::Instant>,
124 #[serde(skip)]
125 pub git_info_fetched_at: Option<std::time::Instant>,
126}
127
128impl WorktreeInfo {
129 pub fn listening_ports(&self) -> Vec<u16> {
130 let mut ports = self
131 .sessions
132 .iter()
133 .flat_map(SessionInfo::listening_ports)
134 .collect::<Vec<_>>();
135 ports.sort_unstable();
136 ports.dedup();
137 ports
138 }
139
140 pub fn display_name(&self) -> &str {
141 self.alias.as_deref().unwrap_or(&self.name)
142 }
143
144 pub fn session_slug(&self, project_name: &str) -> String {
145 canonical_session_slug(project_name, &self.path)
146 }
147}
148
149fn sanitize_slug(raw: &str) -> String {
150 raw.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-")
151}
152
153pub fn canonical_session_slug(project_name: &str, worktree_path: &Path) -> String {
154 let dir_name = worktree_path
155 .file_name()
156 .map(|n| n.to_string_lossy().to_string())
157 .unwrap_or_else(|| project_name.to_string());
158 let proj_prefix = format!("{}-", project_name);
159 let short_name = dir_name.strip_prefix(&proj_prefix).unwrap_or(&dir_name);
160 sanitize_slug(short_name)
161}
162
163#[cfg(test)]
164mod tests {
165 use super::canonical_session_slug;
166 use std::path::Path;
167
168 #[test]
169 fn canonical_slug_uses_human_worktree_identity() {
170 assert_eq!(canonical_session_slug("wsx", Path::new("/tmp/wsx")), "wsx");
171 assert_eq!(
172 canonical_session_slug("wsx", Path::new("/tmp/wsx-feature-auth")),
173 "feature-auth"
174 );
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize)]
179pub struct GitInfo {
180 pub recent_commits: Vec<CommitSummary>,
181 pub modified_files: Vec<String>,
182 pub submodules: Option<Vec<SubmoduleInfo>>,
184 pub subtrees: Vec<SubtreeInfo>,
185 pub ahead: usize,
186 pub behind: usize,
187 pub remote_branch: Option<String>,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
191#[serde(rename_all = "snake_case")]
192pub enum SubmoduleCommitState {
193 InSync,
194 CommitChanged,
195 Uninitialized,
196 Conflict,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
200pub struct SubmoduleInfo {
201 pub path: String,
202 pub commit_state: SubmoduleCommitState,
203 pub modified_content: bool,
204 pub untracked_content: bool,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
208pub struct SubtreeInfo {
209 pub path: String,
210 pub modified_files: Vec<String>,
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize)]
214pub struct CommitSummary {
215 pub hash: String,
216 pub message: String,
217}
218
219#[derive(Debug, Clone, PartialEq)]
221pub enum FlatEntry {
222 Project {
223 idx: usize,
224 },
225 Worktree {
226 project_idx: usize,
227 worktree_idx: usize,
228 },
229 Session {
230 project_idx: usize,
231 worktree_idx: usize,
232 session_idx: usize,
233 },
234 Pane {
235 project_idx: usize,
236 worktree_idx: usize,
237 session_idx: usize,
238 pane_idx: usize,
239 },
240 RoutinesHeader {
241 project_idx: usize,
242 },
243 Routine {
244 project_idx: usize,
245 routine_idx: usize,
246 },
247}
248
249#[allow(dead_code)]
251pub fn flatten_tree(workspace: &WorkspaceState) -> Vec<FlatEntry> {
252 let mut result = Vec::new();
253 for (pi, project) in workspace.projects.iter().enumerate() {
254 result.push(FlatEntry::Project { idx: pi });
255 if project.expanded {
256 for (wi, wt) in project.worktrees.iter().enumerate() {
257 result.push(FlatEntry::Worktree {
258 project_idx: pi,
259 worktree_idx: wi,
260 });
261 if wt.expanded {
262 for (si, session) in wt.sessions.iter().enumerate() {
263 result.push(FlatEntry::Session {
264 project_idx: pi,
265 worktree_idx: wi,
266 session_idx: si,
267 });
268 if session.panes.len() > 1 {
269 for (pane_idx, _) in session.panes.iter().enumerate() {
270 result.push(FlatEntry::Pane {
271 project_idx: pi,
272 worktree_idx: wi,
273 session_idx: si,
274 pane_idx,
275 });
276 }
277 }
278 }
279 }
280 }
281 if !project.routines.is_empty() {
282 result.push(FlatEntry::RoutinesHeader { project_idx: pi });
283 if project.routines_expanded {
284 for (ri, _) in project.routines.iter().enumerate() {
285 result.push(FlatEntry::Routine {
286 project_idx: pi,
287 routine_idx: ri,
288 });
289 }
290 }
291 }
292 }
293 }
294 result
295}
296
297pub fn flatten_tree_filtered(
299 workspace: &WorkspaceState,
300 visible: &HashSet<usize>,
301) -> Vec<FlatEntry> {
302 let mut result = Vec::new();
303 for (pi, project) in workspace.projects.iter().enumerate() {
304 if !visible.contains(&pi) {
305 continue;
306 }
307 result.push(FlatEntry::Project { idx: pi });
308 if project.expanded {
309 for (wi, wt) in project.worktrees.iter().enumerate() {
310 result.push(FlatEntry::Worktree {
311 project_idx: pi,
312 worktree_idx: wi,
313 });
314 if wt.expanded {
315 for (si, session) in wt.sessions.iter().enumerate() {
316 result.push(FlatEntry::Session {
317 project_idx: pi,
318 worktree_idx: wi,
319 session_idx: si,
320 });
321 if session.panes.len() > 1 {
322 for (pane_idx, _) in session.panes.iter().enumerate() {
323 result.push(FlatEntry::Pane {
324 project_idx: pi,
325 worktree_idx: wi,
326 session_idx: si,
327 pane_idx,
328 });
329 }
330 }
331 }
332 }
333 }
334 if !project.routines.is_empty() {
335 result.push(FlatEntry::RoutinesHeader { project_idx: pi });
336 if project.routines_expanded {
337 for (ri, _) in project.routines.iter().enumerate() {
338 result.push(FlatEntry::Routine {
339 project_idx: pi,
340 routine_idx: ri,
341 });
342 }
343 }
344 }
345 }
346 }
347 result
348}
349
350#[derive(Debug, Clone, PartialEq)]
352pub enum Selection {
353 Project(usize),
354 Worktree(usize, usize),
355 Session(usize, usize, usize),
356 Pane(usize, usize, usize, usize),
357 RoutinesHeader(usize),
358 Routine(usize, usize),
359 None,
360}
361
362impl WorkspaceState {
363 pub fn empty() -> Self {
364 Self {
365 projects: Vec::new(),
366 }
367 }
368
369 pub fn worktree(&self, pi: usize, wi: usize) -> Option<&WorktreeInfo> {
370 self.projects.get(pi)?.worktrees.get(wi)
371 }
372
373 pub fn worktree_mut(&mut self, pi: usize, wi: usize) -> Option<&mut WorktreeInfo> {
374 self.projects.get_mut(pi)?.worktrees.get_mut(wi)
375 }
376
377 pub fn session(&self, pi: usize, wi: usize, si: usize) -> Option<&SessionInfo> {
378 self.projects.get(pi)?.worktrees.get(wi)?.sessions.get(si)
379 }
380
381 pub fn session_mut(&mut self, pi: usize, wi: usize, si: usize) -> Option<&mut SessionInfo> {
382 self.projects
383 .get_mut(pi)?
384 .worktrees
385 .get_mut(wi)?
386 .sessions
387 .get_mut(si)
388 }
389
390 pub fn get_selection(&self, flat_idx: usize, flat: &[FlatEntry]) -> Selection {
392 match flat.get(flat_idx) {
393 Some(FlatEntry::Project { idx }) => Selection::Project(*idx),
394 Some(FlatEntry::Worktree {
395 project_idx,
396 worktree_idx,
397 }) => Selection::Worktree(*project_idx, *worktree_idx),
398 Some(FlatEntry::Session {
399 project_idx,
400 worktree_idx,
401 session_idx,
402 }) => Selection::Session(*project_idx, *worktree_idx, *session_idx),
403 Some(FlatEntry::Pane {
404 project_idx,
405 worktree_idx,
406 session_idx,
407 pane_idx,
408 }) => Selection::Pane(*project_idx, *worktree_idx, *session_idx, *pane_idx),
409 Some(FlatEntry::RoutinesHeader { project_idx }) => {
410 Selection::RoutinesHeader(*project_idx)
411 }
412 Some(FlatEntry::Routine {
413 project_idx,
414 routine_idx,
415 }) => Selection::Routine(*project_idx, *routine_idx),
416 None => Selection::None,
417 }
418 }
419}