cydonia 0.1.11

Desktop workspace for the ACP agents you run, keeping what they produce as files on your disk
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! The boards a project holds, and the columns and cards on them.
//!
//! A continuation of [`Workspace`]'s one `impl`, which is why it opens on
//! `use super::*`: these methods work on the same struct and reach the same
//! names as the rest of it.
use super::*;

impl Workspace {
    /// A board in `project`, called and keyed as the dialog that asked for it
    /// has them, and opened as it lands. Gated here as well as in the menus
    /// that call it: this is where a board is born.
    ///
    /// Answers what is wrong rather than making the board anyway — a key that
    /// is taken is something the dialog stays open to say, the way
    /// [`Self::edit_board`] does.
    pub fn new_board(
        &mut self,
        project: usize,
        name: String,
        key: &str,
        cx: &mut Context<Self>,
    ) -> Result<usize, String> {
        if !self.settings.features.boards {
            return Err("Boards are switched off.".to_owned());
        }
        let key = artifact::board::key::normalize(key)
            .ok_or("A key needs at least one letter or digit.".to_owned())?;
        // Read before the project is taken: the seed is the workspace's and the
        // borrow below is over the whole of it.
        let view = self.board_view;
        let open = self
            .projects
            .get_mut(project)
            .ok_or("That project is not open.".to_owned())?;
        // The same reach as [`Self::edit_board`]: a handle is heard by an agent
        // running in this project, so that is as far as it has to carry.
        if open.boards.iter().any(|board| board.key == key) {
            return Err(format!("{key} is another board's key here."));
        }
        let mut board = open
            .store()
            .create_board(name.trim(), &key)
            .ok_or("The board could not be written.".to_owned())?;
        // What the app is set to, written into the board as it is made — see
        // [`artifact::board::Board::view`]. From here the board answers for
        // itself, and the setting moving does not move it.
        board.view = view;
        open.store().save_board(&mut board);
        open.boards.insert(0, board);
        self.reveal_project(project, cx);
        self.open_board(project, 0, cx);
        Ok(0)
    }

    /// Every project's boards are on show, so picking one brings its project
    /// forward with it.
    pub fn open_board(&mut self, project: usize, ix: usize, cx: &mut Context<Self>) {
        let Some(open) = self.projects.get_mut(project) else {
            return;
        };
        if ix >= open.boards.len() {
            return;
        }
        let id = open.boards[ix].id.clone();
        if !open.load_board(&id) {
            return;
        }
        open.board = Some(ix);
        let id = open.boards[ix].id.clone();
        self.active = Some(project);
        self.remember(project, state::Kind::Board, id, cx);
        cx.notify();
    }

    /// Drop the board: the file goes with it.
    pub fn delete_board(&mut self, project: usize, ix: usize, cx: &mut Context<Self>) {
        let Some(project) = self.projects.get_mut(project) else {
            return;
        };
        if ix >= project.boards.len() {
            return;
        }
        project.store().remove_board(&project.boards.remove(ix).id);
        project.board = project
            .board
            .filter(|open| *open != ix)
            .map(|open| if open > ix { open - 1 } else { open });
        cx.notify();
    }

    /// Take the board's identity whole, as the header's panel gives it: what it
    /// is called, and the key its handles carry.
    ///
    /// Answers what is wrong rather than quietly keeping the old one — a key
    /// that is taken is something the panel stays open to say.
    ///
    /// Re-keying renames every handle on the board: `ROAD-12` becomes
    /// `BACK-12`. That is the cost of letting the key be edited at all, and it
    /// is the caller's to accept — the number is what does not move.
    pub fn edit_board(
        &mut self,
        id: &str,
        name: String,
        key: &str,
        cx: &mut Context<Self>,
    ) -> Result<(), String> {
        let key = artifact::board::key::normalize(key)
            .ok_or("A key needs at least one letter or digit.".to_owned())?;
        let Some(open) = self
            .projects
            .iter_mut()
            .find(|open| open.boards.iter().any(|board| board.id == id))
        else {
            return Ok(());
        };
        // Among this project's boards and no further: a handle is heard by an
        // agent running in this project, so that is as far as it has to carry.
        if open
            .boards
            .iter()
            .any(|board| board.id != id && board.key == key)
        {
            return Err(format!("{key} is another board's key here."));
        }
        if !open.load_board(id) {
            return Err("The board could not be read.".into());
        }
        let store = open.store();
        if let Some(board) = open.boards.iter_mut().find(|board| board.id == id) {
            board.name = name.trim().to_owned();
            board.key = key;
            store.save_board(board);
        }
        self.prune_archived(cx);
        cx.notify();
        Ok(())
    }

    /// Lay one board out the other way — see [`artifact::board::View`]. By id,
    /// because the pane that asks may be showing a board other than the one in
    /// front.
    pub fn set_board_view(
        &mut self,
        id: &str,
        view: artifact::board::View,
        cx: &mut Context<Self>,
    ) {
        self.with_board(id, |store, board| {
            board.view = view;
            store.save_board(board);
        });
        cx.notify();
    }

    pub fn archive_board(&mut self, id: &str, archived: bool, cx: &mut Context<Self>) {
        self.with_board(id, |store, board| {
            board.archived = archived;
            store.save_board(board);
        });
        self.prune_archived(cx);
        cx.notify();
    }

    /// The board the board pane would show, and the choke point the boards
    /// switch bites at: with nothing to hand back, the pane is unreachable —
    /// nothing to render, nothing to step to, nothing for the sidebar to light.
    /// The files stay where they are.
    pub fn active_board(&self) -> Option<&Board> {
        if !self.settings.features.boards {
            return None;
        }
        let project = self.active_project()?;
        project.boards.get(project.board?)
    }

    pub fn active_board_mut(&mut self) -> Option<&mut Board> {
        if !self.settings.features.boards {
            return None;
        }
        let project = self.projects.get_mut(self.active?)?;
        project.boards.get_mut(project.board?)
    }

    /// The board a file names, wherever it is open. What a rename holds onto:
    /// an index moves the moment a neighbour is made or dropped, and the file
    /// is the board — it is where [`artifact::project::Project::save_board`] writes.
    pub fn board_at(&self, id: &str) -> Option<&Board> {
        self.projects
            .iter()
            .flat_map(|open| open.boards.iter())
            .find(|board| board.id == id)
    }

    /// Reach a board wherever it is open, with the store that holds it — the
    /// project it is in, and the only thing that can write it back.
    /// A lane on the open board, answered by its id so the pane can open it
    /// straight into its name.
    pub fn new_column(&mut self, cx: &mut Context<Self>) -> Option<String> {
        let id = self
            .active_board_mut()?
            .add_column(artifact::board::column::NAMED)
            .id
            .clone();
        self.save_board();
        cx.notify();
        Some(id)
    }

    /// A lane beside the one named, on the side given — `after` for the lane
    /// that follows it in the board's own order of its columns, which runs
    /// across the lanes and down the list.
    pub fn new_column_beside(
        &mut self,
        id: &str,
        after: bool,
        cx: &mut Context<Self>,
    ) -> Option<String> {
        let minted = self
            .active_board_mut()?
            .add_column_beside(artifact::board::column::NAMED, id, after)?
            .id
            .clone();
        self.save_board();
        cx.notify();
        Some(minted)
    }

    pub fn rename_column(&mut self, id: &str, name: String, cx: &mut Context<Self>) {
        let renamed = self
            .active_board_mut()
            .is_some_and(|board| board.rename_column(id, name.trim()));
        if renamed {
            self.save_board();
        }
        cx.notify();
    }

    /// Step a lane one place along, by the lane it lands in front of — see
    /// [`Board::move_column_before`]. `None` at either end is a lane already
    /// where it is being asked to go.
    pub fn move_column(&mut self, id: &str, step: isize, cx: &mut Context<Self>) {
        let Some(board) = self.active_board_mut() else {
            return;
        };
        let Some(at) = board.columns.iter().position(|column| column.id == id) else {
            return;
        };
        let to = match at.checked_add_signed(step) {
            Some(to) if to < board.columns.len() => to,
            _ => return,
        };
        // The lane it lands in front of, read after the step rather than before
        // it: moving right means going in front of the one *after* the
        // neighbour it swaps with, and off the end means no anchor at all.
        let before = match step > 0 {
            true => board.columns.get(to + 1).map(|column| column.id.clone()),
            false => board.columns.get(to).map(|column| column.id.clone()),
        };
        if board.move_column_before(id, before.as_deref()) {
            self.save_board();
        }
        cx.notify();
    }

    /// Fold a lane shut in the list view, or open it back up. Named by the
    /// board it sits on rather than taken from the active one: a space can
    /// have two boards on screen, and the lane pressed is not always on the
    /// one in front.
    pub fn toggle_column_collapsed(&mut self, board: &str, id: &str, cx: &mut Context<Self>) {
        self.with_board(board, |store, board| {
            let Some(column) = board.columns.iter_mut().find(|column| column.id == id) else {
                return;
            };
            column.collapsed = !column.collapsed;
            store.save_board(board);
        });
        cx.notify();
    }

    /// Drop a lane, which a board refuses while it still holds cards — see
    /// [`Board::remove_column`].
    pub fn remove_column(&mut self, id: &str, cx: &mut Context<Self>) {
        let gone = self
            .active_board_mut()
            .is_some_and(|board| board.remove_column(id));
        if gone {
            self.save_board();
        }
        cx.notify();
    }

    fn with_board(&mut self, id: &str, edit: impl FnOnce(&fs::Project, &mut Board)) {
        for open in &mut self.projects {
            if !open.load_board(id) {
                return;
            }
            let store = open.store();
            if let Some(board) = open.boards.iter_mut().find(|board| board.id == id) {
                edit(&store, board);
                return;
            }
        }
    }

    /// Write the open board back, for an edit the pane made in place.
    /// Move a card between the lanes of one board, named by where it sits
    /// rather than by being the active one: a space can have two boards on
    /// screen, and the one dropped onto is not always the one in front.
    pub fn move_card_within(
        &mut self,
        (project, ix): (usize, usize),
        card: &str,
        column: &str,
        before: Option<&str>,
    ) -> bool {
        let Some(open) = self.projects.get_mut(project) else {
            return false;
        };
        let Some(id) = open.boards.get(ix).map(|board| board.id.clone()) else {
            return false;
        };
        if !open.load_board(&id) {
            return false;
        }
        let store = open.store();
        let Some(board) = open.boards.get_mut(ix) else {
            return false;
        };
        if !board.move_card_before(card, column, before) {
            return false;
        }
        store.save_board(board);
        true
    }

    /// Carry a card to another board, which may be in another project.
    ///
    /// Answers what it is called where it landed — `PLAN-3` — and nothing where
    /// it did not move. It arrives under a new handle with no session on it;
    /// see [`artifact::board::carry_card`].
    ///
    /// A card whose session is still open does not move.
    pub fn carry_card(
        &mut self,
        from: (usize, usize),
        to: (usize, usize),
        card: &str,
        column: Option<&str>,
    ) -> Option<String> {
        if from == to {
            return None;
        }
        let live = self
            .board_in(from.0, from.1)
            .and_then(|board| board.card(card))
            .and_then(|card| card.session.clone())
            .is_some_and(|record| self.session_by_record(&record).is_some());
        if live {
            return None;
        }
        // Both boards in hand before either is touched: a board listed in the
        // sidebar may not have been read off the disk yet, and moving a card
        // onto the half of one that is in memory would write the other half
        // away.
        let mut source = self.loaded_board(from)?;
        let mut landing = self.loaded_board(to)?;
        let landed = artifact::board::carry_card(&mut source, &mut landing, card, column)?;
        for ((project, _), mut board) in [(from, source), (to, landing)] {
            let Some(open) = self.projects.get_mut(project) else {
                continue;
            };
            let store = open.store();
            store.save_board(&mut board);
            if let Some(held) = open.boards.iter_mut().find(|held| held.id == board.id) {
                *held = board;
            }
        }
        Some(landed)
    }

    /// A board by where it sits, read off the disk if it has not been yet.
    fn loaded_board(&mut self, (project, ix): (usize, usize)) -> Option<Board> {
        let open = self.projects.get_mut(project)?;
        let id = open.boards.get(ix)?.id.clone();
        open.load_board(&id).then_some(())?;
        open.boards.get(ix).cloned()
    }

    pub fn save_board(&mut self) {
        let Some(open) = self.active.and_then(|ix| self.projects.get_mut(ix)) else {
            return;
        };
        if let Some(id) = open
            .board
            .and_then(|ix| open.boards.get(ix))
            .map(|board| board.id.clone())
            && !open.load_board(&id)
        {
            return;
        }
        let store = open.store();
        if let Some(board) = open.board.and_then(|ix| open.boards.get_mut(ix)) {
            store.save_board(board);
        }
    }
}