1use super::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
12use crate::data_context::{DataContext, DataDep, RepoKind};
13use crate::theme::Role;
14
15pub struct WorkspaceSegment;
16
17const PRIORITY: u8 = 16;
20
21impl Segment for WorkspaceSegment {
22 fn data_deps(&self) -> &'static [DataDep] {
23 &[DataDep::Git]
24 }
25
26 fn render(&self, ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
27 let Some(workspace) = ctx.status.workspace.as_ref() else {
28 crate::lsm_debug!("workspace: status.workspace absent; hiding");
29 return Ok(None);
30 };
31 let Some(repo_name) = workspace.project_dir.file_name().and_then(|s| s.to_str()) else {
32 crate::lsm_debug!("workspace: status.workspace.project_dir has no basename; hiding");
33 return Ok(None);
34 };
35
36 let text = match &*ctx.git() {
37 Ok(Some(gc)) => match &gc.repo_kind {
38 RepoKind::LinkedWorktree { name } => format!("{repo_name}/{name}"),
39 _ => repo_name.to_string(),
44 },
45 Ok(None) | Err(_) => repo_name.to_string(),
48 };
49
50 Ok(Some(RenderedSegment::new(text).with_role(Role::Info)))
51 }
52
53 fn defaults(&self) -> SegmentDefaults {
54 SegmentDefaults::with_priority(PRIORITY).with_truncatable(true)
55 }
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61 use crate::data_context::{GitContext, GitError, Head, RepoKind};
62 use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
63 use std::path::PathBuf;
64 use std::sync::Arc;
65
66 fn status(project_dir: &str) -> StatusContext {
67 StatusContext {
68 tool: Tool::ClaudeCode,
69 model: Some(ModelInfo {
70 display_name: "Claude Test".into(),
71 }),
72 workspace: Some(WorkspaceInfo {
73 project_dir: PathBuf::from(project_dir),
74 git_worktree: None,
75 }),
76 context_window: None,
77 cost: None,
78 effort: None,
79 vim: None,
80 output_style: None,
81 agent_name: None,
82 version: None,
83 raw: Arc::new(serde_json::Value::Null),
84 }
85 }
86
87 fn rc() -> RenderContext {
88 RenderContext::new(80)
89 }
90
91 fn ctx_with_git(project_dir: &str, git: Result<Option<GitContext>, GitError>) -> DataContext {
92 let dc = DataContext::with_cwd(status(project_dir), None);
93 dc.preseed_git(git).expect("fresh onceCell");
94 dc
95 }
96
97 fn linked_worktree(name: &str) -> GitContext {
98 GitContext::new(
99 RepoKind::LinkedWorktree { name: name.into() },
100 PathBuf::from(format!("/repo/.git/worktrees/{name}")),
101 Head::Branch(name.into()),
102 )
103 }
104
105 #[test]
106 fn renders_directory_outside_repo() {
107 let dc = ctx_with_git("/home/dev/linesmith", Ok(None));
108 assert_eq!(
109 WorkspaceSegment.render(&dc, &rc()).unwrap(),
110 Some(RenderedSegment::new("linesmith").with_role(Role::Info))
111 );
112 }
113
114 #[test]
115 fn renders_basename_in_main_checkout() {
116 let gc = GitContext::new(
117 RepoKind::Main,
118 PathBuf::from("/home/dev/linesmith/.git"),
119 Head::Branch("main".into()),
120 );
121 let dc = ctx_with_git("/home/dev/linesmith", Ok(Some(gc)));
122 assert_eq!(
123 WorkspaceSegment.render(&dc, &rc()).unwrap(),
124 Some(RenderedSegment::new("linesmith").with_role(Role::Info))
125 );
126 }
127
128 #[test]
129 fn renders_hybrid_inside_linked_worktree() {
130 let dc = ctx_with_git(
131 "/home/dev/linesmith",
132 Ok(Some(linked_worktree("feat-segments"))),
133 );
134 assert_eq!(
135 WorkspaceSegment.render(&dc, &rc()).unwrap(),
136 Some(RenderedSegment::new("linesmith/feat-segments").with_role(Role::Info))
137 );
138 }
139
140 #[test]
141 fn renders_worktree_name_containing_slash_verbatim() {
142 let dc = ctx_with_git(
146 "/home/dev/linesmith",
147 Ok(Some(linked_worktree("feature/auth"))),
148 );
149 assert_eq!(
150 WorkspaceSegment.render(&dc, &rc()).unwrap(),
151 Some(RenderedSegment::new("linesmith/feature/auth").with_role(Role::Info))
152 );
153 }
154
155 #[test]
156 fn renders_basename_in_bare_repo() {
157 let gc = GitContext::new(
158 RepoKind::Bare,
159 PathBuf::from("/srv/repos/linesmith.git"),
160 Head::Unborn {
161 symbolic_ref: "main".into(),
162 },
163 );
164 let dc = ctx_with_git("/home/dev/linesmith", Ok(Some(gc)));
165 assert_eq!(
166 WorkspaceSegment.render(&dc, &rc()).unwrap(),
167 Some(RenderedSegment::new("linesmith").with_role(Role::Info))
168 );
169 }
170
171 #[test]
172 fn renders_basename_in_submodule() {
173 let gc = GitContext::new(
174 RepoKind::Submodule,
175 PathBuf::from("/home/dev/parent/.git/modules/linesmith"),
176 Head::Branch("main".into()),
177 );
178 let dc = ctx_with_git("/home/dev/linesmith", Ok(Some(gc)));
179 assert_eq!(
180 WorkspaceSegment.render(&dc, &rc()).unwrap(),
181 Some(RenderedSegment::new("linesmith").with_role(Role::Info))
182 );
183 }
184
185 #[test]
186 fn renders_basename_on_gix_corrupt_repo() {
187 let err = GitError::CorruptRepo {
190 path: PathBuf::from("/home/dev/linesmith"),
191 message: "synthetic".into(),
192 };
193 let dc = ctx_with_git("/home/dev/linesmith", Err(err));
194 assert_eq!(
195 WorkspaceSegment.render(&dc, &rc()).unwrap(),
196 Some(RenderedSegment::new("linesmith").with_role(Role::Info))
197 );
198 }
199
200 #[test]
201 fn renders_basename_on_gix_walk_failed() {
202 let err = GitError::WalkFailed {
207 path: PathBuf::from("/home/dev/linesmith"),
208 message: "synthetic".into(),
209 };
210 let dc = ctx_with_git("/home/dev/linesmith", Err(err));
211 assert_eq!(
212 WorkspaceSegment.render(&dc, &rc()).unwrap(),
213 Some(RenderedSegment::new("linesmith").with_role(Role::Info))
214 );
215 }
216
217 #[test]
218 fn hidden_when_project_dir_has_no_basename() {
219 let dc = ctx_with_git("/", Ok(None));
220 assert_eq!(WorkspaceSegment.render(&dc, &rc()).unwrap(), None);
221 }
222
223 #[test]
224 fn defaults_use_expected_priority() {
225 assert_eq!(WorkspaceSegment.defaults().priority, PRIORITY);
226 }
227
228 #[test]
229 fn declares_git_data_dep() {
230 assert_eq!(WorkspaceSegment.data_deps(), &[DataDep::Git]);
231 }
232
233 #[test]
234 fn hostile_worktree_name_is_stripped_of_control_chars() {
235 let dc = ctx_with_git(
236 "/home/dev/linesmith",
237 Ok(Some(linked_worktree("evil\x1b[2J"))),
238 );
239 let rendered = WorkspaceSegment
240 .render(&dc, &rc())
241 .unwrap()
242 .expect("renders");
243 assert_eq!(rendered.text(), "linesmith/evil[2J");
244 assert!(!rendered.text().contains('\x1b'));
245 }
246
247 #[test]
248 fn hostile_project_dir_basename_is_stripped_of_control_chars() {
249 let dc = ctx_with_git("/tmp/\x1b]0;pwn\x07evil", Ok(None));
253 let rendered = WorkspaceSegment
254 .render(&dc, &rc())
255 .unwrap()
256 .expect("renders");
257 assert_eq!(rendered.text(), "]0;pwnevil");
258 assert!(!rendered.text().contains('\x1b'));
259 assert!(!rendered.text().contains('\x07'));
260 }
261}