Skip to main content

strop_engine/editor/
containers.rs

1//! Existing-container attach and read-only browsing (0037 DC1a): the
2//! local engine's running containers as a picker; attach binds a
3//! `Filesystem::Container` workspace context and opens a real readonly
4//! listing buffer. Listings and file reads are owned, ticketed jobs
5//! through strop-containers — never a local path, never an SSH daemon,
6//! never lifecycle ownership.
7
8use 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/// What one owned job was asked to do.
21#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
22pub enum ContainerJob {
23    /// Probe the engine and list running containers.
24    Discover,
25    /// Attach: resolve the picker choice to canonical identity, then
26    /// list the container root.
27    Attach { id: String },
28    /// List one directory inside an attached container.
29    ListDir { id: String, path: String },
30    /// Read one bounded file inside an attached container.
31    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/// One job's terminal answer.
42#[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    /// Attached identities by canonical id (the registry owns contexts).
64    pub attached: HashMap<String, strop_containers::ContainerIdentity>,
65    /// Listing rows per listing buffer, for Enter resolution.
66    pub entries: HashMap<DocumentId, Vec<strop_containers::DirEntry>>,
67    /// The container each buffer belongs to (listing and file buffers).
68    pub buffers: HashMap<DocumentId, (String, String)>, // id, path
69}
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    /// Listing providers expose dotfiles but do not evaluate project ignore files.
93    /// Keep this distinct from configurable local-workspace search visibility.
94    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    /// `:containers` — probe the local engine and offer running
104    /// containers as a picker.
105    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    /// Picker acceptance: attach to the chosen container (canonicalize
152    /// identity, bind the workspace context, list its root).
153    pub(crate) fn attach_container(&mut self, id: String) {
154        self.start_container_job(ContainerJob::Attach { id });
155    }
156
157    /// Enter on a container listing row: descend into directories, read
158    /// files. Rows resolve through the buffer's recorded entries — never
159    /// by re-parsing display text against the local disk.
160    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; // a file buffer: Enter is not navigation
169        };
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; // a superseded job's answer belongs to no one
192        }
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                // Container files get language services like any document
290                // (DC1b); discovery runs on its own worker.
291                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
302/// The worker body: engine probe, then the job's actual operation. All
303/// docker work is supervised by strop-containers (deadlines, bounded
304/// output, cancellation); nothing here touches the local filesystem.
305fn 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}