Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
bump: patch
---

- docs: document Linux clipboard manager requirement
- refactor(tui): replace last_error with unified Banner system
- feat(tui): add reusable Banner component
- feat(tui): enable Wayland support with clipboard manager handoff
- chore: add Wayland/X11 clipboard dependencies
157 changes: 157 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ cargo install --path crates/kanban-cli
nix run github:fulsomenko/kanban
```

### Linux Clipboard Support

For clipboard operations (`y`/`Y` to copy branch names) to persist after exiting, you need a clipboard manager running:

- **Wayland**: `wl-clip-persist`, `cliphist`, `clipman`, or your DE's built-in manager
- **X11**: Most desktop environments include one by default

Without a clipboard manager, copied content is lost when the app exits (this is a Linux platform limitation, not a bug).

## Quick Start

### TUI
Expand Down
2 changes: 1 addition & 1 deletion crates/kanban-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ uuid.workspace = true
chrono.workspace = true
async-trait.workspace = true
tracing.workspace = true
arboard = "3.4"
arboard = { version = "3.4", features = ["wayland-data-control"] }
pulldown-cmark = "0.13"

[dev-dependencies]
Expand Down
36 changes: 19 additions & 17 deletions crates/kanban-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::{
card_list::{CardList, CardListId},
card_list_component::{CardListComponent, CardListComponentConfig},
clipboard,
components::generic_list::ListComponent,
components::{generic_list::ListComponent, Banner},
editor::edit_in_external_editor,
events::{Event, EventHandler},
filters::FilterDialogState,
Expand Down Expand Up @@ -93,7 +93,7 @@ pub struct App {
pub file_watcher: Option<kanban_persistence::FileWatcher>,
pub save_worker_handle: Option<tokio::task::JoinHandle<()>>,
pub save_completion_rx: Option<tokio::sync::mpsc::UnboundedReceiver<()>>,
pub last_error: Option<(String, Instant)>,
pub banner: Option<Banner>,
}

#[derive(Debug, Clone, PartialEq)]
Expand Down Expand Up @@ -265,7 +265,7 @@ impl App {
file_watcher: None,
save_worker_handle: None,
save_completion_rx,
last_error: None,
banner: None,
};

if let Some(ref filename) = save_file {
Expand Down Expand Up @@ -316,16 +316,16 @@ impl App {
self.push_mode(AppMode::Dialog(dialog));
}

pub fn set_error(&mut self, message: String) {
self.last_error = Some((message, Instant::now()));
pub fn set_error(&mut self, message: impl Into<String>) {
self.banner = Some(Banner::error(message));
}

pub fn clear_error(&mut self) {
self.last_error = None;
pub fn set_success(&mut self, message: impl Into<String>) {
self.banner = Some(Banner::success(message));
}

pub fn should_clear_error(&self) -> bool {
false
pub fn clear_banner(&mut self) {
self.banner = None;
}

fn keycode_matches_binding_key(
Expand Down Expand Up @@ -459,9 +459,9 @@ impl App {
use crossterm::event::KeyCode;
let mut should_restart_events = false;

// Clear error on any key press
if self.last_error.is_some() {
self.clear_error();
// Clear banner on any key press
if self.banner.is_some() {
self.clear_banner();
return false;
}

Expand Down Expand Up @@ -1594,9 +1594,11 @@ impl App {
Event::Tick => {
self.handle_animation_tick();

// Auto-clear errors after 5 seconds
if self.should_clear_error() {
self.clear_error();
// Auto-clear banner after 3 seconds
if let Some(ref banner) = self.banner {
if banner.is_expired(std::time::Duration::from_secs(3)) {
self.clear_banner();
}
}

// Handle pending conflict resolution actions
Expand Down Expand Up @@ -1879,9 +1881,9 @@ impl App {
self.app_config.effective_default_card_prefix(),
);
if let Err(e) = clipboard::copy_to_clipboard(&output) {
tracing::error!("Failed to copy to clipboard: {}", e);
self.set_error(format!("Failed to copy: {}", e));
} else {
tracing::info!("Copied {}: {}", output_type, output);
self.set_success(format!("Copied {}", output_type));
}
}
}
Expand Down
23 changes: 20 additions & 3 deletions crates/kanban-tui/src/clipboard.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,24 @@
use std::io;

pub fn copy_to_clipboard(text: &str) -> io::Result<()> {
arboard::Clipboard::new()
.and_then(|mut clipboard| clipboard.set_text(text))
.map_err(io::Error::other)
let mut clipboard = arboard::Clipboard::new().map_err(io::Error::other)?;

#[cfg(target_os = "linux")]
{
use arboard::SetExtLinux;
use std::time::{Duration, Instant};

// wait_until gives clipboard managers time to take ownership
// This prevents clipboard clearing when our app exits
clipboard
.set()
.wait_until(Instant::now() + Duration::from_millis(250))
.text(text.to_owned())
.map_err(io::Error::other)
}

#[cfg(not(target_os = "linux"))]
{
clipboard.set_text(text).map_err(io::Error::other)
}
}
Loading