Skip to content

Commit 24536c2

Browse files
committed
fix: terminals sharing handling
1 parent d65277a commit 24536c2

9 files changed

Lines changed: 241 additions & 27 deletions

File tree

app/components/ShareModal.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,8 @@ function copyLink() {
432432
font-weight: 600;
433433
}
434434
.guest-item.you {
435-
color: rgba(100, 180, 255, 0.9);
435+
color: rgba(255, 255, 255, 0.9);
436+
font-weight: 600;
436437
}
437438
.user-dot {
438439
width: 6px;

app/components/Terminal.client.vue

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,15 @@ const props = defineProps<{
2222
cwd: string;
2323
active: boolean;
2424
focused: boolean;
25+
shareTerminalId?: string; // if set, subscribe to this existing server terminal instead of creating
26+
}>();
27+
28+
const emit = defineEmits<{
29+
shareCreated: [terminalId: string];
2530
}>();
2631
2732
const { getWsUrl, getMode, getSessionId } = useApi();
28-
const { isSharing, shareId: shareSessionId, guestId: shareGuestId } = useShare();
33+
const { isSharing, isHost: shareIsHost, shareId: shareSessionId, guestId: shareGuestId, getShareTerminalWsUrl } = useShare();
2934
3035
const termContainer = ref<HTMLDivElement | null>(null);
3136
let term: Terminal | null = null;
@@ -64,11 +69,12 @@ let sharedTerminalId: string | null = null;
6469
6570
function connectWs() {
6671
if (disposed || !term) return;
67-
ws = new WebSocket(getWsUrl());
72+
const wsUrl = isSharing.value ? getShareTerminalWsUrl() : getWsUrl();
73+
ws = new WebSocket(wsUrl);
6874
6975
ws.onopen = () => {
7076
if (isSharing.value && shareSessionId.value) {
71-
// Share mode: send auth first, then create after auth-ok
77+
// Share mode: send auth first, then subscribe/create after auth-ok
7278
ws!.send(JSON.stringify({
7379
type: "auth",
7480
shareId: shareSessionId.value,
@@ -89,15 +95,27 @@ function connectWs() {
8995
try {
9096
const msg = JSON.parse(event.data);
9197
if (msg.type === "auth-ok" && isSharing.value) {
92-
// Auth succeeded — now create the shared terminal
93-
ws!.send(JSON.stringify({
94-
type: "create",
95-
cwd: props.cwd || undefined,
96-
cols: term!.cols,
97-
rows: term!.rows,
98-
}));
98+
if (props.shareTerminalId) {
99+
// Subscribe to an existing server terminal
100+
sharedTerminalId = props.shareTerminalId;
101+
ws!.send(JSON.stringify({
102+
type: "subscribe",
103+
terminalId: props.shareTerminalId,
104+
}));
105+
} else {
106+
// Create a new shared terminal
107+
ws!.send(JSON.stringify({
108+
type: "create",
109+
cwd: props.cwd || undefined,
110+
cols: term!.cols,
111+
rows: term!.rows,
112+
}));
113+
}
99114
} else if (msg.type === "terminal-ready") {
100115
sharedTerminalId = msg.terminalId;
116+
if (!props.shareTerminalId) {
117+
emit("shareCreated", msg.terminalId);
118+
}
101119
} else if (msg.type === "output" && term) {
102120
term.write(msg.data);
103121
} else if (msg.type === "exit" && term) {
@@ -250,7 +268,12 @@ onMounted(async () => {
250268
});
251269
252270
defineExpose({
253-
focus() { term?.focus(); }
271+
focus() { term?.focus(); },
272+
closeShared() {
273+
if (sharedTerminalId && ws && ws.readyState === WebSocket.OPEN) {
274+
ws.send(JSON.stringify({ type: "close", terminalId: sharedTerminalId }));
275+
}
276+
},
254277
});
255278
256279
watch(() => props.active, (active) => {

app/components/TerminalPanel.vue

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@
3434
@mousedown="focusedId = s.id">
3535
<Terminal :ref="el => setTermRef(s.id, el)" :cwd="rootPath"
3636
:active="s.id === activeId || s.id === splitId"
37-
:focused="s.id === focusedId" />
37+
:focused="s.id === focusedId"
38+
:shareTerminalId="s.shareTerminalId"
39+
@shareCreated="terminalId => onShareCreated(s.id, terminalId)" />
3840
</div>
3941
</div>
4042
<div v-if="!isMobile" class="terminal-sidebar">
@@ -57,6 +59,8 @@
5759
</template>
5860

5961
<script setup lang="ts">
62+
const { isSharing, sharedTerminals } = useShare();
63+
6064
const props = defineProps<{
6165
rootPath: string;
6266
isMobile: boolean;
@@ -76,6 +80,7 @@ const emit = defineEmits<{
7680
interface TerminalSession {
7781
id: string;
7882
name: string;
83+
shareTerminalId?: string; // server's terminal ID (for subscribed sessions)
7984
}
8085
8186
let nextId = 1;
@@ -91,6 +96,10 @@ function createSessions(count: number): TerminalSession[] {
9196
}
9297
9398
const sessions = ref<TerminalSession[]>(createSessions(1));
99+
100+
// Track server terminal IDs we've already registered (to avoid duplicates on terminal-added)
101+
const knownShareIds = new Set<string>();
102+
94103
// activeId = left terminal in split (or the single visible terminal)
95104
const activeId = ref(sessions.value[0]!.id);
96105
const splitId = ref<string | null>(null);
@@ -104,6 +113,51 @@ const contentRef = ref<HTMLElement | null>(null);
104113
105114
const panelHeight = ref(props.initialTerminalHeight ?? 261);
106115
116+
// --- Share mode: sync sessions from server's sharedTerminals ---
117+
watch(isSharing, (sharing) => {
118+
if (sharing) {
119+
knownShareIds.clear();
120+
sessions.value = [];
121+
splitId.value = null;
122+
savedPairsMap.clear();
123+
}
124+
});
125+
126+
watch(sharedTerminals, (list) => {
127+
if (!isSharing.value) return;
128+
// Add new terminals
129+
for (const t of list) {
130+
if (!knownShareIds.has(t.id)) {
131+
knownShareIds.add(t.id);
132+
sessions.value = [...sessions.value, { id: t.id, name: t.name, shareTerminalId: t.id }];
133+
// Select first terminal automatically
134+
if (sessions.value.length === 1) {
135+
activeId.value = t.id;
136+
focusedId.value = t.id;
137+
}
138+
}
139+
}
140+
// Remove gone terminals
141+
const activeIds = new Set(list.map((t: any) => t.id));
142+
const before = sessions.value.length;
143+
sessions.value = sessions.value.filter(s => {
144+
if (s.shareTerminalId && !activeIds.has(s.shareTerminalId)) {
145+
knownShareIds.delete(s.shareTerminalId);
146+
return false;
147+
}
148+
return true;
149+
});
150+
if (sessions.value.length !== before) {
151+
// Re-select if active was removed
152+
if (!sessions.value.find(s => s.id === activeId.value) && sessions.value.length > 0) {
153+
activeId.value = sessions.value[0]!.id;
154+
focusedId.value = activeId.value;
155+
} else if (sessions.value.length === 0) {
156+
emit("close");
157+
}
158+
}
159+
}, { deep: true });
160+
107161
watch(() => props.initialTerminalHeight, (val) => {
108162
if (val !== undefined) panelHeight.value = val;
109163
});
@@ -120,6 +174,14 @@ function focusTerminal(id: string) {
120174
nextTick(() => termRefs[id]?.focus());
121175
}
122176
177+
function onShareCreated(localId: string, serverTerminalId: string) {
178+
const s = sessions.value.find(s => s.id === localId);
179+
if (s) {
180+
s.shareTerminalId = serverTerminalId;
181+
knownShareIds.add(serverTerminalId);
182+
}
183+
}
184+
123185
function termSlotClass(id: string) {
124186
if (!splitId.value) {
125187
return { hidden: id !== activeId.value };
@@ -296,6 +358,11 @@ function addSession() {
296358
}
297359
298360
function removeSession() {
361+
const removedId = focusedId.value;
362+
// In share mode, tell server to close the terminal
363+
if (isSharing.value) {
364+
termRefs[removedId]?.closeShared?.();
365+
}
299366
if (sessions.value.length <= 1) {
300367
sessions.value = [];
301368
splitId.value = null;
@@ -305,7 +372,6 @@ function removeSession() {
305372
emit("close");
306373
return;
307374
}
308-
const removedId = focusedId.value;
309375
const idx = sessions.value.findIndex(s => s.id === removedId);
310376
sessions.value = sessions.value.filter(s => s.id !== removedId);
311377

app/composables/useApi.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,19 @@ export function useApi() {
8282
if (!import.meta.client) return "";
8383
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
8484

85-
// Guest/host in share mode: use shared terminal WS
86-
const { isSharing } = useShare();
87-
if (isSharing.value) {
85+
// Guests in share mode: use shared terminal WS
86+
const { isGuest } = useShare();
87+
if (isGuest.value) {
8888
return `${protocol}//${window.location.host}/_share-terminal`;
8989
}
9090

91+
return getLocalWsUrl();
92+
}
93+
94+
/** Local terminal WS URL (bypasses share routing — used by relay handler) */
95+
function getLocalWsUrl(): string {
96+
if (!import.meta.client) return "";
97+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
9198
const useSSH = webMode || !!getStoredSSHTarget();
9299
const endpoint = useSSH ? "_ssh-terminal" : "_terminal";
93100
return `${protocol}//${window.location.host}/${endpoint}`;
@@ -98,5 +105,5 @@ export function useApi() {
98105
return getStoredSSHTarget() ? "ssh" : "local";
99106
}
100107

101-
return { apiFetch, getWsUrl, getMode, isWebMode: webMode, getSessionId };
108+
return { apiFetch, getWsUrl, getLocalWsUrl, getMode, isWebMode: webMode, getSessionId };
102109
}

app/composables/useShare.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const allowTerminal = ref(false);
2222
const rootPath = ref("");
2323
const guests = ref<ShareGuest[]>([]);
2424
const connected = ref(false);
25+
const sharedTerminals = ref<{id: string, name: string}[]>([]);
2526

2627
// Control WebSocket (presence + lifecycle events)
2728
let controlWs: WebSocket | null = null;
@@ -97,6 +98,7 @@ export function useShare() {
9798
rootPath.value = data.rootPath;
9899
guests.value = data.guests || [];
99100
connected.value = true;
101+
sharedTerminals.value = data.activeTerminals || [];
100102

101103
connectControlWs(id, "guest", data.guestId);
102104
}
@@ -206,6 +208,14 @@ export function useShare() {
206208
allowTerminal.value = msg.allowTerminal;
207209
}
208210
break;
211+
case "terminal-added":
212+
if (msg.terminal && !sharedTerminals.value.find((t: any) => t.id === msg.terminal.id)) {
213+
sharedTerminals.value = [...sharedTerminals.value, msg.terminal];
214+
}
215+
break;
216+
case "terminal-removed":
217+
sharedTerminals.value = sharedTerminals.value.filter((t: any) => t.id !== msg.terminalId);
218+
break;
209219
case "share-closed":
210220
// Host closed the share — guest gets kicked
211221
if (role.value === "guest") {
@@ -283,6 +293,9 @@ export function useShare() {
283293
if (msg.type === "terminal-resize") {
284294
onRelayTerminalResize?.(msg);
285295
}
296+
if (msg.type === "terminal-close") {
297+
onRelayTerminalClose?.(msg);
298+
}
286299
}
287300

288301
function sendRelayMessage(msg: any) {
@@ -305,6 +318,7 @@ export function useShare() {
305318
rootPath.value = "";
306319
guests.value = [];
307320
connected.value = false;
321+
sharedTerminals.value = [];
308322

309323
if (controlWs) { controlWs.close(); controlWs = null; }
310324
if (relayWs) { relayWs.close(); relayWs = null; }
@@ -321,6 +335,7 @@ export function useShare() {
321335
shareRootPath: readonly(rootPath),
322336
guests: readonly(guests),
323337
connected: readonly(connected),
338+
sharedTerminals: readonly(sharedTerminals),
324339

325340
// Computed
326341
isHost,
@@ -345,3 +360,4 @@ export let onShareClosed: (() => void) | null = null;
345360
export let onRelayTerminalCreate: ((msg: any) => void) | null = null;
346361
export let onRelayTerminalInput: ((msg: any) => void) | null = null;
347362
export let onRelayTerminalResize: ((msg: any) => void) | null = null;
363+
export let onRelayTerminalClose: ((msg: any) => void) | null = null;

app/pages/index.vue

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -628,7 +628,9 @@ function onShareLeft() {
628628
}
629629
630630
function onShareStopped() {
631-
// Host stopped sharing: no rootPath change, just close modal
631+
// Host stopped sharing: close relay terminal WebSockets
632+
for (const ws of relayTerminalWs.values()) ws.close();
633+
relayTerminalWs.clear();
632634
}
633635
634636
async function autoJoinShare(sid: string) {
@@ -645,15 +647,76 @@ async function autoJoinShare(sid: string) {
645647
}
646648
}
647649
648-
// Register callback for when host closes share (guest gets kicked)
649-
import { onShareClosed as _onShareClosedSetter } from '~/composables/useShare';
650+
// Register callbacks for share lifecycle + relay terminal handling
651+
import { onShareClosed as _onShareClosedSetter, onRelayTerminalCreate as _rtcSetter, onRelayTerminalInput as _rtiSetter, onRelayTerminalResize as _rtrSetter, onRelayTerminalClose as _rtclSetter } from '~/composables/useShare';
652+
// Map of relay terminalId → local WebSocket (desktop host bridges guest terminals to local PTY)
653+
const relayTerminalWs = new Map<string, WebSocket>();
654+
650655
if (import.meta.client) {
651-
// Use a module-level assignment to set the callback
652656
(async () => {
653657
const mod = await import('~/composables/useShare');
654658
mod.onShareClosed = () => {
655659
resetToFolderSelector();
656660
};
661+
662+
// Desktop host relay: guest creates a terminal → spawn local terminal, pipe I/O through relay WS
663+
mod.onRelayTerminalCreate = (msg: any) => {
664+
const { terminalId, cwd, cols, rows } = msg;
665+
const { getLocalWsUrl, getSessionId } = useApi();
666+
const { sendRelayMessage } = useShare();
667+
// Connect to the local terminal WS (bypasses share routing to avoid loop)
668+
const localWs = new WebSocket(getLocalWsUrl());
669+
relayTerminalWs.set(terminalId, localWs);
670+
671+
localWs.onopen = () => {
672+
localWs.send(JSON.stringify({
673+
type: "create",
674+
cwd: cwd || rootPath.value,
675+
cols: cols || 80,
676+
rows: rows || 24,
677+
sessionId: getSessionId(),
678+
}));
679+
};
680+
681+
localWs.onmessage = (ev) => {
682+
try {
683+
const data = JSON.parse(ev.data);
684+
if (data.type === "output") {
685+
sendRelayMessage({ type: "terminal-output", terminalId, data: data.data });
686+
} else if (data.type === "exit") {
687+
sendRelayMessage({ type: "terminal-exit", terminalId, code: data.code ?? 0 });
688+
localWs.close();
689+
relayTerminalWs.delete(terminalId);
690+
}
691+
} catch {}
692+
};
693+
694+
localWs.onclose = () => {
695+
relayTerminalWs.delete(terminalId);
696+
};
697+
};
698+
699+
mod.onRelayTerminalInput = (msg: any) => {
700+
const ws = relayTerminalWs.get(msg.terminalId);
701+
if (ws && ws.readyState === WebSocket.OPEN) {
702+
ws.send(JSON.stringify({ type: "input", data: msg.data }));
703+
}
704+
};
705+
706+
mod.onRelayTerminalResize = (msg: any) => {
707+
const ws = relayTerminalWs.get(msg.terminalId);
708+
if (ws && ws.readyState === WebSocket.OPEN) {
709+
ws.send(JSON.stringify({ type: "resize", cols: msg.cols, rows: msg.rows }));
710+
}
711+
};
712+
713+
mod.onRelayTerminalClose = (msg: any) => {
714+
const ws = relayTerminalWs.get(msg.terminalId);
715+
if (ws) {
716+
ws.close();
717+
relayTerminalWs.delete(msg.terminalId);
718+
}
719+
};
657720
})();
658721
}
659722

0 commit comments

Comments
 (0)