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
30 changes: 29 additions & 1 deletion docs/web-viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,25 @@ dropped connection coming back — a tab is one screen however many sockets it
opens. Reloading the page counts as opening it, so it takes the sizing again, as
a new tab would.

Drag a terminal pane by its header onto another to reorder the split-view grid;
The panel draws its panes either side by side, as the TUI does, or one at a time
behind a tab strip. The button beside **+** switches between the two, and a
narrow screen starts on tabs — a split grid gives each pane fewer columns than a
command line needs. Once you pick, that choice sticks on that device, rotation
included; it is stored in the browser rather than on the server, because what a
phone should do with four panes is not what the desktop beside it should do.

Tabs change nothing about the session: **+** still opens a terminal that every
client sees, the tabs sit in pane order, and a tab you are not looking at is a
running program whose output keeps arriving. Every pane is also held at the
panel's full size while tabbed, so switching tabs costs no resize — which is the
same reason a tabbed browser and an attached TUI cannot both be right about how
wide a pane is. Give the sizing to whichever screen you are working on with the
button above, or leave the TUI holding it and read the panes at its width.

A tabbed panel shows no **zoom** button — it already shows one pane — and a zoom
another client set does not move the keyboard here.

Drag a terminal pane by its header, or by its tab, onto another to reorder them;
it works with touch as well as a mouse. The order is kept on the server, so a
refresh, a reconnect, or another device opening the same repository all show the
same arrangement. (It is not written to disk — a server restart clears the
Expand All @@ -140,6 +158,16 @@ column, so instead a bottom bar switches between them: tap **Files**, **Diff**,
or **Terminal** to give one of them the whole screen. Opening a file or commit
jumps to the content view automatically.

**Drag a pane to scroll it.** A finger dragged up or down the terminal turns the
same wheel a mouse would, so where it goes is up to the program in the pane: an
agent or a pager that reads the wheel itself scrolls its own view, `less` and
`man` get the arrow keys they expect under alternate scroll, and a plain shell
scrolls the emulator's scrollback. That routing is the browser terminal's, matching
what the TUI does with `Shift+↑/↓` — which is why a full-screen program that keeps
its transcript in its own memory scrolls at all, rather than dragging an empty
scrollback around. A short drag is still a tap, so tapping to place the cursor and
pinching to zoom both survive.

Because a soft keyboard can't type Escape, Tab, Shift-Tab, Ctrl combinations, or
the arrows, the terminal grows a key bar along its bottom on touch devices that
sends those straight to the shell — so you can interrupt a process (`^C`), leave
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

35 changes: 0 additions & 35 deletions viewer-ui/dist/assets/Terminal-Bdr1dKcl.js

This file was deleted.

35 changes: 35 additions & 0 deletions viewer-ui/dist/assets/Terminal-mSoDE7uj.js

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions viewer-ui/dist/assets/index-DKKs--k3.css

This file was deleted.

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions viewer-ui/dist/assets/index-ZNvc_PEi.css

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions viewer-ui/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions viewer-ui/src/components/icons/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ export function SplitViewIcon() {
);
}

export function TabViewIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
className="h-4 w-4"
>
<rect width="18" height="13" x="3" y="8" rx="2" />
<path d="M3 8V6a2 2 0 0 1 2-2h5v4" />
</svg>
);
}

export function PreviewIcon() {
return (
<svg
Expand Down
146 changes: 146 additions & 0 deletions viewer-ui/src/components/terminal/PaneGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { CSSProperties, MutableRefObject } from "react";
import type { CellPlacement } from "../../lib/terminalLayout";
import type { RecoveryByPane } from "../../lib/recovery";
import { stackedCellStyle, type PaneViewMode } from "../../lib/paneViewMode";
import { TerminalCell } from "./TerminalCell";
import { StartupSlots } from "./StartupSlots";

export interface PaneGridProps {
/** The element the panel measures to size its panes. */
containerRef: React.RefObject<HTMLDivElement | null>;
mode: PaneViewMode;
panes: number[];
titles: Record<number, string>;
active: number | null;
/** In tabs mode the pane on screen; in grid mode the pane filling the panel,
* or null for the grid itself. */
shown: number | null;
layout: { cols: number; rows: number; cells: CellPlacement[] };
/** How many startup terminals are waiting to be measured, or null. */
pending: number | null;
recovery: RecoveryByPane;
draggingPane: number | null;
dragOverPane: number | null;
reorderable: boolean;
bodyTouch: React.ComponentProps<typeof TerminalCell>["bodyTouch"];
slotRefs: MutableRefObject<Map<number, HTMLDivElement>>;
bodyRefs: MutableRefObject<Map<number, HTMLDivElement>>;
onFocus: (pane: number) => void;
onToggleZoom: (pane: number) => void;
onClose: (pane: number) => void;
onCancelRecovery: (pane: number) => void;
onPaneDragStart: (e: React.PointerEvent, pane: number) => void;
onPaneDragMove: (e: React.PointerEvent) => void;
onPaneDragEnd: () => void;
onPaneDragCancel: () => void;
}

/**
* Every pane the panel holds: side by side in the cells `layout` gives them, or
* stacked so a tab strip can bring one forward.
*
* Both arrangements render every pane. A pane the panel is not showing is still
* a running program whose output must land somewhere, and in tabs mode it also
* keeps the size it will be shown at — see `stackedCellStyle`.
*/
export function PaneGrid({
containerRef,
mode,
panes,
titles,
active,
shown,
layout,
pending,
recovery,
draggingPane,
dragOverPane,
reorderable,
bodyTouch,
slotRefs,
bodyRefs,
onFocus,
onToggleZoom,
onClose,
onCancelRecovery,
onPaneDragStart,
onPaneDragMove,
onPaneDragEnd,
onPaneDragCancel,
}: PaneGridProps) {
const tabs = mode === "tabs";
const placedStyle = (index: number): CSSProperties => {
const cell = layout.cells[index];
return {
display: "flex",
gridColumn: `${cell.colStart} / span ${cell.colSpan}`,
gridRow: `${cell.row}`,
};
};
const cellStyle = (pane: number, index: number): CSSProperties => {
if (tabs) return stackedCellStyle(pane === shown);
if (shown !== null) return { display: pane === shown ? "flex" : "none" };
return placedStyle(index);
};

return (
<div
ref={containerRef}
className={tabs ? "relative h-full" : "grid h-full gap-1"}
style={
tabs
? undefined
: shown !== null
? { gridTemplateColumns: "1fr", gridTemplateRows: "1fr" }
: {
gridTemplateColumns: `repeat(${layout.cols}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${layout.rows}, minmax(0, 1fr))`,
}
}
>
{panes.length === 0 && pending !== null && (
<StartupSlots
count={pending}
showHeader={!tabs}
bodyTouch={bodyTouch}
// The first slot stands for the tab that will be on screen; the rest
// are measured behind it, at the same size.
slotStyle={(slot) =>
tabs ? stackedCellStyle(slot === 0) : placedStyle(slot)
}
slotRefs={slotRefs}
/>
)}
{panes.map((pane, index) => (
<TerminalCell
key={pane}
pane={pane}
index={index}
label={titles[pane] ?? `term ${index + 1}`}
cellStyle={cellStyle(pane, index)}
isActive={pane === active}
isZoomed={!tabs && shown === pane}
showZoom={!tabs && panes.length > 1}
isDragged={draggingPane === pane}
isDropTarget={dragOverPane === pane}
reorderable={reorderable}
showHeader={!tabs}
bodyTouch={bodyTouch}
recovery={recovery[pane]}
onCancelRecovery={() => onCancelRecovery(pane)}
onFocus={() => onFocus(pane)}
onToggleZoom={() => onToggleZoom(pane)}
onClose={() => onClose(pane)}
onPaneDragStart={(e) => onPaneDragStart(e, pane)}
onPaneDragMove={onPaneDragMove}
onPaneDragEnd={onPaneDragEnd}
onPaneDragCancel={onPaneDragCancel}
bodyRef={(node) => {
if (node) bodyRefs.current.set(pane, node);
else bodyRefs.current.delete(pane);
}}
/>
))}
</div>
);
}
107 changes: 107 additions & 0 deletions viewer-ui/src/components/terminal/PaneTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { useEffect, useRef } from "react";
import { XIcon } from "../icons/actions";
import { TAB_TITLE_MAX_CELLS, truncateCells } from "../../lib/terminalLayout";

export interface PaneTabsProps {
panes: number[];
titles: Record<number, string>;
/** The pane on screen, which is the tab drawn as selected. */
shown: number | null;
reorderable: boolean;
draggingPane: number | null;
dragOverPane: number | null;
onClose: (pane: number) => void;
onPaneDragStart: (e: React.PointerEvent, pane: number) => void;
onPaneDragMove: (e: React.PointerEvent) => void;
onPaneDragEnd: () => void;
onPaneDragCancel: () => void;
}

/**
* One tab per pane, for the panel that shows a single pane at a time.
*
* Focus comes through `onPaneDragStart`: a tab press and the start of a reorder
* are one gesture until the pointer travels, so the drag hook owns both.
*
* Carries `data-pane-id` because it is the only drop surface a tabbed panel has:
* the reorder drag hit-tests that attribute, and the cells it would otherwise
* land on are stacked behind the one on screen.
*/
export function PaneTabs({
panes,
titles,
shown,
reorderable,
draggingPane,
dragOverPane,
onClose,
onPaneDragStart,
onPaneDragMove,
onPaneDragEnd,
onPaneDragCancel,
}: PaneTabsProps) {
const tabRefs = useRef(new Map<number, HTMLDivElement>());

// A tab focused from elsewhere — a jump key, a pane the server just opened —
// can be scrolled out of the strip.
useEffect(() => {
if (shown === null) return;
tabRefs.current
.get(shown)
?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [shown, panes.length]);

return (
<div
role="tablist"
aria-label="Terminals"
className="flex min-w-0 flex-1 items-stretch gap-1 overflow-x-auto"
>
{panes.map((pane, index) => {
const label = titles[pane] ?? `term ${index + 1}`;
const selected = pane === shown;
return (
<div
key={pane}
data-pane-id={pane}
ref={(node) => {
if (node) tabRefs.current.set(pane, node);
else tabRefs.current.delete(pane);
}}
role="tab"
aria-selected={selected}
title={label}
onPointerDown={(e) => onPaneDragStart(e, pane)}
onPointerMove={onPaneDragMove}
onPointerUp={onPaneDragEnd}
onPointerCancel={onPaneDragCancel}
onLostPointerCapture={onPaneDragCancel}
className={`flex shrink-0 items-center gap-1 rounded-sm border px-2 py-0.5 whitespace-nowrap ${
reorderable ? "cursor-grab touch-none" : ""
} ${draggingPane === pane ? "opacity-60" : ""} ${
dragOverPane === pane ? "ring-1 ring-inset ring-accent" : ""
} ${
selected
? "border-accent bg-ink-950 text-ink-50"
: "border-ink-700 text-ink-400 hover:text-ink-200"
}`}
>
<span>{truncateCells(label, TAB_TITLE_MAX_CELLS)}</span>
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onClose(pane);
}}
title="Close terminal"
aria-label={`close terminal ${index + 1}`}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ink-400 hover:text-removed"
>
<XIcon />
</button>
</div>
);
})}
</div>
);
}
24 changes: 23 additions & 1 deletion viewer-ui/src/components/terminal/PanelToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import type { ReactNode } from "react";
import { PlusIcon } from "../icons/actions";
import { FitScreenIcon, MaximizeIcon } from "../icons/layout";
import { FitScreenIcon, MaximizeIcon, SplitViewIcon, TabViewIcon } from "../icons/layout";
import { RecoveryChip } from "./RecoveryChip";
import { orphanRecovery, type RecoveryByPane } from "../../lib/recovery";
import type { PaneViewMode } from "../../lib/paneViewMode";

export interface PanelToolbarProps {
mode: PaneViewMode;
onToggleMode: () => void;
/** The tab strip, in tabs mode. It shares this row so `+` reads as "add a
* tab" rather than "split the panel again". */
tabs?: ReactNode;
/** Whether this page's layout is what sets the pane sizes. When it is not,
* the button that takes the sizing back appears. */
ownsSize: boolean;
Expand All @@ -23,6 +30,9 @@ export interface PanelToolbarProps {
* first, so the row stays right-aligned whether or not the sizing one is
* showing. */
export function PanelToolbar({
mode,
onToggleMode,
tabs,
ownsSize,
maximized,
recovery,
Expand All @@ -44,6 +54,7 @@ export function PanelToolbar({
onCancel={() => onCancelRecovery(pane)}
/>
))}
{tabs}
{!ownsSize && (
<button
onClick={onClaimSize}
Expand All @@ -62,6 +73,17 @@ export function PanelToolbar({
>
<PlusIcon />
</button>
<button
onClick={onToggleMode}
aria-pressed={mode === "tabs"}
title={mode === "tabs" ? "Show the panes side by side" : "Show one pane per tab"}
aria-label={
mode === "tabs" ? "Show the panes side by side" : "Show one pane per tab"
}
className={button}
>
{mode === "tabs" ? <SplitViewIcon /> : <TabViewIcon />}
</button>
<button
onClick={onToggleMaximized}
aria-pressed={maximized}
Expand Down
Loading