diff --git a/pkg/cmd/tmux/popup/kill/kill.go b/pkg/cmd/tmux/popup/kill/kill.go new file mode 100644 index 0000000..56770ad --- /dev/null +++ b/pkg/cmd/tmux/popup/kill/kill.go @@ -0,0 +1,105 @@ +package kill + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/zkhvan/z/pkg/cmdutil" + "github.com/zkhvan/z/pkg/tmux" +) + +type Options struct { + Name string + All bool + Zombies bool +} + +func NewCmdKill(_ *cmdutil.Factory) *cobra.Command { + opts := &Options{} + + cmd := &cobra.Command{ + Use: "kill [name]", + Short: "Kill popup sessions", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.Name = args[0] + } + if opts.Name == "" && !opts.All && !opts.Zombies { + return fmt.Errorf("provide a popup name or use --all/--zombies") + } + return opts.Run(cmd.Context()) + }, + } + + cmd.Flags().BoolVar(&opts.All, "all", false, "Kill all popup sessions for the current session") + cmd.Flags().BoolVar(&opts.Zombies, "zombies", false, "Kill orphaned popup sessions whose parent no longer exists") + + return cmd +} + +func (opts *Options) Run(ctx context.Context) error { + if opts.Zombies { + return opts.killZombies(ctx) + } + + parentName, err := tmux.CurrentSessionName(ctx) + if err != nil { + return err + } + + if !opts.All { + popupSessionName := tmux.ToPopupSessionName(parentName, opts.Name) + if !tmux.HasSession(ctx, popupSessionName) { + return fmt.Errorf("popup session %q not found", opts.Name) + } + return tmux.KillSession(ctx, tmux.Session{Name: popupSessionName}) + } + + sessions, err := tmux.ListSessions(ctx, nil) + if err != nil { + return err + } + + for _, session := range sessions { + if _, ok := tmux.ExtractPopupName(session.Name, parentName); ok { + if err := tmux.KillSession(ctx, session); err != nil { + return err + } + } + } + + return nil +} + +func (opts *Options) killZombies(ctx context.Context) error { + sessions, err := tmux.ListSessions(ctx, nil) + if err != nil { + return err + } + + // Build a set of non-popup session names + alive := make(map[string]struct{}) + for _, s := range sessions { + if !tmux.IsPopupSession(s.Name) { + alive[s.Name] = struct{}{} + } + } + + // Kill popups whose parent is no longer alive + for _, s := range sessions { + parent, ok := tmux.ExtractPopupParent(s.Name) + if !ok { + continue + } + if _, exists := alive[parent]; !exists { + if err := tmux.KillSession(ctx, s); err != nil { + return err + } + } + } + + return nil +} diff --git a/pkg/cmd/tmux/popup/list/list.go b/pkg/cmd/tmux/popup/list/list.go new file mode 100644 index 0000000..7a6056d --- /dev/null +++ b/pkg/cmd/tmux/popup/list/list.go @@ -0,0 +1,52 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/zkhvan/z/pkg/cmdutil" + "github.com/zkhvan/z/pkg/iolib" + "github.com/zkhvan/z/pkg/tmux" +) + +type Options struct { + io *iolib.IOStreams +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + opts := &Options{ + io: f.IOStreams, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List popup sessions", + RunE: func(cmd *cobra.Command, _ []string) error { + return opts.Run(cmd.Context()) + }, + } + + return cmd +} + +func (opts *Options) Run(ctx context.Context) error { + parentName, err := tmux.CurrentSessionName(ctx) + if err != nil { + return err + } + + sessions, err := tmux.ListSessions(ctx, nil) + if err != nil { + return err + } + + for _, session := range sessions { + if name, ok := tmux.ExtractPopupName(session.Name, parentName); ok { + fmt.Fprintln(opts.io.Out, name) + } + } + + return nil +} diff --git a/pkg/cmd/tmux/popup/popup.go b/pkg/cmd/tmux/popup/popup.go new file mode 100644 index 0000000..09c8398 --- /dev/null +++ b/pkg/cmd/tmux/popup/popup.go @@ -0,0 +1,23 @@ +package popup + +import ( + "github.com/spf13/cobra" + + killCmd "github.com/zkhvan/z/pkg/cmd/tmux/popup/kill" + listCmd "github.com/zkhvan/z/pkg/cmd/tmux/popup/list" + useCmd "github.com/zkhvan/z/pkg/cmd/tmux/popup/use" + "github.com/zkhvan/z/pkg/cmdutil" +) + +func NewCmdPopup(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "popup", + Short: "Manage tmux popup sessions", + } + + cmd.AddCommand(killCmd.NewCmdKill(f)) + cmd.AddCommand(listCmd.NewCmdList(f)) + cmd.AddCommand(useCmd.NewCmdUse(f)) + + return cmd +} diff --git a/pkg/cmd/tmux/popup/use/use.go b/pkg/cmd/tmux/popup/use/use.go new file mode 100644 index 0000000..30bb640 --- /dev/null +++ b/pkg/cmd/tmux/popup/use/use.go @@ -0,0 +1,80 @@ +package use + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/zkhvan/z/pkg/cmdutil" + "github.com/zkhvan/z/pkg/tmux" +) + +type Options struct { + Name string + Width string + Height string +} + +func NewCmdUse(_ *cmdutil.Factory) *cobra.Command { + opts := &Options{} + + cmd := &cobra.Command{ + Use: "use ", + Short: "Open a popup session", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Name = args[0] + return opts.Run(cmd.Context()) + }, + } + + cmd.Flags().StringVar(&opts.Width, "width", "80%", "Popup width") + cmd.Flags().StringVar(&opts.Height, "height", "80%", "Popup height") + + return cmd +} + +func (opts *Options) Run(ctx context.Context) error { + parentName, err := tmux.CurrentSessionName(ctx) + if err != nil { + return err + } + + popupSessionName := tmux.ToPopupSessionName(parentName, opts.Name) + + if !tmux.HasSession(ctx, popupSessionName) { + session, err := tmux.NewSessionDetached(ctx, &tmux.NewOptions{ + Name: popupSessionName, + }) + if err != nil { + return err + } + + if err := tmux.SetSessionOption(ctx, session.ID, "status", "off"); err != nil { + return err + } + if err := tmux.SetSessionOption(ctx, session.ID, "prefix", "None"); err != nil { + return err + } + // Popup keybindings — bound in root table with a conditional so they + // only take effect in popup sessions (name starts with _popup_). + popupPattern := fmt.Sprintf("#{m:%s*,#{session_name}}", tmux.PopupPrefix) + if err := tmux.BindKey(ctx, "root", "S-F1", + "if-shell", "-F", popupPattern, + "detach-client", "send-keys S-F1"); err != nil { + return err + } + if err := tmux.BindKey(ctx, "root", "M-[", + "if-shell", "-F", popupPattern, + "copy-mode", "send-keys M-["); err != nil { + return err + } + } + + return tmux.DisplayPopup(ctx, &tmux.DisplayPopupOptions{ + Width: opts.Width, + Height: opts.Height, + ShellCommand: fmt.Sprintf("tmux attach-session -t '=%s'", popupSessionName), + }) +} diff --git a/pkg/cmd/tmux/session/kill/kill.go b/pkg/cmd/tmux/session/kill/kill.go index 6bc2a85..8206f90 100644 --- a/pkg/cmd/tmux/session/kill/kill.go +++ b/pkg/cmd/tmux/session/kill/kill.go @@ -2,6 +2,7 @@ package kill import ( "context" + "errors" "sort" "github.com/MakeNowJust/heredoc/v2" @@ -41,10 +42,17 @@ func (o *Options) Run(ctx context.Context) error { if err != nil { return err } + currentSessionName, err := tmux.CurrentSessionName(ctx) + if err != nil { + return err + } defer func() { if err == nil { - // If we successfully switch to another session, kill the current one - err = tmux.KillSession(ctx, tmux.Session{ID: currentSessionID}) + err = errors.Join( + // If we successfully switch to another session, kill the current one + tmux.KillSession(ctx, tmux.Session{ID: currentSessionID}), + tmux.KillPopups(ctx, currentSessionName), + ) } }() @@ -56,6 +64,7 @@ func (o *Options) Run(ctx context.Context) error { // Get all sessions except current one sessions, err := tmux.ListSessions(ctx, &tmux.ListOptions{ ExcludeCurrentSession: true, + ExcludePopupSessions: true, }) if err != nil { return err diff --git a/pkg/cmd/tmux/session/list/list.go b/pkg/cmd/tmux/session/list/list.go index 43d38bc..f125a8e 100644 --- a/pkg/cmd/tmux/session/list/list.go +++ b/pkg/cmd/tmux/session/list/list.go @@ -32,7 +32,9 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { } func (opts *Options) Run(ctx context.Context) error { - sessions, err := tmux.ListSessions(ctx, nil) + sessions, err := tmux.ListSessions(ctx, &tmux.ListOptions{ + ExcludePopupSessions: true, + }) if err != nil { return err } diff --git a/pkg/cmd/tmux/session/use/use.go b/pkg/cmd/tmux/session/use/use.go index 19224d4..c32a151 100644 --- a/pkg/cmd/tmux/session/use/use.go +++ b/pkg/cmd/tmux/session/use/use.go @@ -30,6 +30,7 @@ func NewCmdUse(_ *cmdutil.Factory) *cobra.Command { func (opts *Options) Run(ctx context.Context) error { sessions, err := tmux.ListSessions(ctx, &tmux.ListOptions{ ExcludeCurrentSession: true, + ExcludePopupSessions: true, }) if err != nil { return err diff --git a/pkg/cmd/tmux/tmux.go b/pkg/cmd/tmux/tmux.go index d42620c..d321cd0 100644 --- a/pkg/cmd/tmux/tmux.go +++ b/pkg/cmd/tmux/tmux.go @@ -3,6 +3,7 @@ package tmux import ( "github.com/spf13/cobra" + popupCmd "github.com/zkhvan/z/pkg/cmd/tmux/popup" sessionCmd "github.com/zkhvan/z/pkg/cmd/tmux/session" "github.com/zkhvan/z/pkg/cmdutil" ) @@ -13,6 +14,7 @@ func NewCmdTmux(f *cmdutil.Factory) *cobra.Command { Short: "Manage tmux", } + cmd.AddCommand(popupCmd.NewCmdPopup(f)) cmd.AddCommand(sessionCmd.NewCmdSession(f)) return cmd diff --git a/pkg/tmux/popup.go b/pkg/tmux/popup.go new file mode 100644 index 0000000..74a01cf --- /dev/null +++ b/pkg/tmux/popup.go @@ -0,0 +1,61 @@ +package tmux + +import ( + "context" + "errors" + "strings" +) + +const ( + PopupPrefix = "_popup_" + PopupSeparator = "__" +) + +func ToPopupSessionName(parentSession, popupName string) string { + return PopupPrefix + parentSession + PopupSeparator + popupName +} + +func IsPopupSession(sessionName string) bool { + return strings.HasPrefix(sessionName, PopupPrefix) +} + +// ExtractPopupName extracts the popup name from a full popup session name, +// given the parent session name. Returns the popup name and true if the +// session belongs to the parent, or empty string and false otherwise. +func ExtractPopupName(sessionName, parentSession string) (string, bool) { + prefix := PopupPrefix + parentSession + PopupSeparator + if !strings.HasPrefix(sessionName, prefix) { + return "", false + } + return strings.TrimPrefix(sessionName, prefix), true +} + +// ExtractPopupParent extracts the parent session name from a popup session +// name by splitting on the double-underscore separator. Returns the parent +// name and true, or empty string and false if not a popup session. +func ExtractPopupParent(sessionName string) (string, bool) { + if !IsPopupSession(sessionName) { + return "", false + } + rest := strings.TrimPrefix(sessionName, PopupPrefix) + idx := strings.LastIndex(rest, PopupSeparator) + if idx < 0 { + return "", false + } + return rest[:idx], true +} + +// KillPopups kills all popup sessions associated with the given parent session. +func KillPopups(ctx context.Context, parentSessionName string) error { + sessions, err := ListSessions(ctx, nil) + if err != nil { + return err + } + var errs []error + for _, session := range sessions { + if _, ok := ExtractPopupName(session.Name, parentSessionName); ok { + errs = append(errs, KillSession(ctx, session)) + } + } + return errors.Join(errs...) +} diff --git a/pkg/tmux/tmux.go b/pkg/tmux/tmux.go index 86a375a..8f49651 100644 --- a/pkg/tmux/tmux.go +++ b/pkg/tmux/tmux.go @@ -86,6 +86,9 @@ type ListOptions struct { // ExcludeCurrentSession will filter out the currently active session. If // no session is attached, it will filter out the last active session. ExcludeCurrentSession bool + // ExcludePopupSessions will filter out popup sessions (those with the + // _popup_ prefix). + ExcludePopupSessions bool } func ListSessions(ctx context.Context, opts *ListOptions) ([]Session, error) { @@ -140,6 +143,12 @@ func ListSessions(ctx context.Context, opts *ListOptions) ([]Session, error) { return nil, fmt.Errorf("error scanning: %w", err) } + if opts.ExcludePopupSessions { + sessions = lo.Reject(sessions, func(s Session, _ int) bool { + return IsPopupSession(s.Name) + }) + } + if opts.ExcludeCurrentSession { // If there's an error getting the current session, just ignore it. if currentSessionID, err := CurrentSessionID(ctx); err == nil { @@ -200,13 +209,153 @@ func NewSession(ctx context.Context, opts *NewOptions) error { return SwitchClient(ctx, session) } +func CurrentSessionName(ctx context.Context) (string, error) { + cmd := exec.CommandContext( + ctx, + "tmux", + "display-message", + "-p", "#{session_name}", + ) + + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf( + "error running %q: %w", + cmd.String(), + err, + ) + } + out = bytes.TrimSpace(out) + + return string(out), nil +} + +func HasSession(ctx context.Context, name string) bool { + cmd := exec.CommandContext( + ctx, + "tmux", + "has-session", + "-t", name, + ) + + return cmd.Run() == nil +} + +// NewSessionDetached creates a new tmux session in the background and returns +// it without switching the current client. +func NewSessionDetached(ctx context.Context, opts *NewOptions) (Session, error) { + if opts == nil { + opts = &NewOptions{} + } + + cmd := exec.CommandContext( + ctx, + "tmux", + "new-session", + "-d", + "-P", "-F", "#{session_id}", + ) + + if len(opts.Name) > 0 { + cmd.Args = append(cmd.Args, "-s", opts.Name) + } + + if len(opts.Dir) > 0 { + cmd.Args = append(cmd.Args, "-c", opts.Dir) + } + + output, err := cmd.Output() + if err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + if !bytes.HasPrefix(exitError.Stderr, []byte("duplicate session")) { + return Session{}, fmt.Errorf("error running %q: %w", cmd.String(), err) + } + } + } + output = bytes.TrimSpace(output) + + return Session{ + ID: string(output), + Name: opts.Name, + }, nil +} + +func SetSessionOption(ctx context.Context, target, key, value string) error { + cmd := exec.CommandContext( + ctx, + "tmux", + "set-option", + "-t", target, + key, value, + ) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("error running %q: %w", cmd.String(), err) + } + + return nil +} + +func BindKey(ctx context.Context, keyTable, key string, args ...string) error { + cmdArgs := []string{"bind-key", "-T", keyTable, key} + cmdArgs = append(cmdArgs, args...) + + cmd := exec.CommandContext(ctx, "tmux", cmdArgs...) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("error running %q: %w", cmd.String(), err) + } + + return nil +} + +type DisplayPopupOptions struct { + Width string + Height string + ShellCommand string +} + +func DisplayPopup(ctx context.Context, opts *DisplayPopupOptions) error { + if opts == nil { + opts = &DisplayPopupOptions{} + } + + args := []string{"display-popup", "-E"} + + if len(opts.Width) > 0 { + args = append(args, "-w", opts.Width) + } + if len(opts.Height) > 0 { + args = append(args, "-h", opts.Height) + } + + args = append(args, opts.ShellCommand) + + cmd := exec.CommandContext(ctx, "tmux", args...) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("error running %q: %w", cmd.String(), err) + } + + return nil +} + func KillSession(ctx context.Context, session Session) error { + target := session.ID + if len(target) == 0 { + target = session.Name + } + if len(target) == 0 { + return fmt.Errorf("invalid session") + } + // #nosec G204 cmd := exec.CommandContext( ctx, "tmux", "kill-session", - "-t", session.ID, + "-t", target, ) if err := cmd.Run(); err != nil {