kanban-tui 0.9.0

Terminal user interface for the kanban project management tool
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
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
use super::card_list::CardListProvider;
use super::{Keybinding, KeybindingAction, KeybindingContext, KeybindingProvider};

pub struct NormalModeBoardsProvider;

impl KeybindingProvider for NormalModeBoardsProvider {
    fn get_context(&self) -> KeybindingContext {
        KeybindingContext::new(
            "Normal Mode - Projects Panel",
            vec![
                Keybinding::new("?", "help", "Show help", KeybindingAction::ShowHelp),
                Keybinding::new("q", "quit", "Quit application", KeybindingAction::Escape),
                Keybinding::new(
                    "1",
                    "panel 1",
                    "Focus projects panel",
                    KeybindingAction::FocusPanel(0),
                ),
                Keybinding::new(
                    "2",
                    "panel 2",
                    "Focus tasks panel",
                    KeybindingAction::FocusPanel(1),
                ),
                Keybinding::new(
                    "n",
                    "new",
                    "Create new project",
                    KeybindingAction::CreateBoard,
                ),
                Keybinding::new(
                    "r",
                    "rename",
                    "Rename selected project",
                    KeybindingAction::RenameBoard,
                ),
                Keybinding::new(
                    "e",
                    "edit",
                    "Edit selected project",
                    KeybindingAction::EditBoard,
                ),
                Keybinding::new(
                    "x",
                    "export",
                    "Export selected project",
                    KeybindingAction::ExportBoard,
                ),
                Keybinding::new(
                    "X",
                    "export all",
                    "Export all projects",
                    KeybindingAction::ExportAll,
                ),
                Keybinding::new(
                    "i",
                    "import",
                    "Import project from file",
                    KeybindingAction::ImportBoard,
                ),
                Keybinding::new(
                    "d",
                    "archive",
                    "Archive selected project",
                    KeybindingAction::DeleteBoard,
                ),
                Keybinding::new(
                    "D",
                    "archived",
                    "View archived projects",
                    KeybindingAction::ToggleArchivedBoardsView,
                ),
                Keybinding::new(
                    "j/↓",
                    "down",
                    "Navigate down",
                    KeybindingAction::NavigateDown,
                ),
                Keybinding::new("k/↑", "up", "Navigate up", KeybindingAction::NavigateUp),
                Keybinding::new("gg", "top", "Jump to top", KeybindingAction::JumpToTop),
                Keybinding::new(
                    "G",
                    "bottom",
                    "Jump to bottom",
                    KeybindingAction::JumpToBottom,
                ),
                Keybinding::new(
                    "Enter/Space",
                    "detail",
                    "View project detail",
                    KeybindingAction::SelectItem,
                ),
                Keybinding::new("u", "undo", "Undo last action", KeybindingAction::Undo),
                Keybinding::new(
                    "U",
                    "redo",
                    "Redo last undone action",
                    KeybindingAction::Redo,
                ),
                Keybinding::new(
                    "S",
                    "settings",
                    "Open settings view",
                    KeybindingAction::OpenSettings,
                ),
                // Board-sort affordances on the LIVE projects panel (KAN-955):
                // `s` flips the sort order and `o` opens the field picker, the
                // same shared handlers the archived-boards view uses. Without
                // these the live projects panel is unsortable from the TUI.
                Keybinding::new(
                    "s",
                    "sort order",
                    "Toggle project sort order",
                    KeybindingAction::ToggleBoardsSortOrder,
                ),
                Keybinding::new(
                    "o",
                    "sort field",
                    "Choose project sort field",
                    KeybindingAction::OrderBoards,
                ),
            ],
        )
    }
}

pub struct ArchivedCardsViewProvider;

impl ArchivedCardsViewProvider {
    /// Reused keys whose footer behaviour differs enough in the archived view
    /// that they're dropped here: create (`n`, would make an invisible live
    /// card — #414 finding 1), `q`/`Esc` (re-advertised below as the toggle
    /// instead — #414 finding 3), and `V`/`t`/`T`/`1` (mutate shared board or
    /// live-filter display state, or desync focus, with no deliberate design
    /// backing their exposure here — unlike the reused bindings this view
    /// keeps, e.g. detail/priority/move/sprint-assign).
    const EXCLUDED_KEYS: &'static [&'static str] = &["n", "q", "Esc", "V", "t", "T", "1"];
}

impl KeybindingProvider for ArchivedCardsViewProvider {
    /// The archived-cards view is the ordinary card panel showing a different
    /// SET, so it DELEGATES to `CardListProvider` and inherits the full card-list
    /// bindings (LSP) — an archived card is operated exactly like a live one. It
    /// then adjusts only where behaviour differs:
    /// - drops `Self::EXCLUDED_KEYS` (see its doc comment);
    /// - drops the `d` archive binding (`ArchiveCard`): the cards are already
    ///   archived, so re-archiving is redundant/confusing;
    /// - drops the `D` toggle binding (`ToggleArchivedView`): it points the wrong
    ///   direction (back to live) and duplicates the `q`/`Esc` toggle;
    /// - appends the archived extension: `r` restore, `x` permanent-delete.
    ///
    /// Note `1` is dropped from the footer here but the dispatch side
    /// (`handle_archived_cards_view_mode`) still lets it through under
    /// `ColumnView` boards, where it navigates to column 0 rather than
    /// switching focus.
    fn get_context(&self) -> KeybindingContext {
        let mut bindings = CardListProvider.get_context().bindings;

        bindings.retain(|b| {
            !Self::EXCLUDED_KEYS.contains(&b.key.as_str())
                && b.action != KeybindingAction::ArchiveCard
                && b.action != KeybindingAction::ToggleArchivedView
        });

        // Archived extension keys.
        bindings.push(Keybinding::new(
            "r",
            "restore",
            "Restore selected task(s)",
            KeybindingAction::RestoreCard,
        ));
        bindings.push(Keybinding::new(
            "x",
            "delete",
            "Permanently delete selected task(s)",
            KeybindingAction::DeleteCard,
        ));
        // Reconciled toggle-back text (not "Quit" / "clear selection").
        bindings.push(Keybinding::new(
            "q/Esc",
            "back",
            "Back to live tasks view",
            KeybindingAction::Escape,
        ));

        KeybindingContext::new("Archived Cards View", bindings)
    }
}

pub struct ArchivedBoardsViewProvider;

impl ArchivedBoardsViewProvider {
    /// The live-panel actions the archived view REUSES verbatim (same key, same
    /// handler, delegated through `handle_shared_boards_key`). The archived
    /// provider is derived from `NormalModeBoardsProvider` by keeping exactly
    /// these bindings and appending the extension/toggle keys, so navigation,
    /// drill-in, settings and undo/redo can never drift from the live panel.
    /// Live-only operations (create/rename/edit/export/import, the `d` archive,
    /// the `D` toggle) are intentionally NOT reused: they are no-ops or
    /// misbehave against the archived set, so they are curated out here.
    const REUSED_ACTIONS: &'static [KeybindingAction] = &[
        KeybindingAction::ShowHelp,
        KeybindingAction::NavigateDown,
        KeybindingAction::NavigateUp,
        KeybindingAction::JumpToTop,
        KeybindingAction::JumpToBottom,
        KeybindingAction::SelectItem,
        KeybindingAction::Undo,
        KeybindingAction::Redo,
        KeybindingAction::OpenSettings,
    ];
}

impl KeybindingProvider for ArchivedBoardsViewProvider {
    fn get_context(&self) -> KeybindingContext {
        // Delegate to the live projects provider and keep only the shared
        // bindings, then append the archived-view extension (restore /
        // permanent-delete) and the toggle-back binding. This mirrors the card
        // side: the archived view IS the ordinary panel on a different set plus a
        // small extension, not a hand-maintained parallel list.
        let live = NormalModeBoardsProvider.get_context();
        let mut bindings: Vec<Keybinding> = live
            .bindings
            .into_iter()
            .filter(|b| Self::REUSED_ACTIONS.contains(&b.action))
            .collect();

        bindings.extend([
            Keybinding::new(
                "r",
                "restore",
                "Restore selected project",
                KeybindingAction::RestoreBoard,
            ),
            Keybinding::new(
                "x",
                "delete",
                "Permanently delete selected project",
                KeybindingAction::DeleteArchivedBoard,
            ),
            // Reuse the shared SortOrder toggle to flip the projects-panel sort
            // order (the unified board sort, persisted to config).
            Keybinding::new(
                "s",
                "sort order",
                "Toggle project sort order",
                KeybindingAction::ToggleBoardsSortOrder,
            ),
            // Open the board-sort field picker (Position / Name / Date Created /
            // Recency), the board-side analogue of the card `o` picker.
            Keybinding::new(
                "o",
                "sort field",
                "Choose project sort field",
                KeybindingAction::OrderBoards,
            ),
            // Reused binding whose archived-view behavior DIFFERS from the live
            // panel's `q` (quit): here it toggles back to the live projects list.
            // The help text describes the actual behavior.
            Keybinding::new(
                "q/Esc",
                "back",
                "Back to projects view",
                KeybindingAction::Escape,
            ),
        ]);

        KeybindingContext::new("Archived Projects View", bindings)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_boards_provider_binds_d_to_remove_and_shift_d_to_archived_view() {
        // Mirrors the card removal flow where `d` is the primary removal key
        // (now archive) and `D` toggles the archived-boards view.
        let ctx = NormalModeBoardsProvider.get_context();
        let matches: Vec<_> = ctx.bindings.iter().filter(|b| b.key == "d").collect();
        assert_eq!(
            matches.len(),
            1,
            "exactly one 'd' binding on the boards panel"
        );
        assert_eq!(matches[0].action, KeybindingAction::DeleteBoard);
        let shift_d: Vec<_> = ctx.bindings.iter().filter(|b| b.key == "D").collect();
        assert_eq!(
            shift_d.len(),
            1,
            "exactly one 'D' binding on the boards panel"
        );
        assert_eq!(
            shift_d[0].action,
            KeybindingAction::ToggleArchivedBoardsView,
            "'D' toggles the archived-boards view"
        );
    }

    #[test]
    fn test_archived_boards_view_provider_binds_restore_and_delete() {
        let ctx = ArchivedBoardsViewProvider.get_context();
        assert!(ctx
            .bindings
            .iter()
            .any(|b| b.key == "r" && b.action == KeybindingAction::RestoreBoard));
        assert!(ctx
            .bindings
            .iter()
            .any(|b| b.key == "x" && b.action == KeybindingAction::DeleteArchivedBoard));
    }

    #[test]
    fn test_archived_boards_view_provider_binds_sort_order_toggle() {
        let ctx = ArchivedBoardsViewProvider.get_context();
        assert!(
            ctx.bindings
                .iter()
                .any(|b| b.key == "s" && b.action == KeybindingAction::ToggleBoardsSortOrder),
            "'s' toggles the projects-panel sort order via the shared SortOrder toggle"
        );
    }

    /// The LIVE projects panel must also bind the board-sort affordances (`s`
    /// order toggle, `o` field picker); otherwise live-board sort is unreachable
    /// from the TUI even though the service and handler support it (KAN-955).
    #[test]
    fn test_live_projects_panel_binds_sort_keys() {
        let ctx = NormalModeBoardsProvider.get_context();
        assert!(
            ctx.bindings
                .iter()
                .any(|b| b.key == "s" && b.action == KeybindingAction::ToggleBoardsSortOrder),
            "live projects panel binds 's' to toggle the board sort order"
        );
        assert!(
            ctx.bindings
                .iter()
                .any(|b| b.key == "o" && b.action == KeybindingAction::OrderBoards),
            "live projects panel binds 'o' to the board sort field picker"
        );
    }

    /// The archived-boards view is the ordinary projects panel showing a
    /// different SET: it reuses the shared navigation/activation bindings that the
    /// dispatch delegates to `handle_shared_boards_key`, rather than hand-rolling a
    /// divergent list that can drift from the live panel.
    #[test]
    fn test_archived_boards_provider_reuses_shared_navigation_bindings() {
        let ctx = ArchivedBoardsViewProvider.get_context();
        let has = |action: KeybindingAction| ctx.bindings.iter().any(|b| b.action == action);
        assert!(has(KeybindingAction::NavigateDown), "j/↓ navigate down");
        assert!(has(KeybindingAction::NavigateUp), "k/↑ navigate up");
        assert!(has(KeybindingAction::JumpToTop), "gg jump to top");
        assert!(has(KeybindingAction::JumpToBottom), "G jump to bottom");
        assert!(has(KeybindingAction::Undo), "u undo");
        assert!(has(KeybindingAction::Redo), "U redo");
    }

    /// Drilling into an archived board is the SAME activation the live panel uses
    /// (Enter/Space → `handle_selection_activate`). It must be advertised so help
    /// describes the real behavior, not a truncated one.
    #[test]
    fn test_archived_boards_provider_advertises_drill_in_activation() {
        let ctx = ArchivedBoardsViewProvider.get_context();
        assert!(
            ctx.bindings
                .iter()
                .any(|b| b.key == "Enter/Space" && b.action == KeybindingAction::SelectItem),
            "archived view must advertise Enter/Space drill-in (shared activation)"
        );
    }

    /// Live-only board operations that the archived dispatch does NOT handle must
    /// be EXCLUDED from the provider, so help never advertises a binding that is a
    /// no-op (or misbehaves) in the archived view. This is the curation half of
    /// the LSP contract: the archived view substitutes for the live panel only on
    /// the bindings that actually apply.
    #[test]
    fn test_archived_boards_provider_excludes_live_only_operations() {
        let ctx = ArchivedBoardsViewProvider.get_context();
        for action in [
            KeybindingAction::CreateBoard,
            KeybindingAction::DeleteBoard,
            KeybindingAction::EditBoard,
            KeybindingAction::ExportBoard,
            KeybindingAction::ExportAll,
            KeybindingAction::ImportBoard,
            KeybindingAction::ToggleArchivedBoardsView,
        ] {
            assert!(
                !ctx.bindings.iter().any(|b| b.action == action),
                "live-only action {action:?} must not be advertised in the archived view"
            );
        }
    }

    /// In the archived view `q`/`Esc` do NOT quit the app: they toggle back to the
    /// live projects list. The help text for the reused binding must describe that
    /// actual behavior, not the live panel's "quit".
    #[test]
    fn test_archived_board_help_describes_toggle() {
        let ctx = ArchivedBoardsViewProvider.get_context();
        let back = ctx
            .bindings
            .iter()
            .find(|b| b.key == "q/Esc")
            .expect("archived view binds q/Esc");
        assert_eq!(back.action, KeybindingAction::Escape);
        let desc = back.description.to_lowercase();
        assert!(
            desc.contains("projects") && !desc.contains("quit"),
            "q/Esc help must describe toggling back to projects, not quitting; got {:?}",
            back.description
        );
    }

    #[test]
    fn test_delete_board_action_is_distinct_from_delete_column() {
        assert_ne!(
            KeybindingAction::DeleteBoard,
            KeybindingAction::DeleteColumn
        );
    }

    /// The archived-cards view must not advertise inert/wrong keys: `d`
    /// (`ArchiveCard`) archives an already-archived card (redundant), and `D`
    /// (`ToggleArchivedView`) points the wrong direction. Mirror the board side's
    /// REUSED_ACTIONS curation: exclude both from the archived-cards provider.
    #[test]
    fn test_archived_cards_provider_excludes_archive_key() {
        let ctx = ArchivedCardsViewProvider.get_context();
        assert!(
            !ctx.bindings
                .iter()
                .any(|b| b.action == KeybindingAction::ArchiveCard),
            "archived-cards view must not advertise the `d` archive key"
        );
        assert!(
            !ctx.bindings
                .iter()
                .any(|b| b.action == KeybindingAction::ToggleArchivedView),
            "archived-cards view must not advertise `D` toggle back to live (dropped)"
        );
    }
}