mirror of
https://github.com/theniceboy/.config.git
synced 2026-04-29 01:46:02 +08:00
445 lines
12 KiB
Go
445 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type todoScope int
|
|
|
|
const (
|
|
todoScopeGlobal todoScope = iota
|
|
todoScopeSession
|
|
todoScopeWindow
|
|
)
|
|
|
|
const tmuxTodoStoreVersion = 1
|
|
|
|
type tmuxTodoItem struct {
|
|
Title string `json:"title" yaml:"title"`
|
|
Done bool `json:"done" yaml:"done"`
|
|
Priority int `json:"priority,omitempty" yaml:"priority,omitempty"`
|
|
CreatedAt time.Time `json:"created_at,omitempty" yaml:"created_at,omitempty"`
|
|
}
|
|
|
|
type tmuxTodoStore struct {
|
|
Version int `json:"version"`
|
|
Global []tmuxTodoItem `json:"global,omitempty"`
|
|
Sessions map[string][]tmuxTodoItem `json:"sessions,omitempty"`
|
|
Windows map[string][]tmuxTodoItem `json:"windows,omitempty"`
|
|
}
|
|
|
|
type tmuxTodoListFile struct {
|
|
Todos []tmuxTodoItem `json:"todos" yaml:"todos"`
|
|
}
|
|
|
|
type tmuxTodoEntry struct {
|
|
Title string
|
|
Done bool
|
|
Priority int
|
|
Scope todoScope
|
|
ScopeID string
|
|
ScopeName string
|
|
IsCurrent bool
|
|
ItemIndex int
|
|
PanelPane todoPanelPane
|
|
Detail string
|
|
}
|
|
|
|
func tmuxTodoStorePath() string {
|
|
return filepath.Join(os.Getenv("HOME"), ".cache", "agent", "todos.json")
|
|
}
|
|
|
|
func legacyTmuxTodosDir() string {
|
|
return filepath.Join(os.Getenv("HOME"), ".tmux-todos")
|
|
}
|
|
|
|
func normalizeTodoPriority(priority int) int {
|
|
if priority < 1 || priority > 3 {
|
|
return 2
|
|
}
|
|
return priority
|
|
}
|
|
|
|
func normalizeTmuxTodoStore(store *tmuxTodoStore) *tmuxTodoStore {
|
|
if store == nil {
|
|
store = &tmuxTodoStore{}
|
|
}
|
|
if store.Version == 0 {
|
|
store.Version = tmuxTodoStoreVersion
|
|
}
|
|
if store.Global == nil {
|
|
store.Global = []tmuxTodoItem{}
|
|
}
|
|
if store.Sessions == nil {
|
|
store.Sessions = map[string][]tmuxTodoItem{}
|
|
}
|
|
if store.Windows == nil {
|
|
store.Windows = map[string][]tmuxTodoItem{}
|
|
}
|
|
for i := range store.Global {
|
|
store.Global[i].Priority = normalizeTodoPriority(store.Global[i].Priority)
|
|
}
|
|
for key := range store.Sessions {
|
|
for i := range store.Sessions[key] {
|
|
store.Sessions[key][i].Priority = normalizeTodoPriority(store.Sessions[key][i].Priority)
|
|
}
|
|
}
|
|
for key := range store.Windows {
|
|
for i := range store.Windows[key] {
|
|
store.Windows[key][i].Priority = normalizeTodoPriority(store.Windows[key][i].Priority)
|
|
}
|
|
}
|
|
return store
|
|
}
|
|
|
|
func saveTmuxTodoStore(store *tmuxTodoStore) error {
|
|
store = normalizeTmuxTodoStore(store)
|
|
path := tmuxTodoStorePath()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(store, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0644)
|
|
}
|
|
|
|
func loadTmuxTodoStore() (*tmuxTodoStore, error) {
|
|
path := tmuxTodoStorePath()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return bootstrapTmuxTodoStore()
|
|
}
|
|
return nil, err
|
|
}
|
|
var store tmuxTodoStore
|
|
if err := json.Unmarshal(data, &store); err != nil {
|
|
return nil, err
|
|
}
|
|
return normalizeTmuxTodoStore(&store), nil
|
|
}
|
|
|
|
func bootstrapTmuxTodoStore() (*tmuxTodoStore, error) {
|
|
store := normalizeTmuxTodoStore(&tmuxTodoStore{Version: tmuxTodoStoreVersion})
|
|
if err := importLegacyYamlTodos(store); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := saveTmuxTodoStore(store); err != nil {
|
|
return nil, err
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
func todoItemsForScope(store *tmuxTodoStore, scope todoScope, scopeID string) []tmuxTodoItem {
|
|
store = normalizeTmuxTodoStore(store)
|
|
switch scope {
|
|
case todoScopeSession:
|
|
return store.Sessions[scopeID]
|
|
case todoScopeWindow:
|
|
return store.Windows[scopeID]
|
|
default:
|
|
return store.Global
|
|
}
|
|
}
|
|
|
|
func setTodoItemsForScope(store *tmuxTodoStore, scope todoScope, scopeID string, items []tmuxTodoItem) {
|
|
store = normalizeTmuxTodoStore(store)
|
|
switch scope {
|
|
case todoScopeSession:
|
|
store.Sessions[scopeID] = items
|
|
case todoScopeWindow:
|
|
store.Windows[scopeID] = items
|
|
default:
|
|
store.Global = items
|
|
}
|
|
}
|
|
|
|
func appendUniqueTodo(store *tmuxTodoStore, scope todoScope, scopeID string, item tmuxTodoItem) bool {
|
|
item.Title = strings.TrimSpace(item.Title)
|
|
if item.Title == "" {
|
|
return false
|
|
}
|
|
item.Priority = normalizeTodoPriority(item.Priority)
|
|
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
|
|
for _, existing := range items {
|
|
if strings.TrimSpace(existing.Title) == item.Title {
|
|
return false
|
|
}
|
|
}
|
|
items = append(items, item)
|
|
setTodoItemsForScope(store, scope, scopeID, items)
|
|
return true
|
|
}
|
|
|
|
func importLegacyYamlTodos(store *tmuxTodoStore) error {
|
|
entries, err := os.ReadDir(legacyTmuxTodosDir())
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
path := filepath.Join(legacyTmuxTodosDir(), name)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var list tmuxTodoListFile
|
|
if err := yaml.Unmarshal(data, &list); err != nil {
|
|
continue
|
|
}
|
|
scope := todoScopeGlobal
|
|
scopeID := "global"
|
|
switch {
|
|
case name == "global.yaml":
|
|
scope = todoScopeGlobal
|
|
case strings.HasPrefix(name, "session_") && strings.HasSuffix(name, ".yaml"):
|
|
scope = todoScopeSession
|
|
id := strings.TrimSuffix(strings.TrimPrefix(name, "session_"), ".yaml")
|
|
if strings.HasPrefix(id, "_") {
|
|
scopeID = "$" + strings.TrimPrefix(id, "_")
|
|
} else {
|
|
scopeID = id
|
|
}
|
|
case strings.HasPrefix(name, "window_") && strings.HasSuffix(name, ".yaml"):
|
|
scope = todoScopeWindow
|
|
id := strings.TrimSuffix(strings.TrimPrefix(name, "window_"), ".yaml")
|
|
if strings.HasPrefix(id, "_") {
|
|
scopeID = "@" + strings.TrimPrefix(id, "_")
|
|
} else {
|
|
scopeID = id
|
|
}
|
|
default:
|
|
continue
|
|
}
|
|
for _, item := range list.Todos {
|
|
appendUniqueTodo(store, scope, scopeID, item)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func collectAllTmuxTodos(currentSessionID, currentWindowID string) []tmuxTodoEntry {
|
|
store, err := loadTmuxTodoStore()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
entries := make([]tmuxTodoEntry, 0, len(store.Global))
|
|
for idx, item := range store.Global {
|
|
entries = append(entries, tmuxTodoEntry{
|
|
Title: item.Title,
|
|
Done: item.Done,
|
|
Priority: item.Priority,
|
|
Scope: todoScopeGlobal,
|
|
ScopeID: "global",
|
|
ScopeName: "Global",
|
|
IsCurrent: true,
|
|
ItemIndex: idx,
|
|
})
|
|
}
|
|
sessionIDs := make([]string, 0, len(store.Sessions))
|
|
for id := range store.Sessions {
|
|
sessionIDs = append(sessionIDs, id)
|
|
}
|
|
sort.Strings(sessionIDs)
|
|
for _, id := range sessionIDs {
|
|
for idx, item := range store.Sessions[id] {
|
|
entries = append(entries, tmuxTodoEntry{
|
|
Title: item.Title,
|
|
Done: item.Done,
|
|
Priority: item.Priority,
|
|
Scope: todoScopeSession,
|
|
ScopeID: id,
|
|
ScopeName: "Session",
|
|
IsCurrent: strings.TrimSpace(id) == strings.TrimSpace(currentSessionID),
|
|
ItemIndex: idx,
|
|
})
|
|
}
|
|
}
|
|
windowIDs := make([]string, 0, len(store.Windows))
|
|
for id := range store.Windows {
|
|
windowIDs = append(windowIDs, id)
|
|
}
|
|
sort.Strings(windowIDs)
|
|
for _, id := range windowIDs {
|
|
for idx, item := range store.Windows[id] {
|
|
entries = append(entries, tmuxTodoEntry{
|
|
Title: item.Title,
|
|
Done: item.Done,
|
|
Priority: item.Priority,
|
|
Scope: todoScopeWindow,
|
|
ScopeID: id,
|
|
ScopeName: "Window",
|
|
IsCurrent: strings.TrimSpace(id) == strings.TrimSpace(currentWindowID),
|
|
ItemIndex: idx,
|
|
})
|
|
}
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func sortTmuxTodosByScope(entries []tmuxTodoEntry, scopePriority todoScope) {
|
|
sort.SliceStable(entries, func(i, j int) bool {
|
|
ei, ej := entries[i], entries[j]
|
|
if ei.IsCurrent != ej.IsCurrent {
|
|
return ei.IsCurrent
|
|
}
|
|
if ei.Scope != ej.Scope {
|
|
if ei.Scope == scopePriority {
|
|
return true
|
|
}
|
|
if ej.Scope == scopePriority {
|
|
return false
|
|
}
|
|
return ei.Scope < ej.Scope
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
|
|
func addTmuxTodo(scope todoScope, scopeID, title string) error {
|
|
store, err := loadTmuxTodoStore()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
|
|
items = append(items, tmuxTodoItem{Title: strings.TrimSpace(title), Done: false, Priority: 2, CreatedAt: time.Now()})
|
|
setTodoItemsForScope(store, scope, scopeID, items)
|
|
return saveTmuxTodoStore(store)
|
|
}
|
|
|
|
func setTmuxTodoPriorityByIndex(scope todoScope, scopeID string, index int, priority int) error {
|
|
store, err := loadTmuxTodoStore()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
|
|
if index < 0 || index >= len(items) {
|
|
return fmt.Errorf("index out of range")
|
|
}
|
|
items[index].Priority = normalizeTodoPriority(priority)
|
|
setTodoItemsForScope(store, scope, scopeID, items)
|
|
return saveTmuxTodoStore(store)
|
|
}
|
|
|
|
func updateTmuxTodoTitleByIndex(scope todoScope, scopeID string, index int, title string) error {
|
|
store, err := loadTmuxTodoStore()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
|
|
if index < 0 || index >= len(items) {
|
|
return fmt.Errorf("index out of range")
|
|
}
|
|
title = strings.TrimSpace(title)
|
|
if title == "" {
|
|
return fmt.Errorf("todo title is required")
|
|
}
|
|
items[index].Title = title
|
|
setTodoItemsForScope(store, scope, scopeID, items)
|
|
return saveTmuxTodoStore(store)
|
|
}
|
|
|
|
func moveTmuxTodoByIndex(scope todoScope, scopeID string, fromIndex, toIndex int) error {
|
|
store, err := loadTmuxTodoStore()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
|
|
if fromIndex < 0 || fromIndex >= len(items) || toIndex < 0 || toIndex >= len(items) {
|
|
return fmt.Errorf("index out of range")
|
|
}
|
|
if fromIndex == toIndex {
|
|
return nil
|
|
}
|
|
item := items[fromIndex]
|
|
if fromIndex < toIndex {
|
|
copy(items[fromIndex:toIndex], items[fromIndex+1:toIndex+1])
|
|
} else {
|
|
copy(items[toIndex+1:fromIndex+1], items[toIndex:fromIndex])
|
|
}
|
|
items[toIndex] = item
|
|
setTodoItemsForScope(store, scope, scopeID, items)
|
|
return saveTmuxTodoStore(store)
|
|
}
|
|
|
|
func moveTmuxTodoToScopeByIndex(scope todoScope, scopeID string, index int, targetScope todoScope, targetScopeID string) error {
|
|
store, err := loadTmuxTodoStore()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
|
|
if index < 0 || index >= len(items) {
|
|
return fmt.Errorf("index out of range")
|
|
}
|
|
item := items[index]
|
|
items = append(items[:index], items[index+1:]...)
|
|
targetItems := append([]tmuxTodoItem(nil), todoItemsForScope(store, targetScope, targetScopeID)...)
|
|
targetItems = append(targetItems, item)
|
|
setTodoItemsForScope(store, scope, scopeID, items)
|
|
setTodoItemsForScope(store, targetScope, targetScopeID, targetItems)
|
|
return saveTmuxTodoStore(store)
|
|
}
|
|
|
|
func countOpenTmuxTodos(scope todoScope, scopeID string) (int, error) {
|
|
store, err := loadTmuxTodoStore()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
count := 0
|
|
for _, item := range todoItemsForScope(store, scope, scopeID) {
|
|
if !item.Done {
|
|
count++
|
|
}
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func getCurrentTmuxScopeInfo() (sessionID, windowID string) {
|
|
sessionID, _ = runTmuxOutput("display-message", "-p", "#{session_id}")
|
|
windowID, _ = runTmuxOutput("display-message", "-p", "#{window_id}")
|
|
return strings.TrimSpace(sessionID), strings.TrimSpace(windowID)
|
|
}
|
|
|
|
func toggleTmuxTodoByIndex(scope todoScope, scopeID string, index int) error {
|
|
store, err := loadTmuxTodoStore()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
|
|
if index < 0 || index >= len(items) {
|
|
return fmt.Errorf("index out of range")
|
|
}
|
|
items[index].Done = !items[index].Done
|
|
setTodoItemsForScope(store, scope, scopeID, items)
|
|
return saveTmuxTodoStore(store)
|
|
}
|
|
|
|
func deleteTmuxTodoByIndex(scope todoScope, scopeID string, index int) error {
|
|
store, err := loadTmuxTodoStore()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items := append([]tmuxTodoItem(nil), todoItemsForScope(store, scope, scopeID)...)
|
|
if index < 0 || index >= len(items) {
|
|
return fmt.Errorf("index out of range")
|
|
}
|
|
items = append(items[:index], items[index+1:]...)
|
|
setTodoItemsForScope(store, scope, scopeID, items)
|
|
return saveTmuxTodoStore(store)
|
|
}
|