Skip to content

Commit 8f056c7

Browse files
NagyViktNagyVikt
andauthored
Make cockpit lane actions executable on demand (#486)
The cockpit control loop should keep rendering intents without launching work, while selected-lane actions need a bounded runner for files, diff, locks, sync, finish, pane focus, close, copy path, and editor open. This adds that runner and explicit control wrappers without replacing the current TUI reducer. Constraint: Existing control-loop tests require menu selection to emit intents, not launch agents directly. Rejected: Reuse stale worktree control.js wholesale | it deleted the current TUI control loop from PR #482. Confidence: high Scope-risk: moderate Directive: Keep close limited to tmux pane cleanup; do not delete branch/worktree/session metadata from that action. Tested: node --test test/cockpit-action-runner.test.js test/cockpit-control.test.js Not-tested: Manual tmux cockpit interaction Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent d9c2045 commit 8f056c7

3 files changed

Lines changed: 646 additions & 0 deletions

File tree

src/cockpit/action-runner.js

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
'use strict';
2+
3+
const cp = require('node:child_process');
4+
const path = require('node:path');
5+
const { readCockpitSettings } = require('./settings');
6+
7+
const ACTION_ALIASES = new Map([
8+
['finish-pr', 'finish'],
9+
['finish / pr', 'finish'],
10+
['copy path', 'copy-path'],
11+
['open in editor', 'open-editor'],
12+
['open-editor', 'open-editor'],
13+
]);
14+
15+
const CLIPBOARD_COMMANDS = [
16+
{ cmd: 'wl-copy', args: [] },
17+
{ cmd: 'termux-clipboard-set', args: [] },
18+
{ cmd: 'pbcopy', args: [], input: true },
19+
{ cmd: 'xclip', args: ['-selection', 'clipboard'], input: true },
20+
{ cmd: 'xsel', args: ['--clipboard', '--input'], input: true },
21+
];
22+
23+
function defaultRunCommand(cmd, args = [], options = {}) {
24+
return cp.spawnSync(cmd, args, {
25+
cwd: options.cwd,
26+
env: options.env ? { ...process.env, ...options.env } : process.env,
27+
encoding: 'utf8',
28+
input: options.input,
29+
stdio: 'pipe',
30+
timeout: options.timeout,
31+
});
32+
}
33+
34+
function firstString(...values) {
35+
for (const value of values) {
36+
if (typeof value === 'string' && value.trim().length > 0) {
37+
return value.trim();
38+
}
39+
}
40+
return '';
41+
}
42+
43+
function normalizeAction(action) {
44+
const raw = typeof action === 'string'
45+
? action
46+
: firstString(action && action.id, action && action.action, action && action.type, action && action.label);
47+
const normalized = String(raw || '').trim().toLowerCase();
48+
return ACTION_ALIASES.get(normalized) || normalized;
49+
}
50+
51+
function normalizeResult(result) {
52+
const payload = result && typeof result === 'object' ? result : {};
53+
const status = Number.isInteger(payload.status) ? payload.status : 0;
54+
const ok = !payload.error && status === 0;
55+
return {
56+
ok,
57+
stdout: String(payload.stdout || ''),
58+
stderr: payload.error ? String(payload.error.message || payload.error) : String(payload.stderr || ''),
59+
};
60+
}
61+
62+
function shellQuote(value) {
63+
const text = String(value);
64+
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(text)) return text;
65+
return `'${text.replace(/'/g, "'\\''")}'`;
66+
}
67+
68+
function renderCommand(cmd, args = []) {
69+
return [cmd, ...args].map(shellQuote).join(' ');
70+
}
71+
72+
function resultShape({ ok, message, command = '', stdout = '', stderr = '' }) {
73+
return {
74+
ok: Boolean(ok),
75+
message: String(message || ''),
76+
command: String(command || ''),
77+
stdout: String(stdout || ''),
78+
stderr: String(stderr || ''),
79+
};
80+
}
81+
82+
function selectedSession(context = {}) {
83+
return context.session || context.selectedSession || context.lane || {};
84+
}
85+
86+
function resolveBranch(context = {}) {
87+
const session = selectedSession(context);
88+
return firstString(
89+
context.branch,
90+
session.branch,
91+
session.lane && session.lane.branch,
92+
);
93+
}
94+
95+
function resolveWorktreePath(context = {}) {
96+
const session = selectedSession(context);
97+
return firstString(
98+
context.worktreePath,
99+
context.path,
100+
session.worktreePath,
101+
session.worktree && session.worktree.path,
102+
session.path,
103+
);
104+
}
105+
106+
function resolvePaneId(context = {}) {
107+
const session = selectedSession(context);
108+
return firstString(
109+
context.paneId,
110+
context.tmuxPaneId,
111+
context.tmuxTarget,
112+
session.paneId,
113+
session.tmuxPaneId,
114+
session.tmuxTarget,
115+
session.tmux && session.tmux.paneId,
116+
session.pane && session.pane.id,
117+
);
118+
}
119+
120+
function resolveRepoRoot(context = {}) {
121+
return path.resolve(firstString(context.repoRoot, context.repoPath, context.target, process.cwd()));
122+
}
123+
124+
function runCommand(context, cmd, args = [], options = {}) {
125+
const runner = typeof context.runCommand === 'function' ? context.runCommand : defaultRunCommand;
126+
const rendered = renderCommand(cmd, args);
127+
const payload = normalizeResult(runner(cmd, args, options));
128+
const detail = payload.ok ? payload.stdout : payload.stderr || payload.stdout;
129+
return resultShape({
130+
ok: payload.ok,
131+
message: payload.ok ? 'Command completed.' : `Command failed: ${detail.trim() || rendered}`,
132+
command: rendered,
133+
stdout: payload.stdout,
134+
stderr: payload.stderr,
135+
});
136+
}
137+
138+
function commandExists(context, cmd) {
139+
if (typeof context.commandExists === 'function') {
140+
return Boolean(context.commandExists(cmd));
141+
}
142+
const runner = typeof context.runCommand === 'function' ? context.runCommand : defaultRunCommand;
143+
const result = normalizeResult(runner('which', [cmd], { cwd: resolveRepoRoot(context) }));
144+
return result.ok && result.stdout.trim().length > 0;
145+
}
146+
147+
function requireBranch(context, actionName) {
148+
const branch = resolveBranch(context);
149+
if (branch) return { branch };
150+
return resultShape({
151+
ok: false,
152+
message: `${actionName} requires a selected lane branch.`,
153+
});
154+
}
155+
156+
function requireWorktreePath(context, actionName) {
157+
const worktreePath = resolveWorktreePath(context);
158+
if (worktreePath) return { worktreePath };
159+
return resultShape({
160+
ok: false,
161+
message: `${actionName} requires a selected lane worktree path.`,
162+
});
163+
}
164+
165+
function runGxAgentsInspect(subcommand, context) {
166+
const required = requireBranch(context, subcommand);
167+
if (!required.branch) return required;
168+
return runCommand(
169+
context,
170+
context.gxCommand || 'gx',
171+
['agents', subcommand, '--target', resolveRepoRoot(context), '--branch', required.branch],
172+
{ cwd: resolveRepoRoot(context) },
173+
);
174+
}
175+
176+
function runView(context) {
177+
const paneId = resolvePaneId(context);
178+
if (paneId) {
179+
return runCommand(context, context.tmuxCommand || 'tmux', ['select-pane', '-t', paneId], {
180+
cwd: resolveRepoRoot(context),
181+
});
182+
}
183+
184+
return runGxAgentsInspect('files', context);
185+
}
186+
187+
function runSync(context) {
188+
const required = requireWorktreePath(context, 'Sync');
189+
if (!required.worktreePath) return required;
190+
const args = ['sync', '--target', required.worktreePath];
191+
const base = firstString(context.base, context.baseBranch, selectedSession(context).base);
192+
if (base) args.push('--base', base);
193+
return runCommand(context, context.gxCommand || 'gx', args, { cwd: required.worktreePath });
194+
}
195+
196+
function runFinish(context) {
197+
const required = requireBranch(context, 'Finish');
198+
if (!required.branch) return required;
199+
return runCommand(
200+
context,
201+
context.gxCommand || 'gx',
202+
[
203+
'agents',
204+
'finish',
205+
'--target',
206+
resolveRepoRoot(context),
207+
'--branch',
208+
required.branch,
209+
'--via-pr',
210+
'--wait-for-merge',
211+
'--cleanup',
212+
],
213+
{ cwd: resolveRepoRoot(context) },
214+
);
215+
}
216+
217+
function runClose(context) {
218+
const paneId = resolvePaneId(context);
219+
if (!paneId) {
220+
return resultShape({
221+
ok: false,
222+
message: 'Close requires an associated tmux pane; branch, worktree, and session metadata were left untouched.',
223+
});
224+
}
225+
const result = runCommand(context, context.tmuxCommand || 'tmux', ['kill-pane', '-t', paneId], {
226+
cwd: resolveRepoRoot(context),
227+
});
228+
return {
229+
...result,
230+
message: result.ok
231+
? 'Closed associated tmux pane only; branch, worktree, and session metadata were left untouched.'
232+
: result.message,
233+
};
234+
}
235+
236+
function resolveClipboardCommand(context) {
237+
if (context.clipboardCommand && typeof context.clipboardCommand === 'object') {
238+
return {
239+
cmd: context.clipboardCommand.cmd,
240+
args: Array.isArray(context.clipboardCommand.args) ? context.clipboardCommand.args : [],
241+
input: Boolean(context.clipboardCommand.input),
242+
};
243+
}
244+
if (typeof context.clipboardCommand === 'string' && context.clipboardCommand.trim()) {
245+
return { cmd: context.clipboardCommand.trim(), args: [], input: true };
246+
}
247+
return CLIPBOARD_COMMANDS.find((candidate) => commandExists(context, candidate.cmd)) || null;
248+
}
249+
250+
function runCopyPath(context) {
251+
const required = requireWorktreePath(context, 'Copy Path');
252+
if (!required.worktreePath) return required;
253+
const clipboard = resolveClipboardCommand(context);
254+
if (!clipboard) {
255+
return resultShape({
256+
ok: true,
257+
message: 'No clipboard utility found; printed worktree path.',
258+
command: renderCommand('printf', ['%s\\n', required.worktreePath]),
259+
stdout: `${required.worktreePath}\n`,
260+
});
261+
}
262+
263+
const args = clipboard.input ? clipboard.args : [...clipboard.args, required.worktreePath];
264+
const result = runCommand(context, clipboard.cmd, args, {
265+
cwd: required.worktreePath,
266+
input: clipboard.input ? required.worktreePath : undefined,
267+
});
268+
return {
269+
...result,
270+
message: result.ok ? 'Copied worktree path.' : result.message,
271+
};
272+
}
273+
274+
function splitCommand(rawCommand) {
275+
const parts = [];
276+
let current = '';
277+
let quote = '';
278+
let escaped = false;
279+
280+
for (const char of String(rawCommand || '')) {
281+
if (escaped) {
282+
current += char;
283+
escaped = false;
284+
continue;
285+
}
286+
if (char === '\\') {
287+
escaped = true;
288+
continue;
289+
}
290+
if (quote) {
291+
if (char === quote) {
292+
quote = '';
293+
} else {
294+
current += char;
295+
}
296+
continue;
297+
}
298+
if (char === '"' || char === "'") {
299+
quote = char;
300+
continue;
301+
}
302+
if (/\s/.test(char)) {
303+
if (current) {
304+
parts.push(current);
305+
current = '';
306+
}
307+
continue;
308+
}
309+
current += char;
310+
}
311+
if (current) parts.push(current);
312+
return parts;
313+
}
314+
315+
function resolveEditorParts(context, worktreePath) {
316+
const settings = context.settings && typeof context.settings === 'object'
317+
? context.settings
318+
: readCockpitSettings(resolveRepoRoot(context));
319+
const configured = firstString(settings.editorCommand);
320+
if (configured) return splitCommand(configured);
321+
if (commandExists(context, 'code')) return ['code'];
322+
323+
return {
324+
printOnly: true,
325+
parts: ['code', worktreePath],
326+
};
327+
}
328+
329+
function runOpenEditor(context) {
330+
const required = requireWorktreePath(context, 'Open in Editor');
331+
if (!required.worktreePath) return required;
332+
const resolved = resolveEditorParts(context, required.worktreePath);
333+
if (resolved.printOnly) {
334+
return resultShape({
335+
ok: true,
336+
message: 'No editor command configured and code was not found; printed editor command.',
337+
command: renderCommand(resolved.parts[0], resolved.parts.slice(1)),
338+
stdout: `${renderCommand(resolved.parts[0], resolved.parts.slice(1))}\n`,
339+
});
340+
}
341+
342+
const [cmd, ...args] = resolved;
343+
const result = runCommand(context, cmd, [...args, required.worktreePath], {
344+
cwd: required.worktreePath,
345+
});
346+
return {
347+
...result,
348+
message: result.ok ? 'Opened worktree in editor.' : result.message,
349+
};
350+
}
351+
352+
function runCockpitAction(action, context = {}) {
353+
const normalized = normalizeAction(action);
354+
355+
if (normalized === 'view') return runView(context);
356+
if (normalized === 'files') return runGxAgentsInspect('files', context);
357+
if (normalized === 'diff') return runGxAgentsInspect('diff', context);
358+
if (normalized === 'locks') return runGxAgentsInspect('locks', context);
359+
if (normalized === 'sync') return runSync(context);
360+
if (normalized === 'finish') return runFinish(context);
361+
if (normalized === 'close') return runClose(context);
362+
if (normalized === 'copy-path') return runCopyPath(context);
363+
if (normalized === 'open-editor') return runOpenEditor(context);
364+
365+
return resultShape({
366+
ok: false,
367+
message: `Unknown cockpit action: ${normalized || '(empty)'}`,
368+
});
369+
}
370+
371+
module.exports = {
372+
runCockpitAction,
373+
splitCommand,
374+
renderCommand,
375+
};

0 commit comments

Comments
 (0)