Merge fix-spawn-search: lazy PTY spawn, working search, prompt-dup fix
This commit is contained in:
+10
-6
@@ -3,7 +3,6 @@ import { LayoutEngine } from "./LayoutEngine";
|
|||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { TopBar } from "./TopBar";
|
import { TopBar } from "./TopBar";
|
||||||
import { CenterToolbar } from "./CenterToolbar";
|
import { CenterToolbar } from "./CenterToolbar";
|
||||||
import { SearchBar } from "./SearchBar";
|
|
||||||
import { Wizard } from "./Wizard";
|
import { Wizard } from "./Wizard";
|
||||||
import { EventCenter } from "./EventCenter";
|
import { EventCenter } from "./EventCenter";
|
||||||
import { maybeNotify } from "./notify";
|
import { maybeNotify } from "./notify";
|
||||||
@@ -35,9 +34,10 @@ export function App() {
|
|||||||
const [health, setHealth] = useState<DaemonHealth | null>(null);
|
const [health, setHealth] = useState<DaemonHealth | null>(null);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchSurfaceId, setSearchSurfaceId] = useState<string | null>(null);
|
||||||
const [searchNonce, setSearchNonce] = useState(0);
|
const [searchNonce, setSearchNonce] = useState(0);
|
||||||
const activeRef = useRef<string | null>(null);
|
const activeRef = useRef<string | null>(null);
|
||||||
|
const effectiveFocusRef = useRef<string | null>(null);
|
||||||
const wsRef = useRef<WorkspaceView[]>([]);
|
const wsRef = useRef<WorkspaceView[]>([]);
|
||||||
activeRef.current = activeId;
|
activeRef.current = activeId;
|
||||||
wsRef.current = workspaces;
|
wsRef.current = workspaces;
|
||||||
@@ -107,7 +107,11 @@ export function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
|
||||||
if (activeRef.current) { e.preventDefault(); setSearchOpen(true); setSearchNonce((n) => n + 1); }
|
if (activeRef.current && effectiveFocusRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearchSurfaceId(effectiveFocusRef.current); // anchor to the focused panel
|
||||||
|
setSearchNonce((n) => n + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
@@ -121,6 +125,7 @@ export function App() {
|
|||||||
const active = workspaces.find((w) => w.id === activeId) ?? null;
|
const active = workspaces.find((w) => w.id === activeId) ?? null;
|
||||||
const leaves = active ? leafIds(active.layout) : [];
|
const leaves = active ? leafIds(active.layout) : [];
|
||||||
const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null;
|
const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null;
|
||||||
|
effectiveFocusRef.current = effectiveFocus;
|
||||||
|
|
||||||
function selectWorkspace(id: string) {
|
function selectWorkspace(id: string) {
|
||||||
setActiveId(id);
|
setActiveId(id);
|
||||||
@@ -135,13 +140,12 @@ export function App() {
|
|||||||
{sidebarOpen && <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} />}
|
{sidebarOpen && <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} />}
|
||||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
{active && (
|
{active && (
|
||||||
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} />
|
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
|
||||||
)}
|
)}
|
||||||
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||||
{active
|
{active
|
||||||
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} />
|
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} />
|
||||||
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace — create one to begin.</div>}
|
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace — create one to begin.</div>}
|
||||||
{searchOpen && active && <SearchBar surfaceId={effectiveFocus} reopenNonce={searchNonce} onClose={() => setSearchOpen(false)} />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{eventsOpen && (
|
{eventsOpen && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react";
|
import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react";
|
||||||
import { TerminalView } from "./TerminalView";
|
import { TerminalView } from "./TerminalView";
|
||||||
|
import { SearchBar } from "./SearchBar";
|
||||||
import { StatusRing } from "./StatusRing";
|
import { StatusRing } from "./StatusRing";
|
||||||
import { COLORS, FONT, STATE_COLOR } from "./theme";
|
import { COLORS, FONT, STATE_COLOR } from "./theme";
|
||||||
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
|
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
|
||||||
@@ -16,6 +17,11 @@ interface Props {
|
|||||||
focusedId: string | null;
|
focusedId: string | null;
|
||||||
onFocus: (id: string) => void;
|
onFocus: (id: string) => void;
|
||||||
zoomed: string | null;
|
zoomed: string | null;
|
||||||
|
/** The surface whose scrollback search bar is open, or null. Anchored to the
|
||||||
|
* panel it was opened on — it does NOT follow focus. */
|
||||||
|
searchSurfaceId: string | null;
|
||||||
|
searchNonce: number;
|
||||||
|
onCloseSearch: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Edge = "left" | "right" | "top" | "bottom";
|
type Edge = "left" | "right" | "top" | "bottom";
|
||||||
@@ -34,7 +40,7 @@ function shortPath(cwd: string): string {
|
|||||||
return leaf ? `~/${leaf}` : cwd;
|
return leaf ? `~/${leaf}` : cwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed }: Props) {
|
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch }: Props) {
|
||||||
// Panel drag-to-reorder. Implemented with raw pointer events rather than the
|
// Panel drag-to-reorder. Implemented with raw pointer events rather than the
|
||||||
// HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses.
|
// HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses.
|
||||||
const [drop, setDrop] = useState<DropTarget | null>(null);
|
const [drop, setDrop] = useState<DropTarget | null>(null);
|
||||||
@@ -72,7 +78,7 @@ export function LayoutEngine({ workspaceId, layout, running, states, surfaces, f
|
|||||||
if (!layout) {
|
if (!layout) {
|
||||||
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace — apply a preset to add panels.</div>;
|
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace — apply a preset to add panels.</div>;
|
||||||
}
|
}
|
||||||
const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag };
|
const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch };
|
||||||
if (zoomed) {
|
if (zoomed) {
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
|
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
|
||||||
@@ -94,6 +100,9 @@ interface NodeProps {
|
|||||||
zoomed: string | null;
|
zoomed: string | null;
|
||||||
drop: DropTarget | null;
|
drop: DropTarget | null;
|
||||||
onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void;
|
onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void;
|
||||||
|
searchSurfaceId: string | null;
|
||||||
|
searchNonce: number;
|
||||||
|
onCloseSearch: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Node({ node, path, ...rest }: NodeProps) {
|
function Node({ node, path, ...rest }: NodeProps) {
|
||||||
@@ -103,7 +112,7 @@ function Node({ node, path, ...rest }: NodeProps) {
|
|||||||
return <SplitView split={node.split} path={path} {...rest} />;
|
return <SplitView split={node.split} path={path} {...rest} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag }: Omit<NodeProps, "node" | "path"> & { id: string }) {
|
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch }: Omit<NodeProps, "node" | "path"> & { id: string }) {
|
||||||
const focused = focusedId === id;
|
const focused = focusedId === id;
|
||||||
const dropEdge = drop && drop.id === id ? drop.edge : null;
|
const dropEdge = drop && drop.id === id ? drop.edge : null;
|
||||||
|
|
||||||
@@ -114,7 +123,11 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
|||||||
style={{
|
style={{
|
||||||
position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%",
|
position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%",
|
||||||
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden",
|
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden",
|
||||||
border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`,
|
// Constant 2px border, color-only on focus. A width change (1px<->2px)
|
||||||
|
// would resize the inner content box, fire ResizeObserver -> fit -> PTY
|
||||||
|
// SIGWINCH, making zsh/powerlevel10k reprint its prompt on every focus
|
||||||
|
// switch (the "stacked prompts" bug).
|
||||||
|
border: `2px solid ${focused ? COLORS.accent : COLORS.borderSubtle}`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -170,6 +183,9 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
|
|||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
<TerminalView key={id} surfaceId={id} />
|
<TerminalView key={id} surfaceId={id} />
|
||||||
</div>
|
</div>
|
||||||
|
{searchSurfaceId === id && (
|
||||||
|
<SearchBar surfaceId={id} reopenNonce={searchNonce} onClose={onCloseSearch} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,18 +61,20 @@ export function SearchBar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
// Anchored to the focused panel's card (position:relative). Sits over
|
||||||
|
// the 30px header so it's obvious which panel is being searched.
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 8,
|
top: 3,
|
||||||
right: 16,
|
right: 6,
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 6,
|
gap: 4,
|
||||||
height: 32,
|
height: 24,
|
||||||
padding: "0 8px",
|
padding: "0 6px",
|
||||||
background: COLORS.bgElevated,
|
background: COLORS.bgElevated,
|
||||||
border: `1px solid ${COLORS.borderStrong}`,
|
border: `1px solid ${COLORS.borderStrong}`,
|
||||||
borderRadius: 8,
|
borderRadius: 6,
|
||||||
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
|
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -100,7 +102,7 @@ export function SearchBar({
|
|||||||
}}
|
}}
|
||||||
placeholder="Search scrollback"
|
placeholder="Search scrollback"
|
||||||
style={{
|
style={{
|
||||||
width: 200,
|
width: 150,
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false, scrollback: 10000 });
|
// allowProposedApi is required by the search addon: its match decorations
|
||||||
|
// call registerMarker/registerDecoration (proposed API). Without it findNext
|
||||||
|
// throws and the scrollback search counter never updates.
|
||||||
|
const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false, scrollback: 10000, allowProposedApi: true });
|
||||||
try {
|
try {
|
||||||
term.loadAddon(new WebglAddon());
|
term.loadAddon(new WebglAddon());
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ use tokio::time::{Duration, Instant};
|
|||||||
|
|
||||||
/// Spawn (or restart) a surface actor from a persisted spec. Injects
|
/// Spawn (or restart) a surface actor from a persisted spec. Injects
|
||||||
/// SPACESH_SURFACE_ID into the child env, mirroring `new_surface`.
|
/// SPACESH_SURFACE_ID into the child env, mirroring `new_surface`.
|
||||||
|
///
|
||||||
|
/// The child process is spawned lazily — see `spawn_surface_deferred`. This
|
||||||
|
/// lets the first `Resize` from an attaching GUI fix the PTY geometry *before*
|
||||||
|
/// the shell prints its first prompt, so prompts (e.g. powerlevel10k instant
|
||||||
|
/// prompt) render at the correct size instead of being drawn at the 80x24
|
||||||
|
/// default and then reflowed.
|
||||||
pub fn spawn_from_spec(
|
pub fn spawn_from_spec(
|
||||||
id: SurfaceId,
|
id: SurfaceId,
|
||||||
workspace_id: WorkspaceId,
|
workspace_id: WorkspaceId,
|
||||||
@@ -21,21 +27,25 @@ pub fn spawn_from_spec(
|
|||||||
) -> std::io::Result<SurfaceHandle> {
|
) -> std::io::Result<SurfaceHandle> {
|
||||||
let mut env = vec![("SPACESH_SURFACE_ID".to_string(), id.0.clone())];
|
let mut env = vec![("SPACESH_SURFACE_ID".to_string(), id.0.clone())];
|
||||||
env.extend(extra_env);
|
env.extend(extra_env);
|
||||||
let pty = PtyHandle::spawn(SpawnSpec {
|
let spawn_spec = SpawnSpec {
|
||||||
command: spec.command.clone(),
|
command: spec.command.clone(),
|
||||||
args: spec.args.clone(),
|
args: spec.args.clone(),
|
||||||
cwd: std::path::PathBuf::from(&spec.cwd),
|
cwd: std::path::PathBuf::from(&spec.cwd),
|
||||||
cols: spec.cols,
|
cols: spec.cols,
|
||||||
rows: spec.rows,
|
rows: spec.rows,
|
||||||
env,
|
env,
|
||||||
})
|
};
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
|
Ok(spawn_surface_deferred(id, workspace_id, spawn_spec, spec.cols, spec.rows, hooks_active, state_tx, exit_tx))
|
||||||
Ok(spawn_surface(id, workspace_id, pty, spec.cols, spec.rows, hooks_active, state_tx, exit_tx))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BROADCAST_CAP: usize = 1024;
|
const BROADCAST_CAP: usize = 1024;
|
||||||
const FLUSH_INTERVAL: Duration = Duration::from_millis(6);
|
const FLUSH_INTERVAL: Duration = Duration::from_millis(6);
|
||||||
const FLUSH_BYTES: usize = 16 * 1024;
|
const FLUSH_BYTES: usize = 16 * 1024;
|
||||||
|
/// How long a deferred surface waits for the first `Resize` before spawning the
|
||||||
|
/// child at its default geometry. The GUI's fit-driven resize lands within a
|
||||||
|
/// frame (<16ms); this only bounds the wait for headless/CLI surfaces that
|
||||||
|
/// never attach a GUI.
|
||||||
|
const SPAWN_FALLBACK: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
pub enum SurfaceMsg {
|
pub enum SurfaceMsg {
|
||||||
Input(Vec<u8>),
|
Input(Vec<u8>),
|
||||||
@@ -53,22 +63,108 @@ pub struct SurfaceHandle {
|
|||||||
pub tx: mpsc::Sender<SurfaceMsg>,
|
pub tx: mpsc::Sender<SurfaceMsg>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Eager variant: spawn the actor over an already-spawned PTY. Retained for
|
||||||
|
/// tests and callers that have a live PTY in hand; production goes through
|
||||||
|
/// `spawn_surface_deferred` via `spawn_from_spec`.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn spawn_surface(
|
pub fn spawn_surface(
|
||||||
id: SurfaceId,
|
id: SurfaceId,
|
||||||
workspace_id: WorkspaceId,
|
workspace_id: WorkspaceId,
|
||||||
mut pty: PtyHandle,
|
pty: PtyHandle,
|
||||||
cols: u16,
|
cols: u16,
|
||||||
rows: u16,
|
rows: u16,
|
||||||
hooks_active: bool,
|
hooks_active: bool,
|
||||||
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
|
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
|
||||||
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||||||
|
) -> SurfaceHandle {
|
||||||
|
let (tx, rx) = mpsc::channel::<SurfaceMsg>(64);
|
||||||
|
let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
|
||||||
|
tokio::spawn(run_actor(id.clone(), pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, Vec::new()));
|
||||||
|
SurfaceHandle { id, workspace_id, tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a surface whose child process is spawned lazily: on the first
|
||||||
|
/// `Resize` (using its geometry) or after `SPAWN_FALLBACK` (using `def_*`).
|
||||||
|
/// Attaches received before the spawn get an empty-grid snapshot and a live
|
||||||
|
/// subscription; input received before the spawn is buffered and replayed.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn spawn_surface_deferred(
|
||||||
|
id: SurfaceId,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
spawn_spec: SpawnSpec,
|
||||||
|
def_cols: u16,
|
||||||
|
def_rows: u16,
|
||||||
|
hooks_active: bool,
|
||||||
|
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
|
||||||
|
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||||||
) -> SurfaceHandle {
|
) -> SurfaceHandle {
|
||||||
let (tx, mut rx) = mpsc::channel::<SurfaceMsg>(64);
|
let (tx, mut rx) = mpsc::channel::<SurfaceMsg>(64);
|
||||||
let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
|
let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
|
||||||
let actor_id = id.clone();
|
let actor_id = id.clone();
|
||||||
let detect_id = id.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let mut cols = def_cols;
|
||||||
|
let mut rows = def_rows;
|
||||||
|
let mut prebuf: Vec<u8> = Vec::new();
|
||||||
|
let fallback = tokio::time::sleep(SPAWN_FALLBACK);
|
||||||
|
tokio::pin!(fallback);
|
||||||
|
|
||||||
|
// Pre-spawn phase: hold the child until we know the real geometry.
|
||||||
|
let proceed = loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut fallback => break true,
|
||||||
|
msg = rx.recv() => match msg {
|
||||||
|
Some(SurfaceMsg::Resize { cols: c, rows: r }) => { cols = c; rows = r; break true; }
|
||||||
|
Some(SurfaceMsg::Input(bytes)) => prebuf.extend_from_slice(&bytes),
|
||||||
|
Some(SurfaceMsg::Attach { reply }) => { let _ = reply.send(bcast.subscribe()); }
|
||||||
|
Some(SurfaceMsg::AttachSnapshot { reply }) => {
|
||||||
|
let sub = bcast.subscribe();
|
||||||
|
let snap = snapshot_ansi(&GridSurface::new(cols, rows));
|
||||||
|
let _ = reply.send((snap, sub));
|
||||||
|
}
|
||||||
|
Some(SurfaceMsg::Close) | None => break false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !proceed {
|
||||||
|
let _ = exit_tx.send((actor_id, 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut spec = spawn_spec;
|
||||||
|
spec.cols = cols;
|
||||||
|
spec.rows = rows;
|
||||||
|
match PtyHandle::spawn(spec) {
|
||||||
|
Ok(pty) => run_actor(actor_id, pty, cols, rows, hooks_active, bcast, rx, state_tx, exit_tx, prebuf).await,
|
||||||
|
Err(_) => { let _ = exit_tx.send((actor_id, 127)); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
SurfaceHandle { id, workspace_id, tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The surface actor's main loop: owns the PTY, fans output out to subscribers
|
||||||
|
/// through the batching `flush`, services input/resize/attach, and reports exit.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn run_actor(
|
||||||
|
id: SurfaceId,
|
||||||
|
mut pty: PtyHandle,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
hooks_active: bool,
|
||||||
|
bcast: broadcast::Sender<Vec<u8>>,
|
||||||
|
mut rx: mpsc::Receiver<SurfaceMsg>,
|
||||||
|
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
|
||||||
|
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||||||
|
prebuffered_input: Vec<u8>,
|
||||||
|
) {
|
||||||
|
let actor_id = id.clone();
|
||||||
|
let detect_id = id;
|
||||||
|
if !prebuffered_input.is_empty() {
|
||||||
|
let _ = pty.write_input(&prebuffered_input);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
let mut grid = GridSurface::new(cols, rows);
|
let mut grid = GridSurface::new(cols, rows);
|
||||||
let mut pending: Vec<u8> = Vec::with_capacity(FLUSH_BYTES);
|
let mut pending: Vec<u8> = Vec::with_capacity(FLUSH_BYTES);
|
||||||
let mut flush_deadline: Option<Instant> = None;
|
let mut flush_deadline: Option<Instant> = None;
|
||||||
@@ -137,9 +233,7 @@ pub fn spawn_surface(
|
|||||||
}
|
}
|
||||||
let code = pty.wait();
|
let code = pty.wait();
|
||||||
let _ = exit_tx.send((actor_id, code));
|
let _ = exit_tx.send((actor_id, code));
|
||||||
});
|
}
|
||||||
|
|
||||||
SurfaceHandle { id, workspace_id, tx }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feed pending bytes into the grid, run detectors, broadcast output, and emit a
|
/// Feed pending bytes into the grid, run detectors, broadcast output, and emit a
|
||||||
@@ -280,6 +374,63 @@ mod tests {
|
|||||||
assert!(got.contains("RESPAWN"), "got: {got:?}");
|
assert!(got.contains("RESPAWN"), "got: {got:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stty_spec() -> SpawnSpec {
|
||||||
|
// `stty size` prints "<rows> <cols>" — lets a test assert the geometry
|
||||||
|
// the child actually started with.
|
||||||
|
SpawnSpec {
|
||||||
|
command: "/bin/sh".into(),
|
||||||
|
args: vec!["-c".into(), "stty size; sleep 0.3".into()],
|
||||||
|
cwd: std::env::temp_dir(),
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
env: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_until(sub: &mut broadcast::Receiver<Vec<u8>>, needle: &str, ms: u64) -> String {
|
||||||
|
let mut got = String::new();
|
||||||
|
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(ms);
|
||||||
|
while tokio::time::Instant::now() < deadline {
|
||||||
|
if let Ok(Ok(b)) = tokio::time::timeout(tokio::time::Duration::from_millis(100), sub.recv()).await {
|
||||||
|
got.push_str(&String::from_utf8_lossy(&b));
|
||||||
|
if got.contains(needle) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn deferred_spawn_uses_resize_geometry() {
|
||||||
|
let _serial = crate::test_support::serial();
|
||||||
|
let (state_tx, _s) = mpsc::unbounded_channel();
|
||||||
|
let (exit_tx, _e) = mpsc::unbounded_channel();
|
||||||
|
let handle = spawn_surface_deferred(SurfaceId("s_d".into()), WorkspaceId("w_1".into()), stty_spec(), 80, 24, false, state_tx, exit_tx);
|
||||||
|
|
||||||
|
let (rtx, rrx) = oneshot::channel();
|
||||||
|
handle.tx.send(SurfaceMsg::Attach { reply: rtx }).await.unwrap();
|
||||||
|
let mut sub = rrx.await.unwrap();
|
||||||
|
// Resize before the fallback fires -> child must start at 100x40.
|
||||||
|
handle.tx.send(SurfaceMsg::Resize { cols: 100, rows: 40 }).await.unwrap();
|
||||||
|
|
||||||
|
let got = collect_until(&mut sub, "40 100", 2000).await;
|
||||||
|
assert!(got.contains("40 100"), "expected '40 100' (rows cols), got: {got:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn deferred_spawn_falls_back_to_default_geometry() {
|
||||||
|
let _serial = crate::test_support::serial();
|
||||||
|
let (state_tx, _s) = mpsc::unbounded_channel();
|
||||||
|
let (exit_tx, _e) = mpsc::unbounded_channel();
|
||||||
|
let handle = spawn_surface_deferred(SurfaceId("s_f".into()), WorkspaceId("w_1".into()), stty_spec(), 80, 24, false, state_tx, exit_tx);
|
||||||
|
|
||||||
|
let (rtx, rrx) = oneshot::channel();
|
||||||
|
handle.tx.send(SurfaceMsg::Attach { reply: rtx }).await.unwrap();
|
||||||
|
let mut sub = rrx.await.unwrap();
|
||||||
|
// No resize: after SPAWN_FALLBACK the child starts at the default 80x24.
|
||||||
|
let got = collect_until(&mut sub, "24 80", 3000).await;
|
||||||
|
assert!(got.contains("24 80"), "expected '24 80' (rows cols), got: {got:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn osc133_output_drives_state_detection() {
|
async fn osc133_output_drives_state_detection() {
|
||||||
let _serial = crate::test_support::serial();
|
let _serial = crate::test_support::serial();
|
||||||
|
|||||||
Reference in New Issue
Block a user