diff --git a/src/parsers/tmuxParser.ts b/src/parsers/tmuxParser.ts new file mode 100644 index 0000000..3529331 --- /dev/null +++ b/src/parsers/tmuxParser.ts @@ -0,0 +1,267 @@ +import { shellUtils } from '../utils/shellUtils'; +import { Layout, Session, Window, Pane, ParserResult, TerminalType } from '../models/types'; + +interface TmuxPaneInfo { + pane_index: string; + pane_current_path: string; + pane_current_command: string; + pane_title: string; + pane_active: string; + layout: string; + x: string; + y: string; + width: string; + height: string; +} + +interface TmuxWindowInfo { + window_index: string; + window_name: string; + window_active: string; + panes: TmuxPaneInfo[]; +} + +interface TmuxSessionInfo { + session_name: string; + session_windows: TmuxWindowInfo[]; + session_active: string; +} + +export class TmuxParser { + private tmuxPath: string; + + constructor() { + this.tmuxPath = 'tmux'; + } + + async isAvailable(): Promise { + return shellUtils.isCommandAvailable('tmux'); + } + + async listSessions(): Promise { + const result = await shellUtils.exec('tmux list-sessions -F "#{session_name}"'); + if (!result.success) { + return []; + } + return result.stdout.split('\n').filter(Boolean); + } + + async captureSession(sessionName?: string): Promise> { + try { + const sessionInfo = await this.getSessionInfo(sessionName); + if (!sessionInfo) { + return { success: false, error: `Session not found: ${sessionName}` }; + } + + const layout = this.convertToLayout(sessionInfo); + return { success: true, data: layout }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + private async getSessionInfo(sessionName?: string): Promise { + let command = 'tmux list-windows -a -F "#{window_index}:#{window_name}:#{window_active}"'; + if (sessionName) { + command = `tmux list-windows -t "${sessionName}" -F "#{window_index}:#{window_name}:#{window_active}"`; + } + + const windowsResult = await shellUtils.exec(command); + if (!windowsResult.success) { + return null; + } + + const windows: TmuxWindowInfo[] = []; + const windowLines = windowsResult.stdout.split('\n').filter(Boolean); + + for (const line of windowLines) { + const parts = line.split(':'); + if (parts.length < 3) continue; + + const windowIndex = parts[0].trim(); + const windowName = parts[1].trim(); + const isActive = parts[2].trim() === '1'; + + const panes = await this.getPanesForWindow(sessionName, windowIndex); + windows.push({ + window_index: windowIndex, + window_name: windowName, + window_active: isActive ? '1' : '0', + panes, + }); + } + + const sessionNameResult = sessionName || await this.getActiveSessionName(); + + return { + session_name: sessionNameResult || 'unknown', + session_windows: windows, + session_active: windows.find(w => w.window_active === '1')?.window_index || '0', + }; + } + + private async getActiveSessionName(): Promise { + const result = await shellUtils.exec('tmux display-message -p "#{session_name}"'); + return result.success ? result.stdout : null; + } + + private async getPanesForWindow(sessionName: string | undefined, windowIndex: string): Promise { + const target = sessionName ? `${sessionName}:${windowIndex}` : windowIndex; + const result = await shellUtils.exec( + `tmux list-panes -t "${target}" -F "#{pane_index}:#{pane_current_path}:#{pane_current_command}:#{pane_title}:#{pane_active}:#{layout}:#{pane_width}:#{pane_height}:#{pane_x}:#{pane_y}"` + ); + + if (!result.success) { + return []; + } + + const panes: TmuxPaneInfo[] = []; + const paneLines = result.stdout.split('\n').filter(Boolean); + + for (const line of paneLines) { + const parts = line.split(':'); + if (parts.length < 10) continue; + + panes.push({ + pane_index: parts[0], + pane_current_path: parts[1], + pane_current_command: parts[2], + pane_title: parts[3], + pane_active: parts[4], + layout: parts[5], + width: parts[6], + height: parts[7], + x: parts[8], + y: parts[9], + }); + } + + return panes; + } + + private convertToLayout(sessionInfo: TmuxSessionInfo): Layout { + const activeWindowIndex = parseInt(sessionInfo.session_active, 10) || 0; + + const windows: Window[] = sessionInfo.session_windows.map((w) => { + const panes: Pane[] = w.panes.map((p) => ({ + id: `p${p.pane_index}`, + index: parseInt(p.pane_index, 10), + layout: { + x: parseInt(p.x, 10) || 0, + y: parseInt(p.y, 10) || 0, + width: parseInt(p.width, 10) || 80, + height: parseInt(p.height, 10) || 24, + }, + command: p.pane_current_command !== 'zsh' && p.pane_current_command !== 'bash' ? p.pane_current_command : undefined, + cwd: p.pane_current_path, + title: p.pane_title, + isActive: p.pane_active === '1', + })); + + return { + id: `w${w.window_index}`, + index: parseInt(w.window_index, 10), + name: w.window_name, + panes, + layout: w.panes[0]?.layout, + activePaneIndex: panes.findIndex((p) => p.isActive), + }; + }); + + const session: Session = { + id: `s-${sessionInfo.session_name}`, + name: sessionInfo.session_name, + windows, + activeWindowIndex, + }; + + return { + version: '1.0.0', + terminalType: 'tmux' as TerminalType, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + session, + metadata: { + description: `Captured tmux session: ${sessionInfo.session_name}`, + }, + }; + } + + async generateRestoreCommands(layout: Layout, sessionName?: string): Promise { + const commands: string[] = []; + const targetSession = sessionName || layout.session.name; + + commands.push(`tmux new-session -d -s "${targetSession}"`); + + for (let i = 0; i < layout.session.windows.length; i++) { + const window = layout.session.windows[i]; + + if (i === 0) { + commands.push(`tmux rename-window -t "${targetSession}:0" "${window.name}"`); + } else { + commands.push(`tmux new-window -t "${targetSession}" -n "${window.name}"`); + } + + for (let j = 0; j < window.panes.length; j++) { + const pane = window.panes[j]; + + if (j === 0 && i === 0) { + commands.push(`tmux select-pane -t "${targetSession}:${i}.${j}"`); + } else if (j === 0) { + commands.push(`tmux select-pane -t "${targetSession}:${i}.${j}"`); + } else { + commands.push(`tmux split-window -t "${targetSession}:${i}" -${this.getSplitDirection(layout, i, j)}`); + } + + if (pane.cwd) { + commands.push(`tmux send-keys -t "${targetSession}:${i}.${j}" "cd '${pane.cwd}'" C-m`); + } + + if (pane.command) { + commands.push(`tmux send-keys -t "${targetSession}:${i}.${j}" "${pane.command}" C-m`); + } + } + + if (window.layout) { + commands.push(`tmux select-layout -t "${targetSession}:${i}" "${window.layout}"`); + } + } + + if (layout.session.activeWindowIndex > 0) { + commands.push(`tmux select-window -t "${targetSession}:${layout.session.activeWindowIndex}"`); + } + + return commands; + } + + private getSplitDirection(layout: Layout, windowIndex: number, paneIndex: number): string { + if (paneIndex === 0) return 'h'; + + const prevPane = layout.session.windows[windowIndex].panes[paneIndex - 1]; + const currentPane = layout.session.windows[windowIndex].panes[paneIndex]; + + if (currentPane.layout.x > prevPane.layout.x) { + return 'v'; + } + return 'h'; + } + + async restoreLayout(layout: Layout, sessionName?: string): Promise> { + try { + const commands = await this.generateRestoreCommands(layout, sessionName); + + for (const command of commands) { + const result = await shellUtils.exec(command); + if (!result.success) { + return { success: false, error: `Failed to execute: ${command}\n${result.stderr}` }; + } + } + + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } +} + +export const tmuxParser = new TmuxParser();