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
|
text would otherwise wrap (e.g. "📡 all off") would grow the
|
||||||
titlebar, shrink .pane-wrap, and reflow every xterm. nowrap stops
|
titlebar, shrink .pane-wrap, and reflow every xterm. nowrap stops
|
||||||
text-wrap inside buttons, flex-shrink:0 stops children from being
|
text-wrap inside buttons, flex-shrink:0 stops children from being
|
||||||
squeezed, height locks the row height, overflow:hidden clips items
|
squeezed, height locks the row height. Overflow is left visible
|
||||||
that don't fit (widen the window to see them). */
|
so dropdown menus below the chips aren't clipped by the bar. */
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.titlebar > * {
|
.titlebar > * {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
@ -35,12 +34,7 @@
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.distros, .presets {
|
.titlebar-chip, .palette-btn {
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.distro-btn, .preset-btn, .palette-btn {
|
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
@ -51,15 +45,21 @@
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.distro-btn:hover, .preset-btn:hover, .palette-btn:hover {
|
.titlebar-chip:hover, .palette-btn:hover {
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
}
|
}
|
||||||
.distro-btn.active {
|
.titlebar-chip.add-pane {
|
||||||
background: #1a3a5c;
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 8px;
|
||||||
color: #cce6ff;
|
color: #cce6ff;
|
||||||
border-color: #2a5a8c;
|
border-color: #2a5a8c;
|
||||||
}
|
}
|
||||||
|
.titlebar-chip.add-pane:hover {
|
||||||
|
background: #1a3a5c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.palette-btn.bcast-all.on {
|
.palette-btn.bcast-all.on {
|
||||||
background: #4a3010;
|
background: #4a3010;
|
||||||
color: #f0c060;
|
color: #f0c060;
|
||||||
|
|
@ -74,14 +74,6 @@
|
||||||
color: #80e080;
|
color: #80e080;
|
||||||
border-color: #2a6a2a;
|
border-color: #2a6a2a;
|
||||||
}
|
}
|
||||||
.preset-btn {
|
|
||||||
min-width: 28px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.muted {
|
|
||||||
color: #666;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.layout-info {
|
.layout-info {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
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 [ready, setReady] = useState(false);
|
||||||
const [notifications, setNotifications] = useState<Toast[]>([]);
|
const [notifications, setNotifications] = useState<Toast[]>([]);
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
const [defaultShellMenuOpen, setDefaultShellMenuOpen] = useState(false);
|
||||||
|
const [layoutMenuOpen, setLayoutMenuOpen] = useState(false);
|
||||||
|
|
||||||
// ---- non-reactive lookups -----------------------------------------------
|
// ---- non-reactive lookups -----------------------------------------------
|
||||||
const paneIdByLeafRef = useRef<Map<NodeId, PaneId>>(new Map());
|
const paneIdByLeafRef = useRef<Map<NodeId, PaneId>>(new Map());
|
||||||
|
|
@ -249,6 +251,36 @@ export default function App() {
|
||||||
[defaultShell, notify],
|
[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(
|
const close = useCallback(
|
||||||
(leafId: NodeId) => {
|
(leafId: NodeId) => {
|
||||||
const paneId = paneIdByLeafRef.current.get(leafId);
|
const paneId = paneIdByLeafRef.current.get(leafId);
|
||||||
|
|
@ -339,6 +371,21 @@ export default function App() {
|
||||||
|
|
||||||
const openHostManager = useCallback(() => setHostManagerOpen(true), []);
|
const openHostManager = useCallback(() => setHostManagerOpen(true), []);
|
||||||
const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
|
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(
|
const saveHosts = useCallback(
|
||||||
(next: SshHost[]) => {
|
(next: SshHost[]) => {
|
||||||
// Preserve hasPassword flags that aren't included in the payload from
|
// Preserve hasPassword flags that aren't included in the payload from
|
||||||
|
|
@ -742,56 +789,155 @@ export default function App() {
|
||||||
setPaletteOpen(false);
|
setPaletteOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Titlebar default-shell picker: WSL distros + a single PowerShell button.
|
// Label shown on the default-shell chip — current selection at a glance.
|
||||||
// SSH never lives here — connections are always per-pane and explicit.
|
|
||||||
const isDefaultDistro = (d: string) =>
|
const isDefaultDistro = (d: string) =>
|
||||||
defaultShell.shellKind === "wsl" && defaultShell.distro === d;
|
defaultShell.shellKind === "wsl" && defaultShell.distro === d;
|
||||||
const isDefaultPowershell = defaultShell.shellKind === "powershell";
|
const isDefaultPowershell = defaultShell.shellKind === "powershell";
|
||||||
|
const defaultShellLabel =
|
||||||
|
defaultShell.shellKind === "powershell"
|
||||||
|
? "PowerShell"
|
||||||
|
: (defaultShell.distro ?? "(none)");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="titlebar">
|
<header className="titlebar">
|
||||||
<span className="label">tiletopia</span>
|
<span className="label">tiletopia</span>
|
||||||
|
|
||||||
<span className="distros">
|
<span className="distro-wrap">
|
||||||
<span className="muted">default:</span>
|
<button
|
||||||
{distros.length === 0 ? (
|
className="titlebar-chip"
|
||||||
<span className="muted">no WSL distros</span>
|
onClick={(e) => {
|
||||||
) : (
|
e.stopPropagation();
|
||||||
distros.map((d) => (
|
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
|
<button
|
||||||
key={d}
|
className={`distro-menu-item${isDefaultPowershell ? " active" : ""}`}
|
||||||
className={`distro-btn${isDefaultDistro(d) ? " active" : ""}`}
|
onClick={() => {
|
||||||
onClick={() => setDefaultShell({ shellKind: "wsl", distro: d })}
|
setDefaultShell({ shellKind: "powershell" });
|
||||||
title="Set default shell for new panes"
|
setDefaultShellMenuOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{d}
|
PowerShell
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<span className="presets">
|
<button
|
||||||
<span className="muted">layout:</span>
|
className="titlebar-chip add-pane"
|
||||||
<button className="preset-btn" title="Single pane" onClick={() => applyPreset(presetSingle)}>1</button>
|
onClick={addPane}
|
||||||
<button className="preset-btn" title="Two columns" onClick={() => applyPreset(presetTwoColumns)}>2H</button>
|
title={`Spawn a new ${defaultShellLabel} pane (splits the active pane)`}
|
||||||
<button className="preset-btn" title="Three columns" onClick={() => applyPreset(presetThreeColumns)}>3H</button>
|
aria-label="Add pane"
|
||||||
<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>
|
||||||
|
|
||||||
|
<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>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -815,13 +961,6 @@ export default function App() {
|
||||||
>
|
>
|
||||||
⌘K
|
⌘K
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="palette-btn"
|
|
||||||
onClick={() => notify("test toast at " + new Date().toLocaleTimeString())}
|
|
||||||
title="Fire a test toast"
|
|
||||||
>
|
|
||||||
🔔
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className={`palette-btn mcp-btn${mcpStatus.running ? " on" : ""}`}
|
className={`palette-btn mcp-btn${mcpStatus.running ? " on" : ""}`}
|
||||||
onClick={() => setMcpPanelOpen(true)}
|
onClick={() => setMcpPanelOpen(true)}
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,11 @@
|
||||||
height: 24px;
|
height: 24px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
/* Lock height: a narrow pane used to wrap toolbar items to 2+ rows,
|
/* Lock height: a narrow pane used to wrap toolbar items to 2+ rows,
|
||||||
which shrank the xterm beneath and reflowed the terminal. Clip
|
which shrank the xterm beneath and reflowed the terminal. nowrap +
|
||||||
overflow instead of showing a scrollbar (which would itself eat
|
flex-shrink:0 keeps items at natural width on one row; overflow is
|
||||||
into the 24px and crush the buttons). */
|
left visible so the shell-picker dropdown (rendered below the
|
||||||
|
toolbar) isn't clipped. */
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.pane-toolbar > * {
|
.pane-toolbar > * {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue