Skip to content

Commit 2176900

Browse files
lambdalisueclaude
andcommitted
feat(previewer): add shell command previewer
Implements a new previewer that executes shell commands and displays their output (stdout/stderr). Supports custom working directories, environment variables, timeouts, and output truncation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 1875608 commit 2176900

File tree

2 files changed

+164
-0
lines changed

2 files changed

+164
-0
lines changed

builtin/previewer/mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from "./buffer.ts";
33
export * from "./file.ts";
44
export * from "./helptag.ts";
55
export * from "./noop.ts";
6+
export * from "./shell.ts";

builtin/previewer/shell.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { unnullish } from "@lambdalisue/unnullish";
2+
3+
import { definePreviewer, type Previewer } from "../../previewer.ts";
4+
import { splitText } from "../../util/stringutil.ts";
5+
6+
type Detail = {
7+
/**
8+
* Command to execute
9+
*/
10+
command?: string;
11+
12+
/**
13+
* Arguments to pass to the command
14+
*/
15+
args?: string[];
16+
17+
/**
18+
* Current working directory for command execution
19+
*/
20+
cwd?: string;
21+
22+
/**
23+
* Environment variables
24+
*/
25+
env?: Record<string, string>;
26+
27+
/**
28+
* Timeout in milliseconds
29+
*/
30+
timeout?: number;
31+
};
32+
33+
export type ShellOptions = {
34+
/**
35+
* Default shell to use if no command is specified.
36+
* @default ["sh", "-c"]
37+
*/
38+
shell?: string[];
39+
40+
/**
41+
* Default timeout in milliseconds.
42+
* @default 5000
43+
*/
44+
defaultTimeout?: number;
45+
46+
/**
47+
* Maximum number of lines to display.
48+
* @default 1000
49+
*/
50+
maxLines?: number;
51+
};
52+
53+
/**
54+
* Creates a Previewer that executes shell commands and displays their output.
55+
*
56+
* This Previewer runs a specified command and shows its stdout/stderr output.
57+
* It supports custom working directories, environment variables, and timeouts.
58+
*
59+
* @param options - Options to customize shell command execution.
60+
* @returns A Previewer that shows the command output.
61+
*/
62+
export function shell(options: Readonly<ShellOptions> = {}): Previewer<Detail> {
63+
const shell = options.shell ?? ["sh", "-c"];
64+
const defaultTimeout = options.defaultTimeout ?? 5000;
65+
const maxLines = options.maxLines ?? 1000;
66+
67+
return definePreviewer(async (_denops, { item }, { signal }) => {
68+
// Get command from detail or use item value as command
69+
const command = item.detail.command ?? item.value;
70+
const args = item.detail.args ?? [];
71+
const cwd = item.detail.cwd;
72+
const env = item.detail.env;
73+
const timeout = item.detail.timeout ?? defaultTimeout;
74+
75+
// Prepare command array
76+
let cmd: string[];
77+
if (args.length > 0) {
78+
cmd = [command, ...args];
79+
} else {
80+
// Use shell to execute the command string
81+
cmd = [...shell, command];
82+
}
83+
84+
try {
85+
// Create subprocess
86+
const process = new Deno.Command(cmd[0], {
87+
args: cmd.slice(1),
88+
cwd: unnullish(cwd, (v) => v),
89+
env: unnullish(env, (v) => v),
90+
stdout: "piped",
91+
stderr: "piped",
92+
signal,
93+
});
94+
95+
// Set up timeout
96+
const timeoutId = setTimeout(() => {
97+
try {
98+
process.spawn().kill();
99+
} catch {
100+
// Ignore errors when killing
101+
}
102+
}, timeout);
103+
104+
try {
105+
// Execute command
106+
const { stdout, stderr, success } = await process.output();
107+
clearTimeout(timeoutId);
108+
109+
// Decode output
110+
const decoder = new TextDecoder();
111+
const stdoutText = decoder.decode(stdout);
112+
const stderrText = decoder.decode(stderr);
113+
114+
// Combine stdout and stderr
115+
let content: string[] = [];
116+
117+
if (stdoutText) {
118+
content.push(...splitText(stdoutText));
119+
}
120+
121+
if (stderrText) {
122+
if (content.length > 0) {
123+
content.push("--- stderr ---");
124+
}
125+
content.push(...splitText(stderrText));
126+
}
127+
128+
// Add status line if command failed
129+
if (!success) {
130+
content.push("", `[Command failed with non-zero exit code]`);
131+
}
132+
133+
// Limit output lines
134+
if (content.length > maxLines) {
135+
content = content.slice(0, maxLines);
136+
content.push("", `[Output truncated to ${maxLines} lines]`);
137+
}
138+
139+
// Handle empty output
140+
if (content.length === 0) {
141+
content = ["[No output]"];
142+
}
143+
144+
return {
145+
content,
146+
filename: `$ ${cmd.join(" ")}`,
147+
};
148+
} finally {
149+
clearTimeout(timeoutId);
150+
}
151+
} catch (err) {
152+
// Handle command execution errors
153+
return {
154+
content: [
155+
`Error executing command: ${command}`,
156+
"",
157+
...String(err).split("\n"),
158+
],
159+
filename: `$ ${cmd.join(" ")}`,
160+
};
161+
}
162+
});
163+
}

0 commit comments

Comments
 (0)