Tidy titlebar: dropdowns for shell + layout, '+' button to spawn
- Collapse the inline distro buttons + PowerShell + 🔑 SSH hosts into a single 'Ubuntu ▾' dropdown (WSL distros + PowerShell sections), with 🔑 as a separate icon-only button. - Collapse the 5 preset buttons into a 'layout ▾' dropdown. - Add a '+' button next to the shell picker that spawns a new pane of the picked shell by splitting the active pane (smart orientation: splits right if wide, down if tall). Per-pane ⇥/⇣ arrows still inherit from parent — only '+' uses the titlebar selection. - Drop the 🔔 test-toast button. - Drop overflow:hidden from titlebar + pane toolbar so dropdowns aren't clipped; height lock + nowrap still prevent the reflow bug. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e46446444e
commit
fa18307fd9
3 changed files with 197 additions and 66 deletions
32
src/App.css
32
src/App.css
|
|
@ -20,12 +20,11 @@
|
|||
text would otherwise wrap (e.g. "📡 all off") would grow the
|
||||
titlebar, shrink .pane-wrap, and reflow every xterm. nowrap stops
|
||||
text-wrap inside buttons, flex-shrink:0 stops children from being
|
||||
squeezed, height locks the row height, overflow:hidden clips items
|
||||
that don't fit (widen the window to see them). */
|
||||
squeezed, height locks the row height. Overflow is left visible
|
||||
so dropdown menus below the chips aren't clipped by the bar. */
|
||||
white-space: nowrap;
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
.titlebar > * {
|
||||
flex-shrink: 0;
|
||||
|
|
@ -35,12 +34,7 @@
|
|||
color: #ddd;
|
||||
}
|
||||
|
||||
.distros, .presets {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.distro-btn, .preset-btn, .palette-btn {
|
||||
.titlebar-chip, .palette-btn {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 11px;
|
||||
|
|
@ -51,15 +45,21 @@
|
|||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.distro-btn:hover, .preset-btn:hover, .palette-btn:hover {
|
||||
.titlebar-chip:hover, .palette-btn:hover {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
}
|
||||
.distro-btn.active {
|
||||
background: #1a3a5c;
|
||||
.titlebar-chip.add-pane {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 2px 8px;
|
||||
color: #cce6ff;
|
||||
border-color: #2a5a8c;
|
||||
}
|
||||
.titlebar-chip.add-pane:hover {
|
||||
background: #1a3a5c;
|
||||
color: #fff;
|
||||
}
|
||||
.palette-btn.bcast-all.on {
|
||||
background: #4a3010;
|
||||
color: #f0c060;
|
||||
|
|
@ -74,14 +74,6 @@
|
|||
color: #80e080;
|
||||
border-color: #2a6a2a;
|
||||
}
|
||||
.preset-btn {
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.muted {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
.layout-info {
|
||||
margin-left: auto;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
|
|
|
|||
223
src/App.tsx
223
src/App.tsx
|
|
@ -105,6 +105,8 @@ export default function App() {
|
|||
const [ready, setReady] = useState(false);
|
||||
const [notifications, setNotifications] = useState<Toast[]>([]);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [defaultShellMenuOpen, setDefaultShellMenuOpen] = useState(false);
|
||||
const [layoutMenuOpen, setLayoutMenuOpen] = useState(false);
|
||||
|
||||
// ---- non-reactive lookups -----------------------------------------------
|
||||
const paneIdByLeafRef = useRef<Map<NodeId, PaneId>>(new Map());
|
||||
|
|
@ -249,6 +251,36 @@ export default function App() {
|
|||
[defaultShell, notify],
|
||||
);
|
||||
|
||||
// Titlebar "+" — spawn a fresh pane of the picked shell, regardless of
|
||||
// what the active pane is running. Splits off the active pane in
|
||||
// whichever orientation leaves the new sibling closer to square.
|
||||
const addPane = useCallback(() => {
|
||||
const container = paneWrapRef.current;
|
||||
const layout = flattenLayout(treeRef.current);
|
||||
const targetId = activeLeafId ?? layout.leaves[0]?.leaf.id ?? null;
|
||||
if (!targetId || !container) return;
|
||||
const slot = layout.leaves.find((s) => s.leaf.id === targetId);
|
||||
if (!slot) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const paneW = slot.box.width * rect.width;
|
||||
const paneH = slot.box.height * rect.height;
|
||||
// Split along the longer side so both halves stay close to square.
|
||||
const orientation: Orientation = paneW >= paneH ? "h" : "v";
|
||||
const childW = orientation === "h" ? paneW / 2 : paneW;
|
||||
const childH = orientation === "v" ? paneH / 2 : paneH;
|
||||
if (childW < MIN_PANE_PX || childH < MIN_PANE_PX) {
|
||||
notify(
|
||||
`Pane too small to split — would create ${Math.round(childW)}×${Math.round(childH)}px (min ${MIN_PANE_PX}px)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setTree((t) =>
|
||||
splitLeaf(t, targetId, orientation, defaultShellAsLeafProps(defaultShell)),
|
||||
);
|
||||
}, [activeLeafId, defaultShell, notify]);
|
||||
|
||||
const close = useCallback(
|
||||
(leafId: NodeId) => {
|
||||
const paneId = paneIdByLeafRef.current.get(leafId);
|
||||
|
|
@ -339,6 +371,21 @@ export default function App() {
|
|||
|
||||
const openHostManager = useCallback(() => setHostManagerOpen(true), []);
|
||||
const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
|
||||
|
||||
// Outside-click dismissal for the titlebar dropdowns. Mirrors the
|
||||
// per-pane shell-picker pattern in LeafPane.tsx.
|
||||
useEffect(() => {
|
||||
if (!defaultShellMenuOpen) return;
|
||||
const onDocClick = () => setDefaultShellMenuOpen(false);
|
||||
window.addEventListener("click", onDocClick);
|
||||
return () => window.removeEventListener("click", onDocClick);
|
||||
}, [defaultShellMenuOpen]);
|
||||
useEffect(() => {
|
||||
if (!layoutMenuOpen) return;
|
||||
const onDocClick = () => setLayoutMenuOpen(false);
|
||||
window.addEventListener("click", onDocClick);
|
||||
return () => window.removeEventListener("click", onDocClick);
|
||||
}, [layoutMenuOpen]);
|
||||
const saveHosts = useCallback(
|
||||
(next: SshHost[]) => {
|
||||
// Preserve hasPassword flags that aren't included in the payload from
|
||||
|
|
@ -742,56 +789,155 @@ export default function App() {
|
|||
setPaletteOpen(false);
|
||||
}, []);
|
||||
|
||||
// Titlebar default-shell picker: WSL distros + a single PowerShell button.
|
||||
// SSH never lives here — connections are always per-pane and explicit.
|
||||
// Label shown on the default-shell chip — current selection at a glance.
|
||||
const isDefaultDistro = (d: string) =>
|
||||
defaultShell.shellKind === "wsl" && defaultShell.distro === d;
|
||||
const isDefaultPowershell = defaultShell.shellKind === "powershell";
|
||||
const defaultShellLabel =
|
||||
defaultShell.shellKind === "powershell"
|
||||
? "PowerShell"
|
||||
: (defaultShell.distro ?? "(none)");
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="titlebar">
|
||||
<span className="label">tiletopia</span>
|
||||
|
||||
<span className="distros">
|
||||
<span className="muted">default:</span>
|
||||
{distros.length === 0 ? (
|
||||
<span className="muted">no WSL distros</span>
|
||||
) : (
|
||||
distros.map((d) => (
|
||||
<span className="distro-wrap">
|
||||
<button
|
||||
className="titlebar-chip"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDefaultShellMenuOpen((v) => !v);
|
||||
}}
|
||||
title="Shell that '+' will spawn (also the boot default)"
|
||||
>
|
||||
{defaultShellLabel} ▾
|
||||
</button>
|
||||
{defaultShellMenuOpen && (
|
||||
<div
|
||||
className="distro-menu shell-menu"
|
||||
role="menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{distros.length > 0 ? (
|
||||
<>
|
||||
<div className="shell-menu-header">WSL</div>
|
||||
{distros.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
className={`distro-menu-item${isDefaultDistro(d) ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setDefaultShell({ shellKind: "wsl", distro: d });
|
||||
setDefaultShellMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="shell-menu-header">WSL</div>
|
||||
<div className="shell-menu-empty">(no distros)</div>
|
||||
</>
|
||||
)}
|
||||
<div className="shell-menu-header">Windows</div>
|
||||
<button
|
||||
key={d}
|
||||
className={`distro-btn${isDefaultDistro(d) ? " active" : ""}`}
|
||||
onClick={() => setDefaultShell({ shellKind: "wsl", distro: d })}
|
||||
title="Set default shell for new panes"
|
||||
className={`distro-menu-item${isDefaultPowershell ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setDefaultShell({ shellKind: "powershell" });
|
||||
setDefaultShellMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
PowerShell
|
||||
</button>
|
||||
))
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={`distro-btn${isDefaultPowershell ? " active" : ""}`}
|
||||
onClick={() => setDefaultShell({ shellKind: "powershell" })}
|
||||
title="Default new panes to PowerShell"
|
||||
>
|
||||
PowerShell
|
||||
</button>
|
||||
<button
|
||||
className="distro-btn"
|
||||
onClick={openHostManager}
|
||||
title="Add, edit, or remove saved SSH hosts"
|
||||
>
|
||||
🔑 SSH hosts
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<span className="presets">
|
||||
<span className="muted">layout:</span>
|
||||
<button className="preset-btn" title="Single pane" onClick={() => applyPreset(presetSingle)}>1</button>
|
||||
<button className="preset-btn" title="Two columns" onClick={() => applyPreset(presetTwoColumns)}>2H</button>
|
||||
<button className="preset-btn" title="Three columns" onClick={() => applyPreset(presetThreeColumns)}>3H</button>
|
||||
<button className="preset-btn" title="Two rows" onClick={() => applyPreset(presetTwoRows)}>2V</button>
|
||||
<button className="preset-btn" title="2 × 2 grid" onClick={() => applyPreset(presetTwoByTwo)}>2×2</button>
|
||||
<button
|
||||
className="titlebar-chip add-pane"
|
||||
onClick={addPane}
|
||||
title={`Spawn a new ${defaultShellLabel} pane (splits the active pane)`}
|
||||
aria-label="Add pane"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="titlebar-chip"
|
||||
onClick={openHostManager}
|
||||
title="Add, edit, or remove saved SSH hosts"
|
||||
aria-label="Manage SSH hosts"
|
||||
>
|
||||
🔑
|
||||
</button>
|
||||
|
||||
<span className="distro-wrap">
|
||||
<button
|
||||
className="titlebar-chip"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLayoutMenuOpen((v) => !v);
|
||||
}}
|
||||
title="Apply a preset layout (replaces current panes)"
|
||||
>
|
||||
layout ▾
|
||||
</button>
|
||||
{layoutMenuOpen && (
|
||||
<div
|
||||
className="distro-menu shell-menu"
|
||||
role="menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="distro-menu-item"
|
||||
onClick={() => {
|
||||
applyPreset(presetSingle);
|
||||
setLayoutMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
Single pane
|
||||
</button>
|
||||
<button
|
||||
className="distro-menu-item"
|
||||
onClick={() => {
|
||||
applyPreset(presetTwoColumns);
|
||||
setLayoutMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
Two columns
|
||||
</button>
|
||||
<button
|
||||
className="distro-menu-item"
|
||||
onClick={() => {
|
||||
applyPreset(presetThreeColumns);
|
||||
setLayoutMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
Three columns
|
||||
</button>
|
||||
<button
|
||||
className="distro-menu-item"
|
||||
onClick={() => {
|
||||
applyPreset(presetTwoRows);
|
||||
setLayoutMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
Two rows
|
||||
</button>
|
||||
<button
|
||||
className="distro-menu-item"
|
||||
onClick={() => {
|
||||
applyPreset(presetTwoByTwo);
|
||||
setLayoutMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
2 × 2 grid
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
|
|
@ -815,13 +961,6 @@ export default function App() {
|
|||
>
|
||||
⌘K
|
||||
</button>
|
||||
<button
|
||||
className="palette-btn"
|
||||
onClick={() => notify("test toast at " + new Date().toLocaleTimeString())}
|
||||
title="Fire a test toast"
|
||||
>
|
||||
🔔
|
||||
</button>
|
||||
<button
|
||||
className={`palette-btn mcp-btn${mcpStatus.running ? " on" : ""}`}
|
||||
onClick={() => setMcpPanelOpen(true)}
|
||||
|
|
|
|||
|
|
@ -61,11 +61,11 @@
|
|||
height: 24px;
|
||||
box-sizing: border-box;
|
||||
/* Lock height: a narrow pane used to wrap toolbar items to 2+ rows,
|
||||
which shrank the xterm beneath and reflowed the terminal. Clip
|
||||
overflow instead of showing a scrollbar (which would itself eat
|
||||
into the 24px and crush the buttons). */
|
||||
which shrank the xterm beneath and reflowed the terminal. nowrap +
|
||||
flex-shrink:0 keeps items at natural width on one row; overflow is
|
||||
left visible so the shell-picker dropdown (rendered below the
|
||||
toolbar) isn't clipped. */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pane-toolbar > * {
|
||||
flex-shrink: 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue