11"use client" ;
22
3- import React , { useEffect , useRef , useState } from "react" ;
3+ import React , { useCallback , useEffect , useRef , useState } from "react" ;
44import { Terminal } from "@xterm/xterm" ;
55import { FitAddon } from "@xterm/addon-fit" ;
66import "@xterm/xterm/css/xterm.css" ;
7+ import { highlightCodeToAnsi } from "./highlight" ;
8+ import chalk from "chalk" ;
79
810export interface TerminalOutput {
911 type : "stdout" | "stderr" | "error" | "return" ; // 出力の種類
@@ -16,6 +18,8 @@ interface TerminalComponentProps {
1618 initMessage : string ; // ターミナル初期化時のメッセージ
1719 prompt : string ; // プロンプト文字列
1820 promptMore ?: string ;
21+ language ?: string ;
22+ tabSize : number ;
1923 // コマンド実行時のコールバック関数
2024 sendCommand : ( command : string ) => Promise < TerminalOutput [ ] > ;
2125 // 構文チェックのコールバック関数
@@ -26,28 +30,10 @@ export function TerminalComponent(props: TerminalComponentProps) {
2630 const terminalRef = useRef < HTMLDivElement > ( null ! ) ;
2731 const terminalInstanceRef = useRef < Terminal | null > ( null ) ;
2832 const [ termReady , setTermReady ] = useState < boolean > ( false ) ;
29- const inputBuffer = useRef < string [ ] > ( [ "" ] ) ;
33+ const inputBuffer = useRef < string [ ] > ( [ ] ) ;
3034
31- const initMessage = useRef < string > ( null ! ) ;
32- initMessage . current = props . initMessage ;
33- const prompt = useRef < string > ( null ! ) ;
34- prompt . current = props . prompt ;
35- const promptMore = useRef < string > ( null ! ) ;
36- promptMore . current = props . promptMore || props . prompt ;
37- const sendCommand = useRef < ( command : string ) => Promise < TerminalOutput [ ] > > (
38- null !
39- ) ;
40- sendCommand . current = props . sendCommand ;
41- const checkSyntax = useRef < ( code : string ) => Promise < SyntaxStatus > > ( null ! ) ;
42- checkSyntax . current = props . checkSyntax || ( async ( ) => "complete" ) ;
43-
44- useEffect ( ( ) => {
45- if ( terminalInstanceRef . current && termReady && props . ready ) {
46- // 初期メッセージとプロンプトを表示
47- terminalInstanceRef . current . writeln ( initMessage . current ) ;
48- terminalInstanceRef . current . write ( prompt . current ) ;
49- }
50- } , [ props . ready , termReady ] ) ;
35+ const { prompt, promptMore, language, tabSize, sendCommand, checkSyntax } =
36+ props ;
5137
5238 // ターミナルの初期化処理
5339 useEffect ( ( ) => {
@@ -56,6 +42,7 @@ export function TerminalComponent(props: TerminalComponentProps) {
5642 convertEol : true ,
5743 } ) ;
5844 terminalInstanceRef . current = term ;
45+ initDone . current = false ;
5946
6047 const fitAddon = new FitAddon ( ) ;
6148 term . loadAddon ( fitAddon ) ;
@@ -66,72 +53,168 @@ export function TerminalComponent(props: TerminalComponentProps) {
6653 // TODO: loadingメッセージ
6754 // TODO: ターミナルのサイズ変更に対応する
6855
69- const onOutput = ( outputs : TerminalOutput [ ] ) => {
70- for ( const output of outputs ) {
71- // 出力内容に応じて色を変える
72- const message = String ( output . message ) . replace ( / \n / g, "\r\n" ) ;
73- switch ( output . type ) {
74- case "stderr" :
75- case "error" :
76- term . writeln ( `\x1b[1;31m${ message } \x1b[0m` ) ;
77- break ;
78- default :
79- term . writeln ( message ) ;
80- break ;
56+ return ( ) => {
57+ term . dispose ( ) ;
58+ terminalInstanceRef . current = null ;
59+ } ;
60+ } , [ ] ) ;
61+
62+ // bufferを更新し、画面に描画する
63+ const updateBuffer = useCallback (
64+ ( newBuffer : ( ) => string [ ] ) => {
65+ if ( terminalInstanceRef . current ) {
66+ // カーソル非表示
67+ terminalInstanceRef . current . write ( "\x1b[?25l" ) ;
68+ // バッファの行数分カーソルを戻す
69+ if ( inputBuffer . current . length >= 2 ) {
70+ terminalInstanceRef . current . write (
71+ `\x1b[${ inputBuffer . current . length - 1 } A`
72+ ) ;
73+ }
74+ terminalInstanceRef . current . write ( "\r" ) ;
75+ // バッファの内容をクリア
76+ terminalInstanceRef . current . write ( "\x1b[0J" ) ;
77+ // 新しいバッファの内容を表示
78+ inputBuffer . current = newBuffer ( ) ;
79+ for ( let i = 0 ; i < inputBuffer . current . length ; i ++ ) {
80+ terminalInstanceRef . current . write (
81+ i === 0 ? prompt : promptMore || prompt
82+ ) ;
83+ if ( language ) {
84+ terminalInstanceRef . current . write (
85+ highlightCodeToAnsi ( inputBuffer . current [ i ] , language )
86+ ) ;
87+ } else {
88+ terminalInstanceRef . current . write ( inputBuffer . current [ i ] ) ;
89+ }
90+ if ( i < inputBuffer . current . length - 1 ) {
91+ terminalInstanceRef . current . writeln ( "" ) ;
92+ }
8193 }
94+ // カーソルを表示
95+ terminalInstanceRef . current . write ( "\x1b[?25h" ) ;
8296 }
83- // 出力が終わったらプロンプトを表示
84- term . write ( prompt . current ) ;
85- } ;
97+ } ,
98+ [ prompt , promptMore , language ]
99+ ) ;
86100
87- // キー入力のハンドリング
88- const onDataHandler = term . onData ( async ( key ) => {
89- const code = key . charCodeAt ( 0 ) ;
101+ const initDone = useRef < boolean > ( false ) ;
102+ useEffect ( ( ) => {
103+ if (
104+ terminalInstanceRef . current &&
105+ termReady &&
106+ props . ready &&
107+ ! initDone . current
108+ ) {
109+ // 初期メッセージとプロンプトを表示
110+ terminalInstanceRef . current . writeln ( props . initMessage ) ;
111+ initDone . current = true ;
112+ updateBuffer ( ( ) => [ "" ] ) ;
113+ }
114+ } , [ props . ready , termReady , props . initMessage , updateBuffer ] ) ;
90115
91- // inputBufferは必ず1行以上ある状態にする
92- if ( code === 13 ) {
93- // Enter
94- const hasContent =
95- inputBuffer . current [ inputBuffer . current . length - 1 ] . trim ( ) . length > 0 ;
96- const status = await checkSyntax . current (
97- inputBuffer . current . join ( "\n" )
98- ) ;
99- if (
100- ( inputBuffer . current . length === 1 && status === "incomplete" ) ||
101- ( inputBuffer . current . length >= 2 && hasContent )
102- ) {
103- // 次の行に続く
104- term . writeln ( "" ) ;
105- term . write ( promptMore . current ) ;
106- inputBuffer . current . push ( "" ) ;
107- } else {
108- // 実行
109- term . writeln ( "" ) ;
110- const outputs = await sendCommand . current (
111- inputBuffer . current . join ( "\n" ) . trim ( )
112- ) ;
113- onOutput ( outputs ) ;
114- inputBuffer . current = [ "" ] ;
116+ // ランタイムからの出力を処理し、bufferをリセット
117+ const onOutput = useCallback (
118+ ( outputs : TerminalOutput [ ] ) => {
119+ if ( terminalInstanceRef . current ) {
120+ for ( const output of outputs ) {
121+ // 出力内容に応じて色を変える
122+ const message = String ( output . message ) . replace ( / \n / g, "\r\n" ) ;
123+ switch ( output . type ) {
124+ case "stderr" :
125+ case "error" :
126+ terminalInstanceRef . current . writeln ( chalk . red ( message ) ) ;
127+ break ;
128+ default :
129+ terminalInstanceRef . current . writeln ( message ) ;
130+ break ;
131+ }
115132 }
116- } else if ( code === 127 ) {
117- // Backspace
118- if ( inputBuffer . current [ inputBuffer . current . length - 1 ] . length > 0 ) {
119- term . write ( "\b \b" ) ;
120- inputBuffer . current [ inputBuffer . current . length - 1 ] =
121- inputBuffer . current [ inputBuffer . current . length - 1 ] . slice ( 0 , - 1 ) ;
133+ // 出力が終わったらプロンプトを表示
134+ updateBuffer ( ( ) => [ "" ] ) ;
135+ }
136+ } ,
137+ [ updateBuffer ]
138+ ) ;
139+
140+ const keyHandler = useCallback (
141+ async ( key : string ) => {
142+ if ( terminalInstanceRef . current ) {
143+ for ( let i = 0 ; i < key . length ; i ++ ) {
144+ const code = key . charCodeAt ( i ) ;
145+ const isLastChar = i === key . length - 1 ;
146+
147+ // inputBufferは必ず1行以上ある状態にする
148+ if ( code === 13 ) {
149+ // Enter
150+ const hasContent =
151+ inputBuffer . current [ inputBuffer . current . length - 1 ] . trim ( )
152+ . length > 0 ;
153+ const status = checkSyntax
154+ ? await checkSyntax ( inputBuffer . current . join ( "\n" ) )
155+ : "complete" ;
156+ if (
157+ ( inputBuffer . current . length === 1 && status === "incomplete" ) ||
158+ ( inputBuffer . current . length >= 2 && hasContent ) ||
159+ ! isLastChar
160+ ) {
161+ // 次の行に続く
162+ updateBuffer ( ( ) => [ ...inputBuffer . current , "" ] ) ;
163+ } else {
164+ // 実行
165+ terminalInstanceRef . current . writeln ( "" ) ;
166+ const command = inputBuffer . current . join ( "\n" ) . trim ( ) ;
167+ inputBuffer . current = [ ] ;
168+ const outputs = await sendCommand ( command ) ;
169+ onOutput ( outputs ) ;
170+ }
171+ } else if ( code === 127 ) {
172+ // Backspace
173+ if (
174+ inputBuffer . current [ inputBuffer . current . length - 1 ] . length > 0
175+ ) {
176+ updateBuffer ( ( ) => {
177+ const newBuffer = [ ...inputBuffer . current ] ;
178+ newBuffer [ newBuffer . length - 1 ] = newBuffer [
179+ newBuffer . length - 1
180+ ] . slice ( 0 , - 1 ) ;
181+ return newBuffer ;
182+ } ) ;
183+ }
184+ } else if ( code === 9 ) {
185+ // Tab
186+ // タブをスペースに変換
187+ const spaces = " " . repeat ( tabSize ) ;
188+ updateBuffer ( ( ) => {
189+ const newBuffer = [ ...inputBuffer . current ] ;
190+ // 最後の行にスペースを追加
191+ newBuffer [ newBuffer . length - 1 ] += spaces ;
192+ return newBuffer ;
193+ } ) ;
194+ } else if ( code >= 32 ) {
195+ updateBuffer ( ( ) => {
196+ const newBuffer = [ ...inputBuffer . current ] ;
197+ // 最後の行にキーを追加
198+ newBuffer [ newBuffer . length - 1 ] += key [ i ] ;
199+ return newBuffer ;
200+ } ) ;
201+ }
122202 }
123- } else if ( code >= 32 ) {
124- inputBuffer . current [ inputBuffer . current . length - 1 ] += key ;
125- term . write ( key ) ;
126203 }
127- } ) ;
204+ } ,
205+ [ updateBuffer , sendCommand , onOutput , checkSyntax , tabSize ]
206+ ) ;
207+ useEffect ( ( ) => {
208+ if ( terminalInstanceRef . current && termReady && props . ready ) {
209+ // キー入力のハンドリング
210+ const onDataHandler = terminalInstanceRef . current . onData ( keyHandler ) ;
128211
129- // アンマウント時のクリーンアップ
130- return ( ) => {
131- onDataHandler . dispose ( ) ;
132- term . dispose ( ) ;
133- } ;
134- } , [ initMessage , prompt , promptMore ] ) ;
212+ // アンマウント時のクリーンアップ
213+ return ( ) => {
214+ onDataHandler . dispose ( ) ;
215+ } ;
216+ }
217+ } , [ keyHandler , termReady , props . ready ] ) ;
135218
136219 return < div ref = { terminalRef } style = { { width : "100%" , height : "400px" } } /> ;
137220}
0 commit comments