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
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>
)}
</>
)
}
114 changes: 106 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,102 @@ describe('Terminal', () => {
expect(document.title).toBe('vim wire.go')
})

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

it('hangs below the control row, right-aligned over the emptiest ground', () => {
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()
// Under the chips at the right edge: terminal text is left-justified,
// so this is the quietest place a line of badges can stand.
expect(strip()!.className).toMatch(/\btop-12\b/)
expect(strip()!.className).toMatch(/\bright-3\b/)
expect(strip()!.className).toMatch(/\bjustify-end\b/)
expect(strip()!.className).toMatch(/\bz-10\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('keeps the same berth on a coarse pointer', () => {
coarsePointer()
const { sock } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)
act(() => sock.emitControl({ type: 'sessions', sessions: [session({ tags: ['api'] })] }))

expect(strip()!.className).toMatch(/\btop-12\b/)
expect(strip()!.className).toMatch(/\bright-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 +1691,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 +1843,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
36 changes: 36 additions & 0 deletions 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,6 +1256,26 @@ export function Terminal({
}}
/>
)}
{/*
The group's tags, hung below the control row at the right edge.
Terminal text is left-justified, so the right margin under the chips
is the quietest ground on the screen — a floating badge anywhere
left sits on somebody's prompt. Right-aligned and wrapping downward,
so a long set grows into that same margin. Full chrome only, which
is what makes a surface read them once (see chipsPane). The strip
takes no pointer beyond its own footprint.
*/}
{chrome === 'full' && tags.length > 0 && (
<div
data-flue-tags=""
className="absolute top-12 right-3 z-10 flex max-w-[50%] flex-wrap items-center justify-end 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. */}
Expand Down