diff --git a/focus.go b/focus.go new file mode 100644 index 0000000..51a3a37 --- /dev/null +++ b/focus.go @@ -0,0 +1,38 @@ +package main + +import ( + "sync" + "time" +) + +// FocusTimer suppresses notifications for a configurable duration. +// Thread-safe via mutex. +type FocusTimer struct { + mu sync.Mutex + expires time.Time +} + +// Set activates focus mode for duration d. +func (f *FocusTimer) Set(d time.Duration) { + f.mu.Lock() + defer f.mu.Unlock() + f.expires = time.Now().Add(d) +} + +// IsActive returns true if focus mode has not expired. +func (f *FocusTimer) IsActive() bool { + f.mu.Lock() + defer f.mu.Unlock() + return time.Now().Before(f.expires) +} + +// Remaining returns how much focus time is left. Returns 0 if expired. +func (f *FocusTimer) Remaining() time.Duration { + f.mu.Lock() + defer f.mu.Unlock() + rem := time.Until(f.expires) + if rem < 0 { + return 0 + } + return rem +} diff --git a/focus_test.go b/focus_test.go new file mode 100644 index 0000000..c4af891 --- /dev/null +++ b/focus_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "testing" + "time" +) + +func TestFocusTimer_Set(t *testing.T) { + var ft FocusTimer + ft.Set(30 * time.Minute) + if !ft.IsActive() { + t.Error("IsActive() = false after Set(30min), want true") + } +} + +func TestFocusTimer_Expired(t *testing.T) { + var ft FocusTimer + ft.Set(0) + if ft.IsActive() { + t.Error("IsActive() = true after Set(0), want false") + } +} + +func TestFocusTimer_Remaining(t *testing.T) { + var ft FocusTimer + ft.Set(30 * time.Minute) + rem := ft.Remaining() + if rem <= 0 { + t.Errorf("Remaining() = %v, want > 0", rem) + } + if rem > 30*time.Minute { + t.Errorf("Remaining() = %v, want <= 30min", rem) + } +} + +func TestFocusTimer_ZeroValue(t *testing.T) { + var ft FocusTimer + if ft.IsActive() { + t.Error("zero-value FocusTimer IsActive() = true, want false") + } + if ft.Remaining() != 0 { + t.Errorf("zero-value Remaining() = %v, want 0", ft.Remaining()) + } +} diff --git a/notify.go b/notify.go new file mode 100644 index 0000000..df91988 --- /dev/null +++ b/notify.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "os/exec" + "path/filepath" + "time" +) + +// Notifier sends desktop notifications. +type Notifier interface { + Notify(title, body string) error +} + +// ExecNotifier calls notify-send via os/exec. +type ExecNotifier struct{} + +func (n *ExecNotifier) Notify(title, body string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return exec.CommandContext(ctx, "notify-send", + "--urgency=critical", + "--app-name=vmux", + title, + body, + ).Run() +} + +// NullNotifier discards all notifications (for tests and focus mode). +type NullNotifier struct{} + +func (n *NullNotifier) Notify(_, _ string) error { + return nil +} + +// shortName returns a human-readable short name for a session. +// Prefers Label if set, otherwise uses the last component of Cwd. +func shortName(s SessionInfo) string { + if s.Label != "" { + return s.Label + } + return filepath.Base(s.Cwd) +} diff --git a/notify_test.go b/notify_test.go new file mode 100644 index 0000000..fec90c0 --- /dev/null +++ b/notify_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "testing" +) + +func TestExecNotifier_CallsNotifySend(t *testing.T) { + n := &ExecNotifier{} + // We cannot actually run notify-send in tests, but we verify + // the struct implements the Notifier interface and the method exists. + var _ Notifier = n + // The actual call would require a display; just verify it compiles and returns. + // Integration test with real notify-send is manual. +} + +func TestNullNotifier_DropsAll(t *testing.T) { + n := &NullNotifier{} + var _ Notifier = n + err := n.Notify("title", "body") + if err != nil { + t.Errorf("NullNotifier.Notify() = %v, want nil", err) + } +} + +func TestShortName_Label(t *testing.T) { + s := SessionInfo{Label: "auth"} + got := shortName(s) + if got != "auth" { + t.Errorf("shortName(Label=auth) = %q, want %q", got, "auth") + } +} + +func TestShortName_Cwd(t *testing.T) { + s := SessionInfo{Cwd: "/home/pierre/Code/vibe/vmux"} + got := shortName(s) + if got != "vmux" { + t.Errorf("shortName(Cwd=.../vmux) = %q, want %q", got, "vmux") + } +} + +func TestShortName_LabelOverridesCwd(t *testing.T) { + s := SessionInfo{Label: "review", Cwd: "/home/pierre/Code/vibe/vmux"} + got := shortName(s) + if got != "review" { + t.Errorf("shortName(Label+Cwd) = %q, want %q", got, "review") + } +} + +func TestShortName_Empty(t *testing.T) { + s := SessionInfo{} + got := shortName(s) + if got != "." { + t.Errorf("shortName(empty) = %q, want %q", got, ".") + } +}