554 lines
19 KiB
Python
554 lines
19 KiB
Python
import os
|
|
import subprocess
|
|
|
|
from fabric.utils import exec_shell_command_async, idle_add, remove_handler
|
|
from fabric.widgets.box import Box
|
|
from fabric.widgets.button import Button
|
|
from fabric.widgets.entry import Entry
|
|
from fabric.widgets.label import Label
|
|
from fabric.widgets.scrolledwindow import ScrolledWindow
|
|
from gi.repository import Gdk, GLib, Gtk
|
|
|
|
import config.data as data
|
|
import modules.icons as icons
|
|
|
|
|
|
class TmuxManager(Box):
|
|
def __init__(self, **kwargs):
|
|
super().__init__(
|
|
name="tmux-manager",
|
|
visible=False,
|
|
all_visible=False,
|
|
**kwargs,
|
|
)
|
|
|
|
self.notch = kwargs["notch"]
|
|
self.selected_index = -1 # Track the selected item index
|
|
|
|
self._arranger_handler: int = 0
|
|
|
|
self.viewport = Box(name="viewport", spacing=4, orientation="v")
|
|
self.session_name_entry = Entry(
|
|
name="session-name-entry",
|
|
placeholder="Create Tmux Session...",
|
|
h_expand=True,
|
|
h_align="fill",
|
|
on_activate=lambda entry, *_: self.create_session(entry.get_text()),
|
|
on_key_press_event=self.on_entry_key_press,
|
|
)
|
|
self.session_name_entry.props.xalign = 0.5
|
|
self.scrolled_window = ScrolledWindow(
|
|
name="scrolled-window",
|
|
spacing=10,
|
|
h_expand=True,
|
|
v_expand=True,
|
|
h_align="fill",
|
|
v_align="fill",
|
|
child=self.viewport,
|
|
propagate_width=False,
|
|
propagate_height=False,
|
|
)
|
|
|
|
self.header_box = Box(
|
|
name="header_box",
|
|
spacing=10,
|
|
orientation="h",
|
|
children=[
|
|
Button(
|
|
name="new-session-button",
|
|
child=Label(name="new-session-label", markup=icons.add),
|
|
tooltip_text="Create New Session",
|
|
on_clicked=lambda *_: self.create_session(self.session_name_entry.get_text()),
|
|
),
|
|
self.session_name_entry,
|
|
Button(
|
|
name="close-button",
|
|
child=Label(name="close-label", markup=icons.cancel),
|
|
tooltip_text="Exit",
|
|
on_clicked=lambda *_: self.close_manager()
|
|
),
|
|
],
|
|
)
|
|
|
|
self.tmux_box = Box(
|
|
name="tmux-box",
|
|
spacing=10,
|
|
h_expand=True,
|
|
orientation="v",
|
|
children=[
|
|
self.header_box,
|
|
self.scrolled_window,
|
|
],
|
|
)
|
|
|
|
self.add(self.tmux_box)
|
|
self.show_all()
|
|
|
|
def close_manager(self):
|
|
"""Close the tmux manager"""
|
|
self.viewport.children = []
|
|
self.selected_index = -1 # Reset selection
|
|
self.notch.close_notch()
|
|
|
|
def open_manager(self):
|
|
"""Open the tmux manager and refresh sessions"""
|
|
self.refresh_sessions()
|
|
self.session_name_entry.set_text("")
|
|
self.session_name_entry.grab_focus()
|
|
|
|
def refresh_sessions(self):
|
|
"""Get tmux sessions and populate the viewport"""
|
|
remove_handler(self._arranger_handler) if self._arranger_handler else None
|
|
self.viewport.children = []
|
|
self.selected_index = -1 # Clear selection when viewport changes
|
|
|
|
# Get tmux sessions
|
|
sessions = self.get_tmux_sessions()
|
|
if not sessions:
|
|
# Create a container box to better center the message
|
|
container = Box(
|
|
name="no-tmux-container",
|
|
orientation="v",
|
|
h_align="center",
|
|
v_align="center",
|
|
h_expand=True,
|
|
v_expand=True
|
|
)
|
|
|
|
# Show a message if no sessions
|
|
label = Label(
|
|
name="no-tmux",
|
|
markup=icons.terminal,
|
|
h_align="center",
|
|
v_align="center",
|
|
)
|
|
|
|
container.add(label)
|
|
|
|
self.viewport.add(container)
|
|
return
|
|
|
|
# Add session slots to viewport
|
|
for session in sessions:
|
|
self.viewport.add(self.create_session_slot(session))
|
|
|
|
def get_tmux_sessions(self):
|
|
"""Get list of tmux sessions"""
|
|
try:
|
|
result = subprocess.run(
|
|
["tmux", "list-sessions", "-F", "#{session_name}"],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
if result.returncode == 0:
|
|
return [s.strip() for s in result.stdout.strip().split('\n') if s.strip()]
|
|
return []
|
|
except Exception as e:
|
|
print(f"Error getting tmux sessions: {e}")
|
|
return []
|
|
|
|
def create_session_slot(self, session_name):
|
|
"""Create a button for a tmux session"""
|
|
# Create an entry for inline editing (initially hidden)
|
|
name_entry = Entry(
|
|
name="session-name-entry",
|
|
text=session_name,
|
|
visible=False,
|
|
on_activate=lambda entry, *_: self.finish_rename(button, session_name, entry),
|
|
on_key_press_event=self.on_rename_key_press,
|
|
)
|
|
|
|
# Create the label showing the session name
|
|
name_label = Label(
|
|
name="app-label",
|
|
label=session_name,
|
|
ellipsization="end",
|
|
v_align="center",
|
|
h_align="center",
|
|
)
|
|
|
|
# Session slot content box
|
|
slot_box = Box(
|
|
name="slot-box",
|
|
orientation="h",
|
|
spacing=10,
|
|
children=[
|
|
Label(
|
|
name="tmux-icon",
|
|
markup=icons.terminal, # Use existing terminal icon
|
|
),
|
|
name_label,
|
|
name_entry,
|
|
],
|
|
)
|
|
|
|
button = Button(
|
|
name="slot-button", # reuse existing CSS styling
|
|
child=slot_box,
|
|
tooltip_text=f"Attach to session: {session_name}",
|
|
on_clicked=lambda *_: self.attach_to_session(session_name),
|
|
can_focus=True, # Ensure the button can receive focus
|
|
)
|
|
|
|
# Add double-click handler to start renaming
|
|
button.connect("button-press-event", self.on_session_click, session_name, name_label, name_entry)
|
|
|
|
# Add key press handler for 'r' to rename
|
|
button.connect("key-press-event", self.on_slot_key_press, session_name, name_label, name_entry)
|
|
|
|
# Store reference to entry and label in button for later access
|
|
button.name_entry = name_entry
|
|
button.name_label = name_label
|
|
button.session_name = session_name
|
|
|
|
return button
|
|
|
|
def on_session_click(self, button, event, session_name, label, entry):
|
|
"""Handle clicks on session buttons"""
|
|
# Handle double-click to rename
|
|
if event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS and event.button == 1:
|
|
self.start_rename(button, session_name, label, entry)
|
|
return True
|
|
# Handle right click for context menu
|
|
elif event.button == 3:
|
|
menu = Gtk.Menu()
|
|
|
|
# Rename option
|
|
rename_item = Gtk.MenuItem(label="Rename")
|
|
rename_item.connect("activate", lambda _: self.start_rename(button, session_name, label, entry))
|
|
menu.append(rename_item)
|
|
|
|
# Kill option
|
|
kill_item = Gtk.MenuItem(label="Kill Session")
|
|
kill_item.connect("activate", lambda _: self.kill_session(session_name))
|
|
menu.append(kill_item)
|
|
|
|
menu.show_all()
|
|
menu.popup_at_pointer(event)
|
|
return True
|
|
|
|
return False
|
|
|
|
def start_rename(self, button, session_name, label, entry):
|
|
"""Start inline renaming of a session"""
|
|
# Hide label, show entry
|
|
label.set_visible(False)
|
|
entry.set_visible(True)
|
|
|
|
# Focus entry and select all text
|
|
entry.grab_focus()
|
|
entry.select_region(0, -1)
|
|
|
|
# Mark button as being edited
|
|
button.get_style_context().add_class("editing")
|
|
|
|
def finish_rename(self, button, old_name, entry):
|
|
"""Finish renaming a session"""
|
|
new_name = entry.get_text().strip()
|
|
|
|
# Only rename if the name changed and isn't empty
|
|
if new_name and new_name != old_name:
|
|
self.rename_session(old_name, new_name)
|
|
|
|
# Reset UI state
|
|
self.cancel_rename(button)
|
|
|
|
def cancel_rename(self, button):
|
|
"""Cancel renaming operation"""
|
|
# Restore original view
|
|
button.name_entry.set_visible(False)
|
|
button.name_label.set_visible(True)
|
|
|
|
# Remove editing style
|
|
button.get_style_context().remove_class("editing")
|
|
|
|
# Return focus to session name entry
|
|
self.session_name_entry.grab_focus()
|
|
|
|
def on_rename_key_press(self, entry, event):
|
|
"""Handle key presses in the rename entry"""
|
|
if event.keyval == Gdk.KEY_Escape:
|
|
# Find the parent button
|
|
parent = entry.get_parent()
|
|
while parent and not isinstance(parent, Button):
|
|
parent = parent.get_parent()
|
|
|
|
if parent:
|
|
self.cancel_rename(parent)
|
|
return True
|
|
|
|
return False
|
|
|
|
def on_session_right_click(self, button, event, session_name):
|
|
"""Handle right-click on a session button to show context menu"""
|
|
if event.button == 3: # Right click
|
|
menu = Gtk.Menu()
|
|
|
|
# Rename option
|
|
rename_item = Gtk.MenuItem(label="Rename")
|
|
rename_item.connect("activate", lambda _: self.start_rename(
|
|
button,
|
|
session_name,
|
|
button.name_label,
|
|
button.name_entry
|
|
))
|
|
menu.append(rename_item)
|
|
|
|
# Kill option
|
|
kill_item = Gtk.MenuItem(label="Kill Session")
|
|
kill_item.connect("activate", lambda _: self.kill_session(session_name))
|
|
menu.append(kill_item)
|
|
|
|
menu.show_all()
|
|
menu.popup_at_pointer(event)
|
|
return True
|
|
|
|
return False
|
|
|
|
def on_entry_key_press(self, widget, event):
|
|
"""Handle key press events in the entry"""
|
|
if event.keyval == Gdk.KEY_Escape:
|
|
self.close_manager()
|
|
return True
|
|
|
|
# Custom navigation with UP/DOWN keys removed
|
|
return False
|
|
|
|
def scroll_to_selected(self, button):
|
|
"""Scroll to ensure the selected button is visible"""
|
|
def scroll():
|
|
adj = self.scrolled_window.get_vadjustment()
|
|
alloc = button.get_allocation()
|
|
if alloc.height == 0:
|
|
return False # Retry if allocation isn't ready
|
|
|
|
y = alloc.y
|
|
height = alloc.height
|
|
page_size = adj.get_page_size()
|
|
current_value = adj.get_value()
|
|
|
|
# Calculate visible boundaries
|
|
visible_top = current_value
|
|
visible_bottom = current_value + page_size
|
|
|
|
if y < visible_top:
|
|
# Item above viewport - align to top
|
|
adj.set_value(y)
|
|
elif y + height > visible_bottom:
|
|
# Item below viewport - align to bottom
|
|
new_value = y + height - page_size
|
|
adj.set_value(new_value)
|
|
# No action if already fully visible
|
|
return False
|
|
GLib.idle_add(scroll)
|
|
|
|
def create_session(self, session_name):
|
|
"""Create a new tmux session"""
|
|
if not session_name:
|
|
# Get existing session names
|
|
existing_sessions = self.get_tmux_sessions()
|
|
|
|
# Find the next available number
|
|
counter = 0
|
|
while str(counter) in existing_sessions:
|
|
counter += 1
|
|
|
|
session_name = str(counter)
|
|
|
|
try:
|
|
# Clean the session name (replace spaces with underscores)
|
|
clean_name = session_name.strip().replace(" ", "_")
|
|
|
|
# Create session
|
|
subprocess.run(
|
|
["tmux", "new-session", "-d", "-s", clean_name],
|
|
check=True
|
|
)
|
|
|
|
# Refresh the session list
|
|
self.refresh_sessions()
|
|
|
|
# Clear entry
|
|
self.session_name_entry.set_text("")
|
|
|
|
# Launch a terminal and attach to this session
|
|
terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {clean_name}")
|
|
exec_shell_command_async(terminal_cmd)
|
|
|
|
# Close manager
|
|
self.close_manager()
|
|
|
|
except Exception as e:
|
|
print(f"Error creating tmux session: {e}")
|
|
|
|
def attach_to_session(self, session_name):
|
|
"""Attach to an existing tmux session"""
|
|
try:
|
|
# Launch a terminal and attach to this session
|
|
terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {session_name}")
|
|
exec_shell_command_async(terminal_cmd)
|
|
self.close_manager()
|
|
except Exception as e:
|
|
print(f"Error attaching to tmux session: {e}")
|
|
|
|
def get_terminal_command(self, cmd):
|
|
"""Get terminal command based on configured terminal or available terminals"""
|
|
# First try to use the configured terminal command
|
|
if hasattr(data, 'TERMINAL_COMMAND') and data.TERMINAL_COMMAND:
|
|
parts = data.TERMINAL_COMMAND.split()
|
|
terminal = parts[0]
|
|
|
|
try:
|
|
# Check if the configured terminal is available
|
|
subprocess.run(["which", terminal], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
return f"{data.TERMINAL_COMMAND} {cmd}"
|
|
except subprocess.CalledProcessError:
|
|
# If configured terminal is not available, fall back to defaults
|
|
pass
|
|
|
|
# Fallback to checking available terminals
|
|
terminals = [
|
|
("kitty", f"kitty -e {cmd}"),
|
|
("alacritty", f"alacritty -e {cmd}"),
|
|
("foot", f"foot {cmd}"),
|
|
("gnome-terminal", f"gnome-terminal -- {cmd}"),
|
|
("konsole", f"konsole -e {cmd}"),
|
|
("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"),
|
|
]
|
|
|
|
for term, term_cmd in terminals:
|
|
try:
|
|
# Check if terminal is available
|
|
subprocess.run(["which", term], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
return term_cmd
|
|
except subprocess.CalledProcessError:
|
|
continue
|
|
|
|
# Default fallback
|
|
return f"kitty -e {cmd}"
|
|
|
|
def rename_session_dialog(self, old_name):
|
|
"""Show dialog to rename a session"""
|
|
dialog = Gtk.Dialog(
|
|
title="Rename Session",
|
|
transient_for=None,
|
|
flags=0
|
|
)
|
|
|
|
dialog.add_buttons(
|
|
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
|
Gtk.STOCK_OK, Gtk.ResponseType.OK
|
|
)
|
|
|
|
content_area = dialog.get_content_area()
|
|
entry = Gtk.Entry()
|
|
entry.set_text(old_name)
|
|
entry.set_activates_default(True)
|
|
content_area.add(entry)
|
|
|
|
dialog.set_default_response(Gtk.ResponseType.OK)
|
|
dialog.show_all()
|
|
|
|
response = dialog.run()
|
|
if response == Gtk.ResponseType.OK:
|
|
new_name = entry.get_text()
|
|
if new_name and new_name != old_name:
|
|
self.rename_session(old_name, new_name)
|
|
|
|
dialog.destroy()
|
|
|
|
def rename_session(self, old_name, new_name):
|
|
"""Rename a tmux session"""
|
|
try:
|
|
# Clean the session name (replace spaces with underscores)
|
|
clean_name = new_name.strip().replace(" ", "_")
|
|
|
|
# Rename session
|
|
subprocess.run(
|
|
["tmux", "rename-session", "-t", old_name, clean_name],
|
|
check=True
|
|
)
|
|
|
|
# Refresh the session list
|
|
self.refresh_sessions()
|
|
|
|
except Exception as e:
|
|
print(f"Error renaming tmux session: {e}")
|
|
|
|
def kill_session(self, session_name):
|
|
"""Kill a tmux session"""
|
|
try:
|
|
# Kill session
|
|
subprocess.run(
|
|
["tmux", "kill-session", "-t", session_name],
|
|
check=True
|
|
)
|
|
|
|
# Refresh the session list
|
|
self.refresh_sessions()
|
|
|
|
# Close the notch after killing session
|
|
self.close_manager()
|
|
|
|
except Exception as e:
|
|
print(f"Error killing tmux session: {e}")
|
|
|
|
# Add new method to handle key presses on session slots
|
|
def on_slot_key_press(self, button, event, session_name, label, entry):
|
|
"""Handle key presses on session buttons"""
|
|
# Print debugging info
|
|
print(f"Key pressed: {event.keyval}, State: {event.state}")
|
|
|
|
# Check if 'r' key was pressed for renaming
|
|
if event.keyval == Gdk.KEY_r:
|
|
self.start_rename(button, session_name, label, entry)
|
|
return True
|
|
# Check for 'K' (capital K) which indicates Shift is pressed
|
|
elif event.keyval == Gdk.KEY_K:
|
|
print("Shift+K detected - killing session without confirmation")
|
|
self.kill_session(session_name)
|
|
return True
|
|
# Check for lowercase 'k'
|
|
elif event.keyval == Gdk.KEY_k:
|
|
print("Regular k detected - showing confirmation")
|
|
self.show_kill_confirmation_menu(button, session_name)
|
|
return True
|
|
# Check if Delete key was pressed for killing session
|
|
elif event.keyval == Gdk.KEY_Delete:
|
|
self.show_kill_confirmation_menu(button, session_name)
|
|
return True
|
|
return False
|
|
|
|
def show_kill_confirmation_menu(self, button, session_name):
|
|
"""Show a confirmation menu for killing a session"""
|
|
menu = Gtk.Menu()
|
|
|
|
# Confirmation message as a disabled menu item
|
|
msg_item = Gtk.MenuItem(label=f"Kill session '{session_name}'?")
|
|
msg_item.set_sensitive(False)
|
|
menu.append(msg_item)
|
|
|
|
# Separator
|
|
menu.append(Gtk.SeparatorMenuItem())
|
|
|
|
# Confirm option
|
|
confirm_item = Gtk.MenuItem(label="Confirm")
|
|
confirm_item.connect("activate", lambda _: self.kill_session(session_name))
|
|
menu.append(confirm_item)
|
|
|
|
# Cancel option
|
|
cancel_item = Gtk.MenuItem(label="Cancel")
|
|
# Close notch on cancel
|
|
cancel_item.connect("activate", lambda _: self.close_manager())
|
|
menu.append(cancel_item)
|
|
|
|
menu.show_all()
|
|
|
|
# Show the menu positioned at the button
|
|
menu.popup_at_widget(
|
|
button,
|
|
Gdk.Gravity.SOUTH_WEST,
|
|
Gdk.Gravity.NORTH_WEST,
|
|
None
|
|
)
|