Skip to main content

strop_engine/editor/directory/
mod.rs

1//! Directory navigation, view history and owned current-folder filtering.
2//! Native effects belong to strop-fs; rows never become path or command text.
3mod filter;
4mod jobs;
5pub(crate) use filter::apply_filter;
6#[cfg(test)]
7mod tests;
8use super::io::{IoEvent, OpenIntent, Opened};
9use super::{Directory, Editor, Key};
10use crate::files::FileTarget;
11use std::collections::HashMap;
12use strop_core::id::{BufferRevision, DocumentId, LineIndex};
13use strop_core::worker::{self, CancelReason, Completion, FailureKind, Outcome, Ticket, WorkerId};
14use strop_workspace::{Filesystem, ResourceLocation};
15
16#[derive(Default)]
17pub(crate) struct DirectoryState {
18    pub filters: HashMap<WorkerId, FilterKey>,
19    pub views: HashMap<ResourceLocation, SavedDirectoryView>,
20    order: std::collections::VecDeque<ResourceLocation>,
21}
22#[derive(Clone)]
23pub(crate) struct SavedDirectoryView {
24    pub record: super::jumps::JumpRecord,
25    pub directory: Directory,
26    row: usize,
27    top: usize,
28    column: usize,
29}
30#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31pub(crate) enum DirectoryTask {
32    Filter,
33    Reload,
34    EditNames,
35}
36#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37pub struct FilterKey {
38    pub document: DocumentId,
39    pub revision: BufferRevision,
40    pub location: ResourceLocation,
41    pub query: String,
42    pub focus: u64,
43    pub(crate) task: DirectoryTask,
44    pub(crate) draft: Option<WorkerId>,
45}
46
47impl Editor {
48    pub(crate) fn relocate_directory_history(
49        &mut self,
50        source: Option<&ResourceLocation>,
51        destination: Option<&ResourceLocation>,
52        removed: bool,
53    ) {
54        let map = |location: &ResourceLocation| -> Option<ResourceLocation> {
55            if let Some(source) = source.filter(|source| {
56                source.filesystem == location.filesystem && location.path.starts_with(&source.path)
57            }) {
58                if removed {
59                    return None;
60                }
61                if let Some(destination) = destination {
62                    return location.relocated(source, destination);
63                }
64            }
65            Some(location.clone())
66        };
67        let previous = std::mem::take(&mut self.directories.views);
68        let mut moved = Vec::new();
69        for (location, mut view) in previous {
70            let Some(target) = map(&location) else {
71                continue;
72            };
73            if removed || destination.is_some() {
74                if let Some(source) = source {
75                    view.directory.record_relocation(source, destination);
76                }
77            }
78            view.directory.location = target.clone();
79            if source.into_iter().chain(destination).any(|resource| {
80                resource.filesystem == target.filesystem
81                    && (resource.path.parent() == Some(target.path.as_path())
82                        || target.path.starts_with(&resource.path))
83            }) {
84                view.directory.stale = Some("filesystem changed; refresh required".into());
85            }
86            if target != location {
87                moved.push((target, view));
88            } else {
89                self.directories.views.insert(target, view);
90            }
91        }
92        self.directories.views.extend(moved);
93        let mut seen = std::collections::HashSet::new();
94        self.directories.order = std::mem::take(&mut self.directories.order)
95            .into_iter()
96            .filter_map(|location| map(&location))
97            .filter(|location| seen.insert(location.clone()))
98            .collect();
99    }
100    pub fn directory(&self) -> Option<&Directory> {
101        if self.docs.is_empty() {
102            return None;
103        }
104        self.docs
105            .get(self.current())
106            .and_then(|doc| doc.directory_metadata_ref())
107    }
108    pub fn directory_visibility_summary(&self) -> Option<&str> {
109        let source = self.directory()?;
110        Some(if source.stale.is_some() {
111            "stale directory — refresh required"
112        } else if !source.state.is_complete() {
113            "incomplete directory listing"
114        } else {
115            &source.summary
116        })
117    }
118    pub(crate) fn remember_directory_view(&mut self) {
119        let Some(source) = self.directory() else {
120            return;
121        };
122        if source.draft.is_some() || source.view_revision != self.buf().revision() {
123            return;
124        }
125        let location = source.location.clone();
126        let saved = SavedDirectoryView {
127            directory: source.clone(),
128            record: self.jump_record(),
129            row: self.buf().line_of(self.head()),
130            top: self.view_top(),
131            column: self.buf().col_of(self.head()),
132        };
133        self.directories.views.insert(location.clone(), saved);
134        self.directories.order.retain(|old| old != &location);
135        self.directories.order.push_back(location);
136        while self.directories.order.len() > 16
137            || self
138                .directories
139                .views
140                .values()
141                .map(|view| view.directory.entries.len())
142                .sum::<usize>()
143                > 200_000
144        {
145            let Some(old) = self.directories.order.pop_front() else {
146                break;
147            };
148            self.directories.views.remove(&old);
149        }
150    }
151    pub(crate) fn restore_directory_view(&mut self, document: DocumentId) {
152        let Some(source) = self
153            .docs
154            .get(document)
155            .and_then(|doc| doc.directory_metadata_ref())
156        else {
157            return;
158        };
159        if let Some(saved) = self.directories.views.get(&source.location).cloned() {
160            let mut view = saved.record;
161            if view.document != document {
162                let locate = |line| {
163                    saved
164                        .directory
165                        .restoration_location(LineIndex::new(line))
166                        .and_then(|location| source.line_for(&location))
167                        .map(|line| line.get())
168                        .unwrap_or(line.min(1))
169                };
170                let row = locate(saved.row);
171                let top = locate(saved.top);
172                let buffer = &self.doc(document).buf;
173                view.document = document;
174                view.offset = buffer.clamp_boundary(
175                    (buffer.line_start(row) + saved.column).min(buffer.line_end(row)),
176                );
177                view.anchor = view.offset;
178                view.extras.clear();
179                view.view_top = buffer.line_start(top);
180            }
181            self.jump_to(view);
182        } else if source.entry(LineIndex::new(2)).is_some() {
183            self.set_head(self.buf().line_start(2));
184        } else {
185            self.set_head(self.buf().line_start(1));
186        }
187    }
188    /// Textual open operands inherit a Directory scope, not an ordinary remote
189    /// file's namespace. Outside Directory, native paths retain local Ex semantics.
190    pub(crate) fn open_context(&self) -> ResourceLocation {
191        self.directory().map_or_else(
192            || ResourceLocation::local(self.cwd.clone()),
193            |directory| directory.location.clone(),
194        )
195    }
196    /// Operand context is captured in the source namespace; no ambient chdir.
197    pub(crate) fn directory_context(&self) -> ResourceLocation {
198        if let Some(source) = self.directory() {
199            return source.location.clone();
200        }
201        let source = self
202            .navigation_source()
203            .map_or(self.current(), |(source, _)| source);
204        if let Some(target) = self.doc(source).file_target(&self.cwd) {
205            if let Some(location) = target.resource_location() {
206                if let Some(parent) = strop_fs::parent(&location) {
207                    return parent;
208                }
209            }
210        }
211        ResourceLocation::local(self.cwd.clone())
212    }
213    pub(crate) fn directory_operand(&self, value: &str) -> Result<FileTarget, String> {
214        Self::directory_operand_from(self.directory_context(), value)
215    }
216    pub(crate) fn directory_operand_from(
217        mut context: ResourceLocation,
218        value: &str,
219    ) -> Result<FileTarget, String> {
220        if value.starts_with("ssh://")
221            || value.starts_with("file://")
222            || value.starts_with("container:")
223        {
224            return FileTarget::parse(value.into()).map_err(|error| error.to_string());
225        }
226        context.path = context.path.join(value);
227        FileTarget::from_location(&context).map_err(|error| error.to_string())
228    }
229    pub(crate) fn browse_directory(&mut self, operand: &str) -> Result<(), String> {
230        let target = if !operand.is_empty() {
231            Self::directory_operand_from(self.open_context(), operand)?
232        } else if let Some(source) = self.directory() {
233            FileTarget::from_location(&source.location).map_err(|error| error.to_string())?
234        } else {
235            let source = self
236                .navigation_source()
237                .map_or(self.current(), |(source, _)| source);
238            let parent = self
239                .doc(source)
240                .file_target(&self.cwd)
241                .and_then(|target| target.resource_location())
242                .and_then(|location| strop_fs::parent(&location))
243                .unwrap_or_else(|| ResourceLocation::local(self.cwd.clone()));
244            FileTarget::from_location(&parent).map_err(|error| error.to_string())?
245        };
246        self.push_jump();
247        self.request_target(target, OpenIntent::Browse);
248        Ok(())
249    }
250    pub fn reveal_source(&mut self) {
251        if self.directory().is_some() {
252            return;
253        }
254        let source = self
255            .navigation_source()
256            .map_or(self.current(), |(source, _)| source);
257        let child = self
258            .doc(source)
259            .file_target(&self.cwd)
260            .and_then(|target| target.resource_location());
261        let Some(child) = child else {
262            if let Err(error) = self.browse_directory("") {
263                self.message = error;
264            }
265            return;
266        };
267        let Some(parent) = strop_fs::parent(&child) else {
268            self.message = "resource has no parent directory".into();
269            return;
270        };
271        match FileTarget::from_location(&parent) {
272            Ok(target) => {
273                self.push_jump();
274                self.request_target(target, OpenIntent::DirectoryParent { child });
275            }
276            Err(error) => self.message = error.to_string(),
277        }
278    }
279    pub(crate) fn refresh_directory(&mut self) -> bool {
280        let Some(location) = self.directory().map(|source| source.location.clone()) else {
281            return false;
282        };
283        match FileTarget::from_location(&location) {
284            Ok(target) => self.request_target(target, OpenIntent::Refresh),
285            Err(error) => self.message = error.to_string(),
286        }
287        true
288    }
289    pub(crate) fn directory_key(&mut self, key: Key) -> bool {
290        if !matches!(key, Key::Enter | Key::Backspace | Key::Char('-')) {
291            return false;
292        }
293        let Some(source) = self.directory() else {
294            return false;
295        };
296        if source.draft.is_some() || !self.buf().readonly {
297            return false;
298        }
299        if source.view_revision != self.buf().revision() {
300            self.message = "directory presentation changed; refresh before navigating".into();
301            return true;
302        }
303        let line = self.buf().line_of(self.head());
304        let parent = matches!(key, Key::Backspace | Key::Char('-')) || line == 1;
305        let (location, intent) = if parent {
306            (
307                source.parent(),
308                OpenIntent::DirectoryParent {
309                    child: source.location.clone(),
310                },
311            )
312        } else {
313            let entry = source.entry(LineIndex::new(line));
314            (
315                entry.map(|entry| source.location_of(entry)),
316                if entry.is_some_and(|entry| {
317                    entry.observation.kind == strop_workspace::EntryKind::Directory
318                }) {
319                    OpenIntent::Browse
320                } else {
321                    OpenIntent::Switch { readonly: false }
322                },
323            )
324        };
325        match location.map(|location| FileTarget::from_location(&location)) {
326            Some(Ok(target)) => {
327                self.push_jump();
328                self.request_target(target, intent);
329            }
330            Some(Err(error)) => self.message = error.to_string(),
331            None => self.message = "no directory entry at this position".into(),
332        }
333        true
334    }
335}