fix(terminal): reconcile tabs with server sessions and keep shown terminals alive
The tab list lived only in per-tab sessionStorage, so a new browser tab, another device, or cleared storage showed an empty terminal sidebar while PTYs kept running server-side, and orphans leaked until the idle sweep. Add GET /api/terminal/sessions and adopt unknown server sessions into the local tab projection (additive only; a failed listing changes nothing). The idle sweep also reaped terminals in background tabs because only the active tab holds a WebSocket attachment. Add POST /api/terminal/touch and have open clients periodically refresh activity for every session their tabs reference.
This commit is contained in:
@@ -58,6 +58,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
const setActiveTab = useTerminalStore((s) => s.setActiveTab);
|
const setActiveTab = useTerminalStore((s) => s.setActiveTab);
|
||||||
const closeTab = useTerminalStore((s) => s.closeTab);
|
const closeTab = useTerminalStore((s) => s.closeTab);
|
||||||
const setTabSessionId = useTerminalStore((s) => s.setTabSessionId);
|
const setTabSessionId = useTerminalStore((s) => s.setTabSessionId);
|
||||||
|
const adoptServerSessions = useTerminalStore((s) => s.adoptServerSessions);
|
||||||
const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle);
|
const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle);
|
||||||
const setConnecting = useTerminalStore((s) => s.setConnecting);
|
const setConnecting = useTerminalStore((s) => s.setConnecting);
|
||||||
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
|
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
|
||||||
@@ -176,6 +177,50 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
directoryRef.current = effectiveDirectory;
|
directoryRef.current = effectiveDirectory;
|
||||||
}, [effectiveDirectory]);
|
}, [effectiveDirectory]);
|
||||||
|
|
||||||
|
// The tab list is a per-client projection, so ask the server what actually
|
||||||
|
// exists for this directory and adopt sessions no local tab references
|
||||||
|
// (another device, a fresh browser tab, or a reload with cleared storage).
|
||||||
|
// A failed listing changes nothing: adoption is additive only.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!terminalHydrated || !effectiveDirectory || !terminal.listSessions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const directory = effectiveDirectory;
|
||||||
|
void terminal.listSessions(directory)
|
||||||
|
.then((serverSessions) => {
|
||||||
|
if (cancelled || directoryRef.current !== directory) return;
|
||||||
|
adoptServerSessions(directory, serverSessions);
|
||||||
|
})
|
||||||
|
.catch(() => { /* keep local tabs; the next mount or directory switch retries */ });
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [terminalHydrated, effectiveDirectory, terminal, adoptServerSessions]);
|
||||||
|
|
||||||
|
// The server reaps terminals with no attached socket after an idle timeout,
|
||||||
|
// but only the active tab holds an attachment. While this client is open,
|
||||||
|
// periodically mark every session its tabs reference as active so
|
||||||
|
// background tabs (and other directories' terminals) are not reaped.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!terminal.touchSessions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const touch = () => {
|
||||||
|
if (typeof navigator !== 'undefined' && !navigator.onLine) return;
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const dirState of useTerminalStore.getState().sessions.values()) {
|
||||||
|
for (const tab of dirState.tabs) {
|
||||||
|
if (tab.terminalSessionId) ids.push(tab.terminalSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ids.length > 0) void terminal.touchSessions?.(ids).catch(() => {});
|
||||||
|
};
|
||||||
|
touch();
|
||||||
|
const interval = setInterval(touch, 10 * 60 * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [terminal]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!showQuickKeys && activeModifier !== null) {
|
if (!showQuickKeys && activeModifier !== null) {
|
||||||
setActiveModifier(null);
|
setActiveModifier(null);
|
||||||
|
|||||||
@@ -80,8 +80,19 @@ export interface ForceKillOptions {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TerminalServerSession {
|
||||||
|
sessionId: string;
|
||||||
|
cwd: string;
|
||||||
|
status: 'running' | 'exited';
|
||||||
|
createdAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TerminalAPI {
|
export interface TerminalAPI {
|
||||||
listShells?(): Promise<TerminalShellOption[]>;
|
listShells?(): Promise<TerminalShellOption[]>;
|
||||||
|
/** Server-side sessions for a working directory; absent on runtimes without a server terminal list. */
|
||||||
|
listSessions?(cwd: string): Promise<TerminalServerSession[]>;
|
||||||
|
/** Marks the sessions as active so the server's idle sweep does not reap terminals an open client still shows. */
|
||||||
|
touchSessions?(sessionIds: string[]): Promise<void>;
|
||||||
createSession(options: CreateTerminalOptions): Promise<TerminalSession>;
|
createSession(options: CreateTerminalOptions): Promise<TerminalSession>;
|
||||||
connect(sessionId: string, handlers: TerminalHandlers): Subscription;
|
connect(sessionId: string, handlers: TerminalHandlers): Subscription;
|
||||||
sendInput(sessionId: string, input: string): Promise<void>;
|
sendInput(sessionId: string, input: string): Promise<void>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalSession, TerminalShellOption, TerminalStreamEvent } from './api/types';
|
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalShellOption, TerminalStreamEvent } from './api/types';
|
||||||
import { openRuntimeWebSocket } from './relay/runtime-socket';
|
import { openRuntimeWebSocket } from './relay/runtime-socket';
|
||||||
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
|
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
|
||||||
import { runtimeFetch } from './runtime-fetch';
|
import { runtimeFetch } from './runtime-fetch';
|
||||||
@@ -356,6 +356,32 @@ export async function createTerminalSession(options: CreateTerminalOptions): Pro
|
|||||||
if (!response.ok) throw await responseError(response, 'Failed to create terminal session');
|
if (!response.ok) throw await responseError(response, 'Failed to create terminal session');
|
||||||
return response.json() as Promise<TerminalSession>;
|
return response.json() as Promise<TerminalSession>;
|
||||||
}
|
}
|
||||||
|
export async function listTerminalSessions(cwd: string): Promise<TerminalServerSession[]> {
|
||||||
|
const response = await runtimeFetch(`/api/terminal/sessions?cwd=${encodeURIComponent(cwd)}`);
|
||||||
|
if (!response.ok) throw await responseError(response, 'Failed to list terminal sessions');
|
||||||
|
const payload: unknown = await response.json().catch(() => null);
|
||||||
|
const rawSessions = payload && typeof payload === 'object' && 'sessions' in payload ? payload.sessions : null;
|
||||||
|
if (!Array.isArray(rawSessions)) throw new Error('Failed to list terminal sessions');
|
||||||
|
const parsed: TerminalServerSession[] = [];
|
||||||
|
for (const entry of rawSessions as unknown[]) {
|
||||||
|
if (typeof entry !== 'object' || entry === null) continue;
|
||||||
|
// SAFETY: every field is verified below before the value is used.
|
||||||
|
const candidate = entry as Partial<Record<keyof TerminalServerSession, unknown>>;
|
||||||
|
if (typeof candidate.sessionId !== 'string' || typeof candidate.cwd !== 'string') continue;
|
||||||
|
if (candidate.status !== 'running' && candidate.status !== 'exited') continue;
|
||||||
|
parsed.push({
|
||||||
|
sessionId: candidate.sessionId,
|
||||||
|
cwd: candidate.cwd,
|
||||||
|
status: candidate.status,
|
||||||
|
createdAt: typeof candidate.createdAt === 'number' ? candidate.createdAt : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
export async function touchTerminalSessions(sessionIds: string[]): Promise<void> {
|
||||||
|
if (sessionIds.length === 0) return;
|
||||||
|
await command('/api/terminal/touch', 'POST', { sessionIds });
|
||||||
|
}
|
||||||
export async function listTerminalShells(): Promise<TerminalShellOption[]> {
|
export async function listTerminalShells(): Promise<TerminalShellOption[]> {
|
||||||
const response = await runtimeFetch('/api/terminal/shells');
|
const response = await runtimeFetch('/api/terminal/shells');
|
||||||
if (!response.ok) throw await responseError(response, 'Failed to list terminal shells');
|
if (!response.ok) throw await responseError(response, 'Failed to list terminal shells');
|
||||||
|
|||||||
@@ -12,6 +12,47 @@ const buffer = (tabId: string) => useTerminalStore.getState().getBuffer('/repo',
|
|||||||
describe('terminal state reconciliation', () => {
|
describe('terminal state reconciliation', () => {
|
||||||
afterEach(() => useTerminalStore.getState().clearAll());
|
afterEach(() => useTerminalStore.getState().clearAll());
|
||||||
|
|
||||||
|
test('adopts unknown server sessions into the fresh placeholder tab', () => {
|
||||||
|
setup();
|
||||||
|
useTerminalStore.getState().adoptServerSessions('/repo', [
|
||||||
|
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
|
||||||
|
{ sessionId: 'srv-2', status: 'exited', createdAt: null },
|
||||||
|
]);
|
||||||
|
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
|
||||||
|
expect(state.tabs.map((tab) => tab.id)).toEqual(['srv-1', 'srv-2']);
|
||||||
|
expect(state.tabs[0].terminalSessionId).toBe('srv-1');
|
||||||
|
expect(state.tabs[0].lifecycle).toBe('running');
|
||||||
|
expect(state.tabs[1].lifecycle).toBe('exited');
|
||||||
|
expect(state.activeTabId).toBe('srv-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adoption is additive: existing tabs and referenced sessions survive', () => {
|
||||||
|
const tabId = setup();
|
||||||
|
useTerminalStore.getState().appendToBuffer('/repo', tabId, 'output', 1);
|
||||||
|
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-live');
|
||||||
|
useTerminalStore.getState().adoptServerSessions('/repo', [
|
||||||
|
{ sessionId: 'srv-live', status: 'running', createdAt: 1 },
|
||||||
|
{ sessionId: 'srv-orphan', status: 'running', createdAt: 2 },
|
||||||
|
]);
|
||||||
|
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
|
||||||
|
expect(state.tabs).toHaveLength(2);
|
||||||
|
expect(state.tabs[0].id).toBe(tabId);
|
||||||
|
expect(state.tabs[1].id).toBe('srv-orphan');
|
||||||
|
expect(state.activeTabId).toBe(tabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-adopting the same sessions changes nothing', () => {
|
||||||
|
setup();
|
||||||
|
useTerminalStore.getState().adoptServerSessions('/repo', [
|
||||||
|
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
|
||||||
|
]);
|
||||||
|
const before = useTerminalStore.getState().sessions;
|
||||||
|
useTerminalStore.getState().adoptServerSessions('/repo', [
|
||||||
|
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
|
||||||
|
]);
|
||||||
|
expect(useTerminalStore.getState().sessions).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
test('applies snapshots atomically and deduplicates output by sequence', () => {
|
test('applies snapshots atomically and deduplicates output by sequence', () => {
|
||||||
const tabId = setup();
|
const tabId = setup();
|
||||||
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 4);
|
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 4);
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ interface TerminalStore {
|
|||||||
getBuffer: (directory: string, tabId: string) => TerminalBuffer;
|
getBuffer: (directory: string, tabId: string) => TerminalBuffer;
|
||||||
|
|
||||||
createTab: (directory: string) => string;
|
createTab: (directory: string) => string;
|
||||||
|
adoptServerSessions: (
|
||||||
|
directory: string,
|
||||||
|
serverSessions: Array<{ sessionId: string; status: 'running' | 'exited'; createdAt: number | null }>,
|
||||||
|
) => void;
|
||||||
setActiveTab: (directory: string, tabId: string) => void;
|
setActiveTab: (directory: string, tabId: string) => void;
|
||||||
setTabLabel: (directory: string, tabId: string, label: string) => void;
|
setTabLabel: (directory: string, tabId: string, label: string) => void;
|
||||||
setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void;
|
setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void;
|
||||||
@@ -334,6 +338,60 @@ export const useTerminalStore = create<TerminalStore>()(
|
|||||||
return tabId;
|
return tabId;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server owns which terminal sessions exist; the local tab list is
|
||||||
|
* only this client's projection. Adoption is strictly additive: server
|
||||||
|
* sessions no local tab references become tabs (id = session id, the
|
||||||
|
* create/attach contract), and nothing is ever removed here, so a
|
||||||
|
* failed or partial listing cannot destroy local tabs.
|
||||||
|
*/
|
||||||
|
adoptServerSessions: (directory, serverSessions) => {
|
||||||
|
const key = normalizeDirectory(directory);
|
||||||
|
if (!key || serverSessions.length === 0) return;
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const existing = state.sessions.get(key);
|
||||||
|
const knownIds = new Set<string>();
|
||||||
|
for (const tab of existing?.tabs ?? []) {
|
||||||
|
knownIds.add(tab.id);
|
||||||
|
if (tab.terminalSessionId) knownIds.add(tab.terminalSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newcomers = serverSessions.filter((session) => !knownIds.has(session.sessionId));
|
||||||
|
if (newcomers.length === 0) return state;
|
||||||
|
|
||||||
|
const tabs = [...(existing?.tabs ?? [])];
|
||||||
|
// A single untouched placeholder tab (fresh directory state) is
|
||||||
|
// replaced by the first adopted session instead of sitting next to it.
|
||||||
|
const placeholder = tabs.length === 1
|
||||||
|
&& tabs[0].terminalSessionId === null
|
||||||
|
&& tabs[0].lifecycle === 'idle'
|
||||||
|
&& !state.buffers.has(bufferKey(key, tabs[0].id))
|
||||||
|
? tabs[0]
|
||||||
|
: null;
|
||||||
|
if (placeholder) tabs.length = 0;
|
||||||
|
|
||||||
|
for (const session of newcomers) {
|
||||||
|
const tab: TerminalTab = {
|
||||||
|
...createEmptyTab(session.sessionId, placeholder && tabs.length === 0 ? placeholder.label : nextDefaultTabLabel(tabs)),
|
||||||
|
terminalSessionId: session.sessionId,
|
||||||
|
lifecycle: session.status,
|
||||||
|
createdAt: session.createdAt ?? Date.now(),
|
||||||
|
};
|
||||||
|
tabs.push(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousActive = existing?.activeTabId ?? null;
|
||||||
|
const activeTabId = previousActive && tabs.some((tab) => tab.id === previousActive)
|
||||||
|
? previousActive
|
||||||
|
: tabs[0]?.id ?? null;
|
||||||
|
|
||||||
|
const newSessions = new Map(state.sessions);
|
||||||
|
newSessions.set(key, { tabs, activeTabId });
|
||||||
|
return { sessions: newSessions };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
setActiveTab: (directory: string, tabId: string) => {
|
setActiveTab: (directory: string, tabId: string) => {
|
||||||
const key = normalizeDirectory(directory);
|
const key = normalizeDirectory(directory);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
|
|
||||||
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
|
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
|
||||||
|
|
||||||
|
`GET /api/terminal/sessions` enumerates live sessions (optionally filtered by resolved `cwd`) so clients can adopt terminals their local tab projection does not know about — another device, a new browser tab, or cleared storage. `POST /api/terminal/touch` refreshes `lastActivity` for the listed session ids; open clients call it periodically so background tabs, which hold no WebSocket attachment, are not idle-reaped while a client still shows them.
|
||||||
|
|
||||||
## PTY Lifecycle
|
## PTY Lifecycle
|
||||||
|
|
||||||
- IDs are client-provided or generated with `randomUUID()`.
|
- IDs are client-provided or generated with `randomUUID()`.
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export function createTerminalRuntime({
|
|||||||
}
|
}
|
||||||
if (!existing && sessions.size + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached');
|
if (!existing && sessions.size + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached');
|
||||||
const creation = (async () => {
|
const creation = (async () => {
|
||||||
const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false };
|
const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false, createdAt: Date.now() };
|
||||||
await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell });
|
await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell });
|
||||||
sessions.set(id, session);
|
sessions.set(id, session);
|
||||||
return session;
|
return session;
|
||||||
@@ -297,6 +297,34 @@ export function createTerminalRuntime({
|
|||||||
res.status(500).json({ error: error?.message || 'Failed to list terminal shells' });
|
res.status(500).json({ error: error?.message || 'Failed to list terminal shells' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
app.get('/api/terminal/sessions', (req, res) => {
|
||||||
|
const rawCwd = typeof req.query?.cwd === 'string' ? req.query.cwd.trim() : '';
|
||||||
|
const cwdFilter = rawCwd ? path.resolve(rawCwd) : null;
|
||||||
|
const list = [];
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
if (cwdFilter && path.resolve(session.cwd) !== cwdFilter) continue;
|
||||||
|
list.push({
|
||||||
|
sessionId: session.id,
|
||||||
|
cwd: session.cwd,
|
||||||
|
status: session.status,
|
||||||
|
createdAt: Number.isInteger(session.createdAt) ? session.createdAt : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ sessions: list });
|
||||||
|
});
|
||||||
|
app.post('/api/terminal/touch', (req, res) => {
|
||||||
|
const rawIds = Array.isArray(req.body?.sessionIds) ? req.body.sessionIds : [];
|
||||||
|
const now = Date.now();
|
||||||
|
let touched = 0;
|
||||||
|
for (const id of rawIds) {
|
||||||
|
if (typeof id !== 'string') continue;
|
||||||
|
const session = sessions.get(id);
|
||||||
|
if (!session) continue;
|
||||||
|
session.lastActivity = now;
|
||||||
|
touched += 1;
|
||||||
|
}
|
||||||
|
res.json({ touched });
|
||||||
|
});
|
||||||
app.post('/api/terminal/create', async (req, res) => {
|
app.post('/api/terminal/create', async (req, res) => {
|
||||||
try { const session = await createSession(req.body ?? {}); res.json({ sessionId: session.id, cols: session.cols, rows: session.rows, status: session.status }); }
|
try { const session = await createSession(req.body ?? {}); res.json({ sessionId: session.id, cols: session.cols, rows: session.rows, status: session.status }); }
|
||||||
catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json({ error: error?.message || 'Failed to create terminal session' }); }
|
catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json({ error: error?.message || 'Failed to create terminal session' }); }
|
||||||
|
|||||||
@@ -179,6 +179,32 @@ describe('terminal runtime', () => {
|
|||||||
} finally { await harness.runtime.shutdown(); }
|
} finally { await harness.runtime.shutdown(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists sessions scoped to a working directory and refreshes activity via touch', async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
try {
|
||||||
|
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-a', cwd: '/repo' } }, createResponse());
|
||||||
|
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-b', cwd: '/other' } }, createResponse());
|
||||||
|
|
||||||
|
const all = createResponse();
|
||||||
|
harness.routes.get.get('/api/terminal/sessions')({ query: {} }, all);
|
||||||
|
expect(all.body.sessions.map((s) => s.sessionId).sort()).toEqual(['term-a', 'term-b']);
|
||||||
|
|
||||||
|
const scoped = createResponse();
|
||||||
|
harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, scoped);
|
||||||
|
expect(scoped.body.sessions).toEqual([
|
||||||
|
{ sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const touch = createResponse();
|
||||||
|
harness.routes.post.get('/api/terminal/touch')({ body: { sessionIds: ['term-a', 'missing', 42] } }, touch);
|
||||||
|
expect(touch.body).toEqual({ touched: 1 });
|
||||||
|
|
||||||
|
const malformed = createResponse();
|
||||||
|
harness.routes.post.get('/api/terminal/touch')({ body: {} }, malformed);
|
||||||
|
expect(malformed.body).toEqual({ touched: 0 });
|
||||||
|
} finally { await harness.runtime.shutdown(); }
|
||||||
|
});
|
||||||
|
|
||||||
it('strips AppImage ARGV0 from PTY child environments', async () => {
|
it('strips AppImage ARGV0 from PTY child environments', async () => {
|
||||||
const previousArgv0 = process.env.ARGV0;
|
const previousArgv0 = process.env.ARGV0;
|
||||||
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
|
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
restartTerminalSession,
|
restartTerminalSession,
|
||||||
forceKillTerminal,
|
forceKillTerminal,
|
||||||
listTerminalShells,
|
listTerminalShells,
|
||||||
|
listTerminalSessions,
|
||||||
|
touchTerminalSessions,
|
||||||
} from '@openchamber/ui/lib/terminalApi';
|
} from '@openchamber/ui/lib/terminalApi';
|
||||||
import type {
|
import type {
|
||||||
TerminalAPI,
|
TerminalAPI,
|
||||||
@@ -23,6 +25,14 @@ export const createWebTerminalAPI = (): TerminalAPI => ({
|
|||||||
return listTerminalShells();
|
return listTerminalShells();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listSessions(cwd: string) {
|
||||||
|
return listTerminalSessions(cwd);
|
||||||
|
},
|
||||||
|
|
||||||
|
async touchSessions(sessionIds: string[]) {
|
||||||
|
await touchTerminalSessions(sessionIds);
|
||||||
|
},
|
||||||
|
|
||||||
async createSession(options: CreateTerminalOptions): Promise<TerminalSession> {
|
async createSession(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||||
return createTerminalSession(options);
|
return createTerminalSession(options);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user