8000 feat(tabs): shell picker in caret dropdown + WSL home fix + dropdown overflow fixes by egertaia · Pull Request #43 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

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/main/pty-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ export class PtyManager {
args = ['/K', script];
} else if (shellType === 'wsl') {
env.WMUX_INTEGRATION = '1';
// Start in Linux home instead of the Windows CWD (which WSL would mount as /mnt/c/...)
args = ['--cd', '~'];
}

const spawnOptions: pty.IWindowsPtyForkOptions = {
Expand Down
18 changes: 17 additions & 1 deletion src/renderer/components/SplitPane/PaneWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { PaneId, SplitNode, SurfaceId, WorkspaceId, QuickLaunchProfile } from '../../../shared/types';
import { PaneId, SplitNode, SurfaceId, WorkspaceId, QuickLaunchProfile, ShellInfo } from '../../../shared/types';
import { removeLeaf, splitNode } from '../../store/split-utils';
import TerminalPane from '../Terminal/TerminalPane';
import BrowserPane from '../Browser/BrowserPane';
Expand Down Expand Up @@ -35,6 +35,7 @@ export default function PaneWrapper({ leaf, workspaceId, isFocused }: PaneWrappe
const workspace = useStore((s) => s.workspaces.find(w => w.id === workspaceId));
const globalProfiles = useStore((s) => s.quickLaunchProfiles);
const [projectProfiles, setProjectProfiles] = useState<QuickLaunchProfile[]>([]);
const [availableShells, setAvailableShells] = useState<ShellInfo[]>([]);

const surfaceIds = useMemo(() => surfaces.map((s) => s.id), [surfaces]);

Expand Down Expand Up @@ -232,6 +233,19 @@ export default function PaneWrapper({ leaf, workspaceId, isFocused }: PaneWrappe
return () => { cancelled = true; };
}, [workspace?.cwd]);

// Fetch available shells once on mount for the shell picker dropdown
useEffect(() => {
window.wmux?.system?.getShells?.()
.then((shells: ShellInfo[]) => setAvailableShells(Array.isArray(shells) ? shells : []))
.catch(() => {});
}, []);

const handleNewSurfaceShell = (shell: ShellInfo) => {
if (activeWorkspaceId) {
addSurface(activeWorkspaceId, paneId, 'terminal', { shell: shell.command });
}
};

const quickLaunchProfiles = useMemo(
() => [
...globalProfiles.map((p) => ({ ...p, source: 'global' as const })),
Expand Down Expand Up @@ -371,6 +385,8 @@ export default function PaneWrapper({ leaf, workspaceId, isFocused }: PaneWrappe
>
>
>
shells={availableShells}
>
profiles={quickLaunchProfiles}
>
>
Expand Down
233 changes: 163 additions & 70 deletions src/renderer/components/SplitPane/SurfaceTabBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { SurfaceRef, SurfaceId, PaneId, WorkspaceId, QuickLaunchProfile } from '../../../shared/types';
import { createPortal } from 'react-dom';
import { SurfaceRef, SurfaceId, PaneId, WorkspaceId, QuickLaunchProfile, ShellInfo } from '../../../shared/types';
import { useStore } from '../../store';
import { IconAdd, IconSplit, IconSplitDown, IconClose, IconCaret } from './icons';

interface SurfaceTabBarProps {
paneId: PaneId;
Expand All @@ -11,6 +13,9 @@ interface SurfaceTabBarProps {
onClose: (surfaceId: SurfaceId) => void;
onNew: () => void;
onNewTyped?: (type: 'terminal' | 'browser' | 'markdown') => void;
/** Detected shells surfaced in the `+` caret dropdown (PR #43). */
shells?: ShellInfo[];
onNewShell?: (shell: ShellInfo) => void;
/** Quick-launch profiles surfaced in the `+` caret dropdown (issue #32). */
profiles?: QuickLaunchProfile[];
onNewProfile?: (profile: QuickLaunchProfile) => void;
Expand Down Expand Up @@ -68,6 +73,8 @@ export default function SurfaceTabBar({
onClose,
onNew,
onNewTyped,
shells,
onNewShell,
profiles,
onNewProfile,
onClosePane,
Expand All @@ -82,9 +89,15 @@ export default function SurfaceTabBar({
const [insertIndex, setInsertIndex] = useState<number | null>(null);
const [renamingId, setRenamingId] = useState<SurfaceId | null>(null);
const [renameValue, setRenameValue] = useState('');
const [newMenuOpen, setNewMenuOpen] = useState(false);
// Which control-cluster dropdown is open, and where to anchor it (issue #34).
// Menus render through a portal to document.body so the tab bar's
// `overflow: hidden` can no longer clip them (the old caret-dropdown bug).
const [openMenu, setOpenMenu] = useState<'new' | 'layout' | null>(null);
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const newMenuRef = useRef<HTMLDivElement>(null);
const newCaretRef = useRef<HTMLButtonElement>(null);
const layoutCaretRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const agentMeta = useStore((state) => state.agentMeta);
const activeWorkspaceId = useStore((state) => state.activeWorkspaceId);
const renameSurface = useStore((state) => state.renameSurface);
Expand Down Expand Up @@ -129,32 +142,64 @@ export default function SurfaceTabBar({
}
}, [renamingId]);

// Close the "new surface" menu on outside click or Escape
// Toggle a portalled dropdown, anchoring it to the trigger caret button.
const toggleMenu = useCallback((menu: 'new' | 'layout', ref: React.RefObject<HTMLButtonElement | null>) => {
if (openMenu === menu) { setOpenMenu(null); return; }
const rect = ref.current?.getBoundingClientRect();
if (rect) {
setMenuPos({ top: rect.bottom + 2, right: Math.max(4, window.innerWidth - rect.right) });
}
setOpenMenu(menu);
}, [openMenu]);

// Close any open dropdown on outside click, Escape, or viewport change.
// Clicks inside the menu or on either caret are ignored (the caret's own
// onClick handles toggling).
useEffect(() => {
if (!newMenuOpen) return;
if (!openMenu) return;
const MouseEvent) => {
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
const t = e.target as Node;
if (menuRef.current?.contains(t)) return;
if (newCaretRef.current?.contains(t)) return;
if (layoutCaretRef.current?.contains(t)) return;
setOpenMenu(null);
};
const KeyboardEvent) => { if (e.key === 'Escape') setNewMenuOpen(false); };
const KeyboardEvent) => { if (e.key === 'Escape') setOpenMenu(null); };
const => setOpenMenu(null);
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
window.addEventListener('resize', onViewportChange);
window.addEventListener('scroll', onViewportChange, true);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('scroll', onViewportChange, true);
};
}, [newMenuOpen]);
}, [openMenu]);

const pickNew = useCallback((type: 'terminal' | 'browser' | 'markdown') => {
setNewMenuOpen(false);
setOpenMenu(null);
if (onNewTyped) onNewTyped(type);
else onNew();
}, [onNewTyped, onNew]);

const pickShell = useCallback((shell: ShellInfo) => {
setOpenMenu(null);
onNewShell?.(shell);
}, [onNewShell]);

const pickProfile = useCallback((profile: QuickLaunchProfile) => {
setNewMenuOpen(false);
setOpenMenu(null);
onNewProfile?.(profile);
}, [onNewProfile]);

const pickSplit = useCallback((dir: 'right' | 'down') => {
setOpenMenu(null);
if (dir === 'right') onSplitRight?.();
else onSplitDown?.();
}, [onSplitRight, onSplitDown]);

// Always show tab bar (even for 1 surface — like browser tabs)
return (
<div
Expand Down Expand Up @@ -271,92 +316,140 @@ export default function SurfaceTabBar({
})}
</div>

<button
className="surface-tab-bar__new-btn"
>
tabIndex={-1}
title="New terminal tab (Ctrl+T)"
>
+
</button>
{onNewTyped && (
<div className="surface-tab-bar__new-menu-wrap" ref={newMenuRef}>
<div className="surface-tab-bar__cluster">
{/* New (split-button): main click = default terminal, caret = type/shell/profile menu */}
<div className="surface-tab-bar__group">
<button
className="surface-tab-bar__new-caret"
class="x x-first x-last">() => setNewMenuOpen((v) => !v)}
className="surface-tab-bar__ctl surface-tab-bar__ctl--new"
class="x x-first x-last">onNew}
tabIndex={-1}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
title="New tab type…"
title="New terminal tab (Ctrl+T)"
>
▾
<IconAdd />
</button>
{newMenuOpen && (
<div className="surface-tab-bar__new-menu" role="menu">
<button role="menuitem" => pickNew('terminal')}>
<span className="surface-tab-bar__new-menu-icon">{surfaceIcon('terminal', false)}</span> Terminal
</button>
{onNewTyped && (
<button
ref={newCaretRef}
className="surface-tab-bar__ctl surface-tab-bar__caret"
=> toggleMenu('new', newCaretRef)}
tabIndex={-1}
aria-haspopup="menu"
aria-expanded={openMenu === 'new'}
title="New tab type…"
>
<IconCaret />
</button>
)}
</div>

{/* Layout (split-button): main click = split right, caret = right/down menu */}
{onSplitRight && (
<div className="surface-tab-bar__group">
<button
className="surface-tab-bar__ctl surface-tab-bar__ctl--layout"
>
tabIndex={-1}
title="Split right (Ctrl+D)"
>
<IconSplit />
</button>
<button
ref={layoutCaretRef}
className="surface-tab-bar__ctl surface-tab-bar__caret"
=> toggleMenu('layout', layoutCaretRef)}
tabIndex={-1}
aria-haspopup="menu"
aria-expanded={openMenu === 'layout'}
title="Split layout…"
>
<IconCaret />
</button>
</div>
)}

{/* Close pane */}
{onClosePane && (
<button
className="surface-tab-bar__ctl surface-tab-bar__ctl--close"
>
tabIndex={-1}
title="Close pane"
>
<IconClose />
</button>
)}
</div>

{openMenu && menuPos && createPortal(
<div
ref={menuRef}
className="surface-tab-menu"
role="menu"
style={{ position: 'fixed', top: menuPos.top, right: menuPos.right }}
>
{openMenu === 'new' ? (
<>
{shells && shells.length > 0 ? (
<>
{shells.map((shell) => (
<button key={shell.command} role="menuitem" => pickShell(shell)}>
<span className="surface-tab-menu__icon">{surfaceIcon('terminal', false)}</span> {shell.name}
</button>
))}
<div className="surface-tab-menu__sep" role="separator" />
</>
) : (
<button role="menuitem" => pickNew('terminal')}>
<span className="surface-tab-menu__icon">{surfaceIcon('terminal', false)}</span> Terminal
</button>
)}
<button role="menuitem" => pickNew('browser')}>
<span className="surface-tab-bar__new-menu-icon">{surfaceIcon('browser', false)}</span> Browser
<span className="surface-tab-menu__icon">{surfaceIcon('browser', false)}</span> Browser
</button>
<button role="menuitem" => pickNew('markdown')}>
<span className="surface-tab-bar__new-menu-icon">{surfaceIcon('markdown', false)}</span> Markdown
<span className="surface-tab-menu__icon">{surfaceIcon('markdown', false)}</span> Markdown
</button>
{profiles && profiles.length > 0 && (
<>
<div className="surface-tab-bar__new-menu-sep" role="separator" />
<div className="surface-tab-menu__sep" role="separator" />
{profiles.map((profile) => (
<button
key={profile.id}
role="menuitem"
className="surface-tab-bar__new-menu-profile"
className="surface-tab-menu__profile"
=> pickProfile(profile)}
title={profile.source === 'project' ? 'Project profile (.wmux.json)' : 'Global profile'}
>
<span className="surface-tab-bar__new-menu-icon">
<span className="surface-tab-menu__icon">
{profile.icon || surfaceIcon(profile.type, false)}
</span>
<span className="surface-tab-bar__new-menu-profile-name">{profile.name}</span>
<span className="surface-tab-menu__profile-name">{profile.name}</span>
{profile.source === 'project' && (
<span className="surface-tab-bar__new-menu-badge">project</span>
<span className="surface-tab-menu__badge">project</span>
)}
</button>
))}
</>
)}
</div>
</>
) : (
<>
<button role="menuitem" => pickSplit('right')}>
<span className="surface-tab-menu__icon"><IconSplit size={15} /></span>
Split right
<span className="surface-tab-menu__kbd">Ctrl+D</span>
</button>
{onSplitDown && (
<button role="menuitem" => pickSplit('down')}>
<span className="surface-tab-menu__icon"><IconSplitDown size={15} /></span>
Split down
<span className="surface-tab-menu__kbd">Ctrl+Shift+D</span>
</button>
)}
</>
)}
</div>
)}
{onSplitRight && (
<button
className="surface-tab-bar__split-btn"
>
tabIndex={-1}
title="Split right (Ctrl+D)"
>
⏐
</button>
)}
{onSplitDown && (
<button
className="surface-tab-bar__split-btn"
>
tabIndex={-1}
title="Split down (Ctrl+Shift+D)"
>
⎯
</button>
)}
{onClosePane && (
<button
className="surface-tab-bar__close-pane-btn"
>
tabIndex={-1}
title="Close pane"
>
×
</button>
</div>,
document.body,
)}
</div>
);
Expand Down
Loading
0