mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 14:22:51 +00:00
shell integration
This commit is contained in:
@@ -141,6 +141,10 @@ function ShellDetail() {
|
|||||||
logger.warn('sendData failed', e);
|
logger.warn('sendData failed', e);
|
||||||
router.back();
|
router.back();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
xtermRef.current?.getRecentCommands(10).then((commands) => {
|
||||||
|
logger.info('recent commands', commands);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[shell, router, modifierKeysActive],
|
[shell, router, modifierKeysActive],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export const useSshStore = create<SshRegistryStore>((set) => ({
|
|||||||
return { shells: rest };
|
return { shells: rest };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
}).catch((e) => {
|
||||||
|
logger.error('error starting shell', e.name, e.message);
|
||||||
|
throw e;
|
||||||
});
|
});
|
||||||
const storeKey = `${connection.connectionId}-${shell.channelId}`;
|
const storeKey = `${connection.connectionId}-${shell.channelId}`;
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
|
|||||||
132
docs/projects/shell-integration.md
Normal file
132
docs/projects/shell-integration.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Add command detection and history to the RN xterm WebView
|
||||||
|
|
||||||
|
## What we'll build
|
||||||
|
|
||||||
|
- In-WebView OSC-633 parser for VS Code shell integration sequences (A/B/C/D/E,
|
||||||
|
P=Cwd) to get exact command boundaries when present. Falls back to heuristics
|
||||||
|
(Enter-based + prompt learning + alt-screen guard) when sequences are absent.
|
||||||
|
No persistent server install required; optional ephemeral per-session sourcing
|
||||||
|
is supported later.
|
||||||
|
- Ring buffers in WebView to store last N commands and their outputs, with size
|
||||||
|
caps.
|
||||||
|
- New bridge messages and imperative methods so RN can query: last N commands,
|
||||||
|
last N outputs, and a specific command’s output.
|
||||||
|
|
||||||
|
## Key files to change
|
||||||
|
|
||||||
|
- packages/react-native-xtermjs-webview/src-internal/main.tsx
|
||||||
|
- Register xterm OSC handler for 633; parse “A/B/C/D/E” and “P;Cwd=…”.
|
||||||
|
- Track command state and outputs; implement a capped in-memory store.
|
||||||
|
- Respect a runtime flag from injected options to fully disable command
|
||||||
|
tracking/history.
|
||||||
|
- Add message handler for queries (from RN) and send responses.
|
||||||
|
|
||||||
|
- packages/react-native-xtermjs-webview/src/bridge.ts
|
||||||
|
- Extend `BridgeOutboundMessage` (RN→WebView) with query messages.
|
||||||
|
- Extend `BridgeInboundMessage` (WebView→RN) with responses and optional
|
||||||
|
events.
|
||||||
|
|
||||||
|
- packages/react-native-xtermjs-webview/src/index.tsx
|
||||||
|
- Add prop `enableCommandHistory?: boolean` (default true). When false, do not
|
||||||
|
enable OSC handlers/heuristics or allocate history in the WebView.
|
||||||
|
- Extend `XtermWebViewHandle` with:
|
||||||
|
- `getRecentCommands(limit?: number)`
|
||||||
|
- `getRecentOutputs(limit?: number)`
|
||||||
|
- `getCommandOutput(id: string)`
|
||||||
|
- `clearHistory()`
|
||||||
|
- Implement a simple request/response over `injectJavaScript` using
|
||||||
|
correlation IDs.
|
||||||
|
|
||||||
|
- apps/mobile/src/app/(tabs)/shell/detail.tsx
|
||||||
|
- Show example usage via the existing `xtermRef` to fetch recent
|
||||||
|
commands/outputs on demand.
|
||||||
|
|
||||||
|
## Implementation details
|
||||||
|
|
||||||
|
- OSC-633 parsing
|
||||||
|
- Use xterm proposed API to register an OSC handler when available:
|
||||||
|
`terminal.parser.registerOscHandler(633, handler)`.
|
||||||
|
- Handle sequences per VS Code docs: `A` (prompt start), `B` (prompt end), `C`
|
||||||
|
(pre-exec), `D[;code]` (post-exec), `E;<escapedCmd>[;nonce]`, `P;Cwd=…`.
|
||||||
|
- References: VS Code docs and sources.
|
||||||
|
|
||||||
|
- Heuristic fallback (no sequences)
|
||||||
|
- Track local keystrokes (we already have them) to build a transient “input
|
||||||
|
line buffer”.
|
||||||
|
- On Enter (\r or \r\n) outside alt-screen (CSI ? 1049/47/1047 toggles), emit
|
||||||
|
a best-effort “command started”.
|
||||||
|
- Detect new prompt by screen change patterns (stable prompt prefix on the
|
||||||
|
left) to close a command when possible; otherwise time out and roll forward.
|
||||||
|
- Disable detection while in full-screen TUIs (alt screen), and ignore
|
||||||
|
bracketed paste blocks.
|
||||||
|
|
||||||
|
- Storage
|
||||||
|
- Maintain two ring buffers with caps (defaults: 100 commands, 1 MB output per
|
||||||
|
command; configurable via injected options later).
|
||||||
|
- Each entry:
|
||||||
|
`{ id, command, startTime, endTime?, exitCode?, cwd?, outputBytes[] }`.
|
||||||
|
|
||||||
|
- Bridge extensions
|
||||||
|
- RN→WebView: `{ type: 'history:getCommands', limit? }`,
|
||||||
|
`{ type: 'history:getOutputs', limit? }`,
|
||||||
|
`{ type: 'history:getOutput', id }`, `{ type: 'history:clear' }`.
|
||||||
|
- WebView→RN responses: `{ type: 'history:commands', corr, items }`,
|
||||||
|
`{ type: 'history:outputs', corr, items }`,
|
||||||
|
`{ type: 'history:output', corr, item }`,
|
||||||
|
`{ type: 'history:cleared', corr }`.
|
||||||
|
- Optional event stream for live updates: `{ type: 'history:event', event }`.
|
||||||
|
|
||||||
|
- Imperative handle
|
||||||
|
- Implement methods that send queries with a correlation ID and await the
|
||||||
|
matching response via `onMessage`.
|
||||||
|
|
||||||
|
## Critical code touchpoints
|
||||||
|
|
||||||
|
- Where to hook OSC parsing and input/output
|
||||||
|
|
||||||
|
```150:176:packages/react-native-xtermjs-webview/src-internal/main.tsx
|
||||||
|
term.onData((data) => {
|
||||||
|
sendToRn({ type: 'input', str: data });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Where to expose new methods
|
||||||
|
|
||||||
|
```231:246:packages/react-native-xtermjs-webview/src/index.tsx
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
write,
|
||||||
|
writeMany,
|
||||||
|
flush,
|
||||||
|
clear: () => sendToWebView({ type: 'clear' }),
|
||||||
|
focus: () => {
|
||||||
|
sendToWebView({ type: 'focus' });
|
||||||
|
webRef.current?.requestFocus();
|
||||||
|
},
|
||||||
|
resize: (size: { cols: number; rows: number }) => {
|
||||||
|
sendToWebView({ type: 'resize', cols: size.cols, rows: size.rows });
|
||||||
|
autoFitFn();
|
||||||
|
appliedSizeRef.current = size;
|
||||||
|
},
|
||||||
|
fit,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional enhancement (no install, ephemeral session-only)
|
||||||
|
|
||||||
|
- On session open, send a one-shot, in-memory sourced shell snippet
|
||||||
|
(bash/zsh/fish/pwsh) to enable OSC 633 for that session only. No files written
|
||||||
|
server-side. If disabled by user, fallback to heuristics.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- VS Code Shell Integration docs (OSC 633, iTerm/FinalTerm sequences)
|
||||||
|
[Terminal Shell Integration](https://code.visualstudio.com/docs/terminal/shell-integration)
|
||||||
|
- VS Code sources: `shellIntegrationAddon.ts`, `commandDetectionCapability.ts`,
|
||||||
|
`terminalEnvironment.ts`:
|
||||||
|
- [shellIntegrationAddon.ts](https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts)
|
||||||
|
- [commandDetectionCapability.ts](https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts)
|
||||||
|
- [terminalEnvironment.ts](https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/node/terminalEnvironment.ts)
|
||||||
|
- Shell scripts (for optional ephemeral sourcing):
|
||||||
|
- [shellIntegration-bash.sh](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh)
|
||||||
|
- [shellIntegration.ps1](https://cocalc.com/github/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1?utm_source=chatgpt.com)
|
||||||
|
- [Fish integration discussion](https://github.com/microsoft/vscode/issues/184659?utm_source=chatgpt.com)
|
||||||
@@ -3,8 +3,11 @@ import { Terminal, type ITerminalOptions } from '@xterm/xterm';
|
|||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
import {
|
import {
|
||||||
bStrToBinary,
|
bStrToBinary,
|
||||||
|
binaryToBStr,
|
||||||
type BridgeInboundMessage,
|
type BridgeInboundMessage,
|
||||||
type BridgeOutboundMessage,
|
type BridgeOutboundMessage,
|
||||||
|
type CommandMeta,
|
||||||
|
type OutputMeta,
|
||||||
} from '../src/bridge';
|
} from '../src/bridge';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -52,7 +55,11 @@ window.onload = () => {
|
|||||||
|
|
||||||
window.__FRESSH_XTERM_BRIDGE__ = true;
|
window.__FRESSH_XTERM_BRIDGE__ = true;
|
||||||
|
|
||||||
const injectedObject = JSON.parse(injectedObjectJson) as ITerminalOptions;
|
const injectedObject = JSON.parse(
|
||||||
|
injectedObjectJson,
|
||||||
|
) as ITerminalOptions & {
|
||||||
|
__fresshEnableCommandHistory?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// ---- Xterm setup
|
// ---- Xterm setup
|
||||||
const term = new Terminal(injectedObject);
|
const term = new Terminal(injectedObject);
|
||||||
@@ -67,10 +74,169 @@ window.onload = () => {
|
|||||||
window.terminal = term;
|
window.terminal = term;
|
||||||
window.fitAddon = fitAddon;
|
window.fitAddon = fitAddon;
|
||||||
|
|
||||||
|
let sawOsc633 = false;
|
||||||
|
let inputBuffer = '';
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
sendToRn({ type: 'input', str: data });
|
sendToRn({ type: 'input', str: data });
|
||||||
|
if (enableHistory && !sawOsc633) {
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const ch = data[i];
|
||||||
|
if (ch === '\\r' || ch === '\\n') {
|
||||||
|
if (current) finishCommand(undefined);
|
||||||
|
const cmd = inputBuffer.trim();
|
||||||
|
inputBuffer = '';
|
||||||
|
if (cmd.length > 0) startCommand(cmd);
|
||||||
|
} else {
|
||||||
|
inputBuffer += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Command history tracking (OSC 633 and minimal heuristics)
|
||||||
|
const enableHistory = injectedObject.__fresshEnableCommandHistory ?? true;
|
||||||
|
|
||||||
|
type MutableCommand = CommandMeta & {
|
||||||
|
_output: Uint8Array;
|
||||||
|
_truncated: boolean;
|
||||||
|
};
|
||||||
|
const maxCommands = 100;
|
||||||
|
const maxBytesPerCommand = 1 * 1024 * 1024; // 1MB
|
||||||
|
|
||||||
|
const commands: MutableCommand[] = [];
|
||||||
|
let current: MutableCommand | null = null;
|
||||||
|
let cwd: string | undefined = undefined;
|
||||||
|
|
||||||
|
function pushCommand(cmd: MutableCommand) {
|
||||||
|
if (commands.length >= maxCommands) commands.shift();
|
||||||
|
commands.push(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCommand(command?: string) {
|
||||||
|
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
current = {
|
||||||
|
id,
|
||||||
|
command,
|
||||||
|
startTime: Date.now(),
|
||||||
|
cwd,
|
||||||
|
_output: new Uint8Array(0),
|
||||||
|
_truncated: false,
|
||||||
|
};
|
||||||
|
sendToRn({
|
||||||
|
type: 'history:event',
|
||||||
|
event: { kind: 'commandStarted', meta: { ...current } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishCommand(exitCode?: number) {
|
||||||
|
if (!current) return;
|
||||||
|
current.endTime = Date.now();
|
||||||
|
if (exitCode != null) current.exitCode = exitCode;
|
||||||
|
pushCommand(current);
|
||||||
|
sendToRn({
|
||||||
|
type: 'history:event',
|
||||||
|
event: { kind: 'commandFinished', meta: { ...current } },
|
||||||
|
});
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendOutput(bytes: Uint8Array) {
|
||||||
|
if (!current) return;
|
||||||
|
if (current._truncated) return;
|
||||||
|
const newLen = current._output.length + bytes.length;
|
||||||
|
if (newLen > maxBytesPerCommand) {
|
||||||
|
const allowed = Math.max(
|
||||||
|
0,
|
||||||
|
maxBytesPerCommand - current._output.length,
|
||||||
|
);
|
||||||
|
if (allowed > 0) {
|
||||||
|
const merged = new Uint8Array(current._output.length + allowed);
|
||||||
|
merged.set(current._output, 0);
|
||||||
|
merged.set(bytes.subarray(0, allowed), current._output.length);
|
||||||
|
current._output = merged;
|
||||||
|
}
|
||||||
|
current._truncated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const merged = new Uint8Array(newLen);
|
||||||
|
merged.set(current._output, 0);
|
||||||
|
merged.set(bytes, current._output.length);
|
||||||
|
current._output = merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableHistory) {
|
||||||
|
// OSC 633 handler
|
||||||
|
try {
|
||||||
|
term.parser.registerOscHandler(633, (data: string) => {
|
||||||
|
sawOsc633 = true;
|
||||||
|
// data like: 'A' | 'B' | 'C' | 'D;0' | 'E;...escapedCmd[;nonce]' | 'P;Cwd=/path'
|
||||||
|
if (!data) return true;
|
||||||
|
const semi = data.indexOf(';');
|
||||||
|
const tag = semi === -1 ? data : data.slice(0, semi);
|
||||||
|
const rest = semi === -1 ? '' : data.slice(semi + 1);
|
||||||
|
switch (tag) {
|
||||||
|
case 'P': {
|
||||||
|
// property
|
||||||
|
// format: Cwd=<cwd>
|
||||||
|
const eq = rest.indexOf('=');
|
||||||
|
if (eq !== -1) {
|
||||||
|
const key = rest.slice(0, eq);
|
||||||
|
const value = rest.slice(eq + 1);
|
||||||
|
if (key === 'Cwd') {
|
||||||
|
cwd = value;
|
||||||
|
sendToRn({
|
||||||
|
type: 'history:event',
|
||||||
|
event: { kind: 'cwdChanged', cwd: value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'E': {
|
||||||
|
// explicit command line
|
||||||
|
// command is escaped: requires unescaping \ and \xAB (hex)
|
||||||
|
let cmdRaw = rest;
|
||||||
|
const semi2 = cmdRaw.indexOf(';');
|
||||||
|
if (semi2 !== -1) cmdRaw = cmdRaw.slice(0, semi2);
|
||||||
|
// unescape
|
||||||
|
const unescaped = cmdRaw
|
||||||
|
.replace(/\\x([0-9a-fA-F]{2})/g, (_m, p1) =>
|
||||||
|
String.fromCharCode(parseInt(p1, 16)),
|
||||||
|
)
|
||||||
|
.replace(/\\\\/g, '\\');
|
||||||
|
// save for upcoming run if we start immediately
|
||||||
|
if (current) current.command = unescaped;
|
||||||
|
else startCommand(unescaped);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'C': {
|
||||||
|
// pre-execution
|
||||||
|
if (!current) startCommand();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'D': {
|
||||||
|
// execution finished
|
||||||
|
let code: number | undefined = undefined;
|
||||||
|
if (rest) {
|
||||||
|
const n = Number(rest);
|
||||||
|
if (!Number.isNaN(n)) code = n;
|
||||||
|
}
|
||||||
|
finishCommand(code);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 'A' and 'B' (prompt markers) are ignored for storage here
|
||||||
|
default:
|
||||||
|
return true; // swallow
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
sendToRn({
|
||||||
|
type: 'debug',
|
||||||
|
message: `OSC633 handler error: ${String(e)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove old handler if any (just in case)
|
// Remove old handler if any (just in case)
|
||||||
if (window.__FRESSH_XTERM_MSG_HANDLER__)
|
if (window.__FRESSH_XTERM_MSG_HANDLER__)
|
||||||
window.removeEventListener(
|
window.removeEventListener(
|
||||||
@@ -78,7 +244,7 @@ window.onload = () => {
|
|||||||
window.__FRESSH_XTERM_MSG_HANDLER__!,
|
window.__FRESSH_XTERM_MSG_HANDLER__!,
|
||||||
);
|
);
|
||||||
|
|
||||||
// RN -> WebView handler (write, resize, setFont, setTheme, setOptions, clear, focus)
|
// RN -> WebView handler (write, resize, setFont, setTheme, setOptions, clear, focus, history queries)
|
||||||
const handler = (e: MessageEvent<BridgeOutboundMessage>) => {
|
const handler = (e: MessageEvent<BridgeOutboundMessage>) => {
|
||||||
try {
|
try {
|
||||||
const msg = e.data;
|
const msg = e.data;
|
||||||
@@ -89,6 +255,7 @@ window.onload = () => {
|
|||||||
const termWrite = (bStr: string) => {
|
const termWrite = (bStr: string) => {
|
||||||
const bytes = bStrToBinary(bStr);
|
const bytes = bStrToBinary(bStr);
|
||||||
term.write(bytes);
|
term.write(bytes);
|
||||||
|
if (enableHistory) appendOutput(bytes);
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
@@ -140,6 +307,59 @@ window.onload = () => {
|
|||||||
term.focus();
|
term.focus();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'history:getCommands': {
|
||||||
|
const items: CommandMeta[] = commands.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
command: c.command,
|
||||||
|
startTime: c.startTime,
|
||||||
|
endTime: c.endTime,
|
||||||
|
exitCode: c.exitCode,
|
||||||
|
cwd: c.cwd,
|
||||||
|
}));
|
||||||
|
const lim = msg.limit ?? items.length;
|
||||||
|
sendToRn({
|
||||||
|
type: 'history:commands',
|
||||||
|
corr: msg.corr,
|
||||||
|
items: items.slice(-lim),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'history:getOutputs': {
|
||||||
|
const items: OutputMeta[] = commands.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
byteLength: c._output.length,
|
||||||
|
}));
|
||||||
|
const lim = msg.limit ?? items.length;
|
||||||
|
sendToRn({
|
||||||
|
type: 'history:outputs',
|
||||||
|
corr: msg.corr,
|
||||||
|
items: items.slice(-lim),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'history:getOutput': {
|
||||||
|
const found = commands.find((c) => c.id === msg.id);
|
||||||
|
if (!found) {
|
||||||
|
sendToRn({
|
||||||
|
type: 'history:output',
|
||||||
|
corr: msg.corr,
|
||||||
|
item: undefined,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sendToRn({
|
||||||
|
type: 'history:output',
|
||||||
|
corr: msg.corr,
|
||||||
|
item: { id: found.id, bytesB64: binaryToBStr(found._output) },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'history:clear': {
|
||||||
|
commands.length = 0;
|
||||||
|
current = null;
|
||||||
|
sendToRn({ type: 'history:cleared', corr: msg.corr });
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendToRn({
|
sendToRn({
|
||||||
|
|||||||
@@ -1,11 +1,42 @@
|
|||||||
import { Base64 } from 'js-base64';
|
import { Base64 } from 'js-base64';
|
||||||
type ITerminalOptions = import('@xterm/xterm').ITerminalOptions;
|
type ITerminalOptions = import('@xterm/xterm').ITerminalOptions;
|
||||||
type ITerminalInitOnlyOptions = import('@xterm/xterm').ITerminalInitOnlyOptions;
|
type ITerminalInitOnlyOptions = import('@xterm/xterm').ITerminalInitOnlyOptions;
|
||||||
|
|
||||||
|
// ---- History payload shapes
|
||||||
|
export type CommandMeta = {
|
||||||
|
id: string;
|
||||||
|
command?: string;
|
||||||
|
startTime: number; // epoch ms
|
||||||
|
endTime?: number; // epoch ms
|
||||||
|
exitCode?: number;
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OutputMeta = {
|
||||||
|
id: string;
|
||||||
|
byteLength: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OutputItemB64 = {
|
||||||
|
id: string;
|
||||||
|
bytesB64: string; // base64-encoded bytes
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HistoryEvent =
|
||||||
|
| { kind: 'commandStarted'; meta: CommandMeta }
|
||||||
|
| { kind: 'commandFinished'; meta: CommandMeta }
|
||||||
|
| { kind: 'cwdChanged'; cwd: string };
|
||||||
|
|
||||||
// Messages posted from the WebView (xterm page) to React Native
|
// Messages posted from the WebView (xterm page) to React Native
|
||||||
export type BridgeInboundMessage =
|
export type BridgeInboundMessage =
|
||||||
| { type: 'initialized' }
|
| { type: 'initialized' }
|
||||||
| { type: 'input'; str: string }
|
| { type: 'input'; str: string }
|
||||||
| { type: 'debug'; message: string };
|
| { type: 'debug'; message: string }
|
||||||
|
| { type: 'history:commands'; corr: string; items: CommandMeta[] }
|
||||||
|
| { type: 'history:outputs'; corr: string; items: OutputMeta[] }
|
||||||
|
| { type: 'history:output'; corr: string; item?: OutputItemB64 }
|
||||||
|
| { type: 'history:cleared'; corr: string }
|
||||||
|
| { type: 'history:event'; event: HistoryEvent };
|
||||||
|
|
||||||
// Messages injected from React Native into the WebView (xterm page)
|
// Messages injected from React Native into the WebView (xterm page)
|
||||||
export type BridgeOutboundMessage =
|
export type BridgeOutboundMessage =
|
||||||
@@ -18,7 +49,11 @@ export type BridgeOutboundMessage =
|
|||||||
opts: Partial<Omit<ITerminalOptions, keyof ITerminalInitOnlyOptions>>;
|
opts: Partial<Omit<ITerminalOptions, keyof ITerminalInitOnlyOptions>>;
|
||||||
}
|
}
|
||||||
| { type: 'clear' }
|
| { type: 'clear' }
|
||||||
| { type: 'focus' };
|
| { type: 'focus' }
|
||||||
|
| { type: 'history:getCommands'; corr: string; limit?: number }
|
||||||
|
| { type: 'history:getOutputs'; corr: string; limit?: number }
|
||||||
|
| { type: 'history:getOutput'; corr: string; id: string }
|
||||||
|
| { type: 'history:clear'; corr: string };
|
||||||
|
|
||||||
export const binaryToBStr = (binary: Uint8Array): string =>
|
export const binaryToBStr = (binary: Uint8Array): string =>
|
||||||
Base64.fromUint8Array(binary);
|
Base64.fromUint8Array(binary);
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ export type XtermWebViewHandle = {
|
|||||||
focus: () => void;
|
focus: () => void;
|
||||||
resize: (size: { cols: number; rows: number }) => void;
|
resize: (size: { cols: number; rows: number }) => void;
|
||||||
fit: () => void;
|
fit: () => void;
|
||||||
|
getRecentCommands: (
|
||||||
|
limit?: number,
|
||||||
|
) => Promise<import('./bridge').CommandMeta[]>;
|
||||||
|
getRecentOutputs: (
|
||||||
|
limit?: number,
|
||||||
|
) => Promise<import('./bridge').OutputMeta[]>;
|
||||||
|
getCommandOutput: (id: string) => Promise<Uint8Array | null>;
|
||||||
|
clearHistory: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultWebViewProps: WebViewOptions = {
|
const defaultWebViewProps: WebViewOptions = {
|
||||||
@@ -95,6 +103,7 @@ export type XtermJsWebViewProps = {
|
|||||||
rows: number;
|
rows: number;
|
||||||
};
|
};
|
||||||
autoFit?: boolean;
|
autoFit?: boolean;
|
||||||
|
enableCommandHistory?: boolean; // default true
|
||||||
};
|
};
|
||||||
|
|
||||||
function xTermOptionsEquals(
|
function xTermOptionsEquals(
|
||||||
@@ -126,10 +135,30 @@ export function XtermJsWebView({
|
|||||||
logger,
|
logger,
|
||||||
size,
|
size,
|
||||||
autoFit = true,
|
autoFit = true,
|
||||||
|
enableCommandHistory = true,
|
||||||
}: XtermJsWebViewProps) {
|
}: XtermJsWebViewProps) {
|
||||||
const webRef = useRef<WebView>(null);
|
const webRef = useRef<WebView>(null);
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Request/response correlation for history APIs
|
||||||
|
const pendingRef = useRef(
|
||||||
|
new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
resolve: (v: unknown) => void;
|
||||||
|
reject: (e: unknown) => void;
|
||||||
|
type:
|
||||||
|
| 'history:commands'
|
||||||
|
| 'history:outputs'
|
||||||
|
| 'history:output'
|
||||||
|
| 'history:cleared';
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
);
|
||||||
|
const genCorr = useCallback(() => {
|
||||||
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ---- RN -> WebView message sender
|
// ---- RN -> WebView message sender
|
||||||
const sendToWebView = useCallback(
|
const sendToWebView = useCallback(
|
||||||
(obj: BridgeOutboundMessage) => {
|
(obj: BridgeOutboundMessage) => {
|
||||||
@@ -243,6 +272,53 @@ export function XtermJsWebView({
|
|||||||
appliedSizeRef.current = size;
|
appliedSizeRef.current = size;
|
||||||
},
|
},
|
||||||
fit,
|
fit,
|
||||||
|
getRecentCommands: (limit?: number) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const corr = genCorr();
|
||||||
|
pendingRef.current.set(corr, {
|
||||||
|
resolve: (v: unknown) =>
|
||||||
|
resolve(v as unknown as import('./bridge').CommandMeta[]),
|
||||||
|
reject: (e: unknown) => reject(e as unknown as never),
|
||||||
|
type: 'history:commands',
|
||||||
|
});
|
||||||
|
sendToWebView({ type: 'history:getCommands', corr, limit });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getRecentOutputs: (limit?: number) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const corr = genCorr();
|
||||||
|
pendingRef.current.set(corr, {
|
||||||
|
resolve: (v: unknown) =>
|
||||||
|
resolve(v as unknown as import('./bridge').OutputMeta[]),
|
||||||
|
reject: (e: unknown) => reject(e as unknown as never),
|
||||||
|
type: 'history:outputs',
|
||||||
|
});
|
||||||
|
sendToWebView({ type: 'history:getOutputs', corr, limit });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getCommandOutput: (id: string) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const corr = genCorr();
|
||||||
|
pendingRef.current.set(corr, {
|
||||||
|
resolve: (v: unknown) =>
|
||||||
|
resolve((v as unknown as Uint8Array | null) ?? null),
|
||||||
|
reject: (e: unknown) => reject(e as unknown as never),
|
||||||
|
type: 'history:output',
|
||||||
|
});
|
||||||
|
sendToWebView({ type: 'history:getOutput', corr, id });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearHistory: () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const corr = genCorr();
|
||||||
|
pendingRef.current.set(corr, {
|
||||||
|
resolve: () => resolve(),
|
||||||
|
reject: (e: unknown) => reject(e as unknown as never),
|
||||||
|
type: 'history:cleared',
|
||||||
|
});
|
||||||
|
sendToWebView({ type: 'history:clear', corr });
|
||||||
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mergedXTermOptions = useMemo(
|
const mergedXTermOptions = useMemo(
|
||||||
@@ -287,6 +363,25 @@ export function XtermJsWebView({
|
|||||||
logger?.log?.(`received debug msg from webview: `, msg.message);
|
logger?.log?.(`received debug msg from webview: `, msg.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
msg.type === 'history:commands' ||
|
||||||
|
msg.type === 'history:outputs' ||
|
||||||
|
msg.type === 'history:output' ||
|
||||||
|
msg.type === 'history:cleared'
|
||||||
|
) {
|
||||||
|
const pending = pendingRef.current.get(msg.corr);
|
||||||
|
if (pending && pending.type === msg.type) {
|
||||||
|
pendingRef.current.delete(msg.corr);
|
||||||
|
if (msg.type === 'history:commands') pending.resolve(msg.items);
|
||||||
|
else if (msg.type === 'history:outputs') pending.resolve(msg.items);
|
||||||
|
else if (msg.type === 'history:output') {
|
||||||
|
if (!msg.item) pending.resolve(null);
|
||||||
|
else pending.resolve(bStrToBinary(msg.item.bytesB64));
|
||||||
|
} else if (msg.type === 'history:cleared')
|
||||||
|
pending.resolve(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
webViewOptions?.onMessage?.(e);
|
webViewOptions?.onMessage?.(e);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger?.warn?.(
|
logger?.warn?.(
|
||||||
@@ -349,7 +444,10 @@ export function XtermJsWebView({
|
|||||||
source={{ html: htmlString }}
|
source={{ html: htmlString }}
|
||||||
onMessage={onMessage}
|
onMessage={onMessage}
|
||||||
style={style}
|
style={style}
|
||||||
injectedJavaScriptObject={mergedXTermOptions}
|
injectedJavaScriptObject={{
|
||||||
|
...mergedXTermOptions,
|
||||||
|
__fresshEnableCommandHistory: enableCommandHistory,
|
||||||
|
}}
|
||||||
injectedJavaScriptBeforeContentLoaded={
|
injectedJavaScriptBeforeContentLoaded={
|
||||||
mergedXTermOptions.theme?.background
|
mergedXTermOptions.theme?.background
|
||||||
? `
|
? `
|
||||||
|
|||||||
Reference in New Issue
Block a user