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, PartialEq, Eq)]
35pub struct DefaultSessionConfig {
36 pub enabled: bool,
37 pub command: Option<String>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum WorktreeInitialSession {
42 Disabled,
43 Shell,
44 Command(String),
45}
46
47#[derive(Debug, Clone, Default)]
48pub struct ProjectConfig {
49 pub post_create: Option<String>,
50 pub copy_includes: Vec<String>,
51 pub copy_excludes: Vec<String>,
52 pub git_subtrees: Vec<PathBuf>,
54 pub branch_prefix: String,
55 pub default_session: Option<DefaultSessionConfig>,
56 pub notice: Option<String>,
58}
59
60impl ProjectConfig {
61 pub fn initial_session(&self, fallback: WorktreeInitialSession) -> WorktreeInitialSession {
62 match &self.default_session {
63 None => fallback,
64 Some(session) if !session.enabled => WorktreeInitialSession::Disabled,
65 Some(session) => session
66 .command
67 .as_ref()
68 .map_or(WorktreeInitialSession::Shell, |command| {
69 WorktreeInitialSession::Command(command.clone())
70 }),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Serialize)]
76pub struct PaneInfo {
77 pub pane_id: PaneId,
78 pub terminal_id: TerminalId,
79 pub label: String,
80 pub agent: Option<String>,
81 pub agent_status: AgentState,
82 pub revision: u64,
83 pub exited: bool,
84 pub listening_ports: Vec<u16>,
85 pub foreground_job: bool,
86 #[serde(skip)]
88 pub outcome_acknowledged: bool,
89}
90
91#[derive(Debug, Clone, Serialize)]
92pub struct SessionInfo {
93 pub session_id: SessionId,
94 pub pane_id: PaneId,
95 pub terminal_id: TerminalId,
96 pub agent: Option<String>,
98 pub display_name: String,
99 pub agent_status: AgentState,
100 pub revision: u64,
101 pub layout: PaneLayout,
102 pub panes: Vec<PaneInfo>,
103 #[serde(skip)]
104 pub muted: bool,
105 #[serde(skip)]
107 pub outcome_acknowledged: bool,
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize)]
111pub enum FetchFailReason {
112 Auth, Timeout, Network, }
116
117impl SessionInfo {
118 pub fn has_foreground_job(&self) -> bool {
119 self.panes.iter().any(|pane| pane.foreground_job)
120 }
121
122 pub fn is_agentic(&self) -> bool {
123 self.agent.is_some() || self.panes.iter().any(|pane| pane.agent.is_some())
124 }
125
126 pub fn listening_ports(&self) -> Vec<u16> {
127 let mut ports = self
128 .panes
129 .iter()
130 .flat_map(|pane| pane.listening_ports.iter().copied())
131 .collect::<Vec<_>>();
132 ports.sort_unstable();
133 ports.dedup();
134 ports
135 }
136}
137
138#[derive(Debug, Clone, Serialize)]
139pub struct WorktreeInfo {
140 pub name: String,
141 pub branch: String,
142 pub path: PathBuf,
143 pub is_main: bool,
144 pub alias: Option<String>,
145 pub sessions: Vec<SessionInfo>,
146 #[serde(skip)]
147 pub expanded: bool,
148 pub git_info: Option<GitInfo>,
149 pub fetch_failed: bool,
150 pub fetch_fail_count: u32,
151 pub fetch_fail_reason: Option<FetchFailReason>,
152 #[serde(skip)]
153 pub last_fetched: Option<std::time::Instant>,
154 #[serde(skip)]
155 pub git_info_fetched_at: Option<std::time::Instant>,
156}
157
158impl WorktreeInfo {
159 pub fn listening_ports(&self) -> Vec<u16> {
160 let mut ports = self
161 .sessions
162 .iter()
163 .flat_map(SessionInfo::listening_ports)
164 .collect::<Vec<_>>();
165 ports.sort_unstable();
166 ports.dedup();
167 ports
168 }
169
170 pub fn display_name(&self) -> &str {
171 self.alias.as_deref().unwrap_or(&self.name)
172 }
173
174 pub fn session_slug(&self, project_name: &str) -> String {
175 canonical_session_slug(project_name, &self.path)
176 }
177}
178
179fn sanitize_slug(raw: &str) -> String {
180 raw.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-")
181}
182
183pub fn canonical_session_slug(project_name: &str, worktree_path: &Path) -> String {
184 let dir_name = worktree_path
185 .file_name()
186 .map(|n| n.to_string_lossy().to_string())
187 .unwrap_or_else(|| project_name.to_string());
188 let proj_prefix = format!("{}-", project_name);
189 let short_name = dir_name.strip_prefix(&proj_prefix).unwrap_or(&dir_name);
190 sanitize_slug(short_name)
191}
192
193#[cfg(test)]
194mod tests {
195 use super::{
196 canonical_session_slug, DefaultSessionConfig, ProjectConfig, WorktreeInitialSession,
197 };
198 use std::path::Path;
199
200 #[test]
201 fn initial_session_preserves_surface_defaults_until_explicitly_configured() {
202 let mut config = ProjectConfig::default();
203 assert_eq!(
204 config.initial_session(WorktreeInitialSession::Disabled),
205 WorktreeInitialSession::Disabled
206 );
207 assert_eq!(
208 config.initial_session(WorktreeInitialSession::Shell),
209 WorktreeInitialSession::Shell
210 );
211
212 config.default_session = Some(DefaultSessionConfig {
213 enabled: false,
214 command: Some("cargo watch".into()),
215 });
216 assert_eq!(
217 config.initial_session(WorktreeInitialSession::Shell),
218 WorktreeInitialSession::Disabled
219 );
220
221 config.default_session = Some(DefaultSessionConfig {
222 enabled: true,
223 command: None,
224 });
225 assert_eq!(
226 config.initial_session(WorktreeInitialSession::Disabled),
227 WorktreeInitialSession::Shell
228 );
229
230 config.default_session = Some(DefaultSessionConfig {
231 enabled: true,
232 command: Some("cargo watch".into()),
233 });
234 assert_eq!(
235 config.initial_session(WorktreeInitialSession::Disabled),
236 WorktreeInitialSession::Command("cargo watch".into())
237 );
238 }
239
240 #[test]
241 fn canonical_slug_uses_human_worktree_identity() {
242 assert_eq!(canonical_session_slug("wsx", Path::new("/tmp/wsx")), "wsx");
243 assert_eq!(
244 canonical_session_slug("wsx", Path::new("/tmp/wsx-feature-auth")),
245 "feature-auth"
246 );
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Serialize)]
251pub struct GitInfo {
252 pub recent_commits: Vec<CommitSummary>,
253 pub modified_files: Vec<String>,
254 pub submodules: Option<Vec<SubmoduleInfo>>,
256 pub subtrees: Vec<SubtreeInfo>,
257 pub ahead: usize,
258 pub behind: usize,
259 pub remote_branch: Option<String>,
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
263#[serde(rename_all = "snake_case")]
264pub enum SubmoduleCommitState {
265 InSync,
266 CommitChanged,
267 Uninitialized,
268 Conflict,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
272pub struct SubmoduleInfo {
273 pub path: String,
274 pub commit_state: SubmoduleCommitState,
275 pub modified_content: bool,
276 pub untracked_content: bool,
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
280pub struct SubtreeInfo {
281 pub path: String,
282 pub modified_files: Vec<String>,
283}
284
285#[derive(Debug, Clone, PartialEq, Serialize)]
286pub struct CommitSummary {
287 pub hash: String,
288 pub message: String,
289}
290
291#[derive(Debug, Clone, PartialEq)]
293pub enum FlatEntry {
294 Project {
295 idx: usize,
296 },
297 Worktree {
298 project_idx: usize,
299 worktree_idx: usize,
300 },
301 Session {
302 project_idx: usize,
303 worktree_idx: usize,
304 session_idx: usize,
305 },
306 Pane {
307 project_idx: usize,
308 worktree_idx: usize,
309 session_idx: usize,
310 pane_idx: usize,
311 },
312 RoutinesHeader {
313 project_idx: usize,
314 },
315 Routine {
316 project_idx: usize,
317 routine_idx: usize,
318 },
319}
320
321#[allow(dead_code)]
323pub fn flatten_tree(workspace: &WorkspaceState) -> Vec<FlatEntry> {
324 let mut result = Vec::new();
325 for (pi, project) in workspace.projects.iter().enumerate() {
326 result.push(FlatEntry::Project { idx: pi });
327 if project.expanded {
328 for (wi, wt) in project.worktrees.iter().enumerate() {
329 result.push(FlatEntry::Worktree {
330 project_idx: pi,
331 worktree_idx: wi,
332 });
333 if wt.expanded {
334 for (si, session) in wt.sessions.iter().enumerate() {
335 result.push(FlatEntry::Session {
336 project_idx: pi,
337 worktree_idx: wi,
338 session_idx: si,
339 });
340 if session.panes.len() > 1 {
341 for (pane_idx, _) in session.panes.iter().enumerate() {
342 result.push(FlatEntry::Pane {
343 project_idx: pi,
344 worktree_idx: wi,
345 session_idx: si,
346 pane_idx,
347 });
348 }
349 }
350 }
351 }
352 }
353 if !project.routines.is_empty() {
354 result.push(FlatEntry::RoutinesHeader { project_idx: pi });
355 if project.routines_expanded {
356 for (ri, _) in project.routines.iter().enumerate() {
357 result.push(FlatEntry::Routine {
358 project_idx: pi,
359 routine_idx: ri,
360 });
361 }
362 }
363 }
364 }
365 }
366 result
367}
368
369pub fn flatten_tree_filtered(
371 workspace: &WorkspaceState,
372 visible: &HashSet<usize>,
373) -> Vec<FlatEntry> {
374 let mut result = Vec::new();
375 for (pi, project) in workspace.projects.iter().enumerate() {
376 if !visible.contains(&pi) {
377 continue;
378 }
379 result.push(FlatEntry::Project { idx: pi });
380 if project.expanded {
381 for (wi, wt) in project.worktrees.iter().enumerate() {
382 result.push(FlatEntry::Worktree {
383 project_idx: pi,
384 worktree_idx: wi,
385 });
386 if wt.expanded {
387 for (si, session) in wt.sessions.iter().enumerate() {
388 result.push(FlatEntry::Session {
389 project_idx: pi,
390 worktree_idx: wi,
391 session_idx: si,
392 });
393 if session.panes.len() > 1 {
394 for (pane_idx, _) in session.panes.iter().enumerate() {
395 result.push(FlatEntry::Pane {
396 project_idx: pi,
397 worktree_idx: wi,
398 session_idx: si,
399 pane_idx,
400 });
401 }
402 }
403 }
404 }
405 }
406 if !project.routines.is_empty() {
407 result.push(FlatEntry::RoutinesHeader { project_idx: pi });
408 if project.routines_expanded {
409 for (ri, _) in project.routines.iter().enumerate() {
410 result.push(FlatEntry::Routine {
411 project_idx: pi,
412 routine_idx: ri,
413 });
414 }
415 }
416 }
417 }
418 }
419 result
420}
421
422#[derive(Debug, Clone, PartialEq)]
424pub enum Selection {
425 Project(usize),
426 Worktree(usize, usize),
427 Session(usize, usize, usize),
428 Pane(usize, usize, usize, usize),
429 RoutinesHeader(usize),
430 Routine(usize, usize),
431 None,
432}
433
434impl WorkspaceState {
435 pub fn empty() -> Self {
436 Self {
437 projects: Vec::new(),
438 }
439 }
440
441 pub fn worktree(&self, pi: usize, wi: usize) -> Option<&WorktreeInfo> {
442 self.projects.get(pi)?.worktrees.get(wi)
443 }
444
445 pub fn worktree_mut(&mut self, pi: usize, wi: usize) -> Option<&mut WorktreeInfo> {
446 self.projects.get_mut(pi)?.worktrees.get_mut(wi)
447 }
448
449 pub fn session(&self, pi: usize, wi: usize, si: usize) -> Option<&SessionInfo> {
450 self.projects.get(pi)?.worktrees.get(wi)?.sessions.get(si)
451 }
452
453 pub fn session_mut(&mut self, pi: usize, wi: usize, si: usize) -> Option<&mut SessionInfo> {
454 self.projects
455 .get_mut(pi)?
456 .worktrees
457 .get_mut(wi)?
458 .sessions
459 .get_mut(si)
460 }
461
462 pub fn get_selection(&self, flat_idx: usize, flat: &[FlatEntry]) -> Selection {
464 match flat.get(flat_idx) {
465 Some(FlatEntry::Project { idx }) => Selection::Project(*idx),
466 Some(FlatEntry::Worktree {
467 project_idx,
468 worktree_idx,
469 }) => Selection::Worktree(*project_idx, *worktree_idx),
470 Some(FlatEntry::Session {
471 project_idx,
472 worktree_idx,
473 session_idx,
474 }) => Selection::Session(*project_idx, *worktree_idx, *session_idx),
475 Some(FlatEntry::Pane {
476 project_idx,
477 worktree_idx,
478 session_idx,
479 pane_idx,
480 }) => Selection::Pane(*project_idx, *worktree_idx, *session_idx, *pane_idx),
481 Some(FlatEntry::RoutinesHeader { project_idx }) => {
482 Selection::RoutinesHeader(*project_idx)
483 }
484 Some(FlatEntry::Routine {
485 project_idx,
486 routine_idx,
487 }) => Selection::Routine(*project_idx, *routine_idx),
488 None => Selection::None,
489 }
490 }
491}