Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/features/layout/components/main-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { frontendTrace } from "@/utils/frontend-trace";
import { getInternalTabDragData } from "@/features/tabs/utils/internal-tab-drag";
import { VimSearchBar } from "../../vim/components/vim-search-bar";
import CustomTitleBarWithSettings from "../../window/components/custom-title-bar";
import { TerminalHost } from "@/features/terminal/components/terminal-host";
import BottomPane from "./bottom-pane/bottom-pane";
import Footer from "./footer/footer";
import { ResizablePane } from "./resizable-pane";
Expand Down Expand Up @@ -340,6 +341,7 @@ export function MainLayout() {
<LinuxFolderPickerDialog />
<WindowCloseGuard />
<ExtensionDialogs />
<TerminalHost />
</div>
);
}
3 changes: 3 additions & 0 deletions src/features/panes/components/pane-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,7 @@ export function PaneContainer({ pane }: PaneContainerProps) {
<TerminalTab
sessionId={buffer.sessionId}
bufferId={buffer.id}
paneId={pane.id}
initialCommand={buffer.initialCommand}
workingDirectory={buffer.workingDirectory}
remoteConnectionId={buffer.remoteConnectionId}
Expand Down Expand Up @@ -1071,6 +1072,7 @@ export function PaneContainer({ pane }: PaneContainerProps) {
<TerminalTab
sessionId={buffer.sessionId}
bufferId={buffer.id}
paneId={pane.id}
initialCommand={buffer.initialCommand}
workingDirectory={buffer.workingDirectory}
isActive={isActivePane && isActiveBuffer}
Expand Down Expand Up @@ -1161,6 +1163,7 @@ export function PaneContainer({ pane }: PaneContainerProps) {
<TerminalTab
sessionId={b.sessionId}
bufferId={b.id}
paneId={pane.id}
initialCommand={b.initialCommand}
workingDirectory={b.workingDirectory}
isActive={isActive && isActivePane}
Expand Down
13 changes: 12 additions & 1 deletion src/features/terminal/components/terminal-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,19 @@ const TerminalContainer = ({
profileId: activeTerminal.profileId,
});
setTerminalSplitMode(activeTerminalId, true, companionId);
// createTerminal switches active to the new companion; restore the
// initiating terminal as active so the split layout (which renders
// the companion only inside the initiator's iteration) stays visible.
setActiveTerminal(activeTerminalId);
}
}, [activeTerminalId, terminals, setTerminalSplitMode, createTerminal, closeTerminal]);
}, [
activeTerminalId,
terminals,
setTerminalSplitMode,
createTerminal,
closeTerminal,
setActiveTerminal,
]);

const handleSearchTerminal = useCallback(() => {
if (!activeTerminalId) return;
Expand Down
136 changes: 136 additions & 0 deletions src/features/terminal/components/terminal-host.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { useEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { useShallow } from "zustand/react/shallow";
import { useTerminalSlotsStore } from "../stores/terminal-slots-store";
import { useTerminalStore } from "../stores/terminal-store";
import { XtermTerminal } from "./terminal";

// Renders all live xterm instances at app root. Each session owns a stable
// wrapper <div> that's reparented (via raw appendChild) into whichever slot
// is currently displaying it. React always portals XtermTerminal into the
// wrapper — only the wrapper's DOM parent changes. Pane moves never unmount
// xterm; PTY listeners + scrollback survive.
export function TerminalHost() {
const slotIds = useTerminalSlotsStore(useShallow((state) => Array.from(state.slots.keys())));
const sessionStoreIds = useTerminalStore(
useShallow((state) => Array.from(state.sessions.keys())),
);

const knownRef = useRef<{ all: Set<string>; everInStore: Set<string> }>({
all: new Set(),
everInStore: new Set(),
});

for (const id of slotIds) knownRef.current.all.add(id);
for (const id of sessionStoreIds) {
knownRef.current.all.add(id);
knownRef.current.everInStore.add(id);
}

// Once a session has been registered in the terminal store (PTY connected),
// its disappearance from there means it was explicitly closed — drop it.
for (const id of Array.from(knownRef.current.all)) {
if (knownRef.current.everInStore.has(id) && !sessionStoreIds.includes(id)) {
knownRef.current.all.delete(id);
knownRef.current.everInStore.delete(id);
}
}

const liveIds = useMemo(
() => Array.from(knownRef.current.all),
// Recompute whenever either source changes.
[slotIds, sessionStoreIds],
);

return (
<>
{liveIds.map((sessionId) => (
<XtermPortal key={sessionId} sessionId={sessionId} />
))}
</>
);
}

function XtermPortal({ sessionId }: { sessionId: string }) {
const slotEl = useTerminalSlotsStore((state) => state.slots.get(sessionId)?.el);
const slot = useTerminalSlotsStore((state) => state.slots.get(sessionId));

// Stable wrapper that hosts the xterm DOM for the lifetime of this session.
const wrapperRef = useRef<HTMLDivElement | null>(null);
if (!wrapperRef.current && typeof document !== "undefined") {
const wrapper = document.createElement("div");
wrapper.style.display = "flex";
wrapper.style.flexDirection = "column";
wrapper.style.height = "100%";
wrapper.style.width = "100%";
wrapper.style.minHeight = "0";
wrapper.setAttribute("data-terminal-wrapper", sessionId);
wrapperRef.current = wrapper;
}

// Reparent the wrapper into the active slot whenever the slot changes.
// When no slot exists, park it offscreen so xterm stays mounted + sized.
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;

if (slotEl) {
// Append to slot — moves DOM if currently elsewhere.
slotEl.appendChild(wrapper);
} else {
// No slot: park offscreen so xterm stays alive without being visible.
let park = document.querySelector<HTMLDivElement>("[data-terminal-park]");
if (!park) {
park = document.createElement("div");
park.setAttribute("data-terminal-park", "");
park.style.position = "fixed";
park.style.left = "-9999px";
park.style.top = "0";
park.style.width = "800px";
park.style.height = "600px";
park.style.pointerEvents = "none";
park.style.opacity = "0";
document.body.appendChild(park);
}
park.appendChild(wrapper);
}
}, [slotEl]);

// Tear down wrapper on session end.
useEffect(() => {
return () => {
const wrapper = wrapperRef.current;
if (wrapper?.parentNode) {
wrapper.parentNode.removeChild(wrapper);
}
wrapperRef.current = null;
};
}, []);

// After slot swap, kick xterm to refit + repaint — TUIs (CC etc.) need
// a SIGWINCH-like nudge to redraw at the new column count.
useEffect(() => {
if (!slotEl) return;
const id = requestAnimationFrame(() => {
window.dispatchEvent(new CustomEvent("athas-terminal-refit", { detail: { sessionId } }));
});
return () => cancelAnimationFrame(id);
}, [slotEl, sessionId]);

if (!wrapperRef.current) return null;

return createPortal(
<XtermTerminal
sessionId={sessionId}
isActive={slot?.isActive ?? false}
isVisible={slot?.isVisible ?? true}
initialCommand={slot?.initialCommand}
workingDirectory={slot?.workingDirectory}
remoteConnectionId={slot?.remoteConnectionId}
onTerminalExit={slot?.onTerminalExit}
onTerminalRef={slot?.onTerminalRef}
onReady={slot?.onReady}
/>,
wrapperRef.current,
);
}
25 changes: 9 additions & 16 deletions src/features/terminal/components/terminal-session.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from "react";
import type { Terminal as TerminalType } from "@/features/terminal/types/terminal";
import { XtermTerminal } from "./terminal";
import { TerminalErrorBoundary } from "./terminal-error-boundary";
import { TerminalSlot } from "./terminal-slot";

interface TerminalSessionProps {
terminal: TerminalType;
Expand All @@ -27,7 +27,6 @@ const TerminalSession = ({
const terminalRef = useRef<any>(null);
const xtermInstanceRef = useRef<any>(null);

// Focus method that can be called externally, with verified retry
const focusTerminal = useCallback(() => {
const ref = xtermInstanceRef.current || terminalRef.current;
if (!ref?.focus) return;
Expand All @@ -38,7 +37,6 @@ const TerminalSession = ({
attempt++;
ref.focus();

// Verify focus landed on the terminal's textarea
requestAnimationFrame(() => {
const textarea = ref.terminal?.textarea;
if (textarea && document.activeElement !== textarea) {
Expand All @@ -47,7 +45,6 @@ const TerminalSession = ({
});
};

// Wait for layout to settle before first focus attempt
requestAnimationFrame(() => tryFocus());
}, []);

Expand All @@ -60,7 +57,11 @@ const TerminalSession = ({
focusTerminal();
}, [focusTerminal]);

// Register ref with parent
const handleTerminalRef = useCallback((ref: any) => {
xtermInstanceRef.current = ref;
terminalRef.current = ref;
}, []);

useEffect(() => {
if (onRegisterRef) {
onRegisterRef(terminal.id, { focus: focusTerminal, showSearch });
Expand All @@ -70,7 +71,6 @@ const TerminalSession = ({
}
}, [terminal.id, onRegisterRef, focusTerminal, showSearch]);

// Handle activity tracking
useEffect(() => {
if (isActive && onActivity) {
onActivity(terminal.id);
Expand All @@ -80,21 +80,14 @@ const TerminalSession = ({
return (
<div className="flex h-full min-h-0 flex-col" data-terminal-id={terminal.id}>
<TerminalErrorBoundary>
<XtermTerminal
<TerminalSlot
sessionId={terminal.id}
isActive={isActive}
isVisible={isVisible}
initialCommand={terminal.initialCommand}
onReady={() => {
// Additional ready callback if needed
}}
onTerminalRef={(ref) => {
// Store both xterm instance and focus method
xtermInstanceRef.current = ref;
terminalRef.current = ref;
}}
onTerminalExit={onTerminalExit}
remoteConnectionId={terminal.remoteConnectionId}
onTerminalExit={onTerminalExit}
onTerminalRef={handleTerminalRef}
/>
</TerminalErrorBoundary>
</div>
Expand Down
84 changes: 84 additions & 0 deletions src/features/terminal/components/terminal-slot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useEffect, useRef } from "react";
import { type TerminalSlotProps, useTerminalSlotsStore } from "../stores/terminal-slots-store";

interface Props extends Omit<TerminalSlotProps, "el"> {
sessionId: string;
}

// Mounts a stable DOM target for a terminal session. The actual XtermTerminal
// instance is rendered globally by TerminalHost and portaled into this slot.
// Moving the slot between panes only re-targets the portal — xterm state and
// PTY listeners are preserved.
export function TerminalSlot({
sessionId,
isActive,
isVisible,
initialCommand,
workingDirectory,
remoteConnectionId,
onTerminalExit,
onTerminalRef,
onReady,
onActivate,
}: Props) {
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
const el = ref.current;
if (!el) return;
const { register, unregister } = useTerminalSlotsStore.getState();
register(sessionId, {
el,
isActive,
isVisible,
initialCommand,
workingDirectory,
remoteConnectionId,
onTerminalExit,
onTerminalRef,
onReady,
onActivate,
});
return () => unregister(sessionId, el);
// Mount/unmount only — prop updates handled below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId]);

useEffect(() => {
useTerminalSlotsStore.getState().update(sessionId, {
isActive,
isVisible,
initialCommand,
workingDirectory,
remoteConnectionId,
onTerminalExit,
onTerminalRef,
onReady,
onActivate,
});
}, [
sessionId,
isActive,
isVisible,
initialCommand,
workingDirectory,
remoteConnectionId,
onTerminalExit,
onTerminalRef,
onReady,
onActivate,
]);

// Native DOM listener: portaled xterm clicks don't bubble through the React
// tree, so a React handler here would never fire. Native bubbling does
// reach this div.
useEffect(() => {
const el = ref.current;
if (!el) return;
const handler = () => onActivate?.();
el.addEventListener("mousedown", handler, true);
return () => el.removeEventListener("mousedown", handler, true);
}, [onActivate]);

return <div ref={ref} data-terminal-slot={sessionId} className="flex h-full w-full flex-col" />;
}
Loading
Loading