Skip to content

Commit 6bd8583

Browse files
committed
- resize check to int
- modularize, tree-shake terminal, idempotency on navigations
1 parent 567bbe9 commit 6bd8583

File tree

4 files changed

+251
-227
lines changed

4 files changed

+251
-227
lines changed

docker/coolify-realtime/terminal-server.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ wss.on('connection', (ws) => {
6868

6969
const messageHandlers = {
7070
message: (session, data) => session.ptyProcess.write(data),
71-
resize: (session, { cols, rows }) => session.ptyProcess.resize(cols, rows),
71+
resize: (session, { cols, rows }) => {
72+
cols = cols > 0 ? cols : 80;
73+
rows = rows > 0 ? rows : 30;
74+
session.ptyProcess.resize(cols, rows)
75+
},
7276
pause: (session) => session.ptyProcess.pause(),
7377
resume: (session) => session.ptyProcess.resume(),
7478
checkActive: (session, data) => {
@@ -140,6 +144,7 @@ async function handleCommand(ws, command, userId) {
140144

141145
ptyProcess.onData((data) => ws.send(data));
142146

147+
// when parent closes
143148
ptyProcess.onExit(({ exitCode, signal }) => {
144149
console.error(`Process exited with code ${exitCode} and signal ${signal}`);
145150
userSession.isActive = false;

resources/js/app.js

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,13 @@
55
// app.component("magic-bar", MagicBar);
66
// app.mount("#vue");
77

8-
import { Terminal } from '@xterm/xterm';
9-
import '@xterm/xterm/css/xterm.css';
10-
import { FitAddon } from '@xterm/addon-fit';
8+
import { initializeTerminalComponent } from './terminal.js';
119

12-
if (!window.term) {
13-
window.term = new Terminal({
14-
cols: 80,
15-
rows: 30,
16-
fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"',
17-
cursorBlink: true,
10+
['livewire:navigated', 'alpine:init'].forEach((event) => {
11+
document.addEventListener(event, () => {
12+
// tree-shaking
13+
if (document.getElementById('terminal-container')) {
14+
initializeTerminalComponent()
15+
}
1816
});
19-
window.fitAddon = new FitAddon();
20-
window.term.loadAddon(window.fitAddon);
21-
}
17+
});

resources/js/terminal.js

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { Terminal } from '@xterm/xterm';
2+
import '@xterm/xterm/css/xterm.css';
3+
import { FitAddon } from '@xterm/addon-fit';
4+
5+
export function initializeTerminalComponent() {
6+
function terminalData() {
7+
return {
8+
fullscreen: false,
9+
terminalActive: false,
10+
message: '(connection closed)',
11+
term: null,
12+
fitAddon: null,
13+
socket: null,
14+
commandBuffer: '',
15+
pendingWrites: 0,
16+
paused: false,
17+
MAX_PENDING_WRITES: 5,
18+
keepAliveInterval: null,
19+
20+
init() {
21+
this.setupTerminal();
22+
this.initializeWebSocket();
23+
this.setupTerminalEventListeners();
24+
25+
this.$wire.on('send-back-command', (command) => {
26+
this.socket.send(JSON.stringify({
27+
command: command
28+
}));
29+
});
30+
31+
this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
32+
33+
this.$watch('terminalActive', (active) => {
34+
if (!active && this.keepAliveInterval) {
35+
clearInterval(this.keepAliveInterval);
36+
}
37+
this.$nextTick(() => {
38+
if (active) {
39+
this.$refs.terminalWrapper.style.display = 'block';
40+
this.resizeTerminal();
41+
} else {
42+
this.$refs.terminalWrapper.style.display = 'none';
43+
}
44+
});
45+
});
46+
47+
['livewire:navigated', 'beforeunload'].forEach((event) => {
48+
document.addEventListener(event, () => {
49+
this.checkIfProcessIsRunningAndKillIt();
50+
clearInterval(this.keepAliveInterval);
51+
}, { once: true });
52+
});
53+
54+
window.onresize = () => {
55+
this.resizeTerminal()
56+
};
57+
58+
},
59+
60+
setupTerminal() {
61+
const terminalElement = document.getElementById('terminal');
62+
if (terminalElement) {
63+
this.term = new Terminal({
64+
cols: 80,
65+
rows: 30,
66+
fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"',
67+
cursorBlink: true,
68+
});
69+
this.fitAddon = new FitAddon();
70+
this.term.loadAddon(this.fitAddon);
71+
}
72+
},
73+
74+
initializeWebSocket() {
75+
if (!this.socket || this.socket.readyState === WebSocket.CLOSED) {
76+
const predefined = window.terminalConfig
77+
const connectionString = {
78+
protocol: window.location.protocol === 'https:' ? 'wss' : 'ws',
79+
host: window.location.hostname,
80+
port: ":6002",
81+
path: '/terminal/ws'
82+
}
83+
if (!window.location.port) {
84+
connectionString.port = ''
85+
}
86+
if (predefined.host) {
87+
connectionString.host = predefined.host
88+
}
89+
if (predefined.port) {
90+
connectionString.port = `:${predefined.port}`
91+
}
92+
if (predefined.protocol) {
93+
connectionString.protocol = predefined.protocol
94+
}
95+
96+
const url =
97+
`${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}`
98+
this.socket = new WebSocket(url);
99+
100+
this.socket.onmessage = this.handleSocketMessage.bind(this);
101+
this.socket.onerror = (e) => {
102+
console.error('WebSocket error:', e);
103+
};
104+
this.socket.onclose = () => {
105+
console.log('WebSocket connection closed');
106+
107+
};
108+
}
109+
},
110+
111+
handleSocketMessage(event) {
112+
this.message = '(connection closed)';
113+
if (event.data === 'pty-ready') {
114+
if (!this.term._initialized) {
115+
this.term.open(document.getElementById('terminal'));
116+
this.term._initialized = true;
117+
} else {
118+
this.term.reset();
119+
}
120+
this.terminalActive = true;
121+
this.term.focus();
122+
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded');
123+
this.resizeTerminal();
124+
} else if (event.data === 'unprocessable') {
125+
if (this.term) this.term.reset();
126+
this.terminalActive = false;
127+
this.message = '(sorry, something went wrong, please try again)';
128+
} else {
129+
this.pendingWrites++;
130+
this.term.write(event.data, this.flowControlCallback.bind(this));
131+
}
132+
},
133+
134+
flowControlCallback() {
135+
this.pendingWrites--;
136+
if (this.pendingWrites > this.MAX_PENDING_WRITES && !this.paused) {
137+
this.paused = true;
138+
this.socket.send(JSON.stringify({ pause: true }));
139+
} else if (this.pendingWrites <= this.MAX_PENDING_WRITES && this.paused) {
140+
this.paused = false;
141+
this.socket.send(JSON.stringify({ resume: true }));
142+
}
143+
},
144+
145+
setupTerminalEventListeners() {
146+
if (!this.term) return;
147+
148+
this.term.onData((data) => {
149+
this.socket.send(JSON.stringify({ message: data }));
150+
// Handle CTRL + D or exit command
151+
if (data === '\x04' || (data === '\r' && this.stripAnsiCommands(this.commandBuffer).trim().includes('exit'))) {
152+
this.checkIfProcessIsRunningAndKillIt();
153+
setTimeout(() => {
154+
this.terminalActive = false;
155+
this.term.reset();
156+
}, 500);
157+
this.commandBuffer = '';
158+
} else if (data === '\r') {
159+
this.commandBuffer = '';
160+
} else {
161+
this.commandBuffer += data;
162+
}
163+
});
164+
165+
// Copy and paste functionality
166+
this.term.attachCustomKeyEventHandler((arg) => {
167+
if (arg.ctrlKey && arg.code === "KeyV" && arg.type === "keydown") {
168+
navigator.clipboard.readText()
169+
.then(text => {
170+
this.socket.send(JSON.stringify({ message: text }));
171+
});
172+
return false;
173+
}
174+
175+
if (arg.ctrlKey && arg.code === "KeyC" && arg.type === "keydown") {
176+
const selection = this.term.getSelection();
177+
if (selection) {
178+
navigator.clipboard.writeText(selection);
179+
return false;
180+
}
181+
}
182+
return true;
183+
});
184+
},
185+
186+
stripAnsiCommands(input) {
187+
return input.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
188+
},
189+
190+
keepAlive() {
191+
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
192+
this.socket.send(JSON.stringify({ ping: true }));
193+
}
194+
},
195+
196+
checkIfProcessIsRunningAndKillIt() {
197+
if (this.socket && this.socket.readyState == WebSocket.OPEN) {
198+
this.socket.send(JSON.stringify({ checkActive: 'force' }));
199+
}
200+
},
201+
202+
makeFullscreen() {
203+
this.fullscreen = !this.fullscreen;
204+
this.$nextTick(() => {
205+
this.resizeTerminal();
206+
});
207+
},
208+
209+
resizeTerminal() {
210+
if (!this.terminalActive || !this.term || !this.fitAddon) return;
211+
212+
this.fitAddon.fit();
213+
const height = this.$refs.terminalWrapper.clientHeight;
214+
const width = this.$refs.terminalWrapper.clientWidth;
215+
const rows = Math.floor(height / this.term._core._renderService._charSizeService.height) - 1;
216+
const cols = Math.floor(width / this.term._core._renderService._charSizeService.width) - 1;
217+
const termWidth = cols;
218+
const termHeight = rows;
219+
this.term.resize(termWidth, termHeight);
220+
this.socket.send(JSON.stringify({
221+
resize: { cols: termWidth, rows: termHeight }
222+
}));
223+
},
224+
};
225+
}
226+
227+
window.Alpine.data('terminalData', terminalData);
228+
}

0 commit comments

Comments
 (0)