687 lines
19 KiB
Go
687 lines
19 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hjbdev/patterm/internal/mcp"
|
|
)
|
|
|
|
// pendingTimerKind picks the firing rule.
|
|
type pendingTimerKind string
|
|
|
|
const (
|
|
timerKindDelay pendingTimerKind = "delay"
|
|
timerKindIdleAny pendingTimerKind = "idle_any"
|
|
timerKindIdleAll pendingTimerKind = "idle_all"
|
|
)
|
|
|
|
const (
|
|
timerStatusPending = "pending"
|
|
timerStatusPaused = "paused"
|
|
timerStatusFired = "fired"
|
|
timerStatusCanceled = "canceled"
|
|
)
|
|
|
|
// pendingTimer is one live timer tracked by the manager. The body is
|
|
// delivered verbatim to the owning child's PTY as a fresh user turn
|
|
// when the timer fires.
|
|
//
|
|
// Locking: every field is protected by timerManager.mu. The runtime
|
|
// time.Timer (rt) is started outside the lock so the firing goroutine
|
|
// can take the lock without deadlocking.
|
|
type pendingTimer struct {
|
|
id string
|
|
label string
|
|
body string
|
|
ownerID string
|
|
kind pendingTimerKind
|
|
status string
|
|
|
|
watched []string
|
|
idleBaseline map[string]bool // for idle_any: ids already idle at registration (excluded from satisfaction)
|
|
|
|
firesAt time.Time
|
|
pausedRemaining time.Duration
|
|
pausedWasMaxWait bool // for idle_*: true if the active timer was max-wait, not delay
|
|
|
|
rt *time.Timer // delay timer or idle_* max-wait fallback
|
|
}
|
|
|
|
// timerManager owns the pending-timer registry. Mutating operations
|
|
// (set, cancel, pause, resume) all serialise through mu; fire callbacks
|
|
// from the runtime timer also take mu to safely transition state.
|
|
type timerManager struct {
|
|
sess *Session
|
|
|
|
mu sync.Mutex
|
|
nextID int
|
|
timers map[string]*pendingTimer
|
|
changes chan struct{}
|
|
|
|
// fireFn is the callback used to deliver the body to the owning
|
|
// process. Decoupled so tests can substitute a recorder. Defaults
|
|
// to caller.InjectAsOrchestrator + "\r".
|
|
fireFn func(owner *Child, body, label string)
|
|
}
|
|
|
|
func newTimerManager(sess *Session) *timerManager {
|
|
m := &timerManager{
|
|
sess: sess,
|
|
timers: make(map[string]*pendingTimer),
|
|
changes: make(chan struct{}, 1),
|
|
}
|
|
m.fireFn = defaultFireFn
|
|
return m
|
|
}
|
|
|
|
func (m *timerManager) changeEvents() <-chan struct{} {
|
|
return m.changes
|
|
}
|
|
|
|
func (m *timerManager) notifyChanged() {
|
|
select {
|
|
case m.changes <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func defaultFireFn(owner *Child, body, label string) {
|
|
if owner == nil || !owner.IsLive() {
|
|
return
|
|
}
|
|
// Solo delivers body verbatim. patterm's PTY-injection path expects
|
|
// a trailing CR so the line submits in TUI agents (Claude/Codex/
|
|
// OpenCode all paste-detect). A bare body without CR sits in the
|
|
// input buffer; that's almost never what the caller wants.
|
|
if body == "" {
|
|
body = fmt.Sprintf("[system] Your timer [%s] has completed.", label)
|
|
}
|
|
_ = owner.InjectAsOrchestrator([]byte(body + "\r"))
|
|
}
|
|
|
|
func (m *timerManager) mintID() string {
|
|
m.nextID++
|
|
return fmt.Sprintf("t%d", m.nextID)
|
|
}
|
|
|
|
// TimerSet schedules a delay timer. Returns immediately; the body is
|
|
// delivered to the owning child when the timer fires.
|
|
func (m *timerManager) TimerSet(ownerID string, body, label string, seconds float64) (string, error) {
|
|
owner := m.sess.FindChild(ownerID)
|
|
if owner == nil {
|
|
return "", mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", ownerID)
|
|
}
|
|
if seconds < 0 {
|
|
return "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer_set: seconds must be ≥ 0")
|
|
}
|
|
d := time.Duration(seconds * float64(time.Second))
|
|
m.mu.Lock()
|
|
id := m.mintID()
|
|
if label == "" {
|
|
label = id
|
|
}
|
|
t := &pendingTimer{
|
|
id: id,
|
|
label: label,
|
|
body: body,
|
|
ownerID: ownerID,
|
|
kind: timerKindDelay,
|
|
status: timerStatusPending,
|
|
firesAt: time.Now().Add(d),
|
|
}
|
|
m.timers[id] = t
|
|
m.mu.Unlock()
|
|
t.rt = time.AfterFunc(d, func() { m.fireDelay(id) })
|
|
m.notifyChanged()
|
|
return id, nil
|
|
}
|
|
|
|
func (m *timerManager) fireDelay(id string) {
|
|
m.mu.Lock()
|
|
t, ok := m.timers[id]
|
|
if !ok || t.status != timerStatusPending {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
t.status = timerStatusFired
|
|
owner := m.sess.FindChild(t.ownerID)
|
|
body, label := t.body, t.label
|
|
delete(m.timers, id)
|
|
m.mu.Unlock()
|
|
m.notifyChanged()
|
|
m.fireFn(owner, body, label)
|
|
}
|
|
|
|
// TimerFireWhenIdleAny schedules an idle-any timer. Children already
|
|
// idle at registration are excluded from satisfaction — only a
|
|
// transition into idle by a still-active watched child fires the
|
|
// timer. Max-wait, when positive, acts as a fallback fire deadline.
|
|
func (m *timerManager) TimerFireWhenIdleAny(ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) {
|
|
return m.registerIdleTimer(timerKindIdleAny, ownerID, body, label, watched, maxWait)
|
|
}
|
|
|
|
// TimerFireWhenIdleAll schedules an idle-all timer. Already-idle
|
|
// children count as satisfied; if every watched child is already idle
|
|
// at registration time the response is "already_satisfied" with no
|
|
// timer created.
|
|
func (m *timerManager) TimerFireWhenIdleAll(ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) {
|
|
return m.registerIdleTimer(timerKindIdleAll, ownerID, body, label, watched, maxWait)
|
|
}
|
|
|
|
func (m *timerManager) registerIdleTimer(kind pendingTimerKind, ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) {
|
|
if m.sess.FindChild(ownerID) == nil {
|
|
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", ownerID)
|
|
}
|
|
if len(watched) == 0 {
|
|
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "watched must contain at least one process_id")
|
|
}
|
|
if maxWait < 0 {
|
|
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "max_wait_seconds must be ≥ 0")
|
|
}
|
|
// Validate watched ids and compute the idle baseline up front.
|
|
already := make([]string, 0)
|
|
waiting := make([]string, 0)
|
|
baseline := make(map[string]bool, len(watched))
|
|
for _, id := range watched {
|
|
c := m.sess.FindChild(id)
|
|
if c == nil {
|
|
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q in watched", id)
|
|
}
|
|
if isIdleState(c.IdleState()) {
|
|
already = append(already, id)
|
|
baseline[id] = true
|
|
} else {
|
|
waiting = append(waiting, id)
|
|
}
|
|
}
|
|
resp := mcp.TimerFireWhenIdleResponse{AlreadyIdle: already, WaitingOn: waiting}
|
|
|
|
// idle_all: if all watched are already idle, satisfy synchronously
|
|
// — Solo semantics; no pending timer is created.
|
|
if kind == timerKindIdleAll && len(waiting) == 0 {
|
|
resp.Status = "already_satisfied"
|
|
owner := m.sess.FindChild(ownerID)
|
|
go m.fireFn(owner, body, label)
|
|
return resp, nil
|
|
}
|
|
|
|
m.mu.Lock()
|
|
id := m.mintID()
|
|
if label == "" {
|
|
label = id
|
|
}
|
|
t := &pendingTimer{
|
|
id: id,
|
|
label: label,
|
|
body: body,
|
|
ownerID: ownerID,
|
|
kind: kind,
|
|
status: timerStatusPending,
|
|
watched: append([]string(nil), watched...),
|
|
idleBaseline: baseline,
|
|
}
|
|
if maxWait > 0 {
|
|
d := time.Duration(maxWait * float64(time.Second))
|
|
t.firesAt = time.Now().Add(d)
|
|
t.rt = time.AfterFunc(d, func() { m.fireIdleMaxWait(id) })
|
|
}
|
|
m.timers[id] = t
|
|
m.mu.Unlock()
|
|
m.notifyChanged()
|
|
resp.ID = id
|
|
resp.Status = "pending"
|
|
return resp, nil
|
|
}
|
|
|
|
func (m *timerManager) fireIdleMaxWait(id string) {
|
|
m.mu.Lock()
|
|
t, ok := m.timers[id]
|
|
if !ok || t.status != timerStatusPending {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
t.status = timerStatusFired
|
|
owner := m.sess.FindChild(t.ownerID)
|
|
body, label := t.body, t.label
|
|
delete(m.timers, id)
|
|
m.mu.Unlock()
|
|
m.notifyChanged()
|
|
m.fireFn(owner, body, label)
|
|
}
|
|
|
|
// onChildStateChanged evaluates every pending idle_any / idle_all
|
|
// timer whenever any child's IdleState flips. Cheap — there are few
|
|
// pending timers and the per-tick check is just a map lookup + a slice
|
|
// scan.
|
|
func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
|
|
if !isIdleState(state) {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
type firing struct {
|
|
owner *Child
|
|
body string
|
|
label string
|
|
}
|
|
var fires []firing
|
|
var firedIDs []string
|
|
for _, t := range m.timers {
|
|
if t.status != timerStatusPending {
|
|
continue
|
|
}
|
|
if !contains(t.watched, childID) {
|
|
continue
|
|
}
|
|
switch t.kind {
|
|
case timerKindIdleAny:
|
|
if t.idleBaseline[childID] {
|
|
continue // already idle at registration; excluded
|
|
}
|
|
t.status = timerStatusFired
|
|
if t.rt != nil {
|
|
t.rt.Stop()
|
|
}
|
|
fires = append(fires, firing{
|
|
owner: m.sess.FindChild(t.ownerID),
|
|
body: t.body,
|
|
label: t.label,
|
|
})
|
|
firedIDs = append(firedIDs, t.id)
|
|
case timerKindIdleAll:
|
|
if m.allWatchedIdleLocked(t) {
|
|
t.status = timerStatusFired
|
|
if t.rt != nil {
|
|
t.rt.Stop()
|
|
}
|
|
fires = append(fires, firing{
|
|
owner: m.sess.FindChild(t.ownerID),
|
|
body: t.body,
|
|
label: t.label,
|
|
})
|
|
firedIDs = append(firedIDs, t.id)
|
|
}
|
|
}
|
|
}
|
|
for _, id := range firedIDs {
|
|
delete(m.timers, id)
|
|
}
|
|
m.mu.Unlock()
|
|
if len(firedIDs) > 0 {
|
|
m.notifyChanged()
|
|
}
|
|
for _, f := range fires {
|
|
m.fireFn(f.owner, f.body, f.label)
|
|
}
|
|
}
|
|
|
|
// onChildClosed drops pending timer references to childID. Called
|
|
// from Session.Close (and the terminal-corpse cleanup in reapChild)
|
|
// via the session listener bus — a deliberate signal from the host
|
|
// that childID is gone and the parent is not waiting on it anymore.
|
|
//
|
|
// Semantics:
|
|
// - timers owned by childID are cancelled and deleted: their owner
|
|
// is gone, so even if defaultFireFn's IsLive guard would no-op
|
|
// the delivery, the entry has no business surviving a close.
|
|
// - timers watching childID have childID pruned from t.watched
|
|
// (and t.idleBaseline). If t.watched becomes empty the timer is
|
|
// cancelled and deleted; we deliberately do NOT synthesise a
|
|
// fire here. The parent already received any legitimate idle
|
|
// transition before close_process — see allWatchedIdleLocked's
|
|
// "treat as satisfied" comment, which only applies to a
|
|
// concurrent re-evaluation, not to this explicit-removal hook.
|
|
//
|
|
// The natural-exit path (reapChild → emitExit for agent/command
|
|
// kinds) is NOT routed through here: the classifier emits a final
|
|
// idle transition on exit, which fires and deletes any watching
|
|
// timers exactly once. Cancelling on exit would swallow that
|
|
// legitimate fire and leave the parent never notified.
|
|
func (m *timerManager) onChildClosed(childID string) {
|
|
m.mu.Lock()
|
|
changed := false
|
|
for id, t := range m.timers {
|
|
if t.ownerID == childID {
|
|
if t.rt != nil {
|
|
t.rt.Stop()
|
|
t.rt = nil
|
|
}
|
|
t.status = timerStatusCanceled
|
|
delete(m.timers, id)
|
|
changed = true
|
|
continue
|
|
}
|
|
if !contains(t.watched, childID) {
|
|
continue
|
|
}
|
|
pruned := t.watched[:0]
|
|
for _, w := range t.watched {
|
|
if w != childID {
|
|
pruned = append(pruned, w)
|
|
}
|
|
}
|
|
t.watched = pruned
|
|
if t.idleBaseline != nil {
|
|
delete(t.idleBaseline, childID)
|
|
}
|
|
changed = true
|
|
if len(t.watched) == 0 {
|
|
if t.rt != nil {
|
|
t.rt.Stop()
|
|
t.rt = nil
|
|
}
|
|
t.status = timerStatusCanceled
|
|
delete(m.timers, id)
|
|
}
|
|
}
|
|
m.mu.Unlock()
|
|
if changed {
|
|
m.notifyChanged()
|
|
}
|
|
}
|
|
|
|
// allWatchedIdleLocked reports whether every watched child is now
|
|
// idle. Called with m.mu held — uses live Child.IdleState() under the
|
|
// child's own atomic, not under m.mu.
|
|
func (m *timerManager) allWatchedIdleLocked(t *pendingTimer) bool {
|
|
for _, id := range t.watched {
|
|
c := m.sess.FindChild(id)
|
|
if c == nil {
|
|
continue // disappeared; treat as satisfied so we don't hang
|
|
}
|
|
if !isIdleState(c.IdleState()) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// TimerCancel removes a pending or paused timer owned by ownerID.
|
|
func (m *timerManager) TimerCancel(ownerID, id string) error {
|
|
m.mu.Lock()
|
|
t, ok := m.timers[id]
|
|
if !ok {
|
|
m.mu.Unlock()
|
|
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
|
|
}
|
|
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
|
|
// MCP client); allow it to manage every timer in the session.
|
|
// Otherwise the caller's own id must match the timer's owner.
|
|
if ownerID != "" && t.ownerID != ownerID {
|
|
m.mu.Unlock()
|
|
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
|
|
}
|
|
if t.status == timerStatusFired || t.status == timerStatusCanceled {
|
|
// Cancelling a fired/cancelled timer is idempotent.
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
if t.rt != nil {
|
|
t.rt.Stop()
|
|
t.rt = nil
|
|
}
|
|
t.status = timerStatusCanceled
|
|
delete(m.timers, id)
|
|
m.mu.Unlock()
|
|
m.notifyChanged()
|
|
return nil
|
|
}
|
|
|
|
// TimerPause stops the delay clock (or detaches the idle watch) but
|
|
// keeps the timer in the registry.
|
|
func (m *timerManager) TimerPause(ownerID, id string) error {
|
|
m.mu.Lock()
|
|
t, ok := m.timers[id]
|
|
if !ok {
|
|
m.mu.Unlock()
|
|
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
|
|
}
|
|
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
|
|
// MCP client); allow it to manage every timer in the session.
|
|
// Otherwise the caller's own id must match the timer's owner.
|
|
if ownerID != "" && t.ownerID != ownerID {
|
|
m.mu.Unlock()
|
|
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
|
|
}
|
|
if t.status != timerStatusPending {
|
|
m.mu.Unlock()
|
|
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id)
|
|
}
|
|
if t.rt != nil {
|
|
t.pausedRemaining = time.Until(t.firesAt)
|
|
if t.pausedRemaining < 0 {
|
|
t.pausedRemaining = 0
|
|
}
|
|
t.rt.Stop()
|
|
t.rt = nil
|
|
// For idle_* timers, only the max-wait timer rides on rt — the
|
|
// idle-evaluation path lives in onChildStateChanged. Mark the
|
|
// pause so resume rearms the right thing.
|
|
t.pausedWasMaxWait = t.kind != timerKindDelay
|
|
}
|
|
t.status = timerStatusPaused
|
|
m.mu.Unlock()
|
|
m.notifyChanged()
|
|
return nil
|
|
}
|
|
|
|
// TimerResume re-arms a paused timer. For delay timers the remaining
|
|
// duration is restored; idle-* timers re-attach to the state-change
|
|
// watch list, and any remaining max-wait clock resumes.
|
|
//
|
|
// Idle-* timers also re-check their satisfaction condition immediately
|
|
// on resume: idle transitions that occurred while paused are otherwise
|
|
// missed (onChildStateChanged only sees future flips), so a child that
|
|
// went idle during the pause window would never fire the timer. For
|
|
// idle_any we look for any non-baseline watched child currently idle;
|
|
// for idle_all we check whether every watched child is now idle.
|
|
func (m *timerManager) TimerResume(ownerID, id string) error {
|
|
m.mu.Lock()
|
|
t, ok := m.timers[id]
|
|
if !ok {
|
|
m.mu.Unlock()
|
|
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
|
|
}
|
|
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
|
|
// MCP client); allow it to manage every timer in the session.
|
|
// Otherwise the caller's own id must match the timer's owner.
|
|
if ownerID != "" && t.ownerID != ownerID {
|
|
m.mu.Unlock()
|
|
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
|
|
}
|
|
if t.status != timerStatusPaused {
|
|
m.mu.Unlock()
|
|
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not paused", id)
|
|
}
|
|
t.status = timerStatusPending
|
|
if t.pausedRemaining > 0 {
|
|
t.firesAt = time.Now().Add(t.pausedRemaining)
|
|
switch t.kind {
|
|
case timerKindDelay:
|
|
localID := id
|
|
t.rt = time.AfterFunc(t.pausedRemaining, func() { m.fireDelay(localID) })
|
|
default:
|
|
localID := id
|
|
t.rt = time.AfterFunc(t.pausedRemaining, func() { m.fireIdleMaxWait(localID) })
|
|
}
|
|
t.pausedRemaining = 0
|
|
t.pausedWasMaxWait = false
|
|
}
|
|
// For idle-* timers, evaluate the condition right now in case a
|
|
// watched child went idle while paused.
|
|
var fireNow bool
|
|
var owner *Child
|
|
var body, label string
|
|
switch t.kind {
|
|
case timerKindIdleAny:
|
|
for _, wid := range t.watched {
|
|
if t.idleBaseline[wid] {
|
|
continue
|
|
}
|
|
c := m.sess.FindChild(wid)
|
|
if c != nil && isIdleState(c.IdleState()) {
|
|
fireNow = true
|
|
break
|
|
}
|
|
}
|
|
case timerKindIdleAll:
|
|
if m.allWatchedIdleLocked(t) {
|
|
fireNow = true
|
|
}
|
|
}
|
|
if fireNow {
|
|
t.status = timerStatusFired
|
|
if t.rt != nil {
|
|
t.rt.Stop()
|
|
t.rt = nil
|
|
}
|
|
owner = m.sess.FindChild(t.ownerID)
|
|
body, label = t.body, t.label
|
|
delete(m.timers, id)
|
|
}
|
|
m.mu.Unlock()
|
|
m.notifyChanged()
|
|
if fireNow {
|
|
m.fireFn(owner, body, label)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TimerList returns timers owned by ownerID, oldest-first. An empty
|
|
// ownerID lists every active timer — the top-level orchestrator view.
|
|
func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]mcp.TimerInfo, 0)
|
|
for _, t := range m.timers {
|
|
if ownerID != "" && t.ownerID != ownerID {
|
|
continue
|
|
}
|
|
if t.status != timerStatusPending && t.status != timerStatusPaused {
|
|
continue
|
|
}
|
|
info := mcp.TimerInfo{
|
|
ID: t.id,
|
|
Label: t.label,
|
|
Body: t.body,
|
|
Kind: string(t.kind),
|
|
Status: t.status,
|
|
OwnerID: t.ownerID,
|
|
WatchedIDs: append([]string(nil), t.watched...),
|
|
}
|
|
if t.status == timerStatusPending && !t.firesAt.IsZero() {
|
|
info.FiresAtUnixMS = t.firesAt.UnixMilli()
|
|
}
|
|
if t.status == timerStatusPaused && t.pausedRemaining > 0 {
|
|
info.PausedRemainingMS = t.pausedRemaining.Milliseconds()
|
|
}
|
|
out = append(out, info)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// activeForChild returns the nearest pending or paused timer attached
|
|
// to child id (either owned by it or watching it). Used by the sidebar
|
|
// for the "⏱ 12s" indicator. nil when none.
|
|
func (m *timerManager) activeForChild(id string) *mcp.TimerInfo {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var best *pendingTimer
|
|
for _, t := range m.timers {
|
|
if t.status != timerStatusPending && t.status != timerStatusPaused {
|
|
continue
|
|
}
|
|
if t.ownerID != id && !contains(t.watched, id) {
|
|
continue
|
|
}
|
|
if best == nil {
|
|
best = t
|
|
continue
|
|
}
|
|
if t.firesAt.Before(best.firesAt) && !t.firesAt.IsZero() {
|
|
best = t
|
|
}
|
|
}
|
|
if best == nil {
|
|
return nil
|
|
}
|
|
info := mcp.TimerInfo{
|
|
ID: best.id,
|
|
Label: best.label,
|
|
Kind: string(best.kind),
|
|
Status: best.status,
|
|
OwnerID: best.ownerID,
|
|
}
|
|
if best.status == timerStatusPending && !best.firesAt.IsZero() {
|
|
info.FiresAtUnixMS = best.firesAt.UnixMilli()
|
|
}
|
|
if best.status == timerStatusPaused {
|
|
info.PausedRemainingMS = best.pausedRemaining.Milliseconds()
|
|
}
|
|
return &info
|
|
}
|
|
|
|
const (
|
|
timerSidebarMinRefresh = 50 * time.Millisecond
|
|
timerSidebarSubsecondRefresh = 100 * time.Millisecond
|
|
)
|
|
|
|
func nextTimerSidebarLabelChange(d time.Duration) time.Duration {
|
|
if d <= 0 {
|
|
return 0
|
|
}
|
|
if d < time.Second {
|
|
if d < timerSidebarSubsecondRefresh {
|
|
return d
|
|
}
|
|
return timerSidebarSubsecondRefresh
|
|
}
|
|
|
|
step := time.Second
|
|
if d >= time.Hour {
|
|
step = time.Hour
|
|
} else if d >= time.Minute {
|
|
step = time.Minute
|
|
}
|
|
wait := d % step
|
|
if wait <= 0 || wait < timerSidebarMinRefresh {
|
|
return timerSidebarMinRefresh
|
|
}
|
|
return wait
|
|
}
|
|
|
|
func (m *timerManager) nextSidebarRefreshAfter(now time.Time) (time.Duration, bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var best time.Duration
|
|
found := false
|
|
for _, t := range m.timers {
|
|
if t.status != timerStatusPending || t.firesAt.IsZero() {
|
|
continue
|
|
}
|
|
wait := nextTimerSidebarLabelChange(t.firesAt.Sub(now))
|
|
if wait <= 0 {
|
|
wait = timerSidebarMinRefresh
|
|
}
|
|
if !found || wait < best {
|
|
best = wait
|
|
found = true
|
|
}
|
|
}
|
|
return best, found
|
|
}
|
|
|
|
func isIdleState(s IdleState) bool {
|
|
return s == StateIdle
|
|
}
|
|
|
|
func contains(haystack []string, needle string) bool {
|
|
for _, h := range haystack {
|
|
if h == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|