1use std::collections::HashMap;
9use std::sync::mpsc::{channel, Receiver, Sender};
10#[cfg(test)]
11mod tests;
12
13use strop_core::id::DocumentId;
14use strop_core::worker::{self, Completion, FailureKind, Outcome, Ticket, WorkerId};
15use strop_core::Buffer;
16
17use super::document::Document;
18use super::Editor;
19
20#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
22pub enum ContainerJob {
23 Discover,
25 Attach { id: String },
28 ListDir { id: String, path: String },
30 ReadFile { id: String, path: String },
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub struct ContainerKey {
36 pub request: WorkerId,
37 pub focus: u64,
38 pub job: ContainerJob,
39}
40
41#[derive(serde::Serialize, serde::Deserialize)]
43pub enum ContainerResult {
44 Containers(Vec<strop_containers::ContainerIdentity>),
45 Listing {
46 identity: strop_containers::ContainerIdentity,
47 path: String,
48 entries: Vec<strop_containers::DirEntry>,
49 },
50 File {
51 identity: strop_containers::ContainerIdentity,
52 path: String,
53 text: String,
54 },
55}
56
57pub type ContainerEvent = Completion<ContainerKey, ContainerResult>;
58
59pub(crate) struct ContainerState {
60 pub tx: Sender<ContainerEvent>,
61 pub rx: Option<Receiver<ContainerEvent>>,
62 pub pending: Option<Ticket<ContainerKey>>,
63 pub attached: HashMap<String, strop_containers::ContainerIdentity>,
65 pub entries: HashMap<DocumentId, Vec<strop_containers::DirEntry>>,
67 pub buffers: HashMap<DocumentId, (String, String)>, }
70
71impl Default for ContainerState {
72 fn default() -> Self {
73 let (tx, rx) = channel();
74 Self {
75 tx,
76 rx: Some(rx),
77 pending: None,
78 attached: HashMap::new(),
79 entries: HashMap::new(),
80 buffers: HashMap::new(),
81 }
82 }
83}
84
85impl ContainerState {
86 pub fn take_rx(&mut self) -> Option<Receiver<ContainerEvent>> {
87 self.rx.take()
88 }
89}
90
91impl Editor {
92 pub fn directory_visibility_summary(&self) -> Option<&'static str> {
95 if self.docs.is_empty() {
96 return None;
97 }
98 (self.cur().directory_metadata_ref().is_some()
99 || self.containers.entries.contains_key(&self.current()))
100 .then_some("hidden on · no ignores")
101 }
102
103 pub(crate) fn request_containers(&mut self) {
106 self.start_container_job(ContainerJob::Discover);
107 }
108
109 fn start_container_job(&mut self, job: ContainerJob) {
110 if self.containers.pending.is_some() {
111 self.message = "a container request is already running".into();
112 return;
113 }
114 let request = match self.worker_ids.allocate() {
115 Ok(request) => request,
116 Err(error) => {
117 self.message = error.message;
118 return;
119 }
120 };
121 let key = ContainerKey {
122 request,
123 focus: self.focus_epoch,
124 job: job.clone(),
125 };
126 let pending = Ticket {
127 request,
128 key: key.clone(),
129 };
130 let tx = self.containers.tx.clone();
131 let handle = worker::spawn(
132 "container-job",
133 move |outcome| {
134 let _ = tx.send(Completion {
135 ticket: pending,
136 outcome,
137 });
138 },
139 move |token| match run_container_job(&job, &token) {
140 Ok(result) => Outcome::Success(result),
141 Err(strop_containers::ContainerError::Cancelled) => {
142 Outcome::Cancelled(worker::CancelReason::Dismissed)
143 }
144 Err(error) => Outcome::failed(FailureKind::Io, error.to_string()),
145 },
146 );
147 self.containers.pending = Some(Ticket { request, key });
148 self.worker_handles.insert(request, handle);
149 }
150
151 pub(crate) fn attach_container(&mut self, id: String) {
154 self.start_container_job(ContainerJob::Attach { id });
155 }
156
157 pub(crate) fn container_key(&mut self, key: super::Key) -> bool {
161 if key != super::Key::Enter {
162 return false;
163 }
164 let Some((id, base)) = self.containers.buffers.get(&self.current()).cloned() else {
165 return false;
166 };
167 let Some(entries) = self.containers.entries.get(&self.current()) else {
168 return false; };
170 let line = self.buf().line_of(self.head());
171 let Some(entry) = line.checked_sub(1).and_then(|row| entries.get(row)) else {
172 self.message = "no container entry at this position".into();
173 return true;
174 };
175 let path = format!("{}/{}", base.trim_end_matches('/'), entry.name);
176 let job = match entry.kind {
177 strop_containers::DirEntryKind::Dir => ContainerJob::ListDir { id, path },
178 _ => ContainerJob::ReadFile { id, path },
179 };
180 self.start_container_job(job);
181 true
182 }
183
184 pub(crate) fn handle_container_event(&mut self, event: ContainerEvent) {
185 let owned = self
186 .containers
187 .pending
188 .as_ref()
189 .is_some_and(|ticket| ticket.request == event.ticket.request);
190 if !owned {
191 return; }
193 self.containers.pending = None;
194 self.worker_handles.remove(&event.ticket.request);
195 let focused = event.ticket.key.focus == self.focus_epoch && !self.finishing;
196 match event.outcome {
197 Outcome::Success(ContainerResult::Containers(list)) if focused => {
198 if list.is_empty() {
199 self.message = "no running containers on the local engine".into();
200 return;
201 }
202 let items = list
203 .into_iter()
204 .map(|identity| strop_picker::Item {
205 badge: None,
206 text: format!(
207 "{} {} {}",
208 &identity.id[..12],
209 identity.name,
210 identity.image
211 ),
212 payload: strop_picker::Payload::Container(identity.id.clone()),
213 })
214 .collect();
215 self.set_picker(super::picker::PickerGlue::diagnostics(
216 strop_picker::Picker::new(strop_picker::Kind::Containers, items, false),
217 ));
218 }
219 Outcome::Success(ContainerResult::Listing {
220 identity,
221 path,
222 entries,
223 }) if focused => {
224 let Ok(reference) = strop_containers::ContainerRef::of(&identity) else {
225 self.message = "container identity failed validation".into();
226 return;
227 };
228 self.workspaces.bind(
229 strop_workspace::Filesystem::Container(reference.id().clone()),
230 None,
231 );
232 self.containers
233 .attached
234 .insert(identity.id.clone(), identity.clone());
235 let short = &identity.id[..12];
236 let mut text = format!(
237 "container {} ({}:{}) — {} entry(s); enter opens, q closes\n",
238 identity.name,
239 short,
240 path,
241 entries.len()
242 );
243 for entry in &entries {
244 let marker = match entry.kind {
245 strop_containers::DirEntryKind::Dir => "d",
246 strop_containers::DirEntryKind::Symlink => "l",
247 _ => " ",
248 };
249 let suffix = if entry.kind == strop_containers::DirEntryKind::Dir {
250 "/"
251 } else {
252 ""
253 };
254 text.push_str(&format!("{} {}{}\n", marker, entry.name, suffix));
255 }
256 let mut buffer = Buffer::from_text(&text);
257 buffer.name = Some(format!("container:{short}:{path}"));
258 let id = self.open_temporary_output(buffer);
259 self.containers
260 .buffers
261 .insert(id, (identity.id.clone(), path));
262 self.containers.entries.insert(id, entries);
263 }
264 Outcome::Success(ContainerResult::File {
265 identity,
266 path,
267 text,
268 }) if focused => {
269 let short = identity.id[..12].to_string();
270 let mut buffer = Buffer::from_text(&text);
271 buffer.name = Some(format!("container:{short}:{path}"));
272 let Ok(container) = strop_workspace::ContainerId::canonical(identity.id.clone())
273 else {
274 self.message = "container identity failed validation".into();
275 return;
276 };
277 self.push_jump();
278 let id = self.docs.insert(Document::container_file(
279 buffer,
280 container,
281 std::path::PathBuf::from(&path),
282 ));
283 self.drop_stale_scratch(id);
284 self.containers
285 .buffers
286 .insert(id, (identity.id.clone(), path));
287 self.switch_to(id);
288 self.set_head(0);
289 self.lsp_maybe_attach();
292 }
293 Outcome::Success(_) => {}
294 Outcome::Failed { failure, .. } if focused => {
295 self.message = failure.message;
296 }
297 _ => {}
298 }
299 }
300}
301
302fn run_container_job(
306 job: &ContainerJob,
307 token: &strop_core::worker::CancelToken,
308) -> Result<ContainerResult, strop_containers::ContainerError> {
309 let engine = strop_containers::engine(token)?;
310 match job {
311 ContainerJob::Discover => Ok(ContainerResult::Containers(strop_containers::list_running(
312 &engine, token,
313 )?)),
314 ContainerJob::Attach { id } => {
315 let identity = strop_containers::inspect(&engine, id, token)?;
316 let entries = strop_containers::list_dir(
317 &engine,
318 &strop_containers::ContainerRef::of(&identity)?,
319 "/",
320 token,
321 )?;
322 Ok(ContainerResult::Listing {
323 identity,
324 path: "/".into(),
325 entries,
326 })
327 }
328 ContainerJob::ListDir { id, path } => {
329 let identity = strop_containers::inspect(&engine, id, token)?;
330 let entries = strop_containers::list_dir(
331 &engine,
332 &strop_containers::ContainerRef::of(&identity)?,
333 path,
334 token,
335 )?;
336 Ok(ContainerResult::Listing {
337 identity,
338 path: path.clone(),
339 entries,
340 })
341 }
342 ContainerJob::ReadFile { id, path } => {
343 let identity = strop_containers::inspect(&engine, id, token)?;
344 let text = strop_containers::read_file(
345 &engine,
346 &strop_containers::ContainerRef::of(&identity)?,
347 path,
348 8 * 1024 * 1024,
349 token,
350 )?;
351 Ok(ContainerResult::File {
352 identity,
353 path: path.clone(),
354 text: String::from_utf8_lossy(&text).into_owned(),
355 })
356 }
357 }
358}