Merge pull request #75975 from NousResearch/bb/terminal-session-link
Link terminal tabs to the session you're working in
This commit is contained in:
@@ -33,4 +33,20 @@ describe('TerminalRail', () => {
|
||||
expect(label?.classList.contains('inline-flex')).toBe(true)
|
||||
expect(label?.classList.contains('flex')).toBe(false)
|
||||
})
|
||||
|
||||
it('⌘-click closes the tab; a plain click selects it', () => {
|
||||
$terminals.set([
|
||||
...$terminals.get(),
|
||||
{ auto: true, cwd: 'C:\\repo', id: 'term-2', kind: 'user', title: 'zsh' }
|
||||
])
|
||||
|
||||
render(<TerminalRail />)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '2. zsh' }), { metaKey: true })
|
||||
expect($terminals.get().map(term => term.id)).toEqual(['term-1'])
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '1. PowerShell' }))
|
||||
expect($activeTerminalId.get()).toBe('term-1')
|
||||
expect($terminals.get()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { Tip, TipHintLabel } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { middleClickHandlers } from '@/lib/middle-click'
|
||||
import { isMetaClose, middleClickHandlers } from '@/lib/middle-click'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
|
||||
@@ -132,7 +132,8 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T
|
||||
: 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
{...middleClickHandlers(() => closeTerminal(term.id))}
|
||||
onClick={() => selectTerminal(term.id)}
|
||||
// ⌘-click closes (the pane-tab gesture); a plain click selects.
|
||||
onClick={event => (isMetaClose(event) ? closeTerminal(term.id) : selectTerminal(term.id))}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -4,11 +4,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const STORAGE_KEY = 'hermes.desktop.terminals.v1'
|
||||
|
||||
async function loadTerminalStore() {
|
||||
const $currentCwd = atom('/workspace')
|
||||
|
||||
vi.doMock('@/store/session', () => ({
|
||||
$currentCwd: atom('/workspace')
|
||||
$currentCwd
|
||||
}))
|
||||
|
||||
return import('./terminals')
|
||||
return { ...(await import('./terminals')), $currentCwd }
|
||||
}
|
||||
|
||||
describe('terminal store persistence', () => {
|
||||
@@ -121,3 +123,68 @@ describe('terminal store persistence', () => {
|
||||
expect($terminals.get().find(term => term.id === userId)?.restoreCwd).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session cwd → terminal tab linking', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('re-selects the tab already pointed at the new session cwd (trailing slash tolerated)', async () => {
|
||||
const { $activeTerminalId, $currentCwd, createTerminal } = await loadTerminalStore()
|
||||
|
||||
const repoTab = createTerminal('/repo')
|
||||
const otherTab = createTerminal('/elsewhere')
|
||||
expect($activeTerminalId.get()).toBe(otherTab)
|
||||
|
||||
$currentCwd.set('/repo/')
|
||||
expect($activeTerminalId.get()).toBe(repoTab)
|
||||
})
|
||||
|
||||
it('matches the live shell cwd (restoreCwd) over the launch dir', async () => {
|
||||
const { $activeTerminalId, $currentCwd, createTerminal, updateTerminalRestoreCwd } = await loadTerminalStore()
|
||||
|
||||
const movedTab = createTerminal('/repo')
|
||||
updateTerminalRestoreCwd(movedTab, '/repo/packages/api')
|
||||
const otherTab = createTerminal('/elsewhere')
|
||||
expect($activeTerminalId.get()).toBe(otherTab)
|
||||
|
||||
$currentCwd.set('/repo/packages/api')
|
||||
expect($activeTerminalId.get()).toBe(movedTab)
|
||||
|
||||
// The launch dir no longer describes where that shell lives.
|
||||
$currentCwd.set('/repo')
|
||||
expect($activeTerminalId.get()).toBe(movedTab)
|
||||
})
|
||||
|
||||
it('leaves the active tab alone when no tab lives in the session cwd or the cwd is empty', async () => {
|
||||
const { $activeTerminalId, $currentCwd, createTerminal } = await loadTerminalStore()
|
||||
|
||||
createTerminal('/repo')
|
||||
const activeTab = createTerminal('/elsewhere')
|
||||
|
||||
$currentCwd.set('/unrelated')
|
||||
expect($activeTerminalId.get()).toBe(activeTab)
|
||||
|
||||
$currentCwd.set('')
|
||||
expect($activeTerminalId.get()).toBe(activeTab)
|
||||
})
|
||||
|
||||
it('stays put when the active tab already lives in the target cwd, and never matches agent tabs', async () => {
|
||||
const { $activeTerminalId, $currentCwd, createTerminal, ensureAgentTerminal, selectTerminal } =
|
||||
await loadTerminalStore()
|
||||
|
||||
const first = createTerminal('/repo')
|
||||
const second = createTerminal('/repo')
|
||||
ensureAgentTerminal('proc-1', 'background task')
|
||||
selectTerminal(second)
|
||||
|
||||
// Both tabs match; the one already active keeps focus (no first-match steal).
|
||||
$currentCwd.set('/repo')
|
||||
expect($activeTerminalId.get()).toBe(second)
|
||||
|
||||
selectTerminal(first)
|
||||
$currentCwd.set('/repo')
|
||||
expect($activeTerminalId.get()).toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,8 @@ export interface TerminalEntry {
|
||||
/** Working directory, snapshotted once at creation. Terminals live outside
|
||||
* session/project state — the only thing they inherit is this initial cwd
|
||||
* (the project root if opened in one, else the backend's default). Switching
|
||||
* sessions never moves or recreates a terminal. */
|
||||
* sessions never moves or recreates a terminal; at most it re-SELECTS a tab
|
||||
* already pointed at the session's cwd (see the $currentCwd listener). */
|
||||
cwd: string
|
||||
/** Last observed working directory of the live shell (tracked via the PTY
|
||||
* cwd probe / OSC 7). Used to reopen the tab where the user last `cd`'d
|
||||
@@ -223,6 +224,45 @@ export function selectTerminal(id: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Compare-ready form of a directory path: trimmed, trailing separators dropped
|
||||
// (keeping a bare root intact) so `/repo/` and `/repo` are the same place.
|
||||
const normalizePath = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
|
||||
return trimmed.length > 1 ? trimmed.replace(/[\\/]+$/, '') || trimmed : trimmed
|
||||
}
|
||||
|
||||
/** The directory a tab points at right now — the live shell cwd once observed
|
||||
* (survives a `cd`), else the launch dir. */
|
||||
const terminalCwd = (term: TerminalEntry) => normalizePath(term.restoreCwd || term.cwd)
|
||||
|
||||
// Session ↔ terminal linking. Entering a session whose cwd already has a user
|
||||
// terminal pointed at it re-selects that tab, so the terminal pane follows the
|
||||
// workspace you're in. Selection ONLY — it never creates a shell, never closes
|
||||
// one, and never reveals the pane; a detached session (empty cwd) or a cwd no
|
||||
// tab lives in leaves the tabs exactly where they were. `listen` (not
|
||||
// `subscribe`) so boot keeps the persisted active tab.
|
||||
$currentCwd.listen(cwd => {
|
||||
const target = normalizePath(cwd)
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
const list = $terminals.get()
|
||||
const active = list.find(term => term.id === $activeTerminalId.get())
|
||||
|
||||
if (active?.kind === 'user' && terminalCwd(active) === target) {
|
||||
return
|
||||
}
|
||||
|
||||
const match = list.find(term => term.kind === 'user' && terminalCwd(term) === target)
|
||||
|
||||
if (match) {
|
||||
$activeTerminalId.set(match.id)
|
||||
}
|
||||
})
|
||||
|
||||
/** Move the active tab by `direction` (+1 next / -1 prev), wrapping around. */
|
||||
export function cycleTerminal(direction: 1 | -1): void {
|
||||
const list = $terminals.get()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { middleClickHandlers } from '@/lib/middle-click'
|
||||
import { isMetaClose, middleClickHandlers } from '@/lib/middle-click'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Inset stroke for a vertical tab rail — content-facing edge. */
|
||||
@@ -43,12 +43,6 @@ interface PaneTabProps extends React.ComponentProps<'div'> {
|
||||
side?: 'left' | 'right'
|
||||
}
|
||||
|
||||
/** ⌘-click (metaKey + primary button) — the Mac has no middle button, so this
|
||||
* is the trackpad equivalent of middle-click-to-close. Guarded on metaKey so
|
||||
* it never collides with left-click (activate/drag) or ⌃-click (macOS context
|
||||
* menu). */
|
||||
const isMetaClose = (event: { button: number; metaKey: boolean }) => event.button === 0 && event.metaKey
|
||||
|
||||
/**
|
||||
* Editor tab shell — preview rail + zone headers + collapsed vertical rails.
|
||||
*
|
||||
|
||||
@@ -3,6 +3,12 @@ import type * as React from 'react'
|
||||
/** `MouseEvent.button` for the middle (wheel) button. */
|
||||
const MIDDLE_BUTTON = 1
|
||||
|
||||
/** ⌘-click (metaKey + primary button) — the Mac has no middle button, so this
|
||||
* is the trackpad equivalent of middle-click-to-close. Guarded on metaKey so
|
||||
* it never collides with left-click (activate/drag) or ⌃-click (macOS context
|
||||
* menu). */
|
||||
export const isMetaClose = (event: { button: number; metaKey: boolean }) => event.button === 0 && event.metaKey
|
||||
|
||||
/** Where the current middle press started. One pointer holds one button, so a
|
||||
* single slot is the whole state, and it's only ever compared by identity in
|
||||
* the pointerup right after — a value left behind by a press released
|
||||
|
||||
Reference in New Issue
Block a user