|
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'; |
2 | 5 | import { useGitStore } from '../../store/gitStore'; |
3 | 6 | import { gitService } from '../../services/gitService'; |
4 | 7 |
|
5 | | -type Props = { |
| 8 | +type ConsolePanelProps = { |
6 | 9 | height: number; |
7 | 10 | onResizeStart: (event: ReactMouseEvent) => void; |
8 | 11 | }; |
9 | 12 |
|
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) { |
11 | 31 | const consoleLines = useGitStore(s => s.console); |
12 | 32 | const repo = useGitStore(s => s.repo?.path); |
13 | 33 | const run = useGitStore(s => s.run); |
| 34 | + const [tab, setTab] = useState<'output' | 'terminal'>('output'); |
14 | 35 | const hasOutput = consoleLines.length > 0; |
15 | 36 |
|
16 | 37 | 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') && ( |
19 | 43 | <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" |
21 | 45 | onMouseDown={onResizeStart} |
22 | 46 | /> |
23 | 47 | )} |
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 | + )} |
27 | 64 | </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 | + )} |
29 | 85 | </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"> |
32 | 90 | {consoleLines.join('\n\n')} |
33 | 91 | </pre> |
34 | 92 | )} |
| 93 | + |
| 94 | + {tab === 'terminal' && ( |
| 95 | + <TerminalPane cwd={repo ?? ''} height={height - 40} /> |
| 96 | + )} |
35 | 97 | </footer> |
36 | 98 | ); |
37 | 99 | } |
| 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