Skip to content

Commit d2576f1

Browse files
committed
feat: implement run_shell_command to execute shell commands and return output
1 parent cccf3e7 commit d2576f1

3 files changed

Lines changed: 236 additions & 12 deletions

File tree

src-tauri/src/commands/system.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
use std::process::Command;
2+
use serde::Serialize;
3+
4+
#[derive(Serialize)]
5+
pub struct ShellOutput {
6+
pub stdout: String,
7+
pub stderr: String,
8+
pub exit_code: i32,
9+
}
210

311
#[tauri::command]
412
pub fn open_in_terminal(path: String) -> Result<(), String> {
@@ -10,6 +18,32 @@ pub fn open_in_file_manager(path: String) -> Result<(), String> {
1018
open_file_manager_impl(path)
1119
}
1220

21+
#[tauri::command]
22+
pub fn run_shell_command(path: String, command: String) -> Result<ShellOutput, String> {
23+
let output = shell_exec(&path, &command).map_err(|e| e.to_string())?;
24+
Ok(ShellOutput {
25+
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
26+
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
27+
exit_code: output.status.code().unwrap_or(-1),
28+
})
29+
}
30+
31+
#[cfg(target_os = "windows")]
32+
fn shell_exec(path: &str, command: &str) -> std::io::Result<std::process::Output> {
33+
Command::new("cmd")
34+
.args(["/c", command])
35+
.current_dir(path)
36+
.output()
37+
}
38+
39+
#[cfg(not(target_os = "windows"))]
40+
fn shell_exec(path: &str, command: &str) -> std::io::Result<std::process::Output> {
41+
Command::new("sh")
42+
.args(["-c", command])
43+
.current_dir(path)
44+
.output()
45+
}
46+
1347
#[cfg(target_os = "windows")]
1448
fn open_terminal_impl(path: String) -> Result<(), String> {
1549
Command::new("cmd")

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ pub fn run() {
162162
commands::graph::get_commit_graph,
163163
commands::system::open_in_terminal,
164164
commands::system::open_in_file_manager,
165+
commands::system::run_shell_command,
165166
])
166167
.run(tauri::generate_context!())
167168
.expect("failed to run GitPilot");
Lines changed: 201 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,226 @@
1-
import type { MouseEvent as ReactMouseEvent } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
2+
import type { MouseEvent as ReactMouseEvent, KeyboardEvent } from 'react';
3+
import { invoke } from '@tauri-apps/api/core';
4+
import { ChevronRight, Terminal, ScrollText } from 'lucide-react';
25
import { useGitStore } from '../../store/gitStore';
36
import { gitService } from '../../services/gitService';
47

5-
type Props = {
8+
type ConsolePanelProps = {
69
height: number;
710
onResizeStart: (event: ReactMouseEvent) => void;
811
};
912

10-
export function ConsolePanel({ height, onResizeStart }: Props) {
13+
type TerminalEntry = {
14+
id: number;
15+
cmd: string;
16+
stdout: string;
17+
stderr: string;
18+
exitCode: number;
19+
cwd: string;
20+
};
21+
22+
type ShellOutput = {
23+
stdout: string;
24+
stderr: string;
25+
exit_code: number;
26+
};
27+
28+
let entryId = 0;
29+
30+
export function ConsolePanel({ height, onResizeStart }: ConsolePanelProps) {
1131
const consoleLines = useGitStore(s => s.console);
1232
const repo = useGitStore(s => s.repo?.path);
1333
const run = useGitStore(s => s.run);
34+
const [tab, setTab] = useState<'output' | 'terminal'>('output');
1435
const hasOutput = consoleLines.length > 0;
1536

1637
return (
17-
<footer className="relative shrink-0 border-t border-pilot-line bg-black" style={{ height: hasOutput ? height : 40 }}>
18-
{hasOutput && (
38+
<footer
39+
className="relative shrink-0 border-t border-pilot-line bg-[#080d14]"
40+
style={{ height: tab === 'terminal' ? height : hasOutput ? height : 40 }}
41+
>
42+
{(hasOutput || tab === 'terminal') && (
1943
<div
20-
className="absolute left-0 right-0 top-0 z-10 h-1 cursor-row-resize hover:bg-pilot-blue"
44+
className="absolute left-0 right-0 top-0 z-10 h-1 cursor-row-resize hover:bg-pilot-blue/60"
2145
onMouseDown={onResizeStart}
2246
/>
2347
)}
24-
<div className={`flex h-10 items-center gap-2 px-3 text-xs ${hasOutput ? 'border-b border-pilot-line' : ''}`}>
25-
<button className="btn shrink-0" onClick={() => repo && run('validation', () => gitService.runValidation(repo), 'none')}>
26-
Run Validation
48+
49+
{/* Tab bar */}
50+
<div className={`flex h-10 shrink-0 items-center gap-0 border-b ${hasOutput || tab === 'terminal' ? 'border-pilot-line' : 'border-transparent'}`}>
51+
<button
52+
className={`flex items-center gap-1.5 border-r border-pilot-line px-4 h-full text-xs font-medium transition-colors ${
53+
tab === 'output' ? 'bg-[#161b22] text-slate-200' : 'text-slate-400 hover:bg-[#161b22]/60 hover:text-slate-200'
54+
}`}
55+
onClick={() => setTab('output')}
56+
>
57+
<ScrollText size={12} />
58+
Output
59+
{hasOutput && (
60+
<span className="ml-0.5 rounded bg-[#21262d] px-1 py-px text-[10px] font-bold text-slate-400">
61+
{consoleLines.length}
62+
</span>
63+
)}
2764
</button>
28-
<span className="min-w-0 truncate text-slate-400">Git Output - Validation - Problems - AI</span>
65+
<button
66+
className={`flex items-center gap-1.5 border-r border-pilot-line px-4 h-full text-xs font-medium transition-colors ${
67+
tab === 'terminal' ? 'bg-[#161b22] text-slate-200' : 'text-slate-400 hover:bg-[#161b22]/60 hover:text-slate-200'
68+
}`}
69+
onClick={() => setTab('terminal')}
70+
>
71+
<Terminal size={12} />
72+
Terminal
73+
</button>
74+
75+
{tab === 'output' && (
76+
<div className="ml-2 flex items-center gap-2">
77+
<button
78+
className="btn shrink-0 text-xs"
79+
onClick={() => repo && run('validation', () => gitService.runValidation(repo), 'none')}
80+
>
81+
Run Validation
82+
</button>
83+
</div>
84+
)}
2985
</div>
30-
{hasOutput && (
31-
<pre className="h-[calc(100%-40px)] overflow-auto whitespace-pre-wrap p-3 text-xs leading-relaxed text-slate-300">
86+
87+
{/* Content */}
88+
{tab === 'output' && hasOutput && (
89+
<pre className="h-[calc(100%-40px)] overflow-auto whitespace-pre-wrap p-3 font-mono text-xs leading-relaxed text-slate-300">
3290
{consoleLines.join('\n\n')}
3391
</pre>
3492
)}
93+
94+
{tab === 'terminal' && (
95+
<TerminalPane cwd={repo ?? ''} height={height - 40} />
96+
)}
3597
</footer>
3698
);
3799
}
100+
101+
function TerminalPane({ cwd, height }: { cwd: string; height: number }) {
102+
const [entries, setEntries] = useState<TerminalEntry[]>([]);
103+
const [cmd, setCmd] = useState('');
104+
const [history, setHistory] = useState<string[]>([]);
105+
const [histIdx, setHistIdx] = useState(-1);
106+
const [running, setRunning] = useState(false);
107+
const outputRef = useRef<HTMLDivElement>(null);
108+
const inputRef = useRef<HTMLInputElement>(null);
109+
110+
useEffect(() => {
111+
if (outputRef.current) {
112+
outputRef.current.scrollTop = outputRef.current.scrollHeight;
113+
}
114+
}, [entries]);
115+
116+
const submit = async () => {
117+
const c = cmd.trim();
118+
if (!c || !cwd || running) return;
119+
setHistory(h => [c, ...h.filter(x => x !== c)].slice(0, 100));
120+
setHistIdx(-1);
121+
setCmd('');
122+
setRunning(true);
123+
try {
124+
const result = await invoke<ShellOutput>('run_shell_command', { path: cwd, command: c });
125+
setEntries(e => [
126+
...e,
127+
{ id: ++entryId, cmd: c, stdout: result.stdout, stderr: result.stderr, exitCode: result.exit_code, cwd },
128+
]);
129+
} catch (err) {
130+
setEntries(e => [
131+
...e,
132+
{ id: ++entryId, cmd: c, stdout: '', stderr: String(err), exitCode: -1, cwd },
133+
]);
134+
} finally {
135+
setRunning(false);
136+
}
137+
};
138+
139+
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
140+
if (e.key === 'Enter') {
141+
void submit();
142+
} else if (e.key === 'ArrowUp') {
143+
e.preventDefault();
144+
setHistIdx(i => {
145+
const next = Math.min(i + 1, history.length - 1);
146+
if (history[next] !== undefined) setCmd(history[next]);
147+
return next;
148+
});
149+
} else if (e.key === 'ArrowDown') {
150+
e.preventDefault();
151+
setHistIdx(i => {
152+
const next = Math.max(i - 1, -1);
153+
setCmd(next === -1 ? '' : history[next] ?? '');
154+
return next;
155+
});
156+
}
157+
};
158+
159+
const promptLabel = cwd ? cwd.split(/[\\/]/).pop() ?? cwd : '~';
160+
161+
return (
162+
<div
163+
className="flex flex-col bg-[#080d14] font-mono text-xs"
164+
style={{ height }}
165+
onClick={() => inputRef.current?.focus()}
166+
>
167+
{/* Output */}
168+
<div ref={outputRef} className="min-h-0 flex-1 overflow-auto p-2 space-y-2">
169+
{entries.length === 0 && (
170+
<div className="text-slate-600 px-1 pt-1 select-none">
171+
{cwd ? `$ ${cwd}` : 'Open a repository to use the terminal.'}
172+
</div>
173+
)}
174+
{entries.map(entry => (
175+
<div key={entry.id} className="space-y-0.5">
176+
{/* Command line */}
177+
<div className="flex items-center gap-1.5 text-slate-400">
178+
<span className="text-pilot-blue">{promptLabel}</span>
179+
<ChevronRight size={10} className="text-slate-600" />
180+
<span>{entry.cmd}</span>
181+
</div>
182+
{/* stdout */}
183+
{entry.stdout.trim() && (
184+
<pre className="whitespace-pre-wrap text-slate-300 leading-relaxed pl-4">
185+
{entry.stdout.trimEnd()}
186+
</pre>
187+
)}
188+
{/* stderr */}
189+
{entry.stderr.trim() && (
190+
<pre className="whitespace-pre-wrap text-red-400 leading-relaxed pl-4">
191+
{entry.stderr.trimEnd()}
192+
</pre>
193+
)}
194+
{/* exit code (only show if non-zero) */}
195+
{entry.exitCode !== 0 && (
196+
<div className="pl-4 text-red-500">exit {entry.exitCode}</div>
197+
)}
198+
</div>
199+
))}
200+
{running && (
201+
<div className="text-slate-500 animate-pulse px-1">running…</div>
202+
)}
203+
</div>
204+
205+
{/* Input */}
206+
<div className="flex shrink-0 items-center gap-1.5 border-t border-pilot-line bg-[#0d1117] px-3 py-2">
207+
<span className="text-pilot-blue shrink-0">{promptLabel}</span>
208+
<ChevronRight size={10} className="shrink-0 text-slate-600" />
209+
<input
210+
ref={inputRef}
211+
className="min-w-0 flex-1 bg-transparent text-slate-200 caret-pilot-blue outline-none placeholder:text-slate-700"
212+
value={cmd}
213+
onChange={e => { setCmd(e.target.value); setHistIdx(-1); }}
214+
onKeyDown={onKey}
215+
placeholder={cwd ? 'Enter command…' : 'Open a repo first'}
216+
disabled={!cwd || running}
217+
autoFocus
218+
spellCheck={false}
219+
autoComplete="off"
220+
autoCorrect="off"
221+
/>
222+
{running && <span className="shrink-0 text-[10px] text-slate-600 animate-pulse">running</span>}
223+
</div>
224+
</div>
225+
);
226+
}

0 commit comments

Comments
 (0)