Skip to content
Open
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
24 changes: 2 additions & 22 deletions web/src/components/session-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { GripVerticalIcon } from 'lucide-react'

import { SessionPreview } from '@/components/session-preview'
import { TagBadges } from '@/components/tag-badges'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
Expand Down Expand Up @@ -170,14 +171,6 @@ function StateDot({ session }: { session: FleetSession }) {
/** The quiet text the trailing details are set in. */
const META_TEXT = 'text-xs/6 whitespace-nowrap text-zinc-500 dark:text-zinc-400'

/**
* How many tag badges a row shows before folding the rest into a "+n". A row
* is one line that must never push the pane sideways — the old layout's
* overflow-x wrapper died with it, so this cap is what holds the line now —
* and the folded remainder rides in the +n badge's tooltip.
*/
const TAG_CAP = 3

/**
* One session, one row: a single line with the identity on the left, the
* details ranged right, and the whole of it one link.
Expand Down Expand Up @@ -370,20 +363,7 @@ function SessionRow({
<div className="flex shrink-0 items-center gap-x-2.5">
{shown.includes('tags') && s.tags.length > 0 && (
<span className="flex items-center gap-x-1.5 max-sm:hidden">
{s.tags.slice(0, TAG_CAP).map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
{s.tags.length > TAG_CAP && (
<Badge
variant="secondary"
title={s.tags.slice(TAG_CAP).join(', ')}
className="relative z-10"
>
+{s.tags.length - TAG_CAP}
</Badge>
)}
<TagBadges tags={s.tags} overflowClassName="relative z-10" />
</span>
)}
{shown.includes('machine') && (
Expand Down
46 changes: 46 additions & 0 deletions web/src/components/tag-badges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'

/**
* How many tag badges show before the rest fold into a "+n". A session row
* is one line that must never push the pane sideways, and the terminal's
* corner strip owes the same restraint to the output under it — so the cap
* holds the line on both, and the folded remainder rides in the +n badge's
* tooltip.
*/
export const TAG_CAP = 3

/**
* The capped run of tag badges the session row and the terminal share.
* `className` dresses every badge; `overflowClassName` lands on the +n badge
* alone, which is how the row lifts only the tooltip-holder above its
* stretched link.
*/
export function TagBadges({
tags,
className,
overflowClassName,
}: {
tags: string[]
className?: string
overflowClassName?: string
}) {
return (
<>
{tags.slice(0, TAG_CAP).map((tag) => (
<Badge key={tag} variant="secondary" className={className}>
{tag}
</Badge>
))}
{tags.length > TAG_CAP && (
<Badge
variant="secondary"
title={tags.slice(TAG_CAP).join(', ')}
className={cn(className, overflowClassName)}
>
+{tags.length - TAG_CAP}
</Badge>
)}
</>
)
}
112 changes: 104 additions & 8 deletions web/src/components/terminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ describe('Terminal', () => {
vi.useRealTimers()
})


it('reports the process exiting, with its code, once', () => {
const { sock, em } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)
act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
Expand Down Expand Up @@ -591,6 +592,100 @@ describe('Terminal', () => {
expect(document.title).toBe('vim wire.go')
})

describe('the tag strip', () => {
const strip = () => document.querySelector<HTMLElement>('[data-flue-tags]')

it('leads the control row, where the corner is already spent on chrome', () => {
const { sock } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)
act(() =>
sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api', 'prod'] })] }),
)

expect(screen.getByText('api')).toBeTruthy()
expect(screen.getByText('prod')).toBeTruthy()
// Inline in the top-right control row, not floating over the output.
expect(strip()!.closest('[data-flue-controls]')).toBeTruthy()
expect(strip()!.className).not.toMatch(/\babsolute\b/)
})

it('draws nothing at all for a session without tags', () => {
const { sock } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)
expect(strip()).toBeNull()

act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: [] })] }))
expect(strip()).toBeNull()
})

it('caps the badges and folds the remainder into a +n', () => {
const { sock } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)
act(() =>
sock.emitControl({
type: 'sessions',
sessions: [session({ tags: ['api', 'edge', 'ops', 'prod', 'staging'] })],
}),
)

expect(screen.getByText('api')).toBeTruthy()
expect(screen.getByText('edge')).toBeTruthy()
expect(screen.getByText('ops')).toBeTruthy()
expect(screen.queryByText('prod')).toBeNull()
expect(screen.getByText('+2').getAttribute('title')).toBe('prod, staging')
})

it('follows the tags as the sessions poll moves them, for its own row only', () => {
const { sock } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)
act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] }))
expect(screen.getByText('api')).toBeTruthy()

act(() =>
sock.emitControl({
type: 'sessions',
sessions: [session({ tags: ['api', 'v2'] }), session({ id: 'other', tags: ['ops'] })],
}),
)
expect(screen.getByText('v2')).toBeTruthy()
expect(screen.queryByText('ops')).toBeNull()

act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: [] })] }))
expect(strip()).toBeNull()
})

it('wears the anchor tags in a member pane, where the group is what got tagged', () => {
const { sock } = mountTerminal((e) => <Terminal sessionId="s2" createEmulator={e.create} />)
act(() =>
sock.emitControl({
type: 'sessions',
sessions: [
session({ id: 's1', tags: ['api'] }),
session({ id: 's2', group: 's1', tags: [] }),
],
}),
)

expect(screen.getByText('api')).toBeTruthy()
})

it('sits above the key bar on a coarse pointer, off the first line of output', () => {
coarsePointer()
const { sock } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)
act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] }))

expect(strip()!.className).toMatch(/\bbottom-17\b/)
expect(strip()!.className).toMatch(/\bleft-3\b/)
expect(strip()!.className).not.toMatch(/\btop-3\b/)
})

it('stays out of the minimal chrome, whose surface shows them elsewhere', () => {
const { sock } = mountTerminal((e) => (
<Terminal sessionId="s1" chrome="minimal" createEmulator={e.create} />
))
act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] }))

expect(strip()).toBeNull()
expect(screen.queryByText('api')).toBeNull()
})
})

describe('touch scrolling', () => {
/** Attached at 80x24 with a 17px line, ready to be dragged. */
function mountDraggable() {
Expand Down Expand Up @@ -1594,14 +1689,6 @@ describe('Terminal', () => {
})

describe('the key bar', () => {
/** jsdom has no matchMedia; a coarse pointer is claimed explicitly. */
function coarsePointer() {
vi.stubGlobal('matchMedia', (query: string) => ({
matches: query.includes('coarse'),
addEventListener: () => {},
removeEventListener: () => {},
}))
}
const bar = () => document.querySelector<HTMLElement>('[data-flue-keybar]')
const key = (label: string) =>
Array.from(document.querySelectorAll<HTMLButtonElement>('[data-flue-keybar] button')).find(
Expand Down Expand Up @@ -1754,6 +1841,15 @@ describe('Terminal', () => {
})

/** A complete SessionInfo, so a caller only names what it cares about. */
/** jsdom has no matchMedia; a coarse pointer is claimed explicitly. */
function coarsePointer() {
vi.stubGlobal('matchMedia', (query: string) => ({
matches: query.includes('coarse'),
addEventListener: () => {},
removeEventListener: () => {},
}))
}

function session(over: Partial<SessionInfo> = {}): SessionInfo {
return {
id: 's1',
Expand Down
52 changes: 51 additions & 1 deletion web/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { KeyBar } from '@/components/key-bar'
import { PasteBox } from '@/components/paste-box'
import { SelectionMenu, type MenuEnd } from '@/components/selection-menu'
import { ShortcutsHelp } from '@/components/shortcuts-help'
import { TagBadges } from '@/components/tag-badges'
import { ThemeMenu } from '@/components/theme-menu'
import { DARK_SCHEME_QUERY, prefersDark } from '@/emulator/palette'
import { controlColors, resolveTheme, THEME_SYSTEM } from '@/emulator/themes'
Expand All @@ -23,6 +24,7 @@ import { createXtermEmulator, type XtermOptions } from '@/emulator/xterm'
import { createPathDetector } from '@/files/detector'
import { FileViewer, type FileTarget } from '@/files/viewer'
import { loadThemePref, onThemePref, saveThemePref, THEME_PREF_KEY } from '@/lib/theme-pref'
import { anchorIdOf } from '@/sessions/groups'
import {
cellAt,
cellBox,
Expand Down Expand Up @@ -264,6 +266,10 @@ export function Terminal({
// This session's directory, for Restart and the new-session link. From the
// session list, because `attached` does not carry it.
const [cwd, setCwd] = useState<string | null>(null)
// And its tags, for the corner strip, from the same list. Kept only when
// they change: the poll repeats, and an unguarded set of a fresh array
// would re-render the pane on every tick.
const [tags, setTags] = useState<string[]>([])
// The theme choice — global, every session wears it — read once per mount
// and mirrored into a ref so the effect can resolve it without carrying
// the state in its dependency array: a theme change must restyle the live
Expand Down Expand Up @@ -929,6 +935,16 @@ export function Terminal({
const own = list.find((s) => s.id === sessionId)
if (!own) return
setCwd(own.cwd)
// The tags belong to the group, and the sessions list edits them on
// the anchor — the row a group folds to. A member pane (a split, a
// tab) wears the anchor's tags for the same reason; its own list
// entry never carries any.
const tagged = list.find((s) => s.id === anchorIdOf(own)) ?? own
setTags((prev) =>
prev.length === tagged.tags.length && prev.every((t, i) => t === tagged.tags[i])
? prev
: tagged.tags,
)
tabName = own.name
tabOsc = own.title
tabCwd = own.cwd
Expand Down Expand Up @@ -1240,10 +1256,44 @@ export function Terminal({
}}
/>
)}
{/*
The group's tags on a finger, above the key bar: both corners are
spoken for there — the top-left is the first line of whatever just
ran, the top-right is the control row, and a thumb's badges deserve
more width than the row spares. Full chrome only, so a surface reads
them once (see chipsPane). A fine pointer gets them in the control
row below instead. The strip takes no pointer beyond its own
footprint.
*/}
{chrome === 'full' && coarse && tags.length > 0 && (
<div
data-flue-tags=""
className="absolute bottom-17 left-3 z-10 flex max-w-[70%] flex-wrap items-center gap-1.5"
>
<TagBadges
tags={tags}
className="bg-(--chip-bg) text-(--chip-dim) ring-1 ring-(--chip-ring) backdrop-blur-sm"
/>
</div>
)}
{/* z-10: xterm's own layers carry z-indexes, and an unindexed sibling
loses to them — the controls must win the stack or the scrollbar
eats their clicks. */}
<div className="absolute top-3 right-3 z-10 flex items-start gap-x-2">
<div data-flue-controls="" className="absolute top-3 right-3 z-10 flex items-start gap-x-2">
{/*
The group's tags lead the row: the corner is already spent on
chrome, so badges here cover nothing new. Height of the icon
buttons beside them, and clipped rather than wrapped — a row that
grows downward would be floating over output again.
*/}
{chrome === 'full' && !coarse && tags.length > 0 && (
<div data-flue-tags="" className="flex h-7 max-w-64 items-center gap-1.5 overflow-hidden">
<TagBadges
tags={tags}
className="bg-(--chip-bg) text-(--chip-dim) ring-1 ring-(--chip-ring) backdrop-blur-sm"
/>
</div>
)}
{chrome === 'full' && <ThemeMenu value={themeId} dark={dark} onChange={handleTheme} />}
{chrome === 'full' && (
<>
Expand Down